mirror of
https://github.com/luxfi/consensus.git
synced 2026-09-24 00:26:58 +00:00
v1.35.31's bound rejected PartyID >= ValidatorSetSize, but party IDs are 1-based (threshold convention: Shamir x=0 is the secret), so validators are 1..N and the Nth validator (PartyID == ValidatorSetSize) was wrongly dropped — breaking every real N-of-N ceremony (quorum never reached). The bound is a strict UPPER bound now (> ValidatorSetSize): DoS-safe (rejects unbounded PartyID) and 1-based-correct. TestFinalize_BoundaryPartyIDEqualsValidatorSetSize locks it. The v1.35.31 tag is left untouched (immutable); this is v1.35.32.
271 lines
11 KiB
Go
271 lines
11 KiB
Go
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
|
||
// See the file LICENSE for licensing terms.
|
||
|
||
// round_signer.go -- PulsarRoundSigner: the Quasar cert-profile adapter that
|
||
// implements luxfi/pulsar's RoundSigner by CONSUMING its primitives.
|
||
//
|
||
// The three round methods map onto the consensus hot path:
|
||
//
|
||
// Round1 binds the canonical NonceCert (selected via
|
||
// pulsar.CanonicalNonceIndex over the session) to the consensus
|
||
// session id. It refuses a non-canonical nonce (anti-grind).
|
||
// Round2 emits one signer's proof-carrying z Partial. The public bindings
|
||
// (party / session / nonce) are enforced by pulsar.VerifyZPartial.
|
||
// Finalize selects the canonical signer subset (pulsar.CanonicalSignerSet),
|
||
// aggregates z (pulsar.MergeAggregates), assembles the ConsensusCert
|
||
// accountability artifact (signer bitmap + transcript root), and asks
|
||
// the registered BCC core to produce the final FIPS 204 signature.
|
||
// If no core is registered, the Signature is left empty and
|
||
// ErrProfileNotReady is returned for fallback to Corona.
|
||
|
||
package pulsar
|
||
|
||
import (
|
||
pulsarlib "github.com/luxfi/pulsar/pkg/pulsar"
|
||
)
|
||
|
||
// SignatureCore is the secret-bearing seam. Producing the final FIPS 204
|
||
// ML-DSA signature requires the module matrix A, t1, the challenge, and the
|
||
// per-coefficient public hint (FindHint over w' = A·z − c·t1·2^d). All of that
|
||
// is package-private to luxfi/pulsar, so a sound implementation MUST live
|
||
// inside that package and be injected here. Until luxfi/pulsar ships a
|
||
// reviewed Boundary-Cleared / Carry-Elimination signer, no core is registered
|
||
// and the profile fails closed (ErrProfileNotReady), falling back to Corona.
|
||
//
|
||
// The core receives only PUBLIC inputs: the aggregated z (Aggregate.ZSum), the
|
||
// nonce cert (which carries only w1 + a clearance QC, never w), and the
|
||
// session. It never sees secret residuals.
|
||
type SignatureCore interface {
|
||
// AssembleSignature recovers the public hint and emits the FIPS 204
|
||
// signature for the aggregated z under the joint key bound to the session.
|
||
AssembleSignature(
|
||
session PulsarSession,
|
||
cert pulsarlib.NonceCert,
|
||
agg pulsarlib.Aggregate,
|
||
) (pulsarlib.Signature, error)
|
||
}
|
||
|
||
// PulsarRoundSigner is the consensus-side Pulsar cert profile. It is
|
||
// constructed by the Quasar orchestration layer (validator set, sampling, and
|
||
// QC aggregation live there); this type owns only the per-round crypto
|
||
// orchestration that the pulsar.RoundSigner contract names.
|
||
//
|
||
// It satisfies pulsarlib.RoundSigner.
|
||
type PulsarRoundSigner struct {
|
||
// Session is the consensus binding for this round. SessionID() is the
|
||
// 32-byte domain-separated session id every signer agrees on.
|
||
Session PulsarSession
|
||
|
||
// Pool is the background NonceCert pool. Round1 selects the canonical cert
|
||
// from it; an empty pool yields ErrNonceCertPoolEmpty (fallback signal).
|
||
Pool NonceCertPool
|
||
|
||
// Threshold is T: the minimum number of valid partials Finalize aggregates.
|
||
Threshold int
|
||
|
||
// ValidatorSetSize bounds the signer bitmap (every set bit must index a
|
||
// validator). Used by the ConsensusCert structural check.
|
||
ValidatorSetSize int
|
||
|
||
// L is the ML-DSA secret dimension (ℓ) for the active mode; it sizes the
|
||
// z-vector aggregation. For ML-DSA-65 (ModeP65) this is 5.
|
||
L int
|
||
|
||
// Core produces the final FIPS 204 signature. nil means fail-closed: the
|
||
// profile assembles the ConsensusCert but leaves the Signature empty and
|
||
// returns ErrProfileNotReady.
|
||
Core SignatureCore
|
||
}
|
||
|
||
// compile-time assertion: PulsarRoundSigner implements the lib contract.
|
||
var _ pulsarlib.RoundSigner = (*PulsarRoundSigner)(nil)
|
||
|
||
// Profile reports the Pulsar (Module-LWE threshold ML-DSA) certificate
|
||
// profile. This is the value the QuasarCert.Pulsar leg corresponds to.
|
||
func (s *PulsarRoundSigner) Profile() pulsarlib.CertProfile {
|
||
return pulsarlib.ProfilePulsar
|
||
}
|
||
|
||
// Round1 binds the canonical NonceCert to the consensus session.
|
||
//
|
||
// The caller passes the session id (which MUST equal s.Session.SessionID();
|
||
// callers that already hold the session can pass it without recomputing) and a
|
||
// candidate (nonceID, cert). Round1 recomputes the canonical pool index from
|
||
// (sessionID, pool root, pool size) and refuses anything but the canonical
|
||
// cert -- this is the anti-grind rule: a coordinator cannot pick w1 (hence the
|
||
// challenge) by choosing among many boundary-clear certs after seeing the
|
||
// message.
|
||
func (s *PulsarRoundSigner) Round1(
|
||
sessionID, nonceID [32]byte,
|
||
cert pulsarlib.NonceCert,
|
||
) (pulsarlib.SignRound1, error) {
|
||
if s.Pool == nil || s.Pool.Size() == 0 {
|
||
return pulsarlib.SignRound1{}, ErrNonceCertPoolEmpty
|
||
}
|
||
|
||
// Canonical, non-grindable selection over the exact pool bound into the
|
||
// session (s.Session.NoncePoolRoot must equal s.Pool.Root()).
|
||
idx := pulsarlib.CanonicalNonceIndex(sessionID, s.Pool.Root(), s.Pool.Size())
|
||
canonical, ok := s.Pool.At(idx)
|
||
if !ok {
|
||
return pulsarlib.SignRound1{}, ErrNonceCertPoolEmpty
|
||
}
|
||
|
||
// The supplied cert MUST be the canonical one for this session.
|
||
if canonical.NonceID != nonceID || cert.NonceID != canonical.NonceID {
|
||
return pulsarlib.SignRound1{}, ErrNonCanonicalNonce
|
||
}
|
||
|
||
return pulsarlib.SignRound1{
|
||
SessionID: sessionID,
|
||
NonceID: canonical.NonceID,
|
||
NonceCert: canonical,
|
||
}, nil
|
||
}
|
||
|
||
// Round2 produces one signer's proof-carrying z Partial. The PartialInput
|
||
// carries the public bindings (party id, session, nonce, DKG/nonce
|
||
// commitments, the challenge, and the packed z-share); pulsar.VerifyZPartial
|
||
// enforces them and delegates the zero-knowledge correctness check to the
|
||
// registered (fail-closed) PartialZVerifier. The Partial itself carries no
|
||
// hint-secret field (no c*s2, c*t0, r0, LowBits).
|
||
func (s *PulsarRoundSigner) Round2(
|
||
r1 pulsarlib.SignRound1,
|
||
in pulsarlib.PartialInput,
|
||
) (pulsarlib.Partial, error) {
|
||
if r1.NonceID == ([32]byte{}) {
|
||
return pulsarlib.Partial{}, ErrNoNonceCert
|
||
}
|
||
// Bind the partial to this round's session + nonce, regardless of any
|
||
// mismatched values the caller put in PartialInput.
|
||
in.SessionID = r1.SessionID
|
||
in.NonceID = r1.NonceID
|
||
|
||
p := pulsarlib.Partial{
|
||
PartyID: in.PartyID,
|
||
SessionID: r1.SessionID,
|
||
NonceID: r1.NonceID,
|
||
ZShare: in.ZShare,
|
||
}
|
||
// Enforce the public bindings (party/session/nonce/z) + the registered ZK
|
||
// correctness check. Fails closed when no sound PartialZVerifier is set.
|
||
if err := pulsarlib.VerifyZPartial(&p, in); err != nil {
|
||
return pulsarlib.Partial{}, err
|
||
}
|
||
return p, nil
|
||
}
|
||
|
||
// Finalize selects the canonical signer subset, aggregates the z-shares,
|
||
// assembles the two-certificate accountability artifact, and asks the BCC core
|
||
// for the final FIPS 204 signature.
|
||
//
|
||
// Returns the Aggregate (z-sum + bitmap), the ConsensusCert (signer bitmap +
|
||
// transcript root + signature), and an error. When no signature core is
|
||
// registered the ConsensusCert is structurally complete but its Signature is
|
||
// empty and the error is ErrProfileNotReady (fallback to Corona). The Aggregate
|
||
// is returned in BOTH cases so the orchestrator can fan it into a higher tree
|
||
// node regardless.
|
||
func (s *PulsarRoundSigner) Finalize(
|
||
r1 pulsarlib.SignRound1,
|
||
partials []pulsarlib.Partial,
|
||
) (pulsarlib.Aggregate, pulsarlib.ConsensusCert, error) {
|
||
// 0. Bound every PartyID against the validator set BEFORE any
|
||
// PartyID-sized allocation. pulsarlib.CanonicalSignerSet and
|
||
// singletonBitmap size a []byte at (PartyID/8+1); an unbounded
|
||
// attacker-controlled uint32 PartyID would drive an unbounded
|
||
// allocation (~512MiB from a single MaxUint32) — a memory-
|
||
// exhaustion DoS. We check the WHOLE partials slice (not just the
|
||
// chosen subset) so a hostile PartyID cannot hide behind the
|
||
// threshold cut, and we do it here (rather than in pulsarlib) to
|
||
// keep CanonicalSignerSet's published signature non-breaking.
|
||
// Party IDs are 1-based (the threshold convention: a Shamir share at x=0
|
||
// would be the secret itself, so validators are indexed 1..ValidatorSetSize).
|
||
// The valid domain is therefore [1, ValidatorSetSize] and the bound is a
|
||
// strict upper bound: reject PartyID > ValidatorSetSize. (An earlier `>=`
|
||
// wrongly rejected the Nth validator, PartyID == ValidatorSetSize.) This is
|
||
// purely a DoS ceiling on the bitmap allocation, not exact validation —
|
||
// upstream registration already binds each PartyID to a registered voter.
|
||
if s.ValidatorSetSize <= 0 {
|
||
return pulsarlib.Aggregate{}, pulsarlib.ConsensusCert{}, ErrPartyIDOutOfRange
|
||
}
|
||
for _, p := range partials {
|
||
if p.PartyID > uint32(s.ValidatorSetSize) {
|
||
return pulsarlib.Aggregate{}, pulsarlib.ConsensusCert{}, ErrPartyIDOutOfRange
|
||
}
|
||
}
|
||
|
||
// 1. Canonical, anti-grind signer subset: deterministic first-threshold by
|
||
// PartyID, so an aggregator cannot grind z / the hint / the signature
|
||
// bytes by choosing among valid signer sets.
|
||
chosen, bitmap, err := pulsarlib.CanonicalSignerSet(partials, s.Threshold)
|
||
if err != nil {
|
||
return pulsarlib.Aggregate{}, pulsarlib.ConsensusCert{}, err
|
||
}
|
||
|
||
// 2. Aggregate z modulo q via the tree-merge path (associative; equals the
|
||
// flat sum). Each chosen partial becomes a singleton Aggregate so we can
|
||
// use the exported MergeAggregates (z-sums only; no hint material).
|
||
children := make([]pulsarlib.Aggregate, len(chosen))
|
||
for i, p := range chosen {
|
||
singleton, err := singletonBitmap(p.PartyID, s.ValidatorSetSize)
|
||
if err != nil {
|
||
return pulsarlib.Aggregate{}, pulsarlib.ConsensusCert{}, err
|
||
}
|
||
children[i] = pulsarlib.Aggregate{
|
||
SessionID: r1.SessionID,
|
||
NonceID: r1.NonceID,
|
||
SignerBitmap: singleton,
|
||
ZSum: p.ZShare,
|
||
}
|
||
}
|
||
agg, err := pulsarlib.MergeAggregates(children, s.L)
|
||
if err != nil {
|
||
return pulsarlib.Aggregate{}, pulsarlib.ConsensusCert{}, err
|
||
}
|
||
// Carry the canonical bitmap (MergeAggregates rebuilds the union, which
|
||
// equals the canonical bitmap for a disjoint chosen set).
|
||
agg.SignerBitmap = bitmap
|
||
|
||
// 3. Structural accountability artifact. Transcript root binds the nonce
|
||
// transcript the signers cleared; the bitmap proves which validators
|
||
// participated (BLS-like accountability for a lattice scheme).
|
||
cert := pulsarlib.ConsensusCert{
|
||
Epoch: s.Session.Epoch,
|
||
Height: s.Session.Height,
|
||
Round: s.Session.Round,
|
||
BlockHash: s.Session.BlockHash,
|
||
JointPKID: s.Session.JointPKID,
|
||
SignerBitmap: bitmap,
|
||
TranscriptRoot: r1.NonceCert.NonceTranscriptRoot,
|
||
}
|
||
|
||
// 4. The secret-bearing FIPS 204 assembly. Fail closed if no core.
|
||
if s.Core == nil {
|
||
return agg, cert, ErrProfileNotReady
|
||
}
|
||
sig, err := s.Core.AssembleSignature(s.Session, r1.NonceCert, agg)
|
||
if err != nil {
|
||
return agg, cert, err
|
||
}
|
||
cert.Signature = sig
|
||
return agg, cert, nil
|
||
}
|
||
|
||
// singletonBitmap returns a one-bit bitmap with only partyID set, sized to
|
||
// hold that bit. MergeAggregates unions these into the full signer bitmap.
|
||
//
|
||
// validatorSetSize bounds partyID (1-based: must be in [1, validatorSetSize])
|
||
// and is checked BEFORE the PartyID-sized allocation. By the time Finalize
|
||
// calls this it has already rejected any out-of-range PartyID in the whole
|
||
// partials slice, so this is defense-in-depth against a future caller
|
||
// reaching singletonBitmap directly — the allocation is never sized by an
|
||
// unbounded attacker-controlled value.
|
||
func singletonBitmap(partyID uint32, validatorSetSize int) ([]byte, error) {
|
||
if validatorSetSize <= 0 || partyID > uint32(validatorSetSize) {
|
||
return nil, ErrPartyIDOutOfRange
|
||
}
|
||
bm := make([]byte, partyID/8+1)
|
||
bm[partyID/8] |= 1 << (partyID % 8)
|
||
return bm, nil
|
||
}
|