Files
consensus/protocol/quasar/pulsar/round_signer_test.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

291 lines
10 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_test.go -- focused tests that the Pulsar cert profile builds,
// implements the luxfi/pulsar RoundSigner contract, and that a round-trip stub
// (Round1 -> Round2 -> Finalize) drives the consensus-owned orchestration
// correctly using the exported pulsar primitives.
//
// These tests deliberately exercise ONLY the consensus-side orchestration this
// package owns. The secret-bearing FIPS 204 signature assembly lives inside
// luxfi/pulsar and is fail-closed until its BCC/CEF ZK proofs are reviewed; we
// assert the fail-closed fallback and (via a stub core) the success wiring,
// without claiming a real threshold signature.
package pulsar
import (
"bytes"
"testing"
pulsarlib "github.com/luxfi/pulsar/ref/go/pkg/pulsar"
)
func init() {
// The hot path's Round2 calls pulsar.VerifyZPartial, which delegates the
// zero-knowledge check to the registered PartialZVerifier (fail-closed by
// default). For the orchestration round-trip we register an accept-bindings
// verifier exactly as luxfi/pulsar's own partial_test.go does; the public
// bindings (party/session/nonce/z) are still enforced unconditionally.
pulsarlib.RegisterPartialZVerifier(acceptBindingsOnly{})
}
type acceptBindingsOnly struct{}
func (acceptBindingsOnly) VerifyPartial(*pulsarlib.Partial, []byte, []byte, []byte) error {
return nil
}
// stubCore is a TEST-ONLY signature core: it returns a deterministic,
// non-empty Signature so we can exercise the Finalize success path. It is NOT a
// real threshold ML-DSA signer (that must live in luxfi/pulsar). It proves the
// wiring: Finalize hands the core the PUBLIC aggregate + nonce cert + session,
// and threads the returned Signature into the ConsensusCert.
type stubCore struct{ sawZSumLen int }
func (c *stubCore) AssembleSignature(
_ PulsarSession,
cert pulsarlib.NonceCert,
agg pulsarlib.Aggregate,
) (pulsarlib.Signature, error) {
c.sawZSumLen = len(agg.ZSum)
return pulsarlib.Signature{Mode: pulsarlib.ModeP65, Bytes: []byte("stub-sig")}, nil
}
// fullSession returns a PulsarSession with every security-relevant root set
// (so Validate passes) and a NoncePoolRoot matching the supplied pool root.
func fullSession(poolRoot [32]byte) PulsarSession {
mk := func(b byte) [32]byte {
var x [32]byte
for i := range x {
x[i] = b
}
return x
}
return PulsarSession{
ChainID: mk(0x11),
Epoch: 7,
Height: 42,
Round: 3,
BlockHash: mk(0x22),
ValidatorSetRoot: mk(0x33),
JointPKID: mk(0x44),
DKGTranscriptRoot: mk(0x55),
CommitteeID: mk(0x66),
SignerSetRoot: mk(0x77),
NoncePoolRoot: poolRoot,
ProtocolVersion: 1,
}
}
// makePool builds a one-cert in-memory pool whose single NonceCert carries a
// non-zero transcript root (so the ConsensusCert transcript binding is real).
func makePool() (*MemNonceCertPool, [32]byte) {
var poolRoot [32]byte
for i := range poolRoot {
poolRoot[i] = 0xAB
}
var nonceID, trRoot [32]byte
for i := range nonceID {
nonceID[i] = 0xC0
trRoot[i] = 0x0D
}
cert := pulsarlib.NonceCert{NonceID: nonceID, NonceTranscriptRoot: trRoot}
return NewMemNonceCertPool([]pulsarlib.NonceCert{cert}, poolRoot), nonceID
}
// TestPulsarRoundSignerImplementsContract is a compile-time + runtime check
// that the profile satisfies luxfi/pulsar's RoundSigner and reports the right
// profile value.
func TestPulsarRoundSignerImplementsContract(t *testing.T) {
var rs pulsarlib.RoundSigner = &PulsarRoundSigner{}
if got := rs.Profile(); got != pulsarlib.ProfilePulsar {
t.Fatalf("Profile() = %v, want ProfilePulsar", got)
}
}
// TestRoundTripFailClosed drives Round1 -> Round2 -> Finalize with NO signature
// core: the orchestration completes (canonical nonce, canonical signer subset,
// z-aggregate, ConsensusCert), but the FIPS 204 signature is empty and the
// error is the Corona-fallback signal.
func TestRoundTripFailClosed(t *testing.T) {
pool, nonceID := makePool()
sess := fullSession(pool.Root())
if err := sess.Validate(); err != nil {
t.Fatalf("session Validate: %v", err)
}
sid := sess.SessionID()
s := &PulsarRoundSigner{
Session: sess,
Pool: pool,
Threshold: 2,
ValidatorSetSize: 8,
L: 5, // ML-DSA-65 secret dimension â„“
Core: nil,
}
// Round1: bind the canonical nonce.
canonical, ok := pool.At(pulsarlib.CanonicalNonceIndex(sid, pool.Root(), pool.Size()))
if !ok {
t.Fatal("pool.At canonical index failed")
}
r1, err := s.Round1(sid, nonceID, canonical)
if err != nil {
t.Fatalf("Round1: %v", err)
}
if r1.SessionID != sid || r1.NonceID != nonceID {
t.Fatal("Round1 did not bind session/nonce")
}
// Round2: two signers' z-partials. ZShare length must match L*N*polyBytes
// only matters to the real core; for aggregation we just need consistent
// non-empty packed vectors.
partials := make([]pulsarlib.Partial, 0, 2)
for _, party := range []uint32{2, 5} {
in := pulsarlib.PartialInput{
PartyID: party,
ZShare: bytes.Repeat([]byte{byte(party)}, 5*256*4),
}
p, err := s.Round2(r1, in)
if err != nil {
t.Fatalf("Round2 party %d: %v", party, err)
}
partials = append(partials, p)
}
// Finalize: structurally complete cert, empty signature, fallback error.
agg, cert, err := s.Finalize(r1, partials)
if err != ErrProfileNotReady {
t.Fatalf("Finalize err = %v, want ErrProfileNotReady", err)
}
if len(cert.Signature.Bytes) != 0 {
t.Fatal("expected empty signature on fail-closed path")
}
// The ConsensusCert must still be a valid accountability artifact:
// quorum=2 over the canonical 2-signer bitmap, all bits in-set.
if err := cert.VerifyStructure(2, s.ValidatorSetSize); err != nil {
t.Fatalf("ConsensusCert.VerifyStructure: %v", err)
}
if cert.TranscriptRoot != canonical.NonceTranscriptRoot {
t.Fatal("ConsensusCert did not bind the nonce transcript root")
}
if cert.Epoch != sess.Epoch || cert.Height != sess.Height || cert.Round != sess.Round {
t.Fatal("ConsensusCert did not bind the consensus round")
}
if len(agg.ZSum) == 0 {
t.Fatal("aggregate z-sum is empty")
}
}
// TestRoundTripWithStubCore proves the success wiring: with a (stub) core
// registered, Finalize threads the assembled signature into the cert and hands
// the core the public aggregate.
func TestRoundTripWithStubCore(t *testing.T) {
pool, nonceID := makePool()
sess := fullSession(pool.Root())
sid := sess.SessionID()
core := &stubCore{}
s := &PulsarRoundSigner{
Session: sess, Pool: pool, Threshold: 2, ValidatorSetSize: 8, L: 5, Core: core,
}
canonical, _ := pool.At(pulsarlib.CanonicalNonceIndex(sid, pool.Root(), pool.Size()))
r1, err := s.Round1(sid, nonceID, canonical)
if err != nil {
t.Fatalf("Round1: %v", err)
}
var partials []pulsarlib.Partial
for _, party := range []uint32{1, 4} {
p, err := s.Round2(r1, pulsarlib.PartialInput{PartyID: party, ZShare: bytes.Repeat([]byte{byte(party)}, 5*256*4)})
if err != nil {
t.Fatalf("Round2: %v", err)
}
partials = append(partials, p)
}
_, cert, err := s.Finalize(r1, partials)
if err != nil {
t.Fatalf("Finalize: %v", err)
}
if string(cert.Signature.Bytes) != "stub-sig" {
t.Fatal("Finalize did not thread the core's signature into the cert")
}
if core.sawZSumLen == 0 {
t.Fatal("core was not handed the aggregated z-sum")
}
}
// TestEmptyPoolFallsBack: an empty NonceCert pool yields the Corona-fallback
// signal, never an inline nonce.
func TestEmptyPoolFallsBack(t *testing.T) {
var root [32]byte
root[0] = 1
empty := NewMemNonceCertPool(nil, root)
sess := fullSession(root)
s := &PulsarRoundSigner{Session: sess, Pool: empty, Threshold: 2, ValidatorSetSize: 8, L: 5}
var nid [32]byte
nid[0] = 9
if _, err := s.Round1(sess.SessionID(), nid, pulsarlib.NonceCert{NonceID: nid}); err != ErrNonceCertPoolEmpty {
t.Fatalf("Round1 on empty pool err = %v, want ErrNonceCertPoolEmpty", err)
}
}
// TestNonCanonicalNonceRejected: a cert that is not the canonical pool
// selection for the session is refused (anti-grind).
func TestNonCanonicalNonceRejected(t *testing.T) {
pool, _ := makePool()
sess := fullSession(pool.Root())
s := &PulsarRoundSigner{Session: sess, Pool: pool, Threshold: 2, ValidatorSetSize: 8, L: 5}
var bogus [32]byte
bogus[0] = 0xFF // not the canonical nonce id
if _, err := s.Round1(sess.SessionID(), bogus, pulsarlib.NonceCert{NonceID: bogus}); err != ErrNonCanonicalNonce {
t.Fatalf("Round1 with non-canonical nonce err = %v, want ErrNonCanonicalNonce", err)
}
}
// TestSessionIDBindsAllFields: changing any one binding field changes the
// session id (the binding is load-bearing across the whole round context).
func TestSessionIDBindsAllFields(t *testing.T) {
var root [32]byte
root[0] = 0xAB
base := fullSession(root)
baseID := base.SessionID()
mutators := map[string]func(*PulsarSession){
"chain_id": func(s *PulsarSession) { s.ChainID[0] ^= 1 },
"epoch": func(s *PulsarSession) { s.Epoch ^= 1 },
"height": func(s *PulsarSession) { s.Height ^= 1 },
"round": func(s *PulsarSession) { s.Round ^= 1 },
"block_hash": func(s *PulsarSession) { s.BlockHash[0] ^= 1 },
"validator_set_root": func(s *PulsarSession) { s.ValidatorSetRoot[0] ^= 1 },
"joint_pk_id": func(s *PulsarSession) { s.JointPKID[0] ^= 1 },
"dkg_transcript_root": func(s *PulsarSession) { s.DKGTranscriptRoot[0] ^= 1 },
"committee_id": func(s *PulsarSession) { s.CommitteeID[0] ^= 1 },
"signer_set_root": func(s *PulsarSession) { s.SignerSetRoot[0] ^= 1 },
"nonce_pool_root": func(s *PulsarSession) { s.NoncePoolRoot[0] ^= 1 },
"protocol_version": func(s *PulsarSession) { s.ProtocolVersion ^= 1 },
}
for name, mut := range mutators {
s := base
mut(&s)
if s.SessionID() == baseID {
t.Fatalf("mutating %q did not change the session id", name)
}
}
}
// TestSessionValidateRejectsUnsetRoots: an all-zero security-relevant root is
// refused.
func TestSessionValidateRejectsUnsetRoots(t *testing.T) {
var root [32]byte
root[0] = 1
good := fullSession(root)
if err := good.Validate(); err != nil {
t.Fatalf("full session must validate, got %v", err)
}
bad := good
bad.DKGTranscriptRoot = [32]byte{} // unbind a field
if err := bad.Validate(); err != ErrSessionFieldUnset {
t.Fatalf("unset DKG transcript root err = %v, want ErrSessionFieldUnset", err)
}
}