Files
consensus/protocol/quasar/pulsar/round_signer.go
zooqueen f5ab5e2578 quasar: Pulsar threshold ML-DSA as a Quasar certificate profile
Add protocol/quasar/pulsar -- the consensus-side adapter that wires the
luxfi/pulsar threshold ML-DSA primitives into Quasar as a cert profile.
Consensus consumes Pulsar; Pulsar never consumes consensus.

Decomplected ownership (the load-bearing boundary):

  Consensus owns ORCHESTRATION: validator-set membership, committee
  sampling, session binding, canonical nonce selection, QC aggregation,
  signer bitmap, ConsensusCert assembly, retries, fallback, slashing
  inputs.

  Pulsar owns CRYPTO: the two-round shape (Round1 binds the nonce,
  Round2 emits a proof-carrying z partial), z aggregation mod q, the
  public hint recovery (FindHint over w' = A*z - c*t1*2^d), and the
  final FIPS 204 ML-DSA signature.

PulsarRoundSigner implements luxfi/pulsar's RoundSigner interface:

  Round1   selects the canonical NonceCert via pulsar.CanonicalNonceIndex
           over the session and refuses any non-canonical nonce
           (anti-grind: a coordinator cannot pick w1, hence the
           challenge, by choosing among boundary-clear certs after
           seeing the message).
  Round2   emits one signer's z Partial; pulsar.VerifyZPartial enforces
           the public bindings + the registered ZK correctness check.
  Finalize selects the canonical signer subset
           (pulsar.CanonicalSignerSet), aggregates z
           (pulsar.MergeAggregates), and assembles the two-certificate
           ConsensusCert accountability artifact (signer bitmap +
           transcript root).

The final FIPS 204 signature byte production is secret-bearing (needs
the module matrix A, t1, the challenge, and the per-coefficient hint),
all package-private to luxfi/pulsar, so it stays there: Finalize
delegates to a pluggable SignatureCore. The core is fail-closed until
luxfi/pulsar ships a reviewed Boundary-Cleared / Carry-Elimination
signer (pulsar.ProductionBCCSigningReady); until then Finalize returns
a structurally-complete ConsensusCert with an EMPTY Signature and
ErrProfileNotReady, and the orchestrator falls back to Corona. No
secret residual is ever opened on this path.

Session binding (session.go): the 32-byte session id is a
domain-separated cSHAKE256 over chain_id, epoch, height, round,
block_hash, validator_set_root, joint_pk_id, dkg_transcript_root,
committee_id, signer_set_root, nonce_pool_root, protocol_version --
every field fixed-width for canonical (non-grindable) encoding. Validate
refuses a session with an all-zero security-relevant root (cross-round
replay guard).

NonceCert generation is a BACKGROUND validator subprotocol filling a
NonceCertPool (pool.go) -- never inline on the block hot path. An empty
pool yields ErrNonceCertPoolEmpty (fall back to Corona), never an inline
nonce.

The profile registers by the package convention (like wave_signer.go's
RoundSigner): a self-contained pulsar.RoundSigner whose ConsensusCert
Signature bytes feed the existing QuasarCert.Pulsar slot and verify via
the polaris.go verifyPulsarLeg / pulsarwire.VerifyBytes path already
wired. No change to polaris.go (cert assembly is a separate concern).

go.mod: replace github.com/luxfi/pulsar => ../luxfi/pulsar. The
RoundSigner / NonceCert / CertProfile API lands on the
fix/threshold-mldsa-hint-leak branch and is not in any published tag
(cached v1.1.1 predates it). Remove the replace once a tag carrying the
API ships.

Tests (protocol/quasar/pulsar, all green): contract implementation,
Round1->Round2->Finalize fail-closed fallback, stub-core success wiring,
empty-pool fallback, non-canonical-nonce rejection, full session-id
field binding, session validation. go build ./... green; existing
protocol/quasar suite unchanged and green.
2026-06-21 02:00:25 -07:00

232 lines
9.3 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/ref/go/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) {
// 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 {
children[i] = pulsarlib.Aggregate{
SessionID: r1.SessionID,
NonceID: r1.NonceID,
SignerBitmap: singletonBitmap(p.PartyID),
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.
func singletonBitmap(partyID uint32) []byte {
bm := make([]byte, partyID/8+1)
bm[partyID/8] |= 1 << (partyID % 8)
return bm
}