--- 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`. Pick this when the signature must be accepted by existing Ed25519 verifiers. Ed25519 only. Requires strict per-message nonce discipline. Pick this when you control the verifier. Curve-agnostic, three rounds, consumes a [`dkg/frost`](/threshold/dkg) result directly. ## 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 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 } ``` 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. 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. 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. ### 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 `ted25519/frost` needs a completed `dkg/frost` participant as its input. Where the Mina challenge deriver lives. `LagrangeCoeffs`, and the legacy `v1` layer `ted25519/ted25519` is built on. The 2-of-2 ECDSA side of the house.