Files
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

119 lines
3.7 KiB
Go

// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// pool.go -- the background NonceCert pool.
//
// NonceCert generation is a BACKGROUND validator subprotocol (Pulsar's
// NonceMPC: validators jointly compute w1 = HighBits(w) and BoundaryClear(w)
// over hidden w = A·y shares, then quorum-sign a clearance certificate). It is
// NOT on the block hot path. The hot path only CONSUMES a pre-cleared cert
// from this pool. If the pool is empty, the Pulsar profile fails over to
// Corona; it NEVER generates a nonce inline (that would either block consensus
// on an MPC ceremony or, worse, open the secret residual w).
package pulsar
import (
"sync"
pulsarlib "github.com/luxfi/pulsar/ref/go/pkg/pulsar"
)
// NonceCertPool is the consumer interface the hot path sees. A production
// implementation is fed by the background NonceMPC subprotocol; this package
// ships an in-memory FIFO (MemNonceCertPool) for tests and single-process
// deployments. The interface is the seam: consensus owns the pool's lifecycle
// and replenishment; this profile only takes a cert and binds it.
type NonceCertPool interface {
// Root is the Merkle/commitment root of the current pool contents. It is
// bound into the session id (PulsarSession.NoncePoolRoot) so the canonical
// nonce selection is pinned to the exact pool the signers agree on.
Root() [32]byte
// Size is the number of unconsumed certs currently available.
Size() uint64
// At returns the cert at canonical pool index i (i < Size). The index is
// chosen by pulsarlib.CanonicalNonceIndex over (sessionID, Root, Size), so
// no coordinator can grind w1 by choosing among many cleared certs after
// seeing the message.
At(i uint64) (pulsarlib.NonceCert, bool)
// Consume marks the cert at index i as spent so it is never reused across
// rounds (nonce reuse in a Schnorr/Fiat-Shamir-style scheme leaks the key).
Consume(i uint64)
}
// MemNonceCertPool is an in-memory, mutex-guarded NonceCertPool. It is the
// reference consumer for tests and for a single coordinator process that has
// already run the background NonceMPC. It does NOT run the MPC itself.
type MemNonceCertPool struct {
mu sync.Mutex
certs []pulsarlib.NonceCert
consumed []bool
root [32]byte
}
// NewMemNonceCertPool builds a pool from already-cleared NonceCerts and a
// precomputed commitment root. The caller (the background subprotocol) is
// responsible for the root being a sound commitment to certs.
func NewMemNonceCertPool(certs []pulsarlib.NonceCert, root [32]byte) *MemNonceCertPool {
return &MemNonceCertPool{
certs: certs,
consumed: make([]bool, len(certs)),
root: root,
}
}
// Root implements NonceCertPool.
func (p *MemNonceCertPool) Root() [32]byte {
p.mu.Lock()
defer p.mu.Unlock()
return p.root
}
// Size implements NonceCertPool: the count of unconsumed certs.
func (p *MemNonceCertPool) Size() uint64 {
p.mu.Lock()
defer p.mu.Unlock()
var n uint64
for i := range p.certs {
if !p.consumed[i] {
n++
}
}
return n
}
// At implements NonceCertPool over the live (unconsumed) view: index i selects
// the i-th still-available cert in pool order.
func (p *MemNonceCertPool) At(i uint64) (pulsarlib.NonceCert, bool) {
p.mu.Lock()
defer p.mu.Unlock()
var seen uint64
for idx := range p.certs {
if p.consumed[idx] {
continue
}
if seen == i {
return p.certs[idx], true
}
seen++
}
return pulsarlib.NonceCert{}, false
}
// Consume implements NonceCertPool: marks the i-th available cert spent.
func (p *MemNonceCertPool) Consume(i uint64) {
p.mu.Lock()
defer p.mu.Unlock()
var seen uint64
for idx := range p.certs {
if p.consumed[idx] {
continue
}
if seen == i {
p.consumed[idx] = true
return
}
seen++
}
}