Threshold Ed25519
t-of-n Ed25519 signing whose output verifies under a stock Ed25519 verifier, plus FROST threshold Schnorr on top of a dkg/frost result.
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.
ted25519/ted25519
Pick this when the signature must be accepted by existing Ed25519 verifiers. Ed25519 only. Requires strict per-message nonce discipline.
ted25519/frost
Pick this when you control the verifier. Curve-agnostic, three rounds, consumes a
dkg/frost 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.
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
}
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
ShareConfiguration.Tint
Threshold — partial signatures needed to aggregate.
intShareConfiguration.Nint
Total shares issued.
intKeySharestruct{ *v1.ShamirShare }
A share of the expanded signing scalar. Construct with NewKeyShare(identifier byte, secret []byte); serialise with Bytes(), restore with KeyShareFromBytes.
struct{ *v1.ShamirShare }NonceSharestruct{ *KeyShare }
A share of a per-message nonce. Add(other) sums two shares with the same identifier. NewNonceShare / NonceShareFromBytes mirror KeyShare.
struct{ *KeyShare }Commitments[]curves.Point
VSS commitments to the polynomial coefficients. CommitmentsToBytes / CommitmentsFromBytes for transport.
[]curves.PointPartialSignature.ShareIdentifierbyte
Which signer produced this partial — the x-coordinate.
bytePartialSignature.Sig[]byte
64 bytes, R ‖ s. R() and S() slice it; Bytes() returns identifier ‖ Sig.
[]byteSupporting 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.
FROST Schnorr: ted25519/frost
Three rounds implementing the signing half of
eprint 2020/852, consuming a
dkg/frost participant directly. Despite the directory name it is not
Ed25519-specific — it is generic over curves.Curve, and Ed25519 is one available challenge
derivation.
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
}
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.
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.
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.
Verifying
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.
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. 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.