Files
zeekay 44f20309b9 FROST(Ed25519, SHA-512) — signatures Solana and TON will accept
The generic FROST challenge is BLAKE3 over big-endian scalars. RFC 8032 needs
SHA-512(R || A || M) reduced mod L with S encoded little-endian, so a threshold
signature over curve.Ed25519 was rejected by every real verifier — which is why
mpcd returned an empty eddsa_pub_key and SOL and TON had no signable key.

This follows the shape already used twice here: taproot and sr25519 each carry a
scheme through round1 -> round3, and Ed25519 is a third. StartSignCommon and
StartSignSR25519Common keep their exact signatures and both forward to an
unexported startSign, so those two paths are behaviourally untouched.

Only the SCALAR needed reversing. Ed25519Point.MarshalBinary was already the
canonical compressed Edwards encoding; Ed25519Scalar.MarshalBinary is big-endian
and is flipped at the signature boundary alone.

Verification delegates to crypto/ed25519 rather than reimplementing the equation.
An in-house verifier only ever proves we agree with ourselves — exactly how a
scheme that is not really Ed25519 passes its own tests. Round 3 gates on it, so a
signature can only leave the protocol if the verifier Solana runs accepts it.

Validated against all four RFC 8032 §7.1 vectors, each first re-derived with
ed25519.Sign so a transcription typo fails loudly instead of silently weakening
the suite; the production ed25519Challenge has ONE implementation and both round
2 and the vector test call it.

SECURITY, and it is the reason to take this seriously: keygen now rejects
non-prime-order points at Ed25519Point.UnmarshalBinary. The zksch proof on a
polynomial's constant term does NOT exclude torsion — z*G = C + e*phi can be
satisfied with torsion in phi by grinding C, since only e mod 8 matters, ~8
attempts. The payoff is not forgery, it is a BRICK: the group key carries
torsion, the address base58-encodes normally and accepts deposits, and no quorum
can ever sign, because z*G is always torsion-free. Unmarshal is the single wire
entry point (cbor decodes coefficients through BinaryUnmarshaler), and the test
splices an order-2 point into a real serialized commitment to prove it lands
there. Ed25519-specific: secp256k1 has cofactor 1 and Ristretto255 has no
torsion by construction.

Go's verifier is cofactorless and does not torsion-check A; ed25519-dalek's
verify_strict, which Solana runs, does. FROST satisfies both — R, A and [S]B are
all torsion-free.

115 frost tests green.
2026-08-06 12:37:02 -07:00

206 lines
5.8 KiB
Go

