Skip to content
Sonr Crypto
Esc
navigateopen⌘Jpreview
On this page

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

PropType
ShareConfiguration.Tint

Threshold — partial signatures needed to aggregate.

Typeint
ShareConfiguration.Nint

Total shares issued.

Typeint
KeySharestruct{ *v1.ShamirShare }

A share of the expanded signing scalar. Construct with NewKeyShare(identifier byte, secret []byte); serialise with Bytes(), restore with KeyShareFromBytes.

Typestruct{ *v1.ShamirShare }
NonceSharestruct{ *KeyShare }

A share of a per-message nonce. Add(other) sums two shares with the same identifier. NewNonceShare / NonceShareFromBytes mirror KeyShare.

Typestruct{ *KeyShare }
Commitments[]curves.Point

VSS commitments to the polynomial coefficients. CommitmentsToBytes / CommitmentsFromBytes for transport.

Type[]curves.Point
PartialSignature.ShareIdentifierbyte

Which signer produced this partial — the x-coordinate.

Typebyte
PartialSignature.Sig[]byte

64 bytes, R ‖ s. R() and S() slice it; Bytes() returns identifier ‖ Sig.

Type[]byte

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.

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.

Caveats

Next

Last updated on September 2, 2026

Was this page helpful?