Files
zeekay 7154dde59a fix(quasar/pulsar): DoS bound is 1-based (PartyID <= ValidatorSetSize)
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.
2026-07-08 07:56:23 -07:00

271 lines
11 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
}