package sign
import (
"fmt"
"sync"
"github.com/luxfi/threshold/internal/round"
"github.com/luxfi/threshold/pkg/math/curve"
"github.com/luxfi/threshold/pkg/party"
"github.com/luxfi/threshold/pkg/taproot"
)
// This corresponds with step 7 of Figure 3 in the Frost paper:
//
// https://eprint.iacr.org/2020/852.pdf
//
// The big difference, once again, stems from their being no signing authority.
// Instead, each participant calculates the signature on their own.
type round3 struct {
*round2
// R is the group commitment, and the first part of the consortium signature
R curve.Point
// RShares is the fraction each participant contributes to the group commitment
//
// This corresponds to R_i in the Frost paper
RShares map[party.ID]curve.Point
// c is the challenge, computed as H(R, Y, m).
c curve.Scalar
// z contains the response from each participant
//
// z[i] corresponds to zᵢ in the Frost paper
z map[party.ID]curve.Scalar
zMu *sync.Mutex
// Lambda contains all Lagrange coefficients of the parties participating in this session.
// Lambda[l] = λₗ
Lambda map[party.ID]curve.Scalar
}
type broadcast3 struct {
round.NormalBroadcastContent
// ZI is the response scalar computed by the sender of this message.
ZI curve.Scalar
}
// StoreBroadcastMessage implements round.BroadcastRound.
func (r *round3) StoreBroadcastMessage(msg round.Message) error {
from := msg.From
body, ok := msg.Content.(*broadcast3)
if !ok || body == nil {
return round.ErrInvalidContent
}
// check nil
if body.ZI == nil {
return round.ErrNilFields
}
// These steps come from Figure 3 of the Frost paper.
// 7.b "Verify the validity of each response by checking
//
// zᵢ • G = Rᵢ + c * λᵢ * Yᵢ
//
// for each share zᵢ, i in S. If the equality does not hold, identify and report the
// misbehaving participant, and then abort. Otherwise, continue."
//
// Note that step 7.a is an artifact of having a signing authority. In our case,
// we've already computed everything that step computes.
// Verify: zᵢ • G = Rᵢ + c * λᵢ * Yᵢ
expected := r.c.Act(r.Lambda[from].Act(r.YShares[from])).Add(r.RShares[from])
actual := body.ZI.ActOnBase()
if !actual.Equal(expected) {
return fmt.Errorf("failed to verify response from %v", from)
}
r.zMu.Lock()
r.z[from] = body.ZI
r.zMu.Unlock()
return nil
}
// VerifyMessage implements round.Round.
func (*round3) VerifyMessage(round.Message) error { return nil }
// StoreMessage implements round.Round.
func (*round3) StoreMessage(round.Message) error { return nil }
// Finalize implements round.Round.
func (r *round3) Finalize(chan<- *round.Message) (round.Session, error) {
// These steps come from Figure 3 of the Frost paper.
// 7.c "Compute the group's response z = ∑ᵢ zᵢ"
r.zMu.Lock()
zMap := make(map[party.ID]curve.Scalar, len(r.z))
for k, v := range r.z {
zMap[k] = v
}
r.zMu.Unlock()
z := r.Group().NewScalar()
for _, z_l := range zMap {
z.Add(z_l)
}
// The format of our signature depends on the signature scheme
if r.sr25519 {
// sr25519 signature: R (32 bytes) || s (32 bytes), with high bit of s set
// R is the ristretto255 canonical encoding of the group commitment
RBytes, err := r.R.MarshalBinary()
if err != nil {
return r, fmt.Errorf("failed to marshal R: %w", err)
}
// z is the response scalar; for ristretto255 MarshalBinary returns big-endian,
// but go-schnorrkel expects little-endian encoding. Convert.
zBE, err := z.MarshalBinary()
if err != nil {
return r, fmt.Errorf("failed to marshal z: %w", err)
}
zLE := make([]byte, 32)
for i := 0; i < 32; i++ {
zLE[i] = zBE[31-i]
}
var sigBytes [64]byte
copy(sigBytes[:32], RBytes)
copy(sigBytes[32:], zLE)
// Set the schnorrkel marker bit (high bit of byte 63)
sigBytes[63] |= 128
sig := SR25519Signature{
Data: sigBytes,
SigningContext: r.signingContext,
}
// Verify using the Merlin transcript path
if !sig.Verify(r.Y, r.M) {
return r.AbortRound(fmt.Errorf("sr25519 signature verification failed")), nil
}
return r.ResultRound(sig), nil
} else if r.ed25519 {
// R is already the compressed Edwards encoding RFC 8032 wants, and z is
// reduced mod L by edwards25519's scalar arithmetic, so it is canonical
// and its top three bits are clear. Ed25519Signature owns the one
// remaining detail: S goes on the wire little-endian.
sig := Ed25519Signature{R: r.R, S: z}
// Checked against crypto/ed25519 itself, so a signature can only leave
// this protocol if the verifier Solana and TON run accepts it.
if !sig.Verify(r.Y, r.M) {
return r.AbortRound(fmt.Errorf("ed25519 signature failed RFC 8032 verification")), nil
}
return r.ResultRound(sig), nil
} else if r.taproot {
sig := taproot.Signature(make([]byte, 0, taproot.SignatureLen))
sig = append(sig, r.R.(*curve.Secp256k1Point).XBytes()...)
zBytes, err := z.MarshalBinary()
if err != nil {
return r, err
}
sig = append(sig, zBytes...)
taprootPub := taproot.PublicKey(r.Y.(*curve.Secp256k1Point).XBytes())
if !taprootPub.Verify(sig, r.M) {
return r.AbortRound(fmt.Errorf("generated signature failed to verify")), nil
}
return r.ResultRound(sig), nil
} else {
sig := Signature{
R: r.R,
z: z,
}
// Check if signature verifies
if !sig.Verify(r.Y, r.M) {
return r.AbortRound(fmt.Errorf("signature verification failed")), nil
}
return r.ResultRound(sig), nil
}
}
// MessageContent implements round.Round.
func (*round3) MessageContent() round.Content { return nil }
// RoundNumber implements round.Content.
func (broadcast3) RoundNumber() round.Number { return 3 }
// BroadcastContent implements round.BroadcastRound.
func (r *round3) BroadcastContent() round.BroadcastContent {
return &broadcast3{
ZI: r.Group().NewScalar(),
}
}
// Number implements round.Round.
func (*round3) Number() round.Number { return 3 }