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

114 lines
4.3 KiB
Go

// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// session.go -- the consensus-side session binding for a Pulsar round.
//
// PulsarSession captures every field that pins one threshold-sign ceremony to
// exactly one consensus round, so a signature produced for (epoch, height,
// round, block) can never be replayed into a different round, committee, or
// joint key. The 32-byte session_id is the domain-separated hash of all
// fields; every signer recomputes it and refuses to sign a mismatched session.
package pulsar
import (
"encoding/binary"
"golang.org/x/crypto/sha3"
)
// sessionDomain domain-separates the Pulsar session-id hash from every other
// hash in the system (FIPS 204 mu, the nonce-cert payload root, the round
// digest, etc.). cSHAKE customization plus an in-band protocol tag give two
// independent separation layers.
const (
sessionCustomization = "QUASAR-PULSAR-SESSION-v1"
sessionProtocolTag = "Quasar/Pulsar/Session/v1"
)
// PulsarSession is the consensus binding for one Pulsar threshold-sign round.
// Every field is consensus-owned; none is secret. The zero value is invalid
// (Validate rejects all-zero security-relevant roots) so a caller cannot
// silently sign an unbound session.
type PulsarSession struct {
ChainID [32]byte // chain this round finalizes on
Epoch uint64 // DKG / committee epoch
Height uint64 // block height
Round uint64 // intra-height round / view
BlockHash [32]byte // the block (item) being certified
ValidatorSetRoot [32]byte // commitment to the active validator set
JointPKID [32]byte // identifier of the committee joint ML-DSA key
DKGTranscriptRoot [32]byte // commitment to the DKG transcript that made the key
CommitteeID [32]byte // identifier of the sampled committee
SignerSetRoot [32]byte // commitment to the eligible signer set
NoncePoolRoot [32]byte // Merkle root of the background NonceCert pool
ProtocolVersion uint32 // wire/protocol version
}
// SessionID derives the 32-byte session identifier by hashing every binding
// field under a domain-separated cSHAKE256. Each variable-position field is
// fixed-width (arrays) or length-free fixed integers, so the concatenation is
// unambiguous (canonical encoding -- no field-boundary grinding).
//
// Field order is fixed and MUST match the task's session_id definition:
//
// H(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)
func (s *PulsarSession) SessionID() [32]byte {
h := sha3.NewCShake256(nil, []byte(sessionCustomization))
_, _ = h.Write([]byte(sessionProtocolTag))
var u8 [8]byte
var u4 [4]byte
_, _ = h.Write(s.ChainID[:])
binary.BigEndian.PutUint64(u8[:], s.Epoch)
_, _ = h.Write(u8[:])
binary.BigEndian.PutUint64(u8[:], s.Height)
_, _ = h.Write(u8[:])
binary.BigEndian.PutUint64(u8[:], s.Round)
_, _ = h.Write(u8[:])
_, _ = h.Write(s.BlockHash[:])
_, _ = h.Write(s.ValidatorSetRoot[:])
_, _ = h.Write(s.JointPKID[:])
_, _ = h.Write(s.DKGTranscriptRoot[:])
_, _ = h.Write(s.CommitteeID[:])
_, _ = h.Write(s.SignerSetRoot[:])
_, _ = h.Write(s.NoncePoolRoot[:])
binary.BigEndian.PutUint32(u4[:], s.ProtocolVersion)
_, _ = h.Write(u4[:])
var out [32]byte
_, _ = h.Read(out[:])
return out
}
// Validate refuses a session whose security-relevant roots are all zero. A
// zero root means an unbound field (e.g. a missing DKG transcript or
// validator-set commitment); signing under it would let an adversary reuse the
// signature across rounds. ProtocolVersion and the numeric height/round/epoch
// are allowed to be zero (genesis, view 0).
func (s *PulsarSession) Validate() error {
zero := [32]byte{}
switch {
case s.ChainID == zero:
return ErrSessionFieldUnset
case s.BlockHash == zero:
return ErrSessionFieldUnset
case s.ValidatorSetRoot == zero:
return ErrSessionFieldUnset
case s.JointPKID == zero:
return ErrSessionFieldUnset
case s.DKGTranscriptRoot == zero:
return ErrSessionFieldUnset
case s.CommitteeID == zero:
return ErrSessionFieldUnset
case s.SignerSetRoot == zero:
return ErrSessionFieldUnset
case s.NoncePoolRoot == zero:
return ErrSessionFieldUnset
}
return nil
}