mirror of
https://github.com/sonr-io/crypto.git
synced 2026-09-17 01:26:23 +00:00
feat: init docs
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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",
|
||||
],
|
||||
});
|
||||
@@ -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 4–6 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 Diffie–Hellman-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 1–6, 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 1–4 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 6–10 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>
|
||||
@@ -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>
|
||||
@@ -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 1–5 establish the joint public key with Schnorr proofs of knowledge of each share. Rounds
|
||||
6–10 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 1–2 do the share re-randomisation; 2–7 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 6–10 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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user