mirror of
https://github.com/luxfi/threshold.git
synced 2026-10-30 15:15:53 +00:00
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.
145 lines
4.9 KiB
Go
145 lines
4.9 KiB
Go
package sign
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"sync"
|
|
|
|
"github.com/luxfi/threshold/internal/round"
|
|
"github.com/luxfi/threshold/pkg/math/curve"
|
|
"github.com/luxfi/threshold/pkg/math/sample"
|
|
"github.com/luxfi/threshold/pkg/party"
|
|
"github.com/zeebo/blake3"
|
|
)
|
|
|
|
// This round sort of corresponds with Figure 2 of the Frost paper:
|
|
//
|
|
// https://eprint.iacr.org/2020/852.pdf
|
|
//
|
|
// The main difference is that instead of having a separate pre-processing step,
|
|
// we instead have an additional round at the start of the signing step.
|
|
// The goal of this round is to generate two nonces, and corresponding commitments.
|
|
//
|
|
// There are also differences corresponding to the lack of a signing authority,
|
|
// namely that these commitments are broadcast, instead of stored with the authority.
|
|
type round1 struct {
|
|
*round.Base
|
|
// taproot indicates whether or not we need to generate Taproot / BIP-340 signatures.
|
|
//
|
|
// If so, we have a few slight tweaks to make around the evenness of points,
|
|
// and we need to make sure to generate our challenge in the correct way. Naturally,
|
|
// we also return a taproot.Signature instead a generic signature.
|
|
taproot bool
|
|
// sr25519 indicates whether we produce sr25519 (Schnorrkel) compatible signatures.
|
|
//
|
|
// If so, the challenge is computed via Merlin transcripts matching the Substrate/Polkadot
|
|
// sr25519 scheme. The result is an sr25519 Signature (R || s, 64 bytes).
|
|
sr25519 bool
|
|
// ed25519 indicates whether we produce RFC 8032 PureEdDSA signatures.
|
|
//
|
|
// If so, the challenge is SHA-512(R ‖ A ‖ M) mod L over the canonical
|
|
// encodings, and the result is an Ed25519Signature that crypto/ed25519 —
|
|
// and therefore Solana and TON — accepts.
|
|
ed25519 bool
|
|
// signingContext is the application-level context for sr25519 signing.
|
|
// For Substrate, this is always "substrate".
|
|
signingContext []byte
|
|
// M is the hash of the message we're signing.
|
|
//
|
|
// This plays the same role as m in the Frost paper. One slight difference
|
|
// is that instead of including the message directly in various hashes,
|
|
// we include the *hash* of that message instead. This provides the same
|
|
// security.
|
|
//
|
|
// sr25519 and ed25519 are the exceptions: both bind the raw message (for
|
|
// ed25519 because RFC 8032 PureEdDSA is defined over M itself), so for those
|
|
// schemes M carries the message rather than a digest of it.
|
|
M messageHash
|
|
// Y is the public key we're signing for.
|
|
Y curve.Point
|
|
// YShares are verification shares for each participant's fraction of the secret key
|
|
//
|
|
// YShares[i] corresponds with Yᵢ in the Frost paper.
|
|
YShares map[party.ID]curve.Point
|
|
// sI = sᵢ is our private secret share
|
|
sI curve.Scalar
|
|
}
|
|
|
|
// VerifyMessage implements round.Round.
|
|
func (r *round1) VerifyMessage(round.Message) error { return nil }
|
|
func (r *round1) StoreMessage(round.Message) error { return nil }
|
|
|
|
const deriveHashKeyContext = "github.com/luxfi/threshold/frost 2021-07-30T09:48+00:00 Derive hash Key"
|
|
|
|
// Finalize implements round.Round.
|
|
func (r *round1) Finalize(out chan<- *round.Message) (round.Session, error) {
|
|
// We can think of this as roughly implementing Figure 2. The idea is
|
|
// to generate two nonces (dᵢ, eᵢ) in Z/(q)ˣ, then two commitments
|
|
// Dᵢ = dᵢ * G, Eᵢ = eᵢ * G, and then broadcast them.
|
|
|
|
// We use a hedged deterministic process, instead of simply sampling (dI, eI):
|
|
//
|
|
// a = random()
|
|
// hk = KDF(sI)
|
|
// (dI, eI) = H_hk(ctx, m, a)
|
|
//
|
|
// This protects against bad randomness, since a constant value for a is still unpredictable,
|
|
// and fault attacks against the hash function, because of the randomness.
|
|
sIBytes, err := r.sI.MarshalBinary()
|
|
if err != nil {
|
|
return r, err
|
|
}
|
|
|
|
hashKey := make([]byte, 32)
|
|
blake3.DeriveKey(deriveHashKeyContext, sIBytes, hashKey)
|
|
nonceHasher, _ := blake3.NewKeyed(hashKey)
|
|
_, _ = nonceHasher.Write(r.Hash().Sum())
|
|
_, _ = nonceHasher.Write(r.M)
|
|
a := make([]byte, 32)
|
|
_, _ = rand.Read(a)
|
|
_, _ = nonceHasher.Write(a)
|
|
nonceDigest := nonceHasher.Digest()
|
|
|
|
dI := sample.ScalarUnit(nonceDigest, r.Group())
|
|
eI := sample.ScalarUnit(nonceDigest, r.Group())
|
|
|
|
DI := dI.ActOnBase()
|
|
EI := eI.ActOnBase()
|
|
|
|
// Broadcast the commitments
|
|
err = r.BroadcastMessage(out, &broadcast2{D_i: DI, E_i: EI})
|
|
if err != nil {
|
|
return r, err
|
|
}
|
|
|
|
// Initialize maps with expected capacity
|
|
D := make(map[party.ID]curve.Point, len(r.PartyIDs()))
|
|
E := make(map[party.ID]curve.Point, len(r.PartyIDs()))
|
|
|
|
// Store our own values using marshal/unmarshal to ensure clean copy
|
|
dBytes, _ := DI.MarshalBinary()
|
|
eBytes, _ := EI.MarshalBinary()
|
|
|
|
DCopy := r.Group().NewPoint()
|
|
_ = DCopy.UnmarshalBinary(dBytes)
|
|
ECopy := r.Group().NewPoint()
|
|
_ = ECopy.UnmarshalBinary(eBytes)
|
|
|
|
D[r.SelfID()] = DCopy
|
|
E[r.SelfID()] = ECopy
|
|
|
|
return &round2{
|
|
round1: r,
|
|
d_i: dI,
|
|
e_i: eI,
|
|
D: D,
|
|
E: E,
|
|
deMu: &sync.Mutex{},
|
|
}, nil
|
|
}
|
|
|
|
// MessageContent implements round.Round.
|
|
func (*round1) MessageContent() round.Content { return nil }
|
|
|
|
// Number implements round.Round.
|
|
func (*round1) Number() round.Number { return 1 }
|