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

368 lines
12 KiB
Go

package sign
import (
"fmt"
"sort"
"sync"
"github.com/cronokirby/saferith"
"github.com/gtank/merlin"
"github.com/luxfi/threshold/internal/round"
"github.com/luxfi/threshold/pkg/hash"
"github.com/luxfi/threshold/pkg/math/curve"
"github.com/luxfi/threshold/pkg/math/polynomial"
"github.com/luxfi/threshold/pkg/math/sample"
"github.com/luxfi/threshold/pkg/party"
"github.com/luxfi/threshold/pkg/taproot"
)
// This round roughly corresponds with steps 3-6 of Figure 3 in the Frost paper:
//
// https://eprint.iacr.org/2020/852.pdf
//
// The main differences stem from the lack of a signature authority.
//
// This means that instead of receiving a bundle of all the commitments, instead
// each participant sends us their commitment directly.
//
// Then, instead of sending our scalar response to the authority, we broadcast it
// to everyone instead.
type round2 struct {
*round1
// d_i = dᵢ is the first nonce we've created.
d_i curve.Scalar
// e_i = eᵢ is the second nonce we've created.
e_i curve.Scalar
// D[i] = Dᵢ will contain all of the commitments created by each party, ourself included.
D map[party.ID]curve.Point
// E[i] = Eᵢ will contain all of the commitments created by each party, ourself included.
E map[party.ID]curve.Point
deMu *sync.Mutex
}
type broadcast2 struct {
round.ReliableBroadcastContent
// D_i is the first commitment produced by the sender of this message.
D_i curve.Point
// E_i is the second commitment produced by the sender of this message.
E_i curve.Point
}
// StoreBroadcastMessage implements round.BroadcastRound.
func (r *round2) StoreBroadcastMessage(msg round.Message) error {
body, ok := msg.Content.(*broadcast2)
if !ok || body == nil {
return round.ErrInvalidContent
}
// This section roughly follows Figure 3.
// 3. "After receiving (m, B), each Pᵢ first validates the message m,
// and then checks Dₗ, Eₗ in Gˣ for each commitment in B, aborting if
// either check fails."
//
// We make a few departures.
//
// We implicitly assume that the message validation has happened before
// calling this protocol.
//
// We also receive each Dₗ, Eₗ from the participant l directly, instead of
// an entire bundle from a signing authority.
if body.D_i.IsIdentity() || body.E_i.IsIdentity() {
return fmt.Errorf("nonce commitment is the identity point")
}
// Deep copy points to avoid aliasing issues - use marshal/unmarshal for clean copy
dBytes, err := body.D_i.MarshalBinary()
if err != nil {
return fmt.Errorf("failed to marshal D_i: %w", err)
}
eBytes, err := body.E_i.MarshalBinary()
if err != nil {
return fmt.Errorf("failed to marshal E_i: %w", err)
}
dCopy := r.Group().NewPoint()
if err := dCopy.UnmarshalBinary(dBytes); err != nil {
return fmt.Errorf("failed to unmarshal D_i: %w", err)
}
eCopy := r.Group().NewPoint()
if err := eCopy.UnmarshalBinary(eBytes); err != nil {
return fmt.Errorf("failed to unmarshal E_i: %w", err)
}
r.deMu.Lock()
// Only skip if we already have BOTH; otherwise we could drop one
if r.D[msg.From] != nil && r.E[msg.From] != nil {
r.deMu.Unlock()
return nil
}
r.D[msg.From] = dCopy
r.E[msg.From] = eCopy
r.deMu.Unlock()
return nil
}
// VerifyMessage implements round.Round.
func (*round2) VerifyMessage(round.Message) error { return nil }
// StoreMessage implements round.Round.
func (*round2) StoreMessage(round.Message) error { return nil }
// Finalize implements round.Round.
func (r *round2) Finalize(out chan<- *round.Message) (round.Session, error) {
// Check if we have all D and E values from ALL signers
// This is critical - we MUST have D,E from every signer before proceeding
signers := r.PartyIDs()
r.deMu.Lock()
missingCount := 0
for _, l := range signers {
if r.D[l] == nil || r.E[l] == nil {
missingCount++
}
// Also verify they're not identity points (shouldn't happen but double-check)
if r.D[l] != nil && r.D[l].IsIdentity() {
r.deMu.Unlock()
return r, fmt.Errorf("party %s has identity point for D", l)
}
if r.E[l] != nil && r.E[l].IsIdentity() {
r.deMu.Unlock()
return r, fmt.Errorf("party %s has identity point for E", l)
}
}
if missingCount > 0 {
r.deMu.Unlock()
// Not ready yet, return self to continue waiting for broadcasts
return r, nil
}
// Snapshot D and E under the lock, then release.
// After this point no new StoreBroadcastMessage calls will arrive
// for this round (protocol guarantees), so the copies are final.
D := make(map[party.ID]curve.Point, len(r.D))
E := make(map[party.ID]curve.Point, len(r.E))
for k, v := range r.D {
D[k] = v
}
for k, v := range r.E {
E[k] = v
}
r.deMu.Unlock()
// This essentially follows parts of Figure 3.
// 4. "Each Pᵢ then computes the set of binding values ρₗ = H₁(l, m, B).
// Each Pᵢ then derives the group commitment R = ∑ₗ Dₗ + ρₗ * Eₗ and
// the challenge c = H₂(R, Y, m)."
//
// It's easier to calculate H(m, B, l), that way we can simply clone the hash
// state after H(m, B), instead of rehashing them each time.
//
// We also use a hash of the message, instead of the message directly.
rho := make(map[party.ID]curve.Scalar)
// This calculates H(m, B), allowing us to avoid re-hashing this data for
// each extra party l.
// IMPORTANT: We must hash CANONICAL BYTES of D and E values to ensure
// all parties compute identical binding values
rhoPreHash := hash.New()
_ = rhoPreHash.WriteAny(messageHash(r.M)) // Use the messageHash wrapper
// Sort signer IDs to ensure consistent ordering (use only signers in this session)
sortedSigners := make([]party.ID, 0, len(signers))
for _, id := range signers {
sortedSigners = append(sortedSigners, id)
}
sort.Slice(sortedSigners, func(i, j int) bool {
return sortedSigners[i] < sortedSigners[j]
})
// Hash canonical bytes of points, not the points themselves
for _, l := range sortedSigners {
// Write party ID as canonical bytes
_ = rhoPreHash.WriteAny(&hash.BytesWithDomain{
TheDomain: "PartyID",
Bytes: []byte(l),
})
// Write canonical encoding of D[l]
dBytes, _ := D[l].MarshalBinary()
_ = rhoPreHash.WriteAny(&hash.BytesWithDomain{
TheDomain: "D",
Bytes: dBytes,
})
// Write canonical encoding of E[l]
eBytes, _ := E[l].MarshalBinary()
_ = rhoPreHash.WriteAny(&hash.BytesWithDomain{
TheDomain: "E",
Bytes: eBytes,
})
}
for _, l := range sortedSigners {
rhoHash := rhoPreHash.Clone()
_ = rhoHash.WriteAny(&hash.BytesWithDomain{
TheDomain: "PartyID",
Bytes: []byte(l),
})
rho[l] = sample.Scalar(rhoHash.Digest(), r.Group())
}
R := r.Group().NewPoint()
RShares := make(map[party.ID]curve.Point)
// Use sorted order to ensure consistent R computation
for _, l := range sortedSigners {
RShares[l] = rho[l].Act(E[l])
RShares[l] = RShares[l].Add(D[l])
R = R.Add(RShares[l])
}
var c curve.Scalar
if r.sr25519 {
// sr25519 / Schnorrkel challenge via Merlin transcript.
// The transcript labels must exactly match the Rust schnorrkel crate
// so that threshold-produced signatures cross-verify with standard sr25519 verifiers.
//
// Transcript flow (from schnorrkel sign.rs + context.rs):
// 1. NewTranscript("SigningContext")
// 2. AppendMessage("", signingContext) -- e.g. "substrate"
// 3. AppendMessage("sign-bytes", message)
// 4. AppendMessage("proto-name", "Schnorr-sig")
// 5. AppendMessage("sign:pk", groupKeyBytes)
// 6. AppendMessage("sign:R", RBytes)
// 7. challengeBytes = ExtractBytes("sign:c", 64)
// 8. c = Scalar.FromUniformBytes(challengeBytes)
groupKeyBytes, _ := r.Y.MarshalBinary()
RBytes, _ := R.MarshalBinary()
t := merlin.NewTranscript("SigningContext")
t.AppendMessage([]byte(""), r.signingContext)
t.AppendMessage([]byte("sign-bytes"), r.M)
t.AppendMessage([]byte("proto-name"), []byte("Schnorr-sig"))
t.AppendMessage([]byte("sign:pk"), groupKeyBytes)
t.AppendMessage([]byte("sign:R"), RBytes)
challengeBytes := t.ExtractBytes([]byte("sign:c"), 64)
// Reduce 64 uniform bytes to a scalar mod the Ristretto255 group order.
// Use the group's NewScalar() to get a properly initialized scalar.
cScalar := r.Group().NewScalar().(*curve.Ristretto255Scalar)
cScalar.FromUniformBytes(challengeBytes)
c = cScalar
} else if r.ed25519 {
// RFC 8032 §5.1.6: k = SHA-512(R ‖ A ‖ M) mod L, over the 32-byte
// compressed Edwards encodings of R and A and the raw message M.
//
// Nothing else here needs an Ed25519-specific adjustment. Unlike BIP-340
// below, RFC 8032 places no condition on R, so there is no nonce to
// negate; and unlike the generic branch, the challenge is fixed by the
// standard rather than by our hash of choice. That is the whole of the
// difference between this ciphersuite and the generic one.
RBytes, err := R.MarshalBinary()
if err != nil {
return r, fmt.Errorf("failed to marshal R: %w", err)
}
ABytes, err := r.Y.MarshalBinary()
if err != nil {
return r, fmt.Errorf("failed to marshal public key: %w", err)
}
c, err = ed25519Challenge(r.Group(), RBytes, ABytes, r.M)
if err != nil {
return r, err
}
} else if r.taproot {
// BIP-340 adjustment: We need R to have an even y coordinate. This means
// conditionally negating k = ∑ᵢ (dᵢ + (eᵢ ρᵢ)), which we can accomplish
// by negating our dᵢ, eᵢ, if necessary. This entails negating the RShares
// as well.
RSecp := R.(*curve.Secp256k1Point)
if !RSecp.HasEvenY() {
r.d_i.Negate()
r.e_i.Negate()
for _, l := range r.PartyIDs() {
RShares[l] = RShares[l].Negate()
}
}
// BIP-340 adjustment: we need to calculate our hash as specified in:
// https://github.com/bitcoin/bips/blob/master/bip-0340.mediawiki#default-signing
RBytes := RSecp.XBytes()
PBytes := r.Y.(*curve.Secp256k1Point).XBytes()
cHash := taproot.TaggedHash("BIP0340/challenge", RBytes, PBytes, r.M)
c = r.Group().NewScalar().SetNat(new(saferith.Nat).SetBytes(cHash))
} else {
// Use canonical bytes for challenge computation
cHash := hash.New()
rBytes, _ := R.MarshalBinary()
_ = cHash.WriteAny(&hash.BytesWithDomain{
TheDomain: "R",
Bytes: rBytes,
})
yBytes, _ := r.Y.MarshalBinary()
_ = cHash.WriteAny(&hash.BytesWithDomain{
TheDomain: "Y",
Bytes: yBytes,
})
_ = cHash.WriteAny(messageHash(r.M))
c = sample.Scalar(cHash.Digest(), r.Group())
}
// Lambdas[i] = λᵢ
// IMPORTANT: Lagrange coefficients must be computed for the set of signers participating in this session
// r.PartyIDs() returns the signers for this session, not all parties from keygen
Lambdas := polynomial.Lagrange(r.Group(), r.PartyIDs())
// 5. "Each Pᵢ computes their response using their long-lived secret share sᵢ
// by computing zᵢ = dᵢ + (eᵢ ρᵢ) + λᵢ sᵢ c, using S to determine
// the ith lagrange coefficient λᵢ"
// Debug: log the computation
lambda_i := Lambdas[r.SelfID()]
lambda_s_c := r.Group().NewScalar().Set(lambda_i).Mul(r.sI).Mul(c)
e_rho := r.Group().NewScalar().Set(rho[r.SelfID()]).Mul(r.e_i)
zI := r.Group().NewScalar().Set(r.d_i)
zI.Add(e_rho)
zI.Add(lambda_s_c)
// 6. "Each Pᵢ securely deletes ((dᵢ, Dᵢ), (eᵢ, Eᵢ)) from their local storage,
// and returns zᵢ to SA."
//
// Since we don't have a signing authority, we instead broadcast zᵢ.
// Nonce deletion: Go's GC will reclaim the memory. For defense-in-depth,
// the nonce fields are not retained past this round's scope.
// Debug: Log what we computed (commented out)
// fmt.Printf("Party %s round2: R=%v, signers=%v\n", r.SelfID(), R, sortedSigners)
// Broadcast our response
err := r.BroadcastMessage(out, &broadcast3{ZI: zI})
if err != nil {
return r, err
}
return &round3{
round2: r,
R: R,
RShares: RShares,
c: c,
z: map[party.ID]curve.Scalar{r.SelfID(): zI},
zMu: &sync.Mutex{},
Lambda: Lambdas,
}, nil
}
// MessageContent implements round.Round.
func (*round2) MessageContent() round.Content { return nil }
// RoundNumber implements round.Content.
func (broadcast2) RoundNumber() round.Number { return 2 }
// BroadcastContent implements round.BroadcastRound.
func (r *round2) BroadcastContent() round.BroadcastContent {
return &broadcast2{
D_i: r.Group().NewPoint(),
E_i: r.Group().NewPoint(),
}
}
// Number implements round.Round.
func (*round2) Number() round.Number { return 2 }