feat: init docs

This commit is contained in:
Prad Nukala
2026-09-02 15:29:51 -04:00
parent d2390a8aad
commit 69425e2b7a
45 changed files with 11790 additions and 18 deletions
+440
View File
@@ -0,0 +1,440 @@
---
title: Distributed Key Generation
description: FROST, Gennaro, and 2-party Gennaro DKG — interactive protocols that produce a signing key no single participant ever holds.
sidebar:
order: 3
icon: git-branch
---
DKG replaces the trusted dealer. Instead of one process splitting a key it already has, every
participant samples its own contribution, publishes a verifiable commitment to it, and privately
sends one share to each peer. The joint signing key is the sum of every contribution; each party
ends up holding a Shamir share of that sum plus the joint public key. The key itself is never
assembled — not during generation, and not during signing.
Three protocols live here, and they are not interchangeable.
<CardGroup cols={3}>
<Card title="dkg/frost" icon="git-branch">
2 rounds, t-of-n, modern `curves.Curve` API. Feeds
[`ted25519/frost`](/threshold/threshold-ed25519) Schnorr signing.
</Card>
<Card title="dkg/gennaro" icon="git-branch">
4 rounds, t-of-n, built on legacy [`sharing/v1`](/threshold/secret-sharing). Produces the
public shares tECDSA signing wants.
</Card>
<Card title="dkg/gennaro2p" icon="users">
2-of-2 façade over `dkg/gennaro`. Two rounds plus `Finalize`, one message type per round.
</Card>
</CardGroup>
:::note[These are not the DKG used by tecdsa/dklsv1]
`tecdsa/dklsv1` has its own embedded DKLs18 DKG. Nothing in this page feeds it. See
[Threshold ECDSA](/threshold/threshold-ecdsa).
:::
## FROST DKG — `dkg/frost`
Two rounds, implementing the DKG half of [eprint 2020/852](https://eprint.iacr.org/2020/852.pdf)
(the citation is in the package doc comment). Each participant runs Feldman VSS on its own secret
and attaches a Schnorr proof of knowledge of the constant coefficient, which is what stops a
participant from biasing the joint key by choosing its contribution after seeing everyone else's.
```go
import (
"github.com/sonr-io/crypto/core/curves"
"github.com/sonr-io/crypto/dkg/frost"
"github.com/sonr-io/crypto/sharing"
)
func twoPartyFrostDkg() error {
curve := curves.ED25519()
ctx := "1" // see the ctx warning below
// Each participant knows its own id and the ids of all the others.
p1, err := frost.NewDkgParticipant(1, 2, ctx, curve, 2)
if err != nil {
return err
}
p2, err := frost.NewDkgParticipant(2, 2, ctx, curve, 1)
if err != nil {
return err
}
// --- Round 1 --------------------------------------------------------
bcast1, p2pSend1, err := p1.Round1(nil) // nil => sample a fresh secret
if err != nil {
return err
}
bcast2, p2pSend2, err := p2.Round1(nil)
if err != nil {
return err
}
// Broadcasts go to everyone, keyed by SENDER id, and include your own.
bcast := map[uint32]*frost.Round1Bcast{1: bcast1, 2: bcast2}
// P2P inputs are keyed by SENDER id too: p2p1[2] is what participant 2
// sent to participant 1, i.e. p2pSend2[1].
p2p1 := map[uint32]*sharing.ShamirShare{2: p2pSend2[1]}
p2p2 := map[uint32]*sharing.ShamirShare{1: p2pSend1[2]}
// --- Round 2 --------------------------------------------------------
if _, err = p1.Round2(bcast, p2p1); err != nil {
return err
}
if _, err = p2.Round2(bcast, p2p2); err != nil {
return err
}
// p1.SkShare, p1.VkShare, p1.VerificationKey are now populated,
// and p1.VerificationKey == p2.VerificationKey.
_ = p1.SkShare
return nil
}
```
<Steps>
<Step title="Round1(secret []byte) (*Round1Bcast, Round1P2PSend, error)">
Samples (or accepts) a secret `s`, runs Feldman VSS to get `threshold` commitments and `limit`
shares, samples a nonce `k`, and computes the Schnorr-style proof `c = H(i, CTX, a_0·G, k·G)`,
`w = s·c + k`.
**Broadcast** (`*Round1Bcast`): the `*sharing.FeldmanVerifier` and the two scalars `Wi`, `Ci`.
**Point-to-point** (`Round1P2PSend`, a type alias for `map[uint32]*sharing.ShamirShare`): one
private share per peer, keyed by that peer's id. Send `p2pSend[j]` to participant `j` only.
Pass `nil` for `secret` to sample. Passing a secret enables reshare-style flows, but a zero or
out-of-range value is rejected (`internal.ErrZeroValue` or a scalar decode error).
</Step>
<Step title="Round2(bcast, p2psend) (*Round2Bcast, error)">
For every peer: recomputes `c_j` and aborts unless it matches the broadcast `Ci` (this verifies the
proof of knowledge), then runs `FeldmanVerifier.Verify` on the private share that peer sent. Both
maps are keyed by *sender* id; `bcast` must include your own entry, `p2psend` must not.
Then sums the shares into the signing share and sums every peer's `Commitments[0]` into the joint
verification key.
Sets `SkShare` (`curves.Scalar`), `VkShare` (`curves.Point`, `= SkShare · G`), and
`VerificationKey` (`curves.Point`, the joint public key) on the participant, and returns the latter
two as `*Round2Bcast`.
</Step>
</Steps>
### Result fields
<TypeTable
type={{
Id: { type: "uint32", required: true, description: "This participant's identifier." },
Curve: { type: "*curves.Curve", required: true, description: "The curve the DKG ran on." },
SkShare: {
type: "curves.Scalar",
required: true,
description: "Secret signing share. Set by Round2. This is the value to persist and protect.",
},
VkShare: {
type: "curves.Point",
required: true,
description: "SkShare · G. Public; lets peers attribute a partial signature to this id.",
},
VerificationKey: {
type: "curves.Point",
required: true,
description: "The joint public key. Identical across all participants after Round2.",
},
}}
/>
The `SkShare` values are ordinary Shamir shares of the joint key, so
`sharing.NewShamir(t, n, curve).Combine(...)` over `{Id, SkShare.Bytes()}` pairs reconstructs it —
which the package's own test does to prove correctness, and which production code should never do.
### Transport
`Round1Result` bundles the two halves of round 1 for one recipient:
```go
result := &frost.Round1Result{Broadcast: bcast1, P2P: p2pSend1[2]}
wire, err := result.Encode() // gob
// ...
decoded := &frost.Round1Result{}
err = decoded.Decode(wire)
```
`Encode` uses `encoding/gob` and registers the concrete commitment point and `Ci` scalar types on
each call. There is no matching helper for round 2 — serialise `Round2Bcast` yourself.
:::danger[The ctx string is silently reduced to a single byte, usually zero]
`NewDkgParticipant` takes `ctx string` as the fixed context string that binds the Schnorr proofs to
this DKG session. The implementation does:
```go
ctxV, _ := strconv.Atoi(ctx) // error discarded
// ...
ctx: byte(ctxV),
```
Two consequences. First, any non-numeric `ctx` — including the package's own test value
`"string to prevent replay attack"` — fails `Atoi`, the error is thrown away, and the stored context
becomes the byte `0`. Every such session shares an identical context. Second, even a numeric `ctx`
is truncated to one byte, so `"1"`, `"257"`, and `"513"` are indistinguishable.
The context string therefore provides **no meaningful domain separation as implemented**. Do not
rely on it to prevent cross-session replay of round-1 broadcasts; enforce session freshness at your
transport layer. The participant `Id` is hashed as `byte(dp.Id)` and has the same truncation
problem for ids ≥ 256.
:::
:::warning[Round order is enforced; the error is not exported]
Each participant holds an internal round counter. Calling `Round1` twice, or `Round2` before
`Round1`, returns `internal.ErrInvalidRound` — `"invalid round method called"`. Because
`internal` is not importable, you cannot match that sentinel from outside the module; you only get
the message. The same is true of `internal.ErrNilArguments` (`"arguments cannot be nil"`), returned
for a nil curve, an empty `otherParticipants` list, or nil round-2 maps.
:::
## Gennaro DKG — `dkg/gennaro`
Four rounds, implementing the DKG of [eprint 2020/540](https://eprint.iacr.org/2020/540.pdf) (cited
in the package doc). The extra rounds buy a two-phase VSS that FROST's single Feldman pass does not
provide.
```go
import (
"math/big"
"github.com/btcsuite/btcd/btcec/v2"
"github.com/sonr-io/crypto/core/curves"
"github.com/sonr-io/crypto/dkg/gennaro"
)
func twoPartyGennaroDkg() error {
// The blinding generator for Pedersen VSS. Must have unknown discrete log
// w.r.t. the base point in real use — this fixed multiple is test-only.
generator, err := curves.NewScalarBaseMult(btcec.S256(), big.NewInt(3333))
if err != nil {
return err
}
p1, err := gennaro.NewParticipant(1, 2, generator, curves.NewK256Scalar(), 2)
if err != nil {
return err
}
p2, err := gennaro.NewParticipant(2, 2, generator, curves.NewK256Scalar(), 1)
if err != nil {
return err
}
// Round 1
bcast1, p2pSend1, err := p1.Round1(nil)
if err != nil {
return err
}
bcast2, p2pSend2, err := p2.Round1(nil)
if err != nil {
return err
}
bcast := map[uint32]gennaro.Round1Bcast{1: bcast1, 2: bcast2}
p2p1 := map[uint32]*gennaro.Round1P2PSendPacket{2: p2pSend2[1]}
p2p2 := map[uint32]*gennaro.Round1P2PSendPacket{1: p2pSend1[2]}
// Round 2
r2out1, err := p1.Round2(bcast, p2p1)
if err != nil {
return err
}
r2out2, err := p2.Round2(bcast, p2p2)
if err != nil {
return err
}
round3Input := map[uint32]gennaro.Round2Bcast{1: r2out1, 2: r2out2}
// Round 3 — yields the joint public key and this party's secret share
pubKey1, share1, err := p1.Round3(round3Input)
if err != nil {
return err
}
if _, _, err = p2.Round3(round3Input); err != nil {
return err
}
// Round 4 — public shares for tECDSA signing (idempotent)
publicShares1, err := p1.Round4()
if err != nil {
return err
}
_, _, _ = pubKey1, share1, publicShares1
return nil
}
```
<Steps>
<Step title="Round1(secret []byte) (Round1Bcast, Round1P2PSend, error)">
Pedersen-committed sharing. The participant runs Pedersen VSS on its secret, producing a secret
polynomial and a blinding polynomial. `Round1Bcast` is a type alias for `[]*v1.ShareVerifier` — the
`threshold` *blinded* commitments `a_j·G + b_j·H`, which reveal nothing about the secret.
`Round1P2PSend` maps each peer id to a `*Round1P2PSendPacket` carrying that peer's `SecretShare`
and its matching `BlindingShare`.
Passing a non-nil `secret` performs proactive secret resharing rather than fresh key generation:
the public key stays the same and only the shares change.
</Step>
<Step title="Round2(bcast, p2p) (Round2Bcast, error)">
Verifies every received `(secretShare, blindingShare)` pair against the sender's blinded
commitments, then de-blinds: broadcasts the *unblinded* Feldman commitments `a_j·G` as
`Round2Bcast` (also `[]*v1.ShareVerifier`). Splitting the commit and reveal across two rounds is
what makes the joint key unbiasable — nobody can see any `a_0·G` until every participant has
already committed.
</Step>
<Step title="Round3(bcast) (*Round3Bcast, *v1.ShamirShare, error)">
Checks each peer's Feldman commitments against the Pedersen commitments it already holds, then
assembles the joint public key. Returns the verification key (`*Round3Bcast`, an alias for
`v1.ShareVerifier`) and this participant's secret share.
</Step>
<Step title="Round4() (map[uint32]*curves.EcPoint, error)">
Computes the per-participant public shares that tECDSA signing needs — `skShare_i · G` for every
`i` — which get converted to additive shares once the signing set is known. Takes no arguments and
is idempotent: calling it repeatedly returns the same map.
</Step>
</Steps>
:::warning[Participant ids must be exactly 1..n]
`NewParticipant` runs `validIds(append(otherParticipants, id))`, which requires the id set to be
precisely the integers `1, 2, …, n`. `NewParticipant(3, 2, gen, scalar, 4)` fails; so does an id of
`0`, and so does any set with a gap. FROST does not impose this — only Gennaro does.
:::
:::note[Built on the legacy sharing layer]
`dkg/gennaro` uses `sharing/v1` throughout: `*curves.EcPoint` instead of `curves.Point`,
`*curves.Element` instead of `curves.Scalar`, `*v1.ShamirShare` instead of `*sharing.ShamirShare`,
and `elliptic.Curve` instead of `*curves.Curve`. Reconstruction of its shares therefore goes through
`v1.NewShamir(t, n, curves.NewField(btcec.S256().N))`, which inherits the
[`v1.Shamir.Combine` truncation defect](/threshold/secret-sharing). The `scalar curves.EcScalar`
argument supplies curve-specific scalar arithmetic — `curves.NewK256Scalar()` for secp256k1.
:::
## 2-party Gennaro — `dkg/gennaro2p`
A façade over `dkg/gennaro` specialised for the 2-of-2 case. Its package doc states the
simplification directly: no distinction between broadcast and peer messages, and only the
counterparty's message is used as round input because self-inputs are always ignored.
```go
import (
"github.com/btcsuite/btcd/btcec/v2"
"github.com/sonr-io/crypto/core/curves"
"github.com/sonr-io/crypto/dkg/gennaro2p"
)
func twoPartyDkg() (*gennaro2p.DkgResult, *gennaro2p.DkgResult, error) {
curve := btcec.S256()
scalar := curves.NewK256Scalar()
// Passing nil blind makes the client generate a secure blinding generator.
client, err := gennaro2p.NewParticipant(1, 2, nil, scalar, curve)
if err != nil {
return nil, nil, err
}
// Round 1 carries the blind, so the server can adopt the client's.
clientR1, err := client.Round1(nil)
if err != nil {
return nil, nil, err
}
server, err := gennaro2p.NewParticipant(2, 1, clientR1.Blind, scalar, curve)
if err != nil {
return nil, nil, err
}
serverR1, err := server.Round1(nil)
if err != nil {
return nil, nil, err
}
// Round 2 consumes the *counterparty's* round 1 output.
clientR2, err := client.Round2(serverR1)
if err != nil {
return nil, nil, err
}
serverR2, err := server.Round2(clientR1)
if err != nil {
return nil, nil, err
}
// Finalize consumes the counterparty's round 2 output.
clientResult, err := client.Finalize(serverR2)
if err != nil {
return nil, nil, err
}
serverResult, err := server.Finalize(clientR2)
if err != nil {
return nil, nil, err
}
return clientResult, serverResult, nil
}
```
<Steps>
<Step title="Round1(secret []byte) (*Round1Message, error)">
Wraps `gennaro.Round1`. Returns one flat message carrying `Verifiers []*v1.ShareVerifier`,
`SecretShare`, `BlindingShare`, and `Blind *curves.EcPoint`.
</Step>
<Step title="Round2(msg *Round1Message) (*Round2Message, error)">
Wraps `gennaro.Round2` with the counterparty's round-1 message as the sole input. Returns
`Round2Message{Verifiers}`.
</Step>
<Step title="Finalize(msg *Round2Message) (*DkgResult, error)">
Runs `gennaro.Round3` and `gennaro.Round4` back to back and packages the outcome as
`DkgResult{PublicKey *curves.EcPoint, SecretShare *v1.ShamirShare, PublicShares map[uint32]*curves.EcPoint}`.
</Step>
</Steps>
:::tip[Blind synchronisation is the caller's job]
`NewParticipant`'s doc says the blind "must be a generator and must be synchronised between
counterparties. The first participant can set it to `nil` and a secure blinding factor will be
generated." The generated blind is echoed in `Round1Message.Blind`, so the practical ordering is:
party A constructs with `nil` and runs `Round1`, then party B constructs with `Blind` taken from A's
round-1 message. The blind-generation helper itself is unexported, so `nil` is the only way to get
one. Do **not** pass the base point or a known multiple of it — see the
[Pedersen generator warning](/threshold/secret-sharing).
:::
## Caveats
:::warning[No identifiable abort]
None of these protocols tell you *who* misbehaved. Verification failures surface as messages like
`"feldman verify fails for participant with id 2"` (FROST does name the id) or a bare `"not equal"`
(the underlying VSS check). Aborting is correct, but you get no cryptographic evidence to present to
a third party, so a participant can grief the protocol repeatedly without penalty.
:::
:::warning[No transport, no authentication, no replay protection]
These packages produce and consume Go values. Delivering broadcasts to everyone, delivering each
private share to exactly one recipient, authenticating senders, and rejecting replayed round
messages are all your responsibility. Given the `ctx` defect above, replay protection in particular
cannot be delegated to `dkg/frost`.
:::
:::note[Round methods mutate the participant and are not goroutine-safe]
Every round advances an internal counter and stores state on the participant. One participant
value belongs to one goroutine.
:::
## Next
<CardGroup cols={2}>
<Card title="Threshold Ed25519" href="/threshold/threshold-ed25519" icon="key-round">
`ted25519/frost` consumes a `dkg/frost` participant directly.
</Card>
<Card title="Threshold ECDSA" href="/threshold/threshold-ecdsa" icon="pen-tool">
DKLs18 2-of-2, with its own embedded DKG.
</Card>
<Card title="Secret Sharing" href="/threshold/secret-sharing" icon="split">
The Shamir/Feldman/Pedersen machinery all three protocols are built on.
</Card>
<Card title="Schnorr Proofs" href="/zero-knowledge/schnorr" icon="fingerprint">
The proof of knowledge that keeps FROST's joint key unbiasable.
</Card>
</CardGroup>
+119
View File
@@ -0,0 +1,119 @@
---
title: Threshold & MPC
description: Splitting keys across parties so no single machine ever holds a signing key — secret sharing, distributed key generation, and threshold signing.
sidebar:
order: 1
icon: users
---
Everything in this section exists to answer one question: **how do you sign without any single
machine ever holding the private key?** The answer is built in four layers, and each layer is a
separate package in this repository. Reading them bottom-up is the fastest way to make sense of the
code.
## The stack
<Steps>
<Step title="Oblivious transfer — ot/base/simplest, ot/extension/kos">
The raw two-party primitive. A sender holds two messages, a receiver picks one, and neither learns
anything about the other's choice. Threshold ECDSA needs it because ECDSA multiplies two secrets
together, and OT is how two parties multiply shares without revealing them. You will almost never
call this directly. See [Oblivious Transfer](/threshold/oblivious-transfer).
</Step>
<Step title="Secret sharing — sharing, sharing/v1">
Shamir, Feldman, and Pedersen. Given a secret that *already exists*, split it into `n` shares so
that any `t` reconstruct it. Purely local: one process does the splitting. See
[Secret Sharing](/threshold/secret-sharing).
</Step>
<Step title="Distributed key generation — dkg/frost, dkg/gennaro, dkg/gennaro2p">
Each party samples its own contribution and the parties run an interactive protocol. The resulting
signing key is never assembled anywhere. See [Distributed Key Generation](/threshold/dkg).
</Step>
<Step title="Threshold signing — tecdsa/dklsv1, ted25519">
Consume a DKG output and produce a signature that verifies under an ordinary ECDSA or Ed25519
verifier. See [Threshold ECDSA](/threshold/threshold-ecdsa) and
[Threshold Ed25519](/threshold/threshold-ed25519).
</Step>
</Steps>
## Sharing a secret is not the same as DKG
This is the distinction people get wrong, and getting it wrong voids the entire security argument.
**Secret sharing with a dealer** (`sharing.Shamir`, `sharing.Feldman`, `sharing.Pedersen`,
`tecdsa/dklsv1/dealer`) starts from a secret that exists in one process's memory. That process runs
a polynomial, emits `n` shares, and hands them out. For the duration of `Split`, one machine knows
the whole key. If that machine is compromised — or if it neglects to zero the secret, or if it is
swapped to disk — the key is gone. Threshold reconstruction after the fact does not undo that.
**Distributed key generation** (`dkg/frost`, `dkg/gennaro`, `dkg/gennaro2p`, and the DKG phase of
`tecdsa/dklsv1`) never forms the key. Each participant `i` samples its own secret `s_i`, shares
`s_i` with everyone, and the joint key is the sum of every contribution. Each party ends up with a
share of `Σ s_i` and the public key `Σ s_i · G`, and no participant — not even a coalition below
threshold — ever sees the key.
:::warning[Dealer setup is a testing and migration tool]
`tecdsa/dklsv1/dealer.GenerateAndDeal` constructs *both* parties' key shares inside a single
process. Its own package doc says so: "Running actual DKG is ALWAYS recommended over a trusted
dealer." Use it for tests and for migrating a key you already hold; never for fresh key creation in
production.
:::
## Protocol comparison
| Package | Threshold model | Curves | Rounds | Notes |
| --- | --- | --- | --- | --- |
| `sharing` (Shamir/Feldman/Pedersen) | t-of-n, `2 ≤ t ≤ n ≤ 255` | any `curves.Curve` | none (local) | Trusted dealer |
| `sharing/v1` | t-of-n | `elliptic.Curve` / `curves.Field` | none (local) | Legacy; `[]byte` secrets |
| `dkg/frost` | t-of-n | any `curves.Curve` | 2 | Feldman VSS + Schnorr PoK |
| `dkg/gennaro` | t-of-n, ids must be exactly `1..n` | k256 and other `elliptic.Curve` | 4 | Pedersen then Feldman |
| `dkg/gennaro2p` | 2-of-2 | `elliptic.Curve` | 2 + `Finalize` | Façade over `dkg/gennaro` |
| `tecdsa/dklsv1` (DKG) | 2-of-2 only | K256, P256 | 10 interleaved half-rounds | DKLs18 |
| `tecdsa/dklsv1` (sign) | 2-of-2 only | K256, P256 | 4 interleaved half-rounds | Bob receives the signature |
| `tecdsa/dklsv1` (refresh) | 2-of-2 only | K256, P256 | 7 interleaved half-rounds | Public key unchanged |
| `ted25519/ted25519` | t-of-n | Ed25519 only | 1 round + aggregation | Output is a plain Ed25519 signature |
| `ted25519/frost` | t-of-n | any `curves.Curve` | 3 | Schnorr, needs a `dkg/frost` result |
| `ot/base/simplest` | 2-party | any `curves.Curve` | 8 interleaved half-rounds | Internal |
| `ot/extension/kos` | 2-party | K256, P256 (tested) | 3 | Internal |
:::note[Curve support in the table means "exercised in this repository's tests"]
Most of these types are generic over the [curve abstraction](/foundations/curves). The curve column
records what the tests actually run, not a claim about what is safe. `tecdsa/dklsv1`, for instance,
is only ever tested on `curves.K256()` and `curves.P256()`.
:::
## Do not drive rounds by hand
For 2-of-2 ECDSA — which is what a Sonr wallet uses — the round-level API is not the intended entry
point. Two layers sit above it:
1. `tecdsa/dklsv1`'s `protocol.Iterator` wrappers (`NewAliceDkg`, `NewBobSign`, …) reduce every
protocol to a `Next(msg)` loop over opaque `*protocol.Message` values you can put on a wire.
2. The `mpc` package wraps *that* into an enclave with key import/export, signing, and
serialization. Application code should start there. See [MPC Enclave](/identity/mpc-enclave).
Reach for the numbered `Round1..Round10` methods only when you are writing your own transport, or
auditing.
## Where to next
<CardGroup cols={2}>
<Card title="Secret Sharing" href="/threshold/secret-sharing" icon="split">
Shamir, Feldman, and Pedersen VSS: which one, and what each fails to protect against.
</Card>
<Card title="Distributed Key Generation" href="/threshold/dkg" icon="git-branch">
FROST, Gennaro, and the 2-party Gennaro façade, with exact round tables.
</Card>
<Card title="Threshold ECDSA" href="/threshold/threshold-ecdsa" icon="pen-tool">
DKLs18 2-of-2 ECDSA: the iterator API, serialization, refresh, and the dealer shortcut.
</Card>
<Card title="Threshold Ed25519" href="/threshold/threshold-ed25519" icon="key-round">
t-of-n Ed25519 that verifies under a stock verifier, plus FROST Schnorr.
</Card>
<Card title="Oblivious Transfer" href="/threshold/oblivious-transfer" icon="shuffle">
The layer under tECDSA. Read this to audit, not to call.
</Card>
<Card title="MPC Enclave" href="/identity/mpc-enclave" icon="lock">
The batteries-included wrapper most application code should use.
</Card>
</CardGroup>
+15
View File
@@ -0,0 +1,15 @@
import { defineMeta } from "blume";
export default defineMeta({
title: "Threshold & MPC",
icon: "users",
order: 5,
pages: [
"index",
"secret-sharing",
"dkg",
"threshold-ecdsa",
"threshold-ed25519",
"oblivious-transfer",
],
});
+428
View File
@@ -0,0 +1,428 @@
---
title: Oblivious Transfer
description: The base OT and correlated OT extension underneath threshold ECDSA — simplest (Verified Simplest OT) and kos (KOS15 cOT extension).
sidebar:
order: 6
icon: shuffle
---
:::note[These are internal building blocks, not a user-facing API]
`ot/base/simplest` and `ot/extension/kos` exist to serve
[`tecdsa/dklsv1`](/threshold/threshold-ecdsa). They are exported because the tECDSA packages need
them across package boundaries, not because application code is meant to call them. If you are
building a wallet, use [MPC Enclave](/identity/mpc-enclave); if you are building a signing service,
use the `dklsv1` iterators. This page is here so you can *understand and audit* the layer beneath
tECDSA, and so that a `simplest.SenderOutput` appearing in a DKG result type is not a mystery.
:::
## What oblivious transfer is, and why ECDSA needs it
In 1-out-of-2 OT the sender holds two strings `m_0`, `m_1`; the receiver holds a choice bit `b`.
After the protocol the receiver knows `m_b` and nothing about `m_{1-b}`, and the sender learns
nothing about `b`.
ECDSA needs this because signing requires computing `k^{-1}(H(m) + r·sk)` where `k` and `sk` are
both *split across two parties*. Adding shares is free; multiplying them is not. The standard
two-party trick is to expand one party's secret into bits, have the other party offer a correlated
pair per bit, and let OT select. Sum the selections and you have an additive sharing of the product,
with neither side having learned a factor. That is precisely what `sign.MultiplySender` /
`MultiplyReceiver` do, and `kos` is the OT engine they drive.
## Two layers, one reason
Base OT costs public-key operations — a Schnorr proof, a scalar multiplication per instance. A
single ECDSA signature needs thousands of OTs. Running thousands of base OTs would be intolerably
slow.
**OT extension** fixes this. You run a small fixed number of base OTs once — `kos.Kappa` = 256 of
them, the computational security parameter — and then stretch that seed material into arbitrarily
many OTs using nothing but hashing and binary-field arithmetic. In `kos` each extension produces
`L = 2·Kappa + 2·s = 672` correlated OTs (with `s = 80`, the statistical security parameter) from
that one seed set.
So the pipeline is: **`simplest` once → `kos` many times.**
<Steps>
<Step title="Seed OT — 256 instances of ot/base/simplest">
Run during DKG. Its outputs (`SenderOutput` for Bob, `ReceiverOutput` for Alice) are persisted as
part of the DKG result and reused for every subsequent signature.
</Step>
<Step title="cOT extension — ot/extension/kos, per signature">
Consumes the persisted seed OT results and produces the 672 correlated OTs a signature needs, in
three cheap rounds.
</Step>
</Steps>
:::warning[Roles cross between the layers]
`NewCOtSender` takes a `*simplest.ReceiverOutput`, and `NewCOtReceiver` takes a
`*simplest.SenderOutput`. The constructor docs flag this explicitly — "note the reversal of roles".
Wire them the intuitive way and the protocol fails.
:::
## `ot/base/simplest` — Verified Simplest OT
The package doc names its lineage precisely: "Verified Simplest OT" as defined in "protocol 7" of
[DKLs18](https://eprint.iacr.org/2018/499.pdf), with the original Simplest OT from
[CC15](https://eprint.iacr.org/2015/267.pdf). Multiple choice bits run in parallel, and it is
implemented as a **Random OT** — the sender does not choose its messages; both are random pads
produced by the protocol.
### Security model, from the source
- The "Verified" prefix is the point: rounds 46 are a challenge/response/opening phase that lets
the receiver detect a cheating sender. This is the **maliciously secure** variant of Simplest OT,
not the semi-honest one.
- Ideal functionalities are instantiated concretely, and the package says which: ZKP Schnorr realizes
the `F^{R_{DL}}_{ZK}` zero-knowledge functionality, and *"We have used HMAC for realizing the Random
Oracle Hash function, the key for HMAC is received as input to the protocol."* The HMAC key is the
`uniqueSessionId`.
- Session binding uses a Merlin transcript, initialised with the domain string
`"Coinbase_DKLs_SeedOT"` and immediately absorbing `uniqueSessionId`.
### Construction
```go
import (
"crypto/rand"
"github.com/sonr-io/crypto/core/curves"
"github.com/sonr-io/crypto/ot/base/simplest"
)
curve := curves.K256()
// Fresh, unpredictable, and identical on both sides. See the danger callout.
uniqueSessionId := [simplest.DigestSize]byte{}
if _, err := rand.Read(uniqueSessionId[:]); err != nil {
return err
}
const batchSize = 256 // must be a multiple of 8
sender, err := simplest.NewSender(curve, batchSize, uniqueSessionId)
if err != nil {
return err
}
receiver, err := simplest.NewReceiver(curve, batchSize, uniqueSessionId)
if err != nil {
return err
}
```
<TypeTable
type={{
curve: {
type: "*curves.Curve",
required: true,
description: "Group for the DiffieHellman-style pad derivation. Tests exercise K256 and P256.",
},
batchSize: {
type: "int",
required: true,
description: "Number of parallel OTs. MUST be a multiple of 8 — the constructors reject anything else with 'batch size should be a multiple of 8', because choice bits are stored packed. tECDSA passes kos.Kappa (256).",
},
uniqueSessionId: {
type: "[simplest.DigestSize]byte",
required: true,
description: "32 bytes. Doubles as the Merlin transcript session binding and the HMAC key for the random oracle. Both parties must supply the identical value, and it must never repeat.",
},
}}
/>
`DigestSize = 32` — the hash length, and also the plaintext/ciphertext size for the optional
encryption steps.
### The eight interleaved rounds
As in tECDSA, the numbers form one global sequence across both parties; the sender owns the odd
rounds and the receiver the even ones. `ot/ottest.RunSimplestOT` wires all of it up:
```go
import "github.com/sonr-io/crypto/ot/ottest"
// Creates both parties, runs rounds 16, and returns their outputs.
senderOutput, receiverOutput, err := ottest.RunSimplestOT(curve, batchSize, uniqueSessionId)
```
Its own doc says it is "a utility function used _only_ during various tests". The sequence it
performs, which is the canonical call order:
<Steps>
<Step title="Round 1 — sender: Round1ComputeAndZkpToPublicKey() (*schnorr.Proof, error)">
Sender computes its key pair `B = b·G` and returns a Schnorr proof of knowledge of `b`. Protocol 7,
step 1.
</Step>
<Step title="Round 2 — receiver: Round2VerifySchnorrAndPadTransfer(proof) ([]ReceiversMaskedChoices, error)">
Receiver verifies the proof (step 2) and performs the Pad Transfer (step 3), returning the masked
choices — the paper's `A` values, in compressed form. Its own random choice bits were generated in
`NewReceiver`.
</Step>
<Step title="Round 3 — sender: Round3PadTransfer(maskedChoices) ([]OtChallenge, error)">
Steps 4 and 5. Sender derives both one-time pads per instance and emits the challenges `xi`.
</Step>
<Step title="Round 4 — receiver: Round4RespondToChallenge(challenge) ([]OtChallengeResponse, error)">
Step 6. Start of the Verify phase: the receiver returns `rho'` for the sender to check.
</Step>
<Step title="Round 5 — sender: Round5Verify(challengeResponses) ([]ChallengeOpening, error)">
Step 7. Aborts if `rho' != H(H(rho^0))`. On success the sender opens its challenges.
</Step>
<Step title="Round 6 — receiver: Round6Verify(challengeOpenings) error">
Step 8, the last verification. Aborts unless `H(rho^w)` matches what the receiver computed itself
*and* `xi == H(opening_0) XOR H(opening_1)`. After this returns nil the random OT is complete and
`Output` is valid on both sides.
</Step>
<Step title="Rounds 7 and 8 — OPTIONAL, only for non-random OT">
`sender.Round7Encrypt(messages)` and `receiver.Round8Decrypt(ciphertext)` bootstrap the random OT
into an actual OT of chosen messages. The package doc states these are optional and that "in the
setting where this OT is used as the seed OT in an OT Extension protocol, the encryption and
decryption steps are not needed" — so tECDSA never calls them.
</Step>
</Steps>
### Outputs
<TypeTable
type={{
"SenderOutput.OneTimePadEncryptionKeys": {
type: "[]OneTimePadEncryptionKeys",
required: true,
description: "Rho^0 and Rho^1 — both pads per instance, as [2][32]byte. One entry per batch slot. Secret.",
},
"ReceiverOutput.OneTimePadDecryptionKey": {
type: "[]OneTimePadDecryptionKey",
required: true,
description: "Rho^w — exactly one pad per instance, as [32]byte: the one matching the receiver's choice bit. Secret.",
},
"ReceiverOutput.PackedRandomChoiceBits": {
type: "[]byte",
required: true,
description: "The choice vector packed one bit per bit, batchSize/8 bytes. Secret.",
},
"ReceiverOutput.RandomChoiceBits": {
type: "[]int",
required: true,
description: "The same choices unpacked, one int per instance. Derived from the packed form at construction.",
},
}}
/>
The correctness invariant, which the tests assert directly:
$$
\texttt{ReceiverOutput.OneTimePadDecryptionKey}[i] = \texttt{SenderOutput.OneTimePadEncryptionKeys}[i][\texttt{RandomChoiceBits}[i]]
$$
The optional message layer is `SenderOutput.Encrypt(plaintexts)` (protocol step 9) and
`ReceiverOutput.Decrypt(ciphertexts)` (step 10); the round wrappers above just call these.
`ExtractBitFromByteVector(vector []byte, index int) byte` reads the `index`-th bit of a packed
vector, little-endian both across and within bytes — needed to interpret `PackedRandomChoiceBits`
by hand.
### Streaming helpers
```go
senderPipe, receiverPipe := simplest.NewPipeWrappers()
errorsChannel := make(chan error, 2)
go func() { errorsChannel <- simplest.SenderStreamOTRun(sender, senderPipe) }()
go func() { errorsChannel <- simplest.ReceiverStreamOTRun(receiver, receiverPipe) }()
for i := 0; i < 2; i++ {
if err := <-errorsChannel; err != nil {
return err
}
}
```
`SenderStreamOTRun(sender *Sender, rw io.ReadWriter) error` and
`ReceiverStreamOTRun(receiver *Receiver, rw io.ReadWriter) error` run the whole six-round process
over one `io.ReadWriter` — a websocket in practice — handling all encoding and decoding. The docs
frame the purpose as "conveniently bundling up the entire seed OT process, for use in tests".
`NewPipeWrappers()` returns a connected in-memory pair for driving both sides in one process.
## `ot/extension/kos` — correlated OT extension
Maliciously secure OT extension, "Protocol 9" of DKLs18, originally
[KOS15](https://eprint.iacr.org/2015/546.pdf) — both cited in the package doc.
This is *correlated* OT: the receiver supplies a choice vector, the sender supplies input scalars
`alpha_j`, and the two outputs add to `alpha_j` where the choice bit is 1 and to zero where it is 0.
That additive-sharing-of-a-selected-value shape is exactly what the multiplication protocol
consumes.
### Constants
| Constant | Value | Meaning |
| --- | --- | --- |
| `Kappa` | 256 | Computational security parameter — and the number of base OTs required |
| `KappaBytes` | 32 | `Kappa >> 3` |
| `L` | 672 | cOT batch size, `2*Kappa + 2*s` with `s = 80` (statistical security parameter) |
| `COtBlockSizeBytes` | 84 | `L >> 3` — size of the packed choice vector |
| `OtWidth` | 2 | Scalars per cOT slot; both parties get `OtWidth` shares per bit |
### Three rounds
```go
import (
"crypto/rand"
"github.com/sonr-io/crypto/core/curves"
"github.com/sonr-io/crypto/ot/base/simplest"
"github.com/sonr-io/crypto/ot/extension/kos"
"github.com/sonr-io/crypto/ot/ottest"
)
func runCOt(curve *curves.Curve) error {
uniqueSessionId := [simplest.DigestSize]byte{}
if _, err := rand.Read(uniqueSessionId[:]); err != nil {
return err
}
// Seed OT: exactly Kappa base OTs.
baseSenderOutput, baseReceiverOutput, err := ottest.RunSimplestOT(curve, kos.Kappa, uniqueSessionId)
if err != nil {
return err
}
// Note the crossed roles.
sender := kos.NewCOtSender(baseReceiverOutput, curve)
receiver := kos.NewCOtReceiver(baseSenderOutput, curve)
// Receiver's input: the packed choice vector.
choice := [kos.COtBlockSizeBytes]byte{}
if _, err = rand.Read(choice[:]); err != nil {
return err
}
// Sender's input: the correlations alpha_j.
input := [kos.L][kos.OtWidth]curves.Scalar{}
for i := 0; i < kos.L; i++ {
for j := 0; j < kos.OtWidth; j++ {
input[i][j] = curve.Scalar.Random(rand.Reader)
}
}
round1Output, err := receiver.Round1Initialize(uniqueSessionId, choice)
if err != nil {
return err
}
round2Output, err := sender.Round2Transfer(uniqueSessionId, input, round1Output)
if err != nil {
return err
}
if err = receiver.Round3Transfer(round2Output); err != nil {
return err
}
// Invariant: for every slot j and every k < OtWidth,
// sender.OutputAdditiveShares[j][k] + receiver.OutputAdditiveShares[j][k]
// == input[j][k] if choice bit j is 1
// == 0 if choice bit j is 0
return nil
}
```
<Steps>
<Step title="Round 1 — receiver: Round1Initialize(uniqueSessionId, choice) (*Round1Output, error)">
Steps 14 of Protocol 9. The receiver extends its packed `L`-bit choice vector, derives the matrix
`U` from the seed OT pads, and emits `Round1Output{U, WPrime, VPrime}` — `WPrime` and `VPrime` are
the consistency-check values that make the extension maliciously secure rather than merely
semi-honest.
</Step>
<Step title="Round 2 — sender: Round2Transfer(uniqueSessionId, input, round1Output) (*Round2Output, error)">
Steps 2, 5 and 6. The sender checks `WPrime`/`VPrime`, transposes and hashes the matrix, and returns
`Round2Output{Tau}`. Side effect: `sender.OutputAdditiveShares` is populated.
</Step>
<Step title="Round 3 — receiver: Round3Transfer(round2Output) error">
Step 7. The receiver computes its own `OutputAdditiveShares` from `Tau`. No return value beyond the
error.
</Step>
</Steps>
Both parties read their result from the exported field
`OutputAdditiveShares [L][OtWidth]curves.Scalar`.
Streaming equivalents mirror the base layer:
`SenderStreamCOtRun(sender *Sender, hashKeySeed [simplest.DigestSize]byte, input [L][OtWidth]curves.Scalar, rw io.ReadWriter) error`
and
`ReceiverStreamCOtRun(receiver *Receiver, hashKeySeed [simplest.DigestSize]byte, choice [COtBlockSizeBytes]byte, rw io.ReadWriter) error`.
Both take the inputs plus a `ReadWriter` and handle every round and every encode/decode.
## Caveats
:::danger[Never reuse a uniqueSessionId across executions]
The session id is not a label. In `simplest` it is absorbed into the Merlin transcript that binds
the Schnorr proof and every hash in the protocol; the package doc identifies it as *the HMAC key
realizing the random oracle*. In `kos` it is passed to both `Round1Initialize` and `Round2Transfer`
and keys the matrix hashing.
Reusing one across two executions therefore reuses the random-oracle keying. Two runs produce
related pads, the consistency-check values from one run become valid transcripts for another, and
the malicious-security argument — which assumes a fresh independent oracle per session — no longer
holds. Concretely, replaying a recorded round-1 message under a repeated session id is exactly the
attack the transcript binding exists to stop.
The rules:
- 32 bytes from a CSPRNG, per execution. Both parties must hold the identical value, so derive it
from *both* parties' contributions and agree on it before round 1 — that is why
`dklsv1`'s `Round1GenerateRandomSeed` has each side sample 32 bytes and appends both, with the
documented property "secure if either party is honest".
- Never derive it from a counter, a timestamp, a key id, or anything an adversary can predict or
force to repeat.
- Never persist and reuse one across signatures. Each signature runs a fresh cOT extension with a
fresh session id.
- Do not confuse it with the *seed OT output*, which is deliberately long-lived. The seed OT result
is reused for many signatures; the session id of each cOT extension is not.
:::
:::warning[batchSize must be a multiple of 8]
The package doc states the limitation plainly: "currently we only support batch OTs that are
multiples of 8." Choice bits are packed, and both constructors reject a non-multiple with `batch
size should be a multiple of 8`. `kos` always passes `Kappa` (256), which satisfies it.
:::
:::warning[Every output field is key material]
`SenderOutput.OneTimePadEncryptionKeys`, `ReceiverOutput.OneTimePadDecryptionKey`, and
`ReceiverOutput.PackedRandomChoiceBits` / `RandomChoiceBits` are all secret. They are persisted
inside `dkg.AliceOutput.SeedOtResult` and `dkg.BobOutput.SeedOtResult`, and
[serialised in the clear](/threshold/threshold-ecdsa) by the `dklsv1` encoders. Encrypt them at
rest. The one consolation the DKG docs note: unlike a lost `SecretKeyShare`, disclosed seed-OT
material can be replaced by re-running OT — which is what
[key refresh](/threshold/threshold-ecdsa) does.
:::
:::warning[Not constant time]
These packages do byte-level bit manipulation, binary-field multiplication, and matrix transposition
over secret choice vectors, using ordinary indexing and branching. Only the `batchSize & 0x07`
check carries a constant-time comment. Assume nothing here resists timing or cache analysis.
:::
:::note[No audit claim, and correctness only asserted for K256/P256]
This is a port of Coinbase's Kryptology. The live tests run `TestOtOnMultipleCurves`,
`TestOTStreaming`, `TestCOTExtension`, `TestCOTExtensionStreaming`, and `TestBinaryMult` — the cOT
tests over `curves.K256()` and `curves.P256()` only. Nothing here constitutes a security review of
either package. See [Security Notes](/reference/security).
:::
:::note[State is single-use]
A `Sender`/`Receiver` pair, at either layer, serves exactly one protocol execution. Round methods
mutate the value and are not goroutine-safe.
:::
## Next
<CardGroup cols={2}>
<Card title="Threshold ECDSA" href="/threshold/threshold-ecdsa" icon="pen-tool">
The consumer: DKG rounds 610 are the seed OT, and every signature runs a cOT extension.
</Card>
<Card title="Schnorr Proofs" href="/zero-knowledge/schnorr" icon="fingerprint">
The proof of knowledge in base OT round 1.
</Card>
<Card title="Curves & Scalars" href="/foundations/curves" icon="binary">
The `Curve`, `Point`, and `Scalar` types both packages are generic over.
</Card>
<Card title="Threshold Overview" href="/threshold" icon="users">
Where this layer sits in the stack.
</Card>
</CardGroup>
+343
View File
@@ -0,0 +1,343 @@
---
title: Secret Sharing
description: Shamir, Feldman, and Pedersen verifiable secret sharing over any supported curve — plus the legacy sharing/v1 layer and its known defects.
sidebar:
order: 2
icon: split
---
The `sharing` package splits a curve scalar into `n` shares such that any `t` of them reconstruct
it, and fewer than `t` reveal nothing. All three schemes share one share type and one
reconstruction routine; they differ only in what a shareholder can *verify* about the share it was
handed.
This is dealer-based sharing: one process holds the secret while `Split` runs. If you need a key
that never exists in one place, you want [DKG](/threshold/dkg) instead.
## Picking a scheme
<CardGroup cols={3}>
<Card title="Shamir" icon="split">
No verification. Fastest, smallest. Use only when every shareholder is trusted, or when a higher
layer verifies for you.
</Card>
<Card title="Feldman" icon="eye">
Adds polynomial commitments `a_j · G`. A holder can check its own share against them. The
commitments leak `a_0 · G` — i.e. the public key of the secret.
</Card>
<Card title="Pedersen" icon="eye-off">
Feldman plus a blinding polynomial under a second generator `H`. The commitments are
information-theoretically hiding, so nothing about the secret leaks before reconstruction.
</Card>
</CardGroup>
## Shamir
```go
import (
crand "crypto/rand"
"github.com/sonr-io/crypto/core/curves"
"github.com/sonr-io/crypto/sharing"
)
func shamirRoundTrip() error {
curve := curves.ED25519()
scheme, err := sharing.NewShamir(3, 5, curve) // 3-of-5
if err != nil {
return err
}
secret := curve.Scalar.Hash([]byte("test"))
shares, err := scheme.Split(secret, crand.Reader)
if err != nil {
return err
}
// Any 3 of the 5 shares reconstruct the secret.
recovered, err := scheme.Combine(shares[0], shares[2], shares[4])
if err != nil {
return err
}
_ = recovered.Cmp(secret) // == 0
return nil
}
```
`Combine` reconstructs the scalar. `CombinePoints` does the same interpolation in the group,
returning `secret · G` — useful when you want to check that a share set corresponds to a known
public key without materialising the key. `Shamir.LagrangeCoeffs(identities []uint32)` returns the
interpolation coefficients on their own, keyed by identifier, so a caller can compute
`Σ λ_i · share_i` itself; this is exactly what [`ted25519/frost`](/threshold/threshold-ed25519)
needs.
:::info[LagrangeCoeffs has two different signatures]
`Shamir.LagrangeCoeffs` takes `identities []uint32`. `Feldman.LagrangeCoeffs` and
`Pedersen.LagrangeCoeffs` take `shares map[uint32]*ShamirShare` and then delegate to the Shamir
implementation — same computation, different argument shape. Note the subtlety: they read the id
from each map *value* (`share.Id`), never from the map key, so a mismatched key is silently
ignored and a nil value panics. `Combine` and `CombinePoints` likewise delegate, so all three
types behave identically there.
:::
:::danger[Shamir has no integrity check whatsoever]
`Combine` validates that each share's id is nonzero and within `limit`, that ids are not duplicated,
and that the value is a nonzero scalar. It does **not** and cannot check that a share lies on the
dealer's polynomial. One malicious shareholder submitting a well-formed but wrong `Value` silently
produces a wrong secret, with no error. If shareholders are not mutually trusted, use Feldman or
Pedersen and verify every share before combining.
:::
## Feldman
`Feldman.Split` returns a `*FeldmanVerifier` alongside the shares. The verifier holds `Threshold`
points — `a_j · G` for each polynomial coefficient — and `Verify` recomputes
`Σ a_j · id^j` and compares it to `share · G`.
```go
scheme, err := sharing.NewFeldman(3, 5, curves.ED25519())
if err != nil {
return err
}
verifier, shares, err := scheme.Split(secret, crand.Reader)
if err != nil {
return err
}
for _, s := range shares {
if err := verifier.Verify(s); err != nil { // nil == valid
return err
}
}
recovered, err := scheme.Combine(shares[0], shares[1], shares[2])
```
`Verify` returns `fmt.Errorf("not equal")` on a mismatch — there is no typed sentinel error, so
compare against `nil` rather than matching the message.
:::info[The verifier is public data, and it publishes the public key]
`Commitments[0]` *is* `secret · G`. Distributing a `FeldmanVerifier` therefore discloses the public
key of the shared secret. That is normally what you want for a signing key. It is not what you want
if the shared secret must stay hidden even in the exponent — use Pedersen.
:::
## Pedersen
`NewPedersen` takes a **generator point** rather than a curve; the curve is derived from
`generator.CurveName()`. `Split` returns a single struct carrying both verifiers and both share
sets.
```go
curve := curves.ED25519()
// H must have unknown discrete log with respect to G.
h := curve.Point.Generator().Hash([]byte("sonr/pedersen/H/v1"))
scheme, err := sharing.NewPedersen(3, 5, h)
if err != nil {
return err
}
result, err := scheme.Split(secret, crand.Reader)
if err != nil {
return err
}
for i := range result.SecretShares {
// Pedersen verification needs BOTH the secret share and its blinding share.
err = result.PedersenVerifier.Verify(result.SecretShares[i], result.BlindingShares[i])
if err != nil {
return err
}
// The Feldman verifier is also returned and checks the secret share alone.
if err = result.FeldmanVerifier.Verify(result.SecretShares[i]); err != nil {
return err
}
}
recovered, err := scheme.Combine(result.SecretShares[0], result.SecretShares[1], result.SecretShares[2])
```
<TypeTable
type={{
Blinding: {
type: "curves.Scalar",
required: true,
description: "The blinding factor's intercept. Secret — leaking it collapses Pedersen to Feldman.",
},
SecretShares: {
type: "[]*ShamirShare",
required: true,
description: "Shares of the secret. Length == limit.",
},
BlindingShares: {
type: "[]*ShamirShare",
required: true,
description: "Shares of the blinding polynomial, index-aligned with SecretShares.",
},
FeldmanVerifier: {
type: "*FeldmanVerifier",
required: true,
description: "Unblinded commitments a_j · G. Reveals the public key.",
},
PedersenVerifier: {
type: "*PedersenVerifier",
required: true,
description: "Blinded commitments a_j · G + b_j · H, plus the generator H.",
},
}}
/>
:::danger[The generator must have unknown discrete log relative to the base point]
Pedersen's hiding property rests on nobody knowing `x` with `H = x · G`. If the dealer picks
`H = x · G` for a known `x`, it can open the commitment `a_j · G + b_j · H` to any value it likes:
the commitment stops being binding, so the dealer can hand out shares of one secret and later prove
they were shares of another. `NewPedersen` cannot detect this — it only checks that the generator is
on the curve and is not the identity. Derive `H` by hashing to the curve (as above), or use a
published nothing-up-my-sleeve constant. Never derive it as a scalar multiple of `G`.
:::
## ShamirShare
All three schemes emit the same share type.
<TypeTable
type={{
Id: {
type: "uint32",
required: true,
description: "The x-coordinate. 1-indexed; 0 is rejected. Must be ≤ limit.",
},
Value: {
type: "[]byte",
required: true,
description: "The y-coordinate, as the curve's canonical scalar encoding.",
},
}}
/>
`Bytes()` returns the id as 4 big-endian bytes followed by `Value` — a stable wire form.
`Validate(curve)` rejects a zero id, a `Value` that does not decode as a scalar on `curve`, and a
zero scalar. The struct carries `json` tags (`identifier`, `value`) and round-trips through
`encoding/json`.
## Constructor constraints
Identical across `NewShamir`, `NewFeldman`, and `NewPedersen`, checked in this order:
| Check | Error |
| --- | --- |
| `limit >= threshold` | `limit cannot be less than threshold` |
| `threshold >= 2` | `threshold cannot be less than 2` |
| `limit <= 255` | `cannot exceed 255 shares` |
| curve resolvable / non-nil | `invalid curve` |
`Shamir.Split` and `Feldman.Split` additionally reject a zero secret with `invalid secret`.
:::warning[Two rough edges in the constructors]
`Pedersen.Split` reaches the shared polynomial helper directly and **skips the zero-secret check**,
so `NewPedersen(...).Split(curve.Scalar.Zero(), rand)` succeeds and produces shares of zero. Check
`secret.IsZero()` yourself.
`NewPedersen` calls `generator.CurveName()` *before* its `generator == nil` guard, so passing a nil
generator panics with a nil-pointer dereference instead of returning `invalid generator`.
:::
## The Polynomial degree gotcha
`sharing.Polynomial` is exported, and its `Init` signature reads as if it takes a degree:
```go
func (p *Polynomial) Init(intercept curves.Scalar, degree uint32, reader io.Reader) *Polynomial
```
It does not. The implementation allocates `degree` coefficients — `Coefficients[0] = intercept` plus
`degree - 1` random ones — so the resulting polynomial has algebraic degree `degree - 1`. The
parameter is really a *coefficient count*.
Inside the package this is consistent: every scheme calls `Init(secret, threshold, reader)`, which
yields `threshold` coefficients and hence degree `threshold - 1` — precisely what a `t`-of-`n`
scheme requires. But if you call `Init` yourself expecting the named semantics you will get a
polynomial one degree lower than you asked for, and `Init(x, 0, r)` panics on
`Coefficients[0]` before it can return an error.
:::tip
Use `NewShamir`/`NewFeldman`/`NewPedersen`. `Polynomial` is exported incidentally, not as a
supported API.
:::
## Legacy: sharing/v1
<Badge variant="warning">Legacy — do not use for new code</Badge>
`sharing/v1` is the pre-`curves.Curve` generation of the same three schemes. It operates on
`[]byte` secrets over `curves.Field`/`curves.Element` and uses `curves.EcPoint` (aliased locally as
`ShareVerifier`) rather than `curves.Point`. It survives because [`dkg/gennaro`](/threshold/dkg) and
[`ted25519/ted25519`](/threshold/threshold-ed25519) are built on it and have never been ported.
Differences that matter if you must read it:
- `v1.NewShamir(threshold, limit int, field *curves.Field)` takes plain `int`s and a *field*, not a
curve. It enforces only `limit >= threshold` and `threshold >= 2` — **no 255-share ceiling**.
- `v1.NewFeldman(threshold, limit uint32, curve elliptic.Curve)` and
`v1.NewPedersen(threshold, limit uint32, generator *curves.EcPoint)` take standard-library
curves. Tests drive them with `btcec.S256()` and `elliptic.P256()`.
- `Split` takes `[]byte` and reads randomness from an internal source — there is no `io.Reader`
parameter, so you cannot inject a deterministic RNG.
- `Verify` returns `(bool, error)` rather than a bare `error`, and the verifier list is a plain
slice you pass in, not a struct.
- `ShamirShare` here has fields `Identifier uint32` and `Value *curves.Element`, and gains an
`Add` method that panics if the two identifiers differ.
- `ComputeL` is the `LagrangeCoeffs` equivalent, returning an ordered `[]*curves.Element`.
Curve helpers provided by the package: `Ed25519()`, `Bls12381G1()`, `Bls12381G2()`, and
`K256GeneratorFromHashedBytes(bytes []byte) (x, y *big.Int, err error)` — which derives a generator
with unknown discrete log from a byte string, exactly the Pedersen requirement above. There is no
k256 or p256 curve constructor in `v1`; use `btcec.S256()` and `elliptic.P256()` directly, as the
tests do.
:::danger[v1.Bls12381G2 does not return a G2 curve]
Despite its name and its `*Bls12381G1Curve` return type, `Bls12381G2()` initialises a
`Bls12381G2Curve` singleton and then returns `&bls12381g1` — the **G1** curve. Its initialiser also
sets `Name = "Bls12381G1"`, and its `Gy` is a verbatim copy of `B`. The `Bls12381G2Curve` methods
(`Add`, `Double`, `ScalarMult`, `IsOnCurve`, `Hash`) do operate on real G2 points, but there is no
exported constructor that hands you a value of that type. Do not use `Bls12381G2()`.
:::
:::warning[v1.Shamir.Combine ignores shares past the threshold]
`Combine` loops `for i := 0; i < int(s.threshold); i++` over the variadic slice, so passing five
shares to a 3-of-5 scheme interpolates the **first three** and discards the rest. Combined with
`v1`'s lack of Feldman checking inside `Combine`, a corrupt share in one of the leading positions
silently poisons the result even when enough good shares were supplied to notice. The same slicing
applies to `ComputeL`. Note the contrast: the modern `sharing.Shamir.Combine` interpolates over
*all* shares you pass.
:::
## Caveats
:::warning[Not constant time]
Reconstruction goes through `curves.Scalar` arithmetic and, in `v1`, `big.Int`-backed
`curves.Element` arithmetic. Neither is written for constant-time operation on secret data. Treat
these routines as unsafe against local timing adversaries.
:::
:::note[Reconstruction is the dangerous moment]
`Combine` materialises the secret in memory. Any design where `Combine` runs in production has a
window where the key exists in one place — the thing threshold cryptography is meant to eliminate.
Prefer schemes where shares are consumed *as shares* (`LagrangeCoeffs` plus threshold signing) over
schemes that reassemble.
:::
## Next
<CardGroup cols={2}>
<Card title="Distributed Key Generation" href="/threshold/dkg" icon="git-branch">
Same VSS machinery, but nobody ever holds the secret.
</Card>
<Card title="Curves & Scalars" href="/foundations/curves" icon="binary">
The `Curve`, `Point`, and `Scalar` abstractions every scheme here is generic over.
</Card>
</CardGroup>
+516
View File
@@ -0,0 +1,516 @@
---
title: Threshold ECDSA
description: DKLs18 2-of-2 threshold ECDSA — the protocol.Iterator API, serialization, key refresh, the low-level round methods, and the trusted-dealer shortcut.
sidebar:
order: 4
icon: pen-tool
---
`tecdsa/dklsv1` is two-party ECDSA: Alice and Bob each hold a multiplicative share of the private
key, and together they produce a signature that verifies under an ordinary ECDSA verifier. The
package doc names the paper it wraps — [DKLs18](https://eprint.iacr.org/2018/499.pdf) — and the
sub-packages cite specific protocols from it: DKG is "Protocol 2" page 7, signing is "Protocol 4"
page 9, the OT extension is "Protocol 9".
:::warning[2-of-2 only — there is no t-of-n mode]
Every type in this package is named `Alice` or `Bob`. Both parties are required for every
operation; there is no threshold parameter and no way to add a third party or tolerate one being
offline. If you need t-of-n ECDSA, this package cannot provide it. If you need t-of-n Schnorr, see
[Threshold Ed25519](/threshold/threshold-ed25519).
:::
The joint key is *multiplicative*: `pk = (sk_A · sk_B) · G`. That is why the protocol needs
oblivious transfer — multiplying two secret shares without revealing them is the hard part, and
[OT](/threshold/oblivious-transfer) is the machinery that does it.
## Use the iterator API
`tecdsa/dklsv1` exposes six constructors returning types that satisfy `protocol.Iterator`:
```go
type Iterator interface {
Next(input *Message) (*Message, error)
Result(version uint) (*Message, error)
}
```
Each `Next` consumes the counterparty's last message and produces the next one, until it returns
`protocol.ErrProtocolFinished`. Messages are `*protocol.Message` — a JSON-serialisable envelope of
payload bytes, metadata, a protocol name, and a version — so your transport never needs to know
what round it is on.
<TypeTable
type={{
"NewAliceDkg(curve, version)": {
type: "*AliceDkg",
description: "DKG as Alice. Not an error return — construction cannot fail.",
},
"NewBobDkg(curve, version)": {
type: "*BobDkg",
description: "DKG as Bob. Bob moves first in DKG.",
},
"NewAliceSign(curve, hash, message, dkgResultMessage, version)": {
type: "(*AliceSign, error)",
description: "Signing as Alice. Needs Alice's encoded DKG (or refresh) result. Alice moves first in signing.",
},
"NewBobSign(curve, hash, message, dkgResultMessage, version)": {
type: "(*BobSign, error)",
description: "Signing as Bob. Bob is the party that ends up with the signature.",
},
"NewAliceRefresh(curve, dkgResultMessage, version)": {
type: "(*AliceRefresh, error)",
description: "Key refresh as Alice. Alice moves first.",
},
"NewBobRefresh(curve, dkgResultMessage, version)": {
type: "(*BobRefresh, error)",
description: "Key refresh as Bob.",
},
}}
/>
### The crank loop
Both parties advance in lockstep, each `Next` handing its output to the other. This is the harness
the package's own tests use:
```go signing.go
import (
"github.com/sonr-io/crypto/core/protocol"
)
// runIteratedProtocol cranks two parties alternately until both report
// ErrProtocolFinished. firstParty is whichever side moves first.
func runIteratedProtocol(firstParty, secondParty protocol.Iterator) (error, error) {
var (
message *protocol.Message
firstErr error
secondErr error
)
for firstErr != protocol.ErrProtocolFinished || secondErr != protocol.ErrProtocolFinished {
message, firstErr = firstParty.Next(message)
if firstErr != nil && firstErr != protocol.ErrProtocolFinished {
return nil, firstErr
}
message, secondErr = secondParty.Next(message)
if secondErr != nil && secondErr != protocol.ErrProtocolFinished {
return secondErr, nil
}
}
return firstErr, secondErr
}
```
The first `Next` is called with a `nil` message — that is how the mover-first party starts.
:::warning[Who moves first differs per operation]
**DKG: Bob first. Signing: Alice first. Refresh: Alice first.** Getting this backwards does not
produce a clean error; it produces a decode failure on a message the party was not expecting. The
comment in the package's test file states the rule verbatim: *"For DKG bob starts first. For refresh
and sign, Alice starts first."*
:::
### DKG
```go
import (
"github.com/sonr-io/crypto/core/curves"
"github.com/sonr-io/crypto/core/protocol"
"github.com/sonr-io/crypto/tecdsa/dklsv1"
)
func runDkg() (*protocol.Message, *protocol.Message, error) {
curve := curves.K256()
alice := dklsv1.NewAliceDkg(curve, protocol.Version1)
bob := dklsv1.NewBobDkg(curve, protocol.Version1)
// Bob moves first in DKG.
aliceErr, bobErr := runIteratedProtocol(bob, alice)
if aliceErr != protocol.ErrProtocolFinished || bobErr != protocol.ErrProtocolFinished {
return nil, nil, fmt.Errorf("dkg did not complete: alice=%v bob=%v", aliceErr, bobErr)
}
// Both sides now agree on the public key:
// alice.Output().PublicKey.Equal(bob.Output().PublicKey) == true
aliceResult, err := alice.Result(protocol.Version1)
if err != nil {
return nil, nil, err
}
bobResult, err := bob.Result(protocol.Version1)
if err != nil {
return nil, nil, err
}
return aliceResult, bobResult, nil
}
```
`Result` returns the party's *own* state, encoded, ready to be persisted and later fed to
`NewAliceSign` / `NewBobSign`. Alice's result contains her `SecretKeyShare` and her seed-OT
receiver output; Bob's contains his share and his seed-OT sender output. Both contain the shared
`PublicKey`.
<TypeTable
type={{
PublicKey: {
type: "curves.Point",
required: true,
description: "The joint public key. Public; identical for Alice and Bob.",
},
SecretKeyShare: {
type: "curves.Scalar",
required: true,
description: "This party's multiplicative share. Secret. Lose it and the key is unrecoverable.",
},
SeedOtResult: {
type: "*simplest.ReceiverOutput | *simplest.SenderOutput",
required: true,
description: "Seed OT output — ReceiverOutput for Alice, SenderOutput for Bob. Secret, but replaceable by re-running OT (which is what refresh does).",
},
}}
/>
### Signing
```go
import "golang.org/x/crypto/sha3"
func runSign(curve *curves.Curve, aliceDkg, bobDkg *protocol.Message) (*curves.EcdsaSignature, error) {
msg := []byte("As soon as you trust yourself, you will know how to live.")
aliceSign, err := dklsv1.NewAliceSign(curve, sha3.New256(), msg, aliceDkg, protocol.Version1)
if err != nil {
return nil, err
}
bobSign, err := dklsv1.NewBobSign(curve, sha3.New256(), msg, bobDkg, protocol.Version1)
if err != nil {
return nil, err
}
// Alice moves first in signing.
aliceErr, bobErr := runIteratedProtocol(aliceSign, bobSign)
if aliceErr != protocol.ErrProtocolFinished || bobErr != protocol.ErrProtocolFinished {
return nil, fmt.Errorf("sign did not complete")
}
// Only Bob obtains the signature.
resultMessage, err := bobSign.Result(protocol.Version1)
if err != nil {
return nil, err
}
return dklsv1.DecodeSignature(resultMessage)
}
```
:::note[Only Bob gets the signature]
`AliceSign.Result` is documented as *always* returning an error: "Alice does not compute a
signature in the DKLS protocol; only Bob computes the signature." Whichever peer needs the output
must play Bob. Bob also verifies the signature himself before returning it.
:::
The result is a `*curves.EcdsaSignature` and verifies under `curves.VerifyEcdsa` — and under any
standard ECDSA verifier — against the joint public key. The `hash hash.Hash` argument is the digest
function; both parties must pass the same one, and both must pass the same `message`.
### Key refresh
Refresh re-randomises both shares while leaving the public key untouched. The `refresh` package doc
describes the mechanism: Alice draws `k_A`, Bob draws `k_B`, the two are combined through a Merlin
transcript into a single `k`, Bob sets `sk_B *= k` and Alice sets `sk_A *= k^{-1}`. Since
`sk_A · sk_B` is unchanged, so is `pk`. Then the seed OT is redone from scratch.
```go
func runRefresh(curve *curves.Curve, aliceDkg, bobDkg *protocol.Message) (*protocol.Message, *protocol.Message, error) {
aliceRefresh, err := dklsv1.NewAliceRefresh(curve, aliceDkg, protocol.Version1)
if err != nil {
return nil, nil, err
}
bobRefresh, err := dklsv1.NewBobRefresh(curve, bobDkg, protocol.Version1)
if err != nil {
return nil, nil, err
}
// Alice moves first in refresh.
aliceErr, bobErr := runIteratedProtocol(aliceRefresh, bobRefresh)
if aliceErr != protocol.ErrProtocolFinished || bobErr != protocol.ErrProtocolFinished {
return nil, nil, fmt.Errorf("refresh did not complete")
}
aliceOut, err := aliceRefresh.Result(protocol.Version1)
if err != nil {
return nil, nil, err
}
bobOut, err := bobRefresh.Result(protocol.Version1)
if err != nil {
return nil, nil, err
}
// These messages substitute for the DKG results in NewAliceSign / NewBobSign.
return aliceOut, bobOut, nil
}
```
The refresh outputs are the same `*dkg.AliceOutput` / `*dkg.BobOutput` shapes as DKG, so they drop
straight into the signing constructors.
:::tip[Why refresh matters]
Refresh defeats a *mobile adversary* — one that compromises Alice this month and Bob next month. If
shares never change, the two stolen halves reconstruct the key. After a refresh, an old share is
useless with a new one. Refresh also replaces the seed OT material, so it recovers from OT state
disclosure. It does **not** rotate the public key, so on-chain addresses and DID documents stay
valid.
:::
:::warning[Refresh is not exercised by this repository's tests]
In `tecdsa/dklsv1/protocol_test.go` the iterator-level refresh coverage — `TestRefreshProto`, the
`refreshV1` helper, `TestSignColdStart`, and `TestEncodeDecode` — is entirely commented out. Only
`TestDkgProto` and `TestDkgSignProto` actually run. The lower-level `tecdsa/dklsv1/refresh` package
does have live tests (`Test_RefreshLeadsToTheSamePublicKeyButDifferentPrivateMaterial`,
`Test_RefreshOTIsCorrect`, `Test_CanSignAfterRefresh`), so the protocol logic is covered; it is the
iterator wrappers, their serializers, and cold-start decoding that are not. Validate the round-trip
in your own environment before relying on it.
:::
## Serialization
Every helper takes or returns a `*protocol.Message`, which marshals to JSON.
| Direction | Alice | Bob |
| --- | --- | --- |
| DKG encode | `EncodeAliceDkgOutput(*dkg.AliceOutput, version)` | `EncodeBobDkgOutput(*dkg.BobOutput, version)` |
| DKG decode | `DecodeAliceDkgResult(*protocol.Message)` | `DecodeBobDkgResult(*protocol.Message)` |
| Refresh encode | `EncodeAliceRefreshOutput(*dkg.AliceOutput, version)` | `EncodeBobRefreshOutput(*dkg.BobOutput, version)` |
| Refresh decode | `DecodeAliceRefreshResult(*protocol.Message)` | `DecodeBobRefreshResult(*protocol.Message)` |
| Signature decode | — | `DecodeSignature(*protocol.Message)` |
Refresh outputs use the *same* `dkg.AliceOutput` / `dkg.BobOutput` structs as DKG; only the
protocol tag on the message differs (`protocol.Dkls18Refresh` versus `protocol.Dkls18Dkg`).
```go
import "encoding/json"
// Persist Alice's DKG state.
msg, err := dklsv1.EncodeAliceDkgOutput(aliceDkg.Output(), protocol.Version1)
if err != nil {
return err
}
blob, err := json.Marshal(msg)
// ... store blob ...
// Restore it later.
restored := &protocol.Message{}
if err := json.Unmarshal(blob, restored); err != nil {
return err
}
aliceOutput, err := dklsv1.DecodeAliceDkgResult(restored)
```
`protocol.EncodeMessage` / `protocol.DecodeMessage` are also available and produce a
base64-of-JSON string if you want a single opaque token instead of a JSON object.
:::danger[The encoded output is the private key share]
`EncodeAliceDkgOutput` and its siblings serialise `SecretKeyShare` and the seed-OT material in the
clear. The resulting bytes are as sensitive as a raw private key half. Encrypt them at rest — see
[AEAD](/symmetric/aead) — and never log or transmit them unprotected.
:::
### The `version` argument
`version uint` selects the serialization format. `core/protocol` defines exactly two constants, and
they are not the numbers you would guess:
```go
// versions will increment in 100 intervals, to leave room for adding other versions in between them if it is
// ever needed in the future.
// Version0 is version 0!
Version0 = 100
// Version1 is version 2!
Version1 = 200
```
Pass `protocol.Version1` (`200`). It is what every live test uses, and the only value the current
serializers are exercised with. The `// Version1 is version 2!` comment is in the source as
written — treat these as opaque tokens and never hardcode the integers.
## The low-level round API
Underneath the iterators sit explicit round methods. Use them only when writing your own transport
or auditing; they are the mechanism, not the interface.
:::note[The numbers interleave the two parties]
`Round1` … `Round10` are a *single* global sequence across Alice and Bob, not per-party sequences.
Alice owns the even-numbered DKG rounds, Bob the odd ones, and neither type has all ten methods.
The names also carry the mapping down into the seed OT — `Round6DkgRound2Ot` means "global round 6,
which is round 2 of the embedded OT".
:::
### `tecdsa/dklsv1/dkg` — 10 rounds
```go
import "github.com/sonr-io/crypto/tecdsa/dklsv1/dkg"
alice := dkg.NewAlice(curve)
bob := dkg.NewBob(curve)
seed, err := bob.Round1GenerateRandomSeed()
round2Output, err := alice.Round2CommitToProof(seed)
proof, err := bob.Round3SchnorrProve(round2Output)
proof, err = alice.Round4VerifyAndReveal(proof)
proof, err = bob.Round5DecommitmentAndStartOt(proof)
compressedReceiversMaskedChoice, err := alice.Round6DkgRound2Ot(proof)
challenge, err := bob.Round7DkgRound3Ot(compressedReceiversMaskedChoice)
challengeResponse, err := alice.Round8DkgRound4Ot(challenge)
challengeOpenings, err := bob.Round9DkgRound5Ot(challengeResponse)
err = alice.Round10DkgRound6Ot(challengeOpenings)
// Only valid after round 10.
aliceOutput := alice.Output()
bobOutput := bob.Output()
```
Rounds 15 establish the joint public key with Schnorr proofs of knowledge of each share. Rounds
610 are the seed OT — `simplest`'s six rounds, driven through thin wrappers. Round 1 exists to
build a session identifier from 32 random bytes contributed by each side; the method's own doc
comment notes this is not in the paper and is "secure if either party is honest".
:::warning[Output before round 10 is undefined behaviour]
Both `Alice.Output()` and `Bob.Output()` are documented as "Must be called after step 9. Calling it
before that step has undefined behaviour." They do not return an error and do not check state.
:::
### `tecdsa/dklsv1/sign` — 4 rounds
```go
import "github.com/sonr-io/crypto/tecdsa/dklsv1/sign"
alice := sign.NewAlice(curve, sha3.New256(), aliceDkgOutput)
bob := sign.NewBob(curve, sha3.New256(), bobDkgOutput)
message := []byte("A message.")
seed, err := alice.Round1GenerateRandomSeed()
round2Output, err := bob.Round2Initialize(seed)
round3Output, err := alice.Round3Sign(message, round2Output)
err = bob.Round4Final(message, round3Output)
signature := bob.Signature // *curves.EcdsaSignature
```
Four rounds, and Bob's `Signature` field is populated by `Round4Final` — which also verifies it.
Note the role reversal versus DKG: Alice contributes the seed here, Bob initialises.
The multiplication sub-protocol ("protocol 5 of the paper") is exposed separately as
`sign.MultiplySender` and `sign.MultiplyReceiver`, constructed with
`NewMultiplySender(seedOtResults *simplest.ReceiverOutput, curve, uniqueSessionId)` and
`NewMultiplyReceiver(seedOtResults *simplest.SenderOutput, curve, uniqueSessionId)`. Note the
crossed roles, which the constructor docs flag explicitly: the multiplication sender consumes the
seed-OT *receiver's* output, and the multiplication receiver consumes the seed-OT *sender's*.
:::note[A copy-pasted doc comment in the source]
`MultiplyReceiver`'s type comment reads "MultiplyReceiver is the party that plays the role of
Sender in the multiplication protocol" — identical to `MultiplySender`'s. It is a stale comment,
not a behavioural claim; the constructor comments are the accurate ones.
:::
### `tecdsa/dklsv1/refresh` — 7 rounds
```go
import "github.com/sonr-io/crypto/tecdsa/dklsv1/refresh"
alice := refresh.NewAlice(curve, aliceDkgOutput)
bob := refresh.NewBob(curve, bobDkgOutput)
round1Output := alice.Round1RefreshGenerateSeed() // no error return
round2Output, err := bob.Round2RefreshProduceSeedAndMultiplyAndStartOT(round1Output)
round3Output, err := alice.Round3RefreshMultiplyRound2Ot(round2Output)
round4Output, err := bob.Round4RefreshRound3Ot(round3Output)
round5Output, err := alice.Round5RefreshRound4Ot(round4Output)
round6Output, err := bob.Round6RefreshRound5Ot(round5Output)
err = alice.Round7DkgRound6Ot(round6Output)
newAliceOutput := alice.Output()
newBobOutput := bob.Output()
```
Rounds 12 do the share re-randomisation; 27 redo the seed OT. `Round1RefreshGenerateSeed` is the
only round method in the whole package with no error return.
## Trusted dealer
`tecdsa/dklsv1/dealer.GenerateAndDeal(curve)` produces `(*dkg.AliceOutput, *dkg.BobOutput, error)`
in one call, with no interaction. The outputs are shape-identical to DKG's and drop straight into
`sign.NewAlice` / `sign.NewBob`.
```go
import "github.com/sonr-io/crypto/tecdsa/dklsv1/dealer"
aliceOutput, bobOutput, err := dealer.GenerateAndDeal(curves.K256())
if err != nil {
return err
}
alice := sign.NewAlice(curves.K256(), sha3.New256(), aliceOutput)
bob := sign.NewBob(curves.K256(), sha3.New256(), bobOutput)
// ... four signing rounds as above ...
```
:::danger[The dealer defeats the entire point of threshold ECDSA]
`GenerateAndDeal` samples `sk_A` and `sk_B` in a single process, multiplies them to build the
public key, and fabricates a matching pair of seed-OT outputs locally. For the duration of that
call, one machine holds material equivalent to the full private key. Anything that reads that
process's memory — a core dump, a swap page, a compromised host, a hypervisor — gets the key.
The package's own doc comments say it twice, in capitals: *"Note that running actual DKG is ALWAYS
recommended over a trusted dealer"*, and *"this function breaks the security guarantees of DKG.
only use this function if you have a very good reason to."*
Legitimate uses: unit tests, and migrating a key you already hold in one place into 2-of-2 shares.
For that second case, follow the deal immediately with a key refresh (see above) so the shares in
long-term storage were never both resident in the dealing process's memory.
:::
## Caveats
:::warning[Curve support is narrow]
Every live test runs on `curves.K256()` and `curves.P256()` only. Other curves in
[`core/curves`](/foundations/curves) are not exercised by this package.
:::
:::warning[Both parties must agree on message and hash out of band]
`NewAliceSign` and `NewBobSign` each take their own `message` and `hash`. Nothing in the protocol
messages forces them to match. If they disagree, signing either fails at Bob's verification step or
— worse — you get a signature over a message one party never approved. Bind the message to your
session at the application layer.
:::
:::note[State is single-use and mutable]
Each `AliceDkg`/`BobSign`/etc. value tracks a step index and mutates on every `Next`. One value
serves one protocol execution in one goroutine. Reusing a completed iterator, or sharing one across
goroutines, is unsupported.
:::
:::note[No audit claim]
This is a port of Coinbase's Kryptology `dklsv1`. Nothing in this repository establishes that the
port, its serializers, or the surrounding wrappers have been reviewed or verified. See
[Security Notes](/reference/security).
:::
## Next
<CardGroup cols={2}>
<Card title="MPC Enclave" href="/identity/mpc-enclave" icon="lock">
The wrapper over this package that application code should actually call — key import/export,
signing, and persistence without touching rounds.
</Card>
<Card title="Oblivious Transfer" href="/threshold/oblivious-transfer" icon="shuffle">
The seed OT and cOT extension that rounds 610 are driving.
</Card>
<Card title="ECDSA" href="/signatures/ecdsa" icon="pen-tool">
Single-party ECDSA, and the verifier this package's output satisfies.
</Card>
<Card title="Distributed Key Generation" href="/threshold/dkg" icon="git-branch">
The other DKG protocols in the repository — none of which feed this one.
</Card>
</CardGroup>
+390
View File
@@ -0,0 +1,390 @@
---
title: Threshold Ed25519
description: t-of-n Ed25519 signing whose output verifies under a stock Ed25519 verifier, plus FROST threshold Schnorr on top of a dkg/frost result.
sidebar:
order: 5
icon: key-round
---
Two packages, two different bargains.
`ted25519/ted25519` produces **byte-for-byte standard Ed25519 signatures**. A verifier that has
never heard of threshold cryptography — `crypto/ed25519`, a chain node, a JWT library — accepts
them. That compatibility is the whole reason to use it, and it is what forces the package's
unusual, and dangerous, nonce protocol.
`ted25519/frost` produces FROST threshold Schnorr signatures. Cleaner protocol, three tidy rounds,
works over any curve — but the output is a `(Z, C)` pair, not an Ed25519 signature, and needs
`frost.Verify`.
<CardGroup cols={2}>
<Card title="ted25519/ted25519" icon="key-round">
Pick this when the signature must be accepted by existing Ed25519 verifiers. Ed25519 only. Requires
strict per-message nonce discipline.
</Card>
<Card title="ted25519/frost" icon="fingerprint">
Pick this when you control the verifier. Curve-agnostic, three rounds, consumes a
[`dkg/frost`](/threshold/dkg) result directly.
</Card>
</CardGroup>
## Standard-compatible: `ted25519/ted25519`
The package is a fork of Go's `crypto/ed25519` (itself a port of SUPERCOP `ref10`) with the
threshold pieces added. It keeps the standard sizes:
| Constant | Value |
| --- | --- |
| `PublicKeySize` | 32 |
| `PrivateKeySize` | 64 (seed ‖ public key) |
| `SignatureSize` | 64 (R ‖ s) |
| `SeedSize` | 32 |
Single-party helpers are drop-in: `GenerateKey(rand io.Reader)`, `NewKeyFromSeed(seed []byte)`,
`Sign(priv, msg)`, `Verify(pub, msg, sig)`, plus `PrivateKey.Public()`, `.Seed()`, and a
`crypto.Signer` implementation.
### Why the seed must be expanded before splitting
Standard Ed25519 signing hashes the seed to derive the actual scalar. That hash destroys linearity:
shares of the *seed* are not shares of the *signing scalar*, so partial signatures would not
aggregate. `ExpandSeed(seed []byte) []byte` applies that transform up front, and the split happens
on the expanded value. `ThresholdSign` therefore skips the expansion step that ordinary Ed25519
signing performs — which is exactly why it cannot be replaced with `Sign`.
### t-of-n signing
Every party contributes a nonce, all nonce shares are summed, and each party produces a partial
signature under the summed nonce. `Aggregate` interpolates the `s` components.
```go
import (
"github.com/sonr-io/crypto/ted25519/ted25519"
)
func thresholdSign() error {
config := ted25519.ShareConfiguration{T: 2, N: 3}
// 1. Shared key generation (trusted dealer — see the caveat below).
pub, secretShares, keyCommitments, err := ted25519.GenerateSharedKey(&config)
if err != nil {
return err
}
// Each holder can check its own share against the VSS commitments.
for _, s := range secretShares {
ok, err := s.VerifyVSS(keyCommitments, &config)
if err != nil || !ok {
return fmt.Errorf("bad share")
}
}
message := ted25519.Message("test message")
// 2. Every party generates a nonce FOR THIS MESSAGE and shares it out.
noncePub1, nonceShares1, _, err := ted25519.GenerateSharedNonce(&config, secretShares[0], pub, message)
if err != nil {
return err
}
noncePub2, nonceShares2, _, err := ted25519.GenerateSharedNonce(&config, secretShares[1], pub, message)
if err != nil {
return err
}
noncePub3, nonceShares3, _, err := ted25519.GenerateSharedNonce(&config, secretShares[2], pub, message)
if err != nil {
return err
}
// 3. Sum the nonce shares index-wise, and the nonce pubkeys in the group.
nonceShares := []*ted25519.NonceShare{
nonceShares1[0].Add(nonceShares2[0]).Add(nonceShares3[0]),
nonceShares1[1].Add(nonceShares2[1]).Add(nonceShares3[1]),
nonceShares1[2].Add(nonceShares2[2]).Add(nonceShares3[2]),
}
noncePub := ted25519.GeAdd(ted25519.GeAdd(noncePub1, noncePub2), noncePub3)
// 4. Each party produces a partial signature.
sig1 := ted25519.TSign(message, secretShares[0], pub, nonceShares[0], noncePub)
sig2 := ted25519.TSign(message, secretShares[1], pub, nonceShares[1], noncePub)
// 5. Any T partials aggregate into a complete signature.
sig, err := ted25519.Aggregate([]*ted25519.PartialSignature{sig1, sig2}, &config)
if err != nil {
return err
}
// 6. And it verifies under the ordinary Ed25519 verifier.
ok, err := ted25519.Verify(pub, message, sig)
if err != nil || !ok {
return fmt.Errorf("signature failed verification")
}
return nil
}
```
:::success[This is a plain Ed25519 signature]
`sig` is 64 bytes of `R ‖ s` over the 32-byte public key `pub`. `crypto/ed25519.Verify` accepts it
too. Nothing downstream needs to know a threshold protocol produced it — that is this package's
entire value proposition.
:::
Note that **every** participant must run `GenerateSharedNonce`, not just the `T` who will sign.
The nonce is the sum of all `N` contributions; a missing contribution changes `noncePub` and every
partial signature becomes invalid.
### Types
<TypeTable
type={{
"ShareConfiguration.T": { type: "int", required: true, description: "Threshold — partial signatures needed to aggregate." },
"ShareConfiguration.N": { type: "int", required: true, description: "Total shares issued." },
"KeyShare": { type: "struct{ *v1.ShamirShare }", required: true, description: "A share of the expanded signing scalar. Construct with NewKeyShare(identifier byte, secret []byte); serialise with Bytes(), restore with KeyShareFromBytes." },
"NonceShare": { type: "struct{ *KeyShare }", required: true, description: "A share of a per-message nonce. Add(other) sums two shares with the same identifier. NewNonceShare / NonceShareFromBytes mirror KeyShare." },
"Commitments": { type: "[]curves.Point", required: true, description: "VSS commitments to the polynomial coefficients. CommitmentsToBytes / CommitmentsFromBytes for transport." },
"PartialSignature.ShareIdentifier": { type: "byte", required: true, description: "Which signer produced this partial — the x-coordinate." },
"PartialSignature.Sig": { type: "[]byte", required: true, description: "64 bytes, R ‖ s. R() and S() slice it; Bytes() returns identifier ‖ Sig." },
}}
/>
Supporting functions: `PublicKeyFromBytes(bytes []byte)` (length-checks 32 bytes and returns them),
`GeAdd(a, b PublicKey) PublicKey` (group addition of two public keys, used to sum nonce pubkeys),
`Reconstruct(keyShares []*KeyShare, config *ShareConfiguration) ([]byte, error)`, and
`ThresholdSign(expandedSecretKeyShare []byte, publicKey PublicKey, message []byte, rShare []byte, R PublicKey) []byte` —
the raw form behind `TSign`, taking **little-endian** scalar bytes.
:::danger[Never reuse a nonce share across messages]
Ed25519, like every Schnorr-family scheme, leaks the signing key if two signatures over
*different* messages share the same nonce `R`. Given `s₁ = r + c₁·sk` and `s₂ = r + c₂·sk` with the
same `r`, anyone computes `sk = (s₁ s₂)/(c₁ c₂)`. In a threshold setting this is worse, not
better: the attacker only needs the two aggregate signatures, which are public.
`GenerateSharedNonce` takes the message `m` as an argument for exactly this reason — the nonce is
derived per message. The source is explicit that determinism was deliberately avoided:
> We _must_ introduce randomness to the HKDF to make the output non-deterministic because
> deterministic nonces open up threshold schemes to potential nonce-reuse attacks. We continue to
> use the HKDF that takes in context about what is going to be signed as it adds some protection
> against bad local randomness.
Concretely, the HKDF is keyed on `keyShare ‖ 32 fresh random bytes` with
`info = "ted25519nonce" ‖ publicKey ‖ message`. So the rules are:
- Call `GenerateSharedNonce` **once per message per party**. Never cache the result.
- Never persist a `NonceShare`. If a signing attempt aborts, discard every nonce share and start a
fresh nonce round — do not retry with the old one.
- Never sign two different messages with the same `noncePub`.
- Do not "optimise" by making the nonce deterministic in the message. Two parties disagreeing about
the message set while sharing a nonce is the same catastrophe.
:::
:::danger[Not constant time, on secret values, by the source's own admission]
Two `WARN` comments sit directly on the secret-handling paths:
- In `generateSharableNonce`: *"WARN: This operation is not constant time and we are dealing with a
secret value"* — the rejection-sampling loop that reduces the nonce into the field.
- In `NonceShare.Add`: *"WARN: This is not constant time and deals with secrets"* — the
`big.Int`-backed `curves.Element` addition.
`TSign`, `Aggregate`, and `Reconstruct` go through the same arithmetic. Do not run this where an
attacker can measure your timing.
:::
:::warning[GenerateSharedKey is dealer-based, and there is no DKG for this package]
`GenerateSharedKey` samples a key, expands it, and splits it — all in one process. There is no
distributed alternative that yields standard-Ed25519-compatible shares in this repository.
`dkg/frost` produces shares of a `curves.Scalar` in the modern representation, which is not
interchangeable with the little-endian, field-reduced, expanded-seed representation this package
requires. If dealerless generation is a requirement, use `ted25519/frost` and accept the
non-standard signature format.
:::
:::warning[Built on the legacy sharing layer, with its truncation defect]
`KeyShare` embeds `*v1.ShamirShare` and `Reconstruct` calls `v1.Shamir.Combine`, which
[interpolates only the first `T` shares you pass](/threshold/secret-sharing) and silently ignores
the rest. `VerifyVSS` also requires `len(commitments) >= config.T` and returns
`(false, error)` rather than a typed failure — check both return values.
:::
:::info[Endianness will bite you]
The Ed25519 reference code is little-endian; `curves.Field`/`curves.Element` are big-endian. The
package reverses bytes at nearly every boundary (`ThresholdSign` documents that
`expandedSecretKeyShare` and `rShare` "must be little-endian"). If you hand-roll anything with
these types rather than using `TSign`, expect to get this wrong at least once. Prefer the high-level
helpers.
:::
:::note[The 2-of-2 example in the tests is not a protocol]
`twobytwo_test.go` demonstrates a simpler additive scheme with a file comment saying so plainly:
*"We don't intend to use it and it is not modeled off of any specific known protocol."* Do not copy
it into production.
:::
## FROST Schnorr: `ted25519/frost`
Three rounds implementing the signing half of
[eprint 2020/852](https://eprint.iacr.org/2020/852.pdf), consuming a
[`dkg/frost`](/threshold/dkg) participant directly. Despite the directory name it is not
Ed25519-specific — it is generic over `curves.Curve`, and Ed25519 is one available challenge
derivation.
```go
import (
"github.com/sonr-io/crypto/core/curves"
dkg "github.com/sonr-io/crypto/dkg/frost"
"github.com/sonr-io/crypto/sharing"
"github.com/sonr-io/crypto/ted25519/frost"
)
// participants is the output of a completed dkg/frost run, keyed by id.
func frostSign(curve *curves.Curve, participants map[uint32]*dkg.DkgParticipant) error {
threshold, limit := uint32(2), uint32(3)
// Choose the signing set and precompute its Lagrange coefficients once.
signerIds := []uint32{1, 3}
scheme, err := sharing.NewShamir(threshold, limit, curve)
if err != nil {
return err
}
lCoeffs, err := scheme.LagrangeCoeffs(signerIds)
if err != nil {
return err
}
signers := make(map[uint32]*frost.Signer, len(signerIds))
for _, id := range signerIds {
signers[id], err = frost.NewSigner(
participants[id], id, threshold, lCoeffs, signerIds,
&frost.Ed25519ChallengeDeriver{},
)
if err != nil {
return err
}
}
// --- Round 1: commit to nonces -------------------------------------
round2Input := make(map[uint32]*frost.Round1Bcast, len(signers))
for id := range signers {
out, err := signers[id].SignRound1()
if err != nil {
return err
}
round2Input[id] = out
}
// --- Round 2: partial signatures -----------------------------------
msg := []byte("message")
round3Input := make(map[uint32]*frost.Round2Bcast, len(signers))
for id := range signers {
out, err := signers[id].SignRound2(msg, round2Input)
if err != nil {
return err
}
round3Input[id] = out
}
// --- Round 3: aggregate --------------------------------------------
for id := range signers {
out, err := signers[id].SignRound3(round3Input)
if err != nil {
return err
}
// Every signer derives the identical signature (out.Z, out.C).
_ = out
}
return nil
}
```
<Steps>
<Step title="SignRound1() (*Round1Bcast, error)">
The signer samples two secret nonces `d_i`, `e_i` and **broadcasts their commitments**
`Round1Bcast{Di, Ei curves.Point}` — the two group elements only. Both secret nonces stay local.
Two commitments rather than one is what lets FROST bind the final nonce to the whole signing set
without an extra round.
</Step>
<Step title="SignRound2(msg []byte, round2Input map[uint32]*Round1Bcast) (*Round2Bcast, error)">
Consumes every signer's round-1 broadcast (keyed by signer id, **including your own**), derives the
binding factors and the joint nonce `R`, derives the challenge `c` via the injected
`ChallengeDerive`, and **broadcasts** `Round2Bcast{Zi curves.Scalar, Vki curves.Point}` — this
signer's partial signature `Zi` and its verification-key share `Vki`, which lets peers attribute
and check the partial.
</Step>
<Step title="SignRound3(round3Input map[uint32]*Round2Bcast) (*Round3Bcast, error)">
Consumes every partial, validates each against its `Vki`, and sums them. Returns
`Round3Bcast{R curves.Point, Z, C curves.Scalar}`. Every honest signer produces the identical
`Z` and `C`, so there is no separate coordinator role — whoever needs the signature simply keeps
its own round-3 output.
</Step>
</Steps>
### Verifying
```go
sig := &frost.Signature{Z: out.Z, C: out.C}
ok, err := frost.Verify(curve, &frost.Ed25519ChallengeDeriver{}, vk, msg, sig)
```
`vk` is the joint verification key from DKG (`participant.VerificationKey`). The same
`ChallengeDerive` used for signing must be used for verification.
```go
type ChallengeDerive interface {
DeriveChallenge(msg []byte, pubKey curves.Point, r curves.Point) (curves.Scalar, error)
}
```
`Ed25519ChallengeDeriver` is the implementation shipped in this package. A Mina-flavoured deriver
lives with the [chain signature schemes](/signatures/chain-schemes). Supplying your own is how you
adapt FROST to another verifier's challenge convention — and is also how you break interoperability
if you get it wrong.
`Round1Bcast` and `Round2Bcast` each provide `Encode() ([]byte, error)` and
`Decode(input []byte) error` for transport. `Round3Bcast` does not — it is a local output, not a
message.
### Caveats
:::warning[Lagrange coefficients pin the signing set]
`NewSigner` takes `lcoeffs map[uint32]curves.Scalar` and `cosigners []uint32`. The coefficients are
precomputed for that exact set — the constructor doc cites this as the optimisation from paragraph 3
of section 3 of the FROST draft. **Every signer must be constructed with the same `cosigners` and
the same `lcoeffs`.** Change the set and you must build fresh `Signer` values with fresh
coefficients; reusing stale coefficients yields a signature that fails verification, with no
diagnostic pointing at the cause.
:::
:::danger[One Signer, one signature]
The nonces sampled in `SignRound1` are single-use, and the round counter enforces it: calling
`SignRound1` twice on the same `Signer` returns an error. Construct a new `Signer` per signature.
Never persist a `Signer` between signatures, and never reuse round-1 broadcasts for a second
message — that is nonce reuse and it discloses the signing share.
:::
:::warning[Inherits dkg/frost's context-string defect]
`frost.NewSigner` takes a `*dkg.DkgParticipant` whose `ctx` was collapsed to a single byte at
construction time. That flaw belongs to
[DKG](/threshold/dkg) and is not repaired here — the signing rounds provide no additional
cross-session replay protection.
:::
:::note[No aggregate-only role, no identifiable abort]
Every signer runs all three rounds; there is no lightweight aggregator. And a failed partial-signature
check aborts without producing transferable evidence of which signer cheated.
:::
## Next
<CardGroup cols={2}>
<Card title="Distributed Key Generation" href="/threshold/dkg" icon="git-branch">
`ted25519/frost` needs a completed `dkg/frost` participant as its input.
</Card>
<Card title="Chain Signature Schemes" href="/signatures/chain-schemes" icon="link">
Where the Mina challenge deriver lives.
</Card>
<Card title="Secret Sharing" href="/threshold/secret-sharing" icon="split">
`LagrangeCoeffs`, and the legacy `v1` layer `ted25519/ted25519` is built on.
</Card>
<Card title="Threshold ECDSA" href="/threshold/threshold-ecdsa" icon="pen-tool">
The 2-of-2 ECDSA side of the house.
</Card>
</CardGroup>