quasar: verifiable weighted quorum certificate (threshold certification, not signing)

The production PQ consensus threshold object: each validator signs the
domain-separated round message INDEPENDENTLY with its own FIPS 204 ML-DSA /
FIPS 205 SLH-DSA / Corona key; the certificate proves a quorum predicate over
those independent signatures — NO key sharing, DKG, seed/WOTS+/FORS
reconstruction, or per-leaf secret exposure. This is the safe, leaderless,
permissionless, NIST-clean realization of QuasarCert's non-threshold path
(theory: cheap black-box threshold hash-based signing is impossible in the
no-dealer model — Kondi-Kumar-Vanegas; only independent-sig certification or
expensive generic MPC remain).

  - weighted_merkle.go: epoch validator-set Merkle commitment; leaf binds
    (epoch, validator_id, pubkey, voting_weight, param_set_id, key_version),
    sorted, second-preimage-guarded.
  - quorum_message.go: one domain-separated consensus message binding
    chain/epoch/height/round/value/qc_type — no cross-domain validity.
  - quorum_scheme.go: pluggable per-signer verify via UNMODIFIED FIPS 204/205
    (+ Corona) verification; one framework, three schemes.
  - quorum_cert.go: prover (permissionless, no secrets) + verifier predicate —
    Merkle inclusion, strictly-increasing distinct signers, Σweight ≥ threshold,
    aggregate-weight + signer-commitment binding; unknown validator = invalid
    (not fatal). Compact (commitment-only) + full (records) DA encodings.
  - quorum_quasarcert.go: QuasarCert weighted-quorum leg + discriminator.
  - config: VerifierDirectWeightedQuorumPQ = 0x0030 (carries no trusted setup).

40 tests: independent-verifier interop, distinct-signer, weight-threshold,
cross-domain non-transferability, Merkle soundness, tamper rejection,
mixed-scheme, compact↔full round-trip. (go.sum: correct stale luxfi/threshold
v1.9.4 hash to the published tag.)
This commit is contained in:
zooqueen
2026-06-21 12:53:08 -07:00
parent f81668bf1c
commit 4dec3e7738
10 changed files with 3096 additions and 3 deletions
+42 -1
View File
@@ -503,6 +503,16 @@ func (p ProofPolicyID) IsForbiddenInPQMode() bool {
// 0x22 = P3Q_STARK_FRI_SHA3_PQ Lux P3Q (Plonky3 fork, cSHAKE256 Merkle)
// 0x23 = STONE_CAIRO_STARK_PQ Cairo / Stone backend
// 0x24 = STWO_CIRCLE_STARK_PQ Stwo Circle STARK
// 0x30..0x3F — Direct full-node verification (no succinct proof). The
// "proof" is the certificate itself: each signer's
// unmodified FIPS 204/205 signature, a weighted-validator-set
// Merkle inclusion path, and a weight-threshold predicate.
// Soundness rests ONLY on the underlying FIPS verifier plus
// Merkle second-preimage resistance — there is no
// succinctness layer and therefore no STARK/SNARK trusted
// component to audit. A STARK compression backend over the
// SAME relation is a separate id in the 0x10/0x20 block.
// 0x30 = DIRECT_WEIGHTED_QUORUM_PQ per-signer FIPS verify + Merkle + weight predicate
// 0x70..0x7F — Dev-only / non-production (CI, testnet, audit fuzzing)
// 0x70 = RISC0_RAW_STARK_DEV
// 0x71 = SP1_CORE_STARK_DEV
@@ -519,6 +529,18 @@ const (
ProofBackendStoneCairoSTARK ProofBackendID = 0x23
ProofBackendStwoCircleSTARK ProofBackendID = 0x24
// ProofBackendDirectWeightedQuorum is the direct full-node-verifiable
// weighted quorum certificate backend (HIP-0079 §"full-node-verifiable
// mode"). Verification is direct: for each signer record, run unmodified
// FIPS 204 (ML-DSA) or FIPS 205 (SLH-DSA) Verify over the
// domain-separated consensus message, check the weighted-validator-set
// Merkle inclusion path, then assert the accumulated voting weight meets
// the quorum threshold. No succinct proof, no recursion, no trusted
// setup; the per-signer signatures ARE the proof. This is threshold
// CERTIFICATION, not threshold signing — no key material is shared,
// reconstructed, or combined. See protocol/quasar/quorum_cert.go.
ProofBackendDirectWeightedQuorum ProofBackendID = 0x30
// Dev-only — never produced in production strict-PQ deployments.
ProofBackendRISC0RawSTARKDev ProofBackendID = 0x70
ProofBackendSP1CoreSTARKDev ProofBackendID = 0x71
@@ -545,6 +567,8 @@ func (b ProofBackendID) String() string {
return "stone-cairo-stark-pq"
case ProofBackendStwoCircleSTARK:
return "stwo-circle-stark-pq"
case ProofBackendDirectWeightedQuorum:
return "direct-weighted-quorum-pq"
case ProofBackendRISC0RawSTARKDev:
return "risc0-raw-stark-dev"
case ProofBackendSP1CoreSTARKDev:
@@ -561,18 +585,35 @@ func (b ProofBackendID) String() string {
// IsProductionPQ reports whether this backend is acceptable in strict-PQ
// production mode. Dev backends (0x70 block) and forbidden backends
// (0x80 block) return false.
//
// ProofBackendDirectWeightedQuorum qualifies: its soundness rests on
// unmodified FIPS 204/205 verification plus Merkle second-preimage
// resistance, which is strictly inside the NIST-approved algorithm set
// (no classical EC, no trusted setup). It is the most conservative
// production-PQ posture — there is no succinctness assumption to break.
func (b ProofBackendID) IsProductionPQ() bool {
switch b {
case ProofBackendRISC0SuccinctSTARK,
ProofBackendSP1CompressedSTARK,
ProofBackendP3QSTARKFRISHA3,
ProofBackendStoneCairoSTARK,
ProofBackendStwoCircleSTARK:
ProofBackendStwoCircleSTARK,
ProofBackendDirectWeightedQuorum:
return true
}
return false
}
// IsDirectVerifiable reports whether this backend is verified directly by
// a full node — no succinct proof, no recursion, no trusted setup. The
// certificate carries the per-signer FIPS signatures and a Merkle/weight
// predicate; the verifier replays them. Distinguishes the
// "full-node-verifiable mode" from the succinct (STARK/SNARK) backends so
// audit tooling can assert which trust model a cert envelope rides under.
func (b ProofBackendID) IsDirectVerifiable() bool {
return b == ProofBackendDirectWeightedQuorum
}
// IsForbiddenInPQMode mirrors ProofPolicyID.IsForbiddenInPQMode at the
// backend layer.
func (b ProofBackendID) IsForbiddenInPQMode() bool {
+2 -2
View File
@@ -202,8 +202,8 @@ github.com/luxfi/runtime v1.1.0 h1:6TrvzAmZVCTVbR1ebntHTO3/kVBaogPUSkxdDMnrTiw=
github.com/luxfi/runtime v1.1.0/go.mod h1:Mfv2zlXqvfRFMS+/zXgG1TieyP9VnvtVzOGB437+o4Y=
github.com/luxfi/sampler v1.1.0 h1:u3iRDl7V06ARh0e85h3HT+aZ1saCFo2yMMsh+dCJbqk=
github.com/luxfi/sampler v1.1.0/go.mod h1:kJa53S3tC9+VSbuV3RFu68MmbCCBlr2UM39LOClQ/Hs=
github.com/luxfi/threshold v1.9.4 h1:H69e9QkvygDtWkni1FkD5ztLdiTasmpJXcuVzgebdxo=
github.com/luxfi/threshold v1.9.4/go.mod h1:o50rsc4H9fIXHuZXSf93qDFfRTXncXaXf1Q+6JAD1vU=
github.com/luxfi/threshold v1.9.4 h1:/TsgIzo/e/DIx++J0+9eNuS7HkpSaXbVk+HvhlUOsmE=
github.com/luxfi/threshold v1.9.4/go.mod h1:yFEqzc07h+olG8CpJ+i2FjSLxCznk/GcISRfxMuDBvE=
github.com/luxfi/utils v1.1.5 h1:6q85557b7djbZ/OF6S1k5wyGddozWnsoUuhokPHQRvo=
github.com/luxfi/utils v1.1.5/go.mod h1:T2OCKT1xG9jtKR/gyJQoSkticzrE9WFQ8eohJHGu9Fg=
github.com/luxfi/validators v1.2.0 h1:VygpiBqBAdGrfkb7xzE2yrVmnXaqE+hm8FLWdGXO7G8=
+907
View File
@@ -0,0 +1,907 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// quorum_cert.go — the verifiable weighted quorum certificate.
//
// A WeightedQuorumCert proves: a quorum-WEIGHT subset of the epoch
// validator set each produced a valid INDEPENDENT FIPS 204 ML-DSA /
// FIPS 205 SLH-DSA signature over the same domain-separated consensus
// message. The proof is the certificate itself — there is no succinct
// (STARK/SNARK) layer here; verification is direct (full-node-verifiable
// mode):
//
// for each signer record:
// (a) the record's leaf is included under validator_set_root (Merkle)
// (b) the record's parameter set is in the allowed set
// (c) the record's signature verifies under the stock FIPS verifier
// (d) validator_ids are STRICTLY INCREASING (distinct, canonical,
// anti-double-count)
// accumulate voting weight; then
// Σ weight ≥ quorum_threshold
// Σ weight == aggregate_weight
// commit(signer_ids) == signer_commitment
//
// Security posture (NIST Class N1): soundness rests ONLY on (i) unmodified
// FIPS 204/205 verification — each signer's signature is byte-identical to
// what cloudflare/circl, the NIST reference, or openssl-pq accept; and (ii)
// the weighted-validator-set Merkle commitment's second-preimage resistance
// (weighted_merkle.go). No private key material is shared, reconstructed,
// or combined. This is threshold CERTIFICATION, NOT threshold ML-DSA /
// SLH-DSA signing: there is no DKG, no seed / WOTS+ / FORS / secret share
// ever combined or exposed. A STARK compression layer over the SAME
// relation is a SEPARATE future backend (ProofBackendID 0x10/0x20 block);
// it is out of scope here.
//
// Permissionless: building a cert needs NO secrets — a prover collects
// already-produced signer records, sorts them, and assembles. Leaderless:
// any node with the records can build the identical cert.
package quasar
import (
"bytes"
"encoding/binary"
"errors"
"fmt"
"github.com/luxfi/consensus/config"
)
// QuorumCertVersion is the wire/struct version of the weighted quorum
// certificate. Bound into the signer commitment and the encodings so a
// version bump is non-malleable.
const QuorumCertVersion uint16 = 1
// signerCommitmentCustomization is the cSHAKE256 customization tag for the
// signer-set commitment. Wire-stable cryptographic constant.
const signerCommitmentCustomization = "QUASAR-WQC-SIGNERS-V1"
// signerCommitmentProtocolTag is the in-band redundant protocol tag for the
// signer-set commitment.
const signerCommitmentProtocolTag = "Quasar/WQC/Signers"
// QuorumSignerRecord is one signer's contribution to a weighted quorum
// certificate: their identity, the public key + accountability fields that
// MUST match the validator-set leaf, the Merkle path proving that leaf is
// committed, and their independent signature over the consensus message.
//
// Every field except MerklePath and Signature is also a validator-set leaf
// field; the verifier reconstructs the leaf from these and checks the path.
// A record whose embedded fields do not match a committed leaf fails the
// Merkle check — that is how an unknown validator / pubkey-mismatch /
// out-of-set record is rejected (cleanly, never a panic).
type QuorumSignerRecord struct {
// ValidatorID is the signer's canonical 32-byte identifier. Records are
// sorted strictly-increasing by this field.
ValidatorID [32]byte
// PublicKey is the signer's canonical public-key bytes. Bound into the
// leaf AND used as the verification key — both must agree.
PublicKey []byte
// VotingWeight is the signer's stake weight. Bound into the leaf and
// accumulated toward the quorum threshold.
VotingWeight uint64
// Scheme names the signature scheme this record was produced under
// (ML-DSA / SLH-DSA per FIPS 204 / 205). The verifier dispatches to the
// stock FIPS verifier for this scheme.
Scheme QuorumSchemeID
// ParamSetID is the parameter-set byte bound into the validator-set
// leaf. It MUST equal byte(Scheme) — the leaf commits to the parameter
// set, so a record cannot claim one scheme while the leaf pins another.
ParamSetID uint8
// KeyVersion is the signer's key-rotation counter, bound into the leaf.
KeyVersion uint32
// MerklePath proves leaf(ValidatorID, PublicKey, VotingWeight,
// ParamSetID, KeyVersion) is included under validator_set_root.
MerklePath *WeightedInclusionProof
// Signature is the signer's independent signature over the consensus
// message. Verified by the stock FIPS verifier for Scheme.
Signature []byte
}
// leaf reconstructs the validator-set leaf this record claims.
func (r *QuorumSignerRecord) leaf() WeightedValidatorLeaf {
return WeightedValidatorLeaf{
ValidatorID: r.ValidatorID,
PublicKey: r.PublicKey,
VotingWeight: r.VotingWeight,
ParameterSetID: r.ParamSetID,
KeyVersion: r.KeyVersion,
}
}
// WeightedQuorumCert is the certificate. It is NOT a signature: there is no
// "aggregate_signature" field, because nothing is aggregated — the cert
// carries (in full mode) the per-signer records, or (in compact mode) just
// the commitment to them. Verification is the predicate documented at the
// top of this file.
type WeightedQuorumCert struct {
// Version pins the cert format.
Version uint16
// Consensus position — the message-binding axes. These MUST equal the
// values the signers' message was derived from (quorum_message.go).
ChainID uint32
Epoch uint64
Height uint64
Round uint32
// ValueHash is the block_id / value hash this quorum finalises (the
// payload anchor of the consensus message).
ValueHash [32]byte
// QCType names the certificate's semantic role (prepare / commit /
// finality / …) so a signature for one role cannot be replayed as
// another. Bound into the consensus message.
QCType uint8
// ValidatorSetRoot is the weighted-validator-set commitment every
// signer's leaf is proven against (weighted_merkle.go). Equals the
// epoch's committed root.
ValidatorSetRoot [48]byte
// QuorumThreshold is the minimum total voting weight required for the
// cert to be valid. Σ signer weight must meet or exceed it.
QuorumThreshold uint64
// AggregateWeight is the prover's claimed total signer weight. The
// verifier recomputes Σ weight and asserts equality — a tampered
// aggregate is rejected.
AggregateWeight uint64
// SignerCommitment is the cSHAKE256 commitment to the ordered signer-id
// set (+ version + position). The verifier recomputes it from the
// records and asserts equality — closes signer-set malleability.
SignerCommitment [48]byte
// SignerCount is the number of signer records (the cardinality the
// compact encoding still commits to even when records are omitted).
SignerCount uint32
// Signers is the per-signer records. Populated in FULL mode (records in
// the certificate); EMPTY in COMPACT mode (records live in the DA layer,
// the header carries only the commitment). A compact cert is verified
// by re-attaching records from DA and calling Verify.
Signers []QuorumSignerRecord
}
// Typed verification errors. Each maps 1:1 to one predicate clause so a
// caller / test can name the exact failure. Every one is a CLEAN rejection
// (the cert is invalid); none is a panic or an unbounded-work DoS.
var (
ErrQCNil = errors.New("quasar: nil weighted quorum cert")
ErrQCVersion = errors.New("quasar: weighted quorum cert version mismatch")
ErrQCNoSigners = errors.New("quasar: weighted quorum cert has no signer records")
ErrQCCompactNoRecords = errors.New("quasar: compact cert carries no records; re-attach from DA before Verify")
ErrQCSignerCountMismatch = errors.New("quasar: signer record count does not match SignerCount")
ErrQCNotStrictlyIncreasing = errors.New("quasar: signer validator_ids are not strictly increasing (duplicate or unsorted)")
ErrQCSchemeNotAllowed = errors.New("quasar: signer scheme not in allowed set")
ErrQCParamSetMismatch = errors.New("quasar: signer ParamSetID does not equal scheme byte")
ErrQCPubKeyLen = errors.New("quasar: signer public key length wrong for its scheme")
ErrQCMerkleInclusion = errors.New("quasar: signer leaf not included under validator_set_root")
ErrQCSigInvalid = errors.New("quasar: signer signature failed FIPS verification")
ErrQCBelowThreshold = errors.New("quasar: total signer weight below quorum threshold")
ErrQCAggregateWeight = errors.New("quasar: recomputed signer weight != AggregateWeight")
ErrQCSignerCommitment = errors.New("quasar: recomputed signer commitment != SignerCommitment")
ErrQCWeightOverflow = errors.New("quasar: signer weight sum overflows uint64")
ErrQCThresholdZero = errors.New("quasar: quorum threshold is zero")
)
// QuorumVerifierConfig pins the policy axes the verifier enforces. Kept
// separate from the cert so verification policy (what's allowed) and the
// cert (what's claimed) are not braided.
type QuorumVerifierConfig struct {
// AllowedSchemes is the set of per-signer schemes admissible on this
// chain. A record whose scheme is absent fails (ErrQCSchemeNotAllowed).
// Empty means "every scheme the registry knows" — convenient for tests;
// production callers pin an explicit set via the chain profile.
AllowedSchemes map[QuorumSchemeID]bool
// Context is the FIPS context string passed to every per-signer verify.
// It MUST match what signers bound at sign time. Length ≤ 255.
Context []byte
}
// schemeAllowed reports whether scheme is admissible under this config.
func (c *QuorumVerifierConfig) schemeAllowed(scheme QuorumSchemeID) bool {
if len(c.AllowedSchemes) == 0 {
_, known := defaultQuorumVerifiers[scheme]
return known
}
return c.AllowedSchemes[scheme]
}
// Verify checks the certificate against message under cfg. Returns nil iff
// every predicate clause holds; otherwise a typed error naming the first
// failure. NEVER panics and NEVER does unbounded work — an adversarial cert
// (unknown validator, bad signature, tampered weight) yields a clean error,
// not a node crash.
//
// message is the domain-separated consensus message the signers signed —
// build it with QuorumConsensusMessage so the binding to
// (chain_id, epoch, height, round, value_hash, qc_type, validator_set_root)
// is canonical and matches what the cert claims.
//
// The verifier cross-checks the cert's own position fields against the
// supplied message via the caller's discipline: the caller derives message
// from the SAME (chain_id, epoch, …) it expects, so a cert whose signers
// signed a different message fails clause (c) for every record. The
// position fields are additionally bound into SignerCommitment, so a cert
// that lies about its own position fails clause (commitment) too.
func (c *WeightedQuorumCert) Verify(message []byte, cfg QuorumVerifierConfig) error {
if c == nil {
return ErrQCNil
}
if c.Version != QuorumCertVersion {
return fmt.Errorf("%w: got %d want %d", ErrQCVersion, c.Version, QuorumCertVersion)
}
if c.QuorumThreshold == 0 {
return ErrQCThresholdZero
}
if c.SignerCount == 0 {
return ErrQCNoSigners
}
if len(c.Signers) == 0 {
// Compact cert: records were stripped to the DA layer. Cannot verify
// the predicate without them. Caller must re-attach records first.
return ErrQCCompactNoRecords
}
if uint32(len(c.Signers)) != c.SignerCount {
return fmt.Errorf("%w: records=%d SignerCount=%d",
ErrQCSignerCountMismatch, len(c.Signers), c.SignerCount)
}
var total uint64
var prev [32]byte
havePrev := false
for i := range c.Signers {
rec := &c.Signers[i]
// Clause (d): strictly increasing validator_ids. Distinct +
// canonical order; closes duplicate-signer double counting and
// signer-set malleability (re-ordering a valid cert).
if havePrev && !bytesLess(prev[:], rec.ValidatorID[:]) {
return fmt.Errorf("%w: record %d", ErrQCNotStrictlyIncreasing, i)
}
prev = rec.ValidatorID
havePrev = true
// Clause (b): scheme in the allowed set.
if !cfg.schemeAllowed(rec.Scheme) {
return fmt.Errorf("%w: record %d scheme %s", ErrQCSchemeNotAllowed, i, rec.Scheme)
}
// ParamSetID must equal the scheme byte — the leaf commits to the
// parameter set, so a record cannot claim ML-DSA-65 while its leaf
// pins ML-DSA-44 (cross-parameter confusion).
if rec.ParamSetID != uint8(rec.Scheme) {
return fmt.Errorf("%w: record %d param=0x%02x scheme=0x%02x",
ErrQCParamSetMismatch, i, rec.ParamSetID, uint8(rec.Scheme))
}
// Public-key length must match the scheme before any dispatch.
wantLen, known := expectedPubKeyLen(rec.Scheme)
if !known {
return fmt.Errorf("%w: record %d scheme %s", ErrQCSchemeNotAllowed, i, rec.Scheme)
}
if len(rec.PublicKey) != wantLen {
return fmt.Errorf("%w: record %d scheme %s have=%d want=%d",
ErrQCPubKeyLen, i, rec.Scheme, len(rec.PublicKey), wantLen)
}
// Clause (a): Merkle inclusion under validator_set_root. This is
// where an unknown validator / pubkey-mismatch / out-of-set record
// is rejected — its reconstructed leaf is not in the committed tree.
if !VerifyWeightedInclusion(c.ValidatorSetRoot, c.Epoch, rec.leaf(), rec.MerklePath) {
return fmt.Errorf("%w: record %d", ErrQCMerkleInclusion, i)
}
// Clause (c): unmodified FIPS verify. The dispatch is a thin call
// into the stock FIPS 204 / 205 verifier for the scheme.
verify := defaultQuorumVerifiers[rec.Scheme]
if verify == nil || !verify(rec.PublicKey, message, cfg.Context, rec.Signature) {
return fmt.Errorf("%w: record %d scheme %s", ErrQCSigInvalid, i, rec.Scheme)
}
// Accumulate weight (overflow-checked).
if total+rec.VotingWeight < total {
return ErrQCWeightOverflow
}
total += rec.VotingWeight
}
// Σ weight ≥ quorum_threshold.
if total < c.QuorumThreshold {
return fmt.Errorf("%w: have %d need %d", ErrQCBelowThreshold, total, c.QuorumThreshold)
}
// Σ weight == aggregate_weight (tamper check).
if total != c.AggregateWeight {
return fmt.Errorf("%w: recomputed %d claimed %d", ErrQCAggregateWeight, total, c.AggregateWeight)
}
// commit(signer_ids) == signer_commitment (signer-set malleability).
want := c.computeSignerCommitment()
if want != c.SignerCommitment {
return ErrQCSignerCommitment
}
return nil
}
// computeSignerCommitment recomputes the cSHAKE256 commitment over the
// ordered signer-id set plus the cert's version and consensus position.
// Binding the position fields here means a cert that lies about its own
// (chain_id, epoch, height, round, value_hash, qc_type, validator_set_root,
// quorum_threshold) fails the commitment check even before the per-signer
// message binding catches it — defence-in-depth against header tampering.
//
// Layout (TupleHash256, customization "QUASAR-WQC-SIGNERS-V1"):
//
// parts[0] = "Quasar/WQC/Signers"
// parts[1] = version (2 BE)
// parts[2] = chain_id (4 BE)
// parts[3] = epoch (8 BE)
// parts[4] = height (8 BE)
// parts[5] = round (4 BE)
// parts[6] = value_hash ([32]byte)
// parts[7] = qc_type (1)
// parts[8] = validator_set_root ([48]byte)
// parts[9] = quorum_threshold (8 BE)
// parts[10] = signer_count (4 BE)
// parts[11+i] = validator_id[i] ([32]byte), in record order
func (c *WeightedQuorumCert) computeSignerCommitment() [48]byte {
var u16 [2]byte
var u32 [4]byte
var u64 [8]byte
binary.BigEndian.PutUint16(u16[:], c.Version)
verBytes := append([]byte(nil), u16[:]...)
binary.BigEndian.PutUint32(u32[:], c.ChainID)
chainBytes := append([]byte(nil), u32[:]...)
binary.BigEndian.PutUint64(u64[:], c.Epoch)
epochBytes := append([]byte(nil), u64[:]...)
binary.BigEndian.PutUint64(u64[:], c.Height)
heightBytes := append([]byte(nil), u64[:]...)
binary.BigEndian.PutUint32(u32[:], c.Round)
roundBytes := append([]byte(nil), u32[:]...)
binary.BigEndian.PutUint64(u64[:], c.QuorumThreshold)
threshBytes := append([]byte(nil), u64[:]...)
binary.BigEndian.PutUint32(u32[:], c.SignerCount)
countBytes := append([]byte(nil), u32[:]...)
parts := make([][]byte, 0, 11+len(c.Signers))
parts = append(parts,
[]byte(signerCommitmentProtocolTag),
verBytes,
chainBytes,
epochBytes,
heightBytes,
roundBytes,
c.ValueHash[:],
[]byte{c.QCType},
c.ValidatorSetRoot[:],
threshBytes,
countBytes,
)
for i := range c.Signers {
parts = append(parts, c.Signers[i].ValidatorID[:])
}
out := tupleHash256RoundDigest(parts, 48, signerCommitmentCustomization)
var h [48]byte
copy(h[:], out)
return h
}
// ----------------------------------------------------------------------------
// Prover (permissionless — needs NO secrets)
// ----------------------------------------------------------------------------
// QuorumCertParams names the consensus position + policy a cert is built
// for. Separate from the records so the prover's two inputs (what we're
// certifying / who signed) are not braided.
type QuorumCertParams struct {
ChainID uint32
Epoch uint64
Height uint64
Round uint32
ValueHash [32]byte
QCType uint8
ValidatorSetRoot [48]byte
QuorumThreshold uint64
}
// BuildWeightedQuorumCert assembles a FULL (records-included) certificate
// from already-produced signer records. It is permissionless and
// deterministic: NO secrets, NO randomness, NO hidden state. Given the same
// records and params, the same cert bytes come out.
//
// The prover sorts records strictly-increasing by validator_id (rejecting
// duplicates), computes aggregate_weight, and computes the signer
// commitment. It does NOT verify the records' signatures — assembly is
// orthogonal to verification (a relaying node assembles; verifiers verify).
// It DOES reject a structurally impossible cert (no records, duplicate ids,
// zero threshold) so a malformed cert is never produced.
func BuildWeightedQuorumCert(params QuorumCertParams, records []QuorumSignerRecord) (*WeightedQuorumCert, error) {
if params.QuorumThreshold == 0 {
return nil, ErrQCThresholdZero
}
if len(records) == 0 {
return nil, ErrQCNoSigners
}
sorted := make([]QuorumSignerRecord, len(records))
copy(sorted, records)
// Defensive copy of variable-length fields so a later caller mutation
// cannot move the assembled cert underneath us.
for i := range sorted {
sorted[i].PublicKey = append([]byte(nil), sorted[i].PublicKey...)
sorted[i].Signature = append([]byte(nil), sorted[i].Signature...)
}
sortSignerRecords(sorted)
var total uint64
for i := range sorted {
if i > 0 && sorted[i].ValidatorID == sorted[i-1].ValidatorID {
return nil, fmt.Errorf("%w: %x", ErrWVSetDuplicateID, sorted[i].ValidatorID[:])
}
if total+sorted[i].VotingWeight < total {
return nil, ErrQCWeightOverflow
}
total += sorted[i].VotingWeight
}
cert := &WeightedQuorumCert{
Version: QuorumCertVersion,
ChainID: params.ChainID,
Epoch: params.Epoch,
Height: params.Height,
Round: params.Round,
ValueHash: params.ValueHash,
QCType: params.QCType,
ValidatorSetRoot: params.ValidatorSetRoot,
QuorumThreshold: params.QuorumThreshold,
AggregateWeight: total,
SignerCount: uint32(len(sorted)),
Signers: sorted,
}
cert.SignerCommitment = cert.computeSignerCommitment()
return cert, nil
}
// sortSignerRecords sorts records strictly by validator_id ascending.
func sortSignerRecords(recs []QuorumSignerRecord) {
// insertion-free stable sort via the std sort on a closure would alloc;
// validator IDs are public so a simple comparison sort is fine.
for i := 1; i < len(recs); i++ {
for j := i; j > 0 && bytesLess(recs[j].ValidatorID[:], recs[j-1].ValidatorID[:]); j-- {
recs[j], recs[j-1] = recs[j-1], recs[j]
}
}
}
// ----------------------------------------------------------------------------
// Compact ↔ full (DA hybrid mode)
// ----------------------------------------------------------------------------
// Compact returns a copy of the cert with the per-signer records stripped —
// the commitment-only form a block header carries. The raw signer records
// live in the data-availability layer; a verifier re-attaches them with
// WithRecords before calling Verify. The SignerCommitment + SignerCount are
// retained, so the header still binds exactly which signer set the cert
// claims (a DA layer cannot substitute a different record set without
// failing the commitment check on re-attach).
func (c *WeightedQuorumCert) Compact() *WeightedQuorumCert {
cp := *c
cp.Signers = nil
return &cp
}
// IsCompact reports whether the cert is in commitment-only form (no
// records). A compact cert cannot be verified until records are re-attached.
func (c *WeightedQuorumCert) IsCompact() bool {
return len(c.Signers) == 0 && c.SignerCount > 0
}
// WithRecords re-attaches signer records to a compact cert (records fetched
// from the DA layer). It does NOT trust the records: WithRecords binds them
// in, and a subsequent Verify recomputes the signer commitment — if the DA
// layer served a different record set than the header committed to, Verify
// returns ErrQCSignerCommitment. Returns a copy; the receiver is unmodified.
func (c *WeightedQuorumCert) WithRecords(records []QuorumSignerRecord) *WeightedQuorumCert {
cp := *c
cp.Signers = make([]QuorumSignerRecord, len(records))
copy(cp.Signers, records)
return &cp
}
// ----------------------------------------------------------------------------
// Wire codec — full and compact encodings
// ----------------------------------------------------------------------------
//
// Compact header layout (deterministic, big-endian):
//
// kind:1 = wqcKindCompact (0x00) | wqcKindFull (0x01)
// version:2
// chain_id:4
// epoch:8
// height:8
// round:4
// value_hash:32
// qc_type:1
// validator_set_root:48
// quorum_threshold:8
// aggregate_weight:8
// signer_commitment:48
// signer_count:4
//
// Full layout = compact header (kind=0x01) followed by signer_count signer
// records, each:
//
// validator_id:32
// scheme:1
// param_set_id:1
// key_version:4
// voting_weight:8
// pubkey_len:4 pubkey:pubkey_len
// sig_len:4 sig:sig_len
// merkle_leaf_index:4
// merkle_leaf_count:4
// merkle_step_count:4
// step[i]: flags:1 (bit0=promoted, bit1=sibling_is_right) [+ sibling:48 if !promoted]
const (
wqcKindCompact byte = 0x00
wqcKindFull byte = 0x01
)
// wqcHeaderSize is the fixed compact-header byte length.
const wqcHeaderSize = 1 + 2 + 4 + 8 + 8 + 4 + 32 + 1 + 48 + 8 + 8 + 48 + 4
// ErrQCWireCorrupt is returned by the decoders on any structural defect.
var ErrQCWireCorrupt = errors.New("quasar: weighted quorum cert wire corrupt")
// MarshalBinary encodes the cert. If records are present it emits the FULL
// form; otherwise the COMPACT form. Deterministic: equal certs encode to
// equal bytes.
func (c *WeightedQuorumCert) MarshalBinary() ([]byte, error) {
if c == nil {
return nil, ErrQCNil
}
full := len(c.Signers) > 0
kind := wqcKindCompact
if full {
kind = wqcKindFull
}
buf := make([]byte, 0, wqcHeaderSize)
buf = append(buf, kind)
buf = appendU16(buf, c.Version)
buf = appendU32(buf, c.ChainID)
buf = appendU64(buf, c.Epoch)
buf = appendU64(buf, c.Height)
buf = appendU32(buf, c.Round)
buf = append(buf, c.ValueHash[:]...)
buf = append(buf, c.QCType)
buf = append(buf, c.ValidatorSetRoot[:]...)
buf = appendU64(buf, c.QuorumThreshold)
buf = appendU64(buf, c.AggregateWeight)
buf = append(buf, c.SignerCommitment[:]...)
buf = appendU32(buf, c.SignerCount)
if !full {
return buf, nil
}
for i := range c.Signers {
rec := &c.Signers[i]
buf = append(buf, rec.ValidatorID[:]...)
buf = append(buf, byte(rec.Scheme))
buf = append(buf, rec.ParamSetID)
buf = appendU32(buf, rec.KeyVersion)
buf = appendU64(buf, rec.VotingWeight)
buf = appendU32(buf, uint32(len(rec.PublicKey)))
buf = append(buf, rec.PublicKey...)
buf = appendU32(buf, uint32(len(rec.Signature)))
buf = append(buf, rec.Signature...)
if rec.MerklePath == nil {
return nil, fmt.Errorf("%w: record %d nil merkle path", ErrQCWireCorrupt, i)
}
buf = appendU32(buf, rec.MerklePath.LeafIndex)
buf = appendU32(buf, rec.MerklePath.LeafCount)
buf = appendU32(buf, uint32(len(rec.MerklePath.Steps)))
for _, st := range rec.MerklePath.Steps {
var flags byte
if st.Promoted {
flags |= 0x01
}
if st.SiblingIsRight {
flags |= 0x02
}
buf = append(buf, flags)
if !st.Promoted {
buf = append(buf, st.Sibling[:]...)
}
}
}
return buf, nil
}
// UnmarshalWeightedQuorumCert is the inverse of MarshalBinary. Strict
// trailing-bytes policy: any byte after the declared frame rejects the
// input. Fail-closed on every short read.
func UnmarshalWeightedQuorumCert(data []byte) (*WeightedQuorumCert, error) {
r := &qcReader{buf: data}
kind, err := r.u8()
if err != nil {
return nil, ErrQCWireCorrupt
}
if kind != wqcKindCompact && kind != wqcKindFull {
return nil, fmt.Errorf("%w: bad kind 0x%02x", ErrQCWireCorrupt, kind)
}
c := &WeightedQuorumCert{}
if c.Version, err = r.u16(); err != nil {
return nil, ErrQCWireCorrupt
}
if c.ChainID, err = r.u32(); err != nil {
return nil, ErrQCWireCorrupt
}
if c.Epoch, err = r.u64(); err != nil {
return nil, ErrQCWireCorrupt
}
if c.Height, err = r.u64(); err != nil {
return nil, ErrQCWireCorrupt
}
if c.Round, err = r.u32(); err != nil {
return nil, ErrQCWireCorrupt
}
if err = r.read32(&c.ValueHash); err != nil {
return nil, ErrQCWireCorrupt
}
if c.QCType, err = r.u8(); err != nil {
return nil, ErrQCWireCorrupt
}
if err = r.read48(&c.ValidatorSetRoot); err != nil {
return nil, ErrQCWireCorrupt
}
if c.QuorumThreshold, err = r.u64(); err != nil {
return nil, ErrQCWireCorrupt
}
if c.AggregateWeight, err = r.u64(); err != nil {
return nil, ErrQCWireCorrupt
}
if err = r.read48(&c.SignerCommitment); err != nil {
return nil, ErrQCWireCorrupt
}
if c.SignerCount, err = r.u32(); err != nil {
return nil, ErrQCWireCorrupt
}
if kind == wqcKindCompact {
if len(r.buf) != 0 {
return nil, fmt.Errorf("%w: %d trailing bytes (compact)", ErrQCWireCorrupt, len(r.buf))
}
return c, nil
}
// Full: decode signer_count records.
c.Signers = make([]QuorumSignerRecord, 0, c.SignerCount)
for i := uint32(0); i < c.SignerCount; i++ {
var rec QuorumSignerRecord
if err = r.read32(&rec.ValidatorID); err != nil {
return nil, ErrQCWireCorrupt
}
sch, err := r.u8()
if err != nil {
return nil, ErrQCWireCorrupt
}
rec.Scheme = QuorumSchemeID(sch)
if rec.ParamSetID, err = r.u8(); err != nil {
return nil, ErrQCWireCorrupt
}
if rec.KeyVersion, err = r.u32(); err != nil {
return nil, ErrQCWireCorrupt
}
if rec.VotingWeight, err = r.u64(); err != nil {
return nil, ErrQCWireCorrupt
}
if rec.PublicKey, err = r.lenPrefixed(); err != nil {
return nil, ErrQCWireCorrupt
}
if rec.Signature, err = r.lenPrefixed(); err != nil {
return nil, ErrQCWireCorrupt
}
var path WeightedInclusionProof
if path.LeafIndex, err = r.u32(); err != nil {
return nil, ErrQCWireCorrupt
}
if path.LeafCount, err = r.u32(); err != nil {
return nil, ErrQCWireCorrupt
}
stepCount, err := r.u32()
if err != nil {
return nil, ErrQCWireCorrupt
}
// Bound the step count: a Merkle path over N leaves is ≤ ceil(log2 N)
// + a small constant; cap at 64 (supports 2^64 leaves) so a malicious
// header cannot force a huge allocation.
if stepCount > 64 {
return nil, fmt.Errorf("%w: step count %d too large", ErrQCWireCorrupt, stepCount)
}
path.Steps = make([]WeightedProofStep, 0, stepCount)
for s := uint32(0); s < stepCount; s++ {
flags, err := r.u8()
if err != nil {
return nil, ErrQCWireCorrupt
}
step := WeightedProofStep{
Promoted: flags&0x01 != 0,
SiblingIsRight: flags&0x02 != 0,
}
if !step.Promoted {
if err = r.read48(&step.Sibling); err != nil {
return nil, ErrQCWireCorrupt
}
}
path.Steps = append(path.Steps, step)
}
rec.MerklePath = &path
c.Signers = append(c.Signers, rec)
}
if len(r.buf) != 0 {
return nil, fmt.Errorf("%w: %d trailing bytes (full)", ErrQCWireCorrupt, len(r.buf))
}
return c, nil
}
// qcReader is a bounds-checked sequential reader for the cert codec.
type qcReader struct{ buf []byte }
func (r *qcReader) need(n int) bool { return len(r.buf) >= n }
func (r *qcReader) u8() (uint8, error) {
if !r.need(1) {
return 0, ErrQCWireCorrupt
}
v := r.buf[0]
r.buf = r.buf[1:]
return v, nil
}
func (r *qcReader) u16() (uint16, error) {
if !r.need(2) {
return 0, ErrQCWireCorrupt
}
v := binary.BigEndian.Uint16(r.buf[:2])
r.buf = r.buf[2:]
return v, nil
}
func (r *qcReader) u32() (uint32, error) {
if !r.need(4) {
return 0, ErrQCWireCorrupt
}
v := binary.BigEndian.Uint32(r.buf[:4])
r.buf = r.buf[4:]
return v, nil
}
func (r *qcReader) u64() (uint64, error) {
if !r.need(8) {
return 0, ErrQCWireCorrupt
}
v := binary.BigEndian.Uint64(r.buf[:8])
r.buf = r.buf[8:]
return v, nil
}
func (r *qcReader) read32(dst *[32]byte) error {
if !r.need(32) {
return ErrQCWireCorrupt
}
copy(dst[:], r.buf[:32])
r.buf = r.buf[32:]
return nil
}
func (r *qcReader) read48(dst *[48]byte) error {
if !r.need(48) {
return ErrQCWireCorrupt
}
copy(dst[:], r.buf[:48])
r.buf = r.buf[48:]
return nil
}
func (r *qcReader) lenPrefixed() ([]byte, error) {
n, err := r.u32()
if err != nil {
return nil, err
}
if uint64(n) > uint64(len(r.buf)) {
return nil, ErrQCWireCorrupt
}
out := make([]byte, n)
copy(out, r.buf[:n])
r.buf = r.buf[n:]
return out, nil
}
// Equal reports structural equality of two certs (used in round-trip
// tests). Compares all fields including the per-signer records.
func (c *WeightedQuorumCert) Equal(o *WeightedQuorumCert) bool {
if c == nil || o == nil {
return c == o
}
if c.Version != o.Version || c.ChainID != o.ChainID || c.Epoch != o.Epoch ||
c.Height != o.Height || c.Round != o.Round || c.ValueHash != o.ValueHash ||
c.QCType != o.QCType || c.ValidatorSetRoot != o.ValidatorSetRoot ||
c.QuorumThreshold != o.QuorumThreshold || c.AggregateWeight != o.AggregateWeight ||
c.SignerCommitment != o.SignerCommitment || c.SignerCount != o.SignerCount ||
len(c.Signers) != len(o.Signers) {
return false
}
for i := range c.Signers {
a, b := &c.Signers[i], &o.Signers[i]
if a.ValidatorID != b.ValidatorID || a.Scheme != b.Scheme ||
a.ParamSetID != b.ParamSetID || a.KeyVersion != b.KeyVersion ||
a.VotingWeight != b.VotingWeight ||
!bytes.Equal(a.PublicKey, b.PublicKey) || !bytes.Equal(a.Signature, b.Signature) {
return false
}
if !equalProof(a.MerklePath, b.MerklePath) {
return false
}
}
return true
}
func equalProof(a, b *WeightedInclusionProof) bool {
if a == nil || b == nil {
return a == b
}
if a.LeafIndex != b.LeafIndex || a.LeafCount != b.LeafCount || len(a.Steps) != len(b.Steps) {
return false
}
for i := range a.Steps {
if a.Steps[i].Promoted != b.Steps[i].Promoted ||
a.Steps[i].SiblingIsRight != b.Steps[i].SiblingIsRight ||
a.Steps[i].Sibling != b.Steps[i].Sibling {
return false
}
}
return true
}
// ProofBackendID returns the canonical wire ProofBackendID for a direct
// weighted quorum certificate. A caller assembling the round-digest
// envelope (quorum_message.go) MUST stamp this backend so the signed
// transcript binds the full-node-verifiable trust model — a cert produced
// under this backend cannot be re-presented under a STARK backend's
// envelope.
func (c *WeightedQuorumCert) ProofBackendID() config.ProofBackendID {
return config.ProofBackendDirectWeightedQuorum
}
// ProofFormatID returns the canonical wire ProofFormatID for the full
// (records-included) weighted quorum cert encoding.
func (c *WeightedQuorumCert) ProofFormatID() config.ProofFormatID {
return config.ProofFormatDirectWeightedQuorumV1
}
// VerifierID returns the canonical pinned VerifierID for the direct
// weighted quorum predicate.
func (c *WeightedQuorumCert) VerifierID() config.VerifierID {
return config.VerifierDirectWeightedQuorumPQ
}
+599
View File
@@ -0,0 +1,599 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package quasar
import (
"bytes"
"crypto/rand"
"errors"
"testing"
"github.com/luxfi/consensus/config"
"github.com/luxfi/crypto/mldsa"
magnetar "github.com/luxfi/magnetar/ref/go/pkg/magnetar"
)
// ----------------------------------------------------------------------------
// Test scaffold: a real validator set with real ML-DSA / SLH-DSA keys,
// real independent signatures over a real domain-separated message, and a
// real assembled cert. Every cryptographic operation is the production path
// — no mocks, no stubs.
// ----------------------------------------------------------------------------
// testSigner is one validator's full key material + accountability fields.
type testSigner struct {
id [32]byte
scheme QuorumSchemeID
weight uint64
keyVer uint32
pubBytes []byte
// exactly one of these is set per scheme
mldsaPriv *mldsa.PrivateKey
slhPriv *magnetar.PrivateKey
}
// sign produces this signer's independent signature over message under ctx.
func (s *testSigner) sign(t *testing.T, message, ctx []byte) []byte {
t.Helper()
switch {
case s.mldsaPriv != nil:
sig, err := s.mldsaPriv.SignCtx(rand.Reader, message, ctx)
if err != nil {
t.Fatalf("ml-dsa sign: %v", err)
}
return sig
case s.slhPriv != nil:
// magnetar.ValidatorSign binds the empty FIPS context; tests that
// use SLH-DSA records therefore pass ctx=nil at verify time.
sig, err := magnetar.ValidatorSign(s.slhPriv, rand.Reader, message)
if err != nil {
t.Fatalf("slh-dsa sign: %v", err)
}
return sig
default:
t.Fatal("testSigner has no key")
return nil
}
}
// newMLDSASigner builds a real ML-DSA-65 signer at the given id/weight.
func newMLDSASigner(t *testing.T, idByte byte, weight uint64) *testSigner {
t.Helper()
priv, err := mldsa.GenerateKey(rand.Reader, mldsa.MLDSA65)
if err != nil {
t.Fatalf("ml-dsa keygen: %v", err)
}
pub := priv.Public().(*mldsa.PublicKey)
var id [32]byte
for i := range id {
id[i] = idByte
}
return &testSigner{
id: id,
scheme: QuorumSchemeMLDSA65,
weight: weight,
keyVer: 1,
pubBytes: pub.Bytes(),
mldsaPriv: priv,
}
}
// newSLHDSASigner builds a real SLH-DSA-192s signer at the given id/weight.
func newSLHDSASigner(t *testing.T, idByte byte, weight uint64) *testSigner {
t.Helper()
priv, err := magnetar.GenerateKey(magnetar.ParamsM192s, rand.Reader)
if err != nil {
t.Fatalf("slh-dsa keygen: %v", err)
}
var id [32]byte
for i := range id {
id[i] = idByte
}
return &testSigner{
id: id,
scheme: QuorumSchemeSLHDSA192s,
weight: weight,
keyVer: 1,
pubBytes: priv.Public().Bytes,
slhPriv: priv,
}
}
// testEnv builds the canonical message envelope for the scenario.
func testEnv() QuorumMessageEnvelope {
return QuorumMessageEnvelope{
ProfileID: 0xC57E0001,
HashSuite: config.HashSuiteSHA3NIST,
IdentityScheme: config.IdentitySchemeMLDSA65,
FinalityScheme: config.SigSchemePulsar65,
ProofPolicy: config.ProofPolicySTARKFRISHA3PQ,
ProofBackend: config.ProofBackendDirectWeightedQuorum,
ProofFormat: config.ProofFormatDirectWeightedQuorumV1,
VerifierID: config.VerifierDirectWeightedQuorumPQ,
EffectivePolicy: 1,
NetworkID: 0xC0DE0001,
ChainID: 0xDEADBEEF,
Epoch: 42,
Height: 1000,
Round: 3,
QCType: 2,
}
}
// scenario is a fully-built, valid cert + everything needed to verify it.
type scenario struct {
env QuorumMessageEnvelope
cfg QuorumVerifierConfig
message []byte
cert *WeightedQuorumCert
set *WeightedValidatorSet
signers []*testSigner
}
// buildScenario assembles a real, valid cert from real signers. threshold is
// the quorum-weight floor; ctx is the FIPS context (nil for the SLH-DSA
// path). The value hash is fixed and bound into the message + cert.
func buildScenario(t *testing.T, signers []*testSigner, threshold uint64, ctx []byte) scenario {
t.Helper()
env := testEnv()
var valueHash [32]byte
for i := range valueHash {
valueHash[i] = 0xAB
}
env.ValueHash = valueHash
// Build the weighted validator set commitment from the signers' leaves.
leaves := make([]WeightedValidatorLeaf, len(signers))
for i, s := range signers {
leaves[i] = WeightedValidatorLeaf{
ValidatorID: s.id,
PublicKey: s.pubBytes,
VotingWeight: s.weight,
ParameterSetID: uint8(s.scheme),
KeyVersion: s.keyVer,
}
}
set, err := BuildWeightedValidatorSet(env.Epoch, leaves)
if err != nil {
t.Fatalf("build set: %v", err)
}
env.ValidatorSetRoot = set.Root()
// The domain-separated consensus message.
message, err := QuorumConsensusMessage(env)
if err != nil {
t.Fatalf("build message: %v", err)
}
// Each signer signs INDEPENDENTLY; build records with Merkle paths.
sorted := set.Leaves()
idxOf := func(id [32]byte) int {
for i := range sorted {
if sorted[i].ValidatorID == id {
return i
}
}
t.Fatalf("signer id %x not in set", id[:])
return -1
}
records := make([]QuorumSignerRecord, 0, len(signers))
for _, s := range signers {
proof, err := set.InclusionProof(idxOf(s.id))
if err != nil {
t.Fatalf("inclusion proof: %v", err)
}
records = append(records, QuorumSignerRecord{
ValidatorID: s.id,
PublicKey: s.pubBytes,
VotingWeight: s.weight,
Scheme: s.scheme,
ParamSetID: uint8(s.scheme),
KeyVersion: s.keyVer,
MerklePath: proof,
Signature: s.sign(t, message, ctx),
})
}
cert, err := BuildWeightedQuorumCert(QuorumCertParams{
ChainID: env.ChainID,
Epoch: env.Epoch,
Height: env.Height,
Round: env.Round,
ValueHash: env.ValueHash,
QCType: env.QCType,
ValidatorSetRoot: set.Root(),
QuorumThreshold: threshold,
}, records)
if err != nil {
t.Fatalf("build cert: %v", err)
}
return scenario{
env: env,
cfg: QuorumVerifierConfig{Context: ctx},
message: message,
cert: cert,
set: set,
signers: signers,
}
}
// fourMLDSA builds a standard 4-signer ML-DSA scenario, weights 25 each,
// threshold 100 (== Σ weight). ctx = nil.
func fourMLDSA(t *testing.T) scenario {
signers := []*testSigner{
newMLDSASigner(t, 0x01, 25),
newMLDSASigner(t, 0x02, 25),
newMLDSASigner(t, 0x03, 25),
newMLDSASigner(t, 0x04, 25),
}
return buildScenario(t, signers, 100, nil)
}
// ----------------------------------------------------------------------------
// Happy path + independent-verifier interop
// ----------------------------------------------------------------------------
func TestQuorumCert_HappyPath(t *testing.T) {
sc := fourMLDSA(t)
if err := sc.cert.Verify(sc.message, sc.cfg); err != nil {
t.Fatalf("valid cert rejected: %v", err)
}
}
// TestQuorumCert_IndependentVerifierInterop proves each record's signature
// verifies under the STOCK FIPS verifier directly — no Lux cert machinery,
// just mldsa.PublicKey.VerifySignatureCtx. This is the wire-identity claim:
// any peer can verify a signer's contribution with a stock FIPS library.
func TestQuorumCert_IndependentVerifierInterop(t *testing.T) {
sc := fourMLDSA(t)
for i := range sc.cert.Signers {
rec := &sc.cert.Signers[i]
pk, err := mldsa.PublicKeyFromBytes(rec.PublicKey, mldsa.MLDSA65)
if err != nil {
t.Fatalf("record %d: parse pub: %v", i, err)
}
if !pk.VerifySignatureCtx(sc.message, rec.Signature, sc.cfg.Context) {
t.Fatalf("record %d: stock FIPS 204 verifier rejected the signature", i)
}
}
}
// TestQuorumCert_SLHDSAInterop proves the SLH-DSA records verify under the
// stock FIPS 205 verifier (magnetar's thin VerifyCtx over circl/slhdsa).
func TestQuorumCert_SLHDSAInterop(t *testing.T) {
signers := []*testSigner{
newSLHDSASigner(t, 0x01, 60),
newSLHDSASigner(t, 0x02, 60),
}
sc := buildScenario(t, signers, 100, nil)
if err := sc.cert.Verify(sc.message, sc.cfg); err != nil {
t.Fatalf("valid SLH-DSA cert rejected: %v", err)
}
for i := range sc.cert.Signers {
rec := &sc.cert.Signers[i]
params := magnetar.ParamsM192s
pk := &magnetar.PublicKey{Mode: magnetar.ModeM192s, Bytes: rec.PublicKey}
sig := &magnetar.Signature{Mode: magnetar.ModeM192s, Bytes: rec.Signature}
if err := magnetar.VerifyCtx(params, pk, sc.message, sc.cfg.Context, sig); err != nil {
t.Fatalf("record %d: stock FIPS 205 verifier rejected: %v", i, err)
}
}
}
// TestQuorumCert_MixedScheme proves a single cert can carry ML-DSA and
// SLH-DSA signer records side by side (the honest realization of a combined
// per-validator surface).
func TestQuorumCert_MixedScheme(t *testing.T) {
signers := []*testSigner{
newMLDSASigner(t, 0x01, 40),
newSLHDSASigner(t, 0x02, 40),
newMLDSASigner(t, 0x03, 40),
}
sc := buildScenario(t, signers, 100, nil)
if err := sc.cert.Verify(sc.message, sc.cfg); err != nil {
t.Fatalf("valid mixed-scheme cert rejected: %v", err)
}
}
// TestQuorumCert_MLDSAContextBinding proves the FIPS context plumbing works:
// signing under ctx="A" and verifying under ctx="A" succeeds, under ctx="B"
// fails.
func TestQuorumCert_MLDSAContextBinding(t *testing.T) {
ctxA := []byte("lux-quasar-wqc-ctx-A")
signers := []*testSigner{
newMLDSASigner(t, 0x01, 60),
newMLDSASigner(t, 0x02, 60),
}
sc := buildScenario(t, signers, 100, ctxA)
if err := sc.cert.Verify(sc.message, QuorumVerifierConfig{Context: ctxA}); err != nil {
t.Fatalf("ctx-A cert rejected under ctx-A: %v", err)
}
if err := sc.cert.Verify(sc.message, QuorumVerifierConfig{Context: []byte("ctx-B")}); err == nil {
t.Fatal("cert signed under ctx-A verified under ctx-B (context not bound)")
}
}
// ----------------------------------------------------------------------------
// Distinct-signer enforcement
// ----------------------------------------------------------------------------
func TestQuorumCert_RejectsUnsorted(t *testing.T) {
sc := fourMLDSA(t)
// Swap two records so they are no longer strictly increasing.
sc.cert.Signers[0], sc.cert.Signers[1] = sc.cert.Signers[1], sc.cert.Signers[0]
// Recompute the commitment so the failure is the ordering check, not the
// commitment check (we want to prove the ordering clause specifically).
sc.cert.SignerCommitment = sc.cert.computeSignerCommitment()
err := sc.cert.Verify(sc.message, sc.cfg)
if !errors.Is(err, ErrQCNotStrictlyIncreasing) {
t.Fatalf("unsorted cert err = %v, want ErrQCNotStrictlyIncreasing", err)
}
}
func TestQuorumCert_RejectsDuplicateSigner(t *testing.T) {
sc := fourMLDSA(t)
// Duplicate record 1 over record 2's slot → two identical ids adjacent.
sc.cert.Signers[2] = sc.cert.Signers[1]
sc.cert.SignerCommitment = sc.cert.computeSignerCommitment()
err := sc.cert.Verify(sc.message, sc.cfg)
if !errors.Is(err, ErrQCNotStrictlyIncreasing) {
t.Fatalf("duplicate-signer cert err = %v, want ErrQCNotStrictlyIncreasing", err)
}
}
func TestQuorumCert_ProverRejectsDuplicate(t *testing.T) {
// The prover itself must refuse to assemble a cert with duplicate ids.
s := newMLDSASigner(t, 0x01, 50)
rec := QuorumSignerRecord{
ValidatorID: s.id,
PublicKey: s.pubBytes,
VotingWeight: s.weight,
Scheme: s.scheme,
ParamSetID: uint8(s.scheme),
KeyVersion: s.keyVer,
MerklePath: &WeightedInclusionProof{LeafCount: 1},
Signature: []byte{0x00},
}
_, err := BuildWeightedQuorumCert(QuorumCertParams{
ChainID: 1, Epoch: 1, QuorumThreshold: 1, ValidatorSetRoot: [48]byte{1},
}, []QuorumSignerRecord{rec, rec})
if !errors.Is(err, ErrWVSetDuplicateID) {
t.Fatalf("prover duplicate err = %v, want ErrWVSetDuplicateID", err)
}
}
// ----------------------------------------------------------------------------
// Weight threshold
// ----------------------------------------------------------------------------
func TestQuorumCert_BelowThresholdRejected(t *testing.T) {
// Σ weight = 100; set threshold to 101 → below threshold.
signers := []*testSigner{
newMLDSASigner(t, 0x01, 25),
newMLDSASigner(t, 0x02, 25),
newMLDSASigner(t, 0x03, 25),
newMLDSASigner(t, 0x04, 25),
}
sc := buildScenario(t, signers, 101, nil)
err := sc.cert.Verify(sc.message, sc.cfg)
if !errors.Is(err, ErrQCBelowThreshold) {
t.Fatalf("below-threshold err = %v, want ErrQCBelowThreshold", err)
}
}
func TestQuorumCert_AtThresholdAccepted(t *testing.T) {
// Σ weight = 100; threshold exactly 100 → accept (≥, not >).
sc := fourMLDSA(t) // threshold == 100 == Σ weight
if err := sc.cert.Verify(sc.message, sc.cfg); err != nil {
t.Fatalf("at-threshold cert rejected: %v", err)
}
}
// ----------------------------------------------------------------------------
// Wrong message / domain / cross-epoch / cross-chain
// ----------------------------------------------------------------------------
func TestQuorumCert_WrongMessageRejected(t *testing.T) {
sc := fourMLDSA(t)
wrong := append([]byte(nil), sc.message...)
wrong[0] ^= 0xFF
err := sc.cert.Verify(wrong, sc.cfg)
if !errors.Is(err, ErrQCSigInvalid) {
t.Fatalf("wrong-message err = %v, want ErrQCSigInvalid", err)
}
}
// crossAxis re-derives a message with a single envelope axis changed and
// confirms the original cert's signatures do NOT verify against it.
func crossAxis(t *testing.T, name string, mutate func(*QuorumMessageEnvelope)) {
t.Helper()
sc := fourMLDSA(t)
env := sc.env
mutate(&env)
msg2, err := QuorumConsensusMessage(env)
if err != nil {
t.Fatalf("%s: rebuild message: %v", name, err)
}
if bytes.Equal(msg2, sc.message) {
t.Fatalf("%s: message unchanged after mutating axis (no binding!)", name)
}
if err := sc.cert.Verify(msg2, sc.cfg); err == nil {
t.Fatalf("%s: cert verified against cross-domain message (replay surface)", name)
}
}
func TestQuorumCert_CrossDomainNonTransferability(t *testing.T) {
crossAxis(t, "chain_id", func(e *QuorumMessageEnvelope) { e.ChainID++ })
crossAxis(t, "epoch", func(e *QuorumMessageEnvelope) { e.Epoch++ })
crossAxis(t, "height", func(e *QuorumMessageEnvelope) { e.Height++ })
crossAxis(t, "round", func(e *QuorumMessageEnvelope) { e.Round++ })
crossAxis(t, "qc_type", func(e *QuorumMessageEnvelope) { e.QCType++ })
crossAxis(t, "value_hash", func(e *QuorumMessageEnvelope) { e.ValueHash[0] ^= 0xFF })
crossAxis(t, "network_id", func(e *QuorumMessageEnvelope) { e.NetworkID++ })
crossAxis(t, "proof_backend", func(e *QuorumMessageEnvelope) { e.ProofBackend = config.ProofBackendP3QSTARKFRISHA3 })
crossAxis(t, "profile_id", func(e *QuorumMessageEnvelope) { e.ProfileID++ })
}
// ----------------------------------------------------------------------------
// Merkle-inclusion soundness (non-member rejected) at the cert layer
// ----------------------------------------------------------------------------
func TestQuorumCert_NonMemberRejected(t *testing.T) {
sc := fourMLDSA(t)
// Mutate a record's weight so its reconstructed leaf is no longer the
// committed one — its Merkle path no longer proves inclusion. Re-sign
// nothing (the signature is over the message, not the leaf); the failure
// must be the Merkle clause.
sc.cert.Signers[1].VotingWeight = 999
// Keep aggregate consistent with the records so we reach the Merkle check
// (otherwise we'd trip the aggregate-weight clause first — but here the
// per-record Merkle check runs before the aggregate, so adjust both to be
// safe and prove the Merkle clause specifically).
sc.cert.AggregateWeight = sc.cert.AggregateWeight - 25 + 999
sc.cert.SignerCommitment = sc.cert.computeSignerCommitment()
err := sc.cert.Verify(sc.message, sc.cfg)
if !errors.Is(err, ErrQCMerkleInclusion) {
t.Fatalf("non-member err = %v, want ErrQCMerkleInclusion", err)
}
}
func TestQuorumCert_UnknownValidatorNotFatal(t *testing.T) {
// A record for a validator NOT in the committed set must make the cert
// INVALID — but cleanly (a typed error), never a panic / DoS. We build a
// fresh signer not in the set, give it a syntactically-valid but bogus
// Merkle path, and confirm Verify returns ErrQCMerkleInclusion without
// panicking.
sc := fourMLDSA(t)
stranger := newMLDSASigner(t, 0xFE, 25)
// A path shaped for a 4-leaf tree at index 0, with a random sibling.
var sib [48]byte
for i := range sib {
sib[i] = 0x77
}
bogus := &WeightedInclusionProof{
LeafIndex: 0,
LeafCount: 4,
Steps: []WeightedProofStep{
{Sibling: sib, SiblingIsRight: true},
{Sibling: sib, SiblingIsRight: true},
},
}
// Replace the lowest-id record so ordering stays strictly increasing.
rec := QuorumSignerRecord{
ValidatorID: stranger.id, // 0xFE.. is the largest id → goes last
PublicKey: stranger.pubBytes,
VotingWeight: stranger.weight,
Scheme: stranger.scheme,
ParamSetID: uint8(stranger.scheme),
KeyVersion: stranger.keyVer,
MerklePath: bogus,
Signature: stranger.sign(t, sc.message, nil),
}
sc.cert.Signers = append(sc.cert.Signers, rec)
sc.cert.SignerCount++
sc.cert.AggregateWeight += rec.VotingWeight
sc.cert.SignerCommitment = sc.cert.computeSignerCommitment()
// Must not panic; must return the Merkle clause error.
err := sc.cert.Verify(sc.message, sc.cfg)
if !errors.Is(err, ErrQCMerkleInclusion) {
t.Fatalf("unknown-validator err = %v, want ErrQCMerkleInclusion (clean, not fatal)", err)
}
}
// ----------------------------------------------------------------------------
// Aggregate-weight / signer-commitment tamper rejection
// ----------------------------------------------------------------------------
func TestQuorumCert_AggregateWeightTamper(t *testing.T) {
sc := fourMLDSA(t)
sc.cert.AggregateWeight++ // claim one more than Σ weight
sc.cert.SignerCommitment = sc.cert.computeSignerCommitment()
err := sc.cert.Verify(sc.message, sc.cfg)
if !errors.Is(err, ErrQCAggregateWeight) {
t.Fatalf("aggregate tamper err = %v, want ErrQCAggregateWeight", err)
}
}
func TestQuorumCert_SignerCommitmentTamper(t *testing.T) {
sc := fourMLDSA(t)
sc.cert.SignerCommitment[0] ^= 0xFF
err := sc.cert.Verify(sc.message, sc.cfg)
if !errors.Is(err, ErrQCSignerCommitment) {
t.Fatalf("commitment tamper err = %v, want ErrQCSignerCommitment", err)
}
}
func TestQuorumCert_HeaderPositionTamperFailsCommitment(t *testing.T) {
// Flipping a position field the commitment binds (but NOT re-signing /
// re-committing) must fail the commitment check.
sc := fourMLDSA(t)
sc.cert.Height++ // commitment was computed over the old height
err := sc.cert.Verify(sc.message, sc.cfg)
if !errors.Is(err, ErrQCSignerCommitment) {
t.Fatalf("position tamper err = %v, want ErrQCSignerCommitment", err)
}
}
// ----------------------------------------------------------------------------
// Scheme allow-list
// ----------------------------------------------------------------------------
func TestQuorumCert_SchemeNotAllowed(t *testing.T) {
sc := fourMLDSA(t)
// Allow only SLH-DSA; the cert is all ML-DSA → rejected.
cfg := QuorumVerifierConfig{
Context: nil,
AllowedSchemes: map[QuorumSchemeID]bool{QuorumSchemeSLHDSA192s: true},
}
err := sc.cert.Verify(sc.message, cfg)
if !errors.Is(err, ErrQCSchemeNotAllowed) {
t.Fatalf("scheme-not-allowed err = %v, want ErrQCSchemeNotAllowed", err)
}
}
func TestQuorumCert_ParamSetMismatch(t *testing.T) {
sc := fourMLDSA(t)
// Claim a param byte that disagrees with the scheme byte.
sc.cert.Signers[0].ParamSetID = uint8(QuorumSchemeMLDSA87)
sc.cert.SignerCommitment = sc.cert.computeSignerCommitment()
err := sc.cert.Verify(sc.message, sc.cfg)
if !errors.Is(err, ErrQCParamSetMismatch) {
t.Fatalf("param mismatch err = %v, want ErrQCParamSetMismatch", err)
}
}
// ----------------------------------------------------------------------------
// Structural / fail-closed
// ----------------------------------------------------------------------------
func TestQuorumCert_StructuralRejections(t *testing.T) {
sc := fourMLDSA(t)
var nilCert *WeightedQuorumCert
if err := nilCert.Verify(sc.message, sc.cfg); !errors.Is(err, ErrQCNil) {
t.Fatalf("nil cert err = %v, want ErrQCNil", err)
}
badVer := *sc.cert
badVer.Version = 99
if err := (&badVer).Verify(sc.message, sc.cfg); !errors.Is(err, ErrQCVersion) {
t.Fatalf("bad version err = %v, want ErrQCVersion", err)
}
zeroThresh := *sc.cert
zeroThresh.QuorumThreshold = 0
if err := (&zeroThresh).Verify(sc.message, sc.cfg); !errors.Is(err, ErrQCThresholdZero) {
t.Fatalf("zero threshold err = %v, want ErrQCThresholdZero", err)
}
countMismatch := *sc.cert
countMismatch.SignerCount = 99
if err := (&countMismatch).Verify(sc.message, sc.cfg); !errors.Is(err, ErrQCSignerCountMismatch) {
t.Fatalf("count mismatch err = %v, want ErrQCSignerCountMismatch", err)
}
}
+267
View File
@@ -0,0 +1,267 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package quasar
import (
"errors"
"testing"
"github.com/luxfi/consensus/config"
)
// ----------------------------------------------------------------------------
// Compact ↔ full encoding round-trip
// ----------------------------------------------------------------------------
func TestQuorumCert_FullRoundTrip(t *testing.T) {
sc := fourMLDSA(t)
wire, err := sc.cert.MarshalBinary()
if err != nil {
t.Fatalf("marshal full: %v", err)
}
got, err := UnmarshalWeightedQuorumCert(wire)
if err != nil {
t.Fatalf("unmarshal full: %v", err)
}
if !sc.cert.Equal(got) {
t.Fatal("full round-trip not byte-stable")
}
// And the decoded cert must still verify with real signatures.
if err := got.Verify(sc.message, sc.cfg); err != nil {
t.Fatalf("decoded full cert failed verify: %v", err)
}
}
func TestQuorumCert_CompactRoundTrip(t *testing.T) {
sc := fourMLDSA(t)
compact := sc.cert.Compact()
if !compact.IsCompact() {
t.Fatal("Compact() did not produce a compact cert")
}
wire, err := compact.MarshalBinary()
if err != nil {
t.Fatalf("marshal compact: %v", err)
}
got, err := UnmarshalWeightedQuorumCert(wire)
if err != nil {
t.Fatalf("unmarshal compact: %v", err)
}
if !compact.Equal(got) {
t.Fatal("compact round-trip not byte-stable")
}
// A compact cert cannot be verified until records are re-attached.
if err := got.Verify(sc.message, sc.cfg); !errors.Is(err, ErrQCCompactNoRecords) {
t.Fatalf("compact verify err = %v, want ErrQCCompactNoRecords", err)
}
}
// TestQuorumCert_CompactToFullViaDA proves the DA-hybrid flow: header carries
// the compact commitment; records are fetched from DA and re-attached; the
// re-attached cert verifies. A DA layer that serves a DIFFERENT record set
// is caught by the commitment recomputation.
func TestQuorumCert_CompactToFullViaDA(t *testing.T) {
sc := fourMLDSA(t)
compact := sc.cert.Compact()
// Honest DA: the real records re-attach and verify.
reattached := compact.WithRecords(sc.cert.Signers)
if err := reattached.Verify(sc.message, sc.cfg); err != nil {
t.Fatalf("honest re-attach failed verify: %v", err)
}
// Malicious DA: drop one record (different signer set than committed).
tampered := compact.WithRecords(sc.cert.Signers[:3])
err := tampered.Verify(sc.message, sc.cfg)
// SignerCount (4) != records (3) is caught first; either count-mismatch
// or commitment-mismatch is an acceptable clean rejection.
if err == nil {
t.Fatal("malicious DA record substitution accepted")
}
if !errors.Is(err, ErrQCSignerCountMismatch) && !errors.Is(err, ErrQCSignerCommitment) {
t.Fatalf("malicious DA err = %v, want count or commitment mismatch", err)
}
}
func TestQuorumCert_DecoderFailClosed(t *testing.T) {
sc := fourMLDSA(t)
wire, _ := sc.cert.MarshalBinary()
// Truncation at every prefix length must error, never panic.
for n := 0; n < len(wire); n++ {
if _, err := UnmarshalWeightedQuorumCert(wire[:n]); err == nil {
t.Fatalf("truncated-to-%d decoded without error", n)
}
}
// Trailing garbage must error.
withTrailer := append(append([]byte(nil), wire...), 0xFF)
if _, err := UnmarshalWeightedQuorumCert(withTrailer); !errors.Is(err, ErrQCWireCorrupt) {
t.Fatalf("trailing byte err = %v, want ErrQCWireCorrupt", err)
}
// Bad kind byte.
badKind := append([]byte(nil), wire...)
badKind[0] = 0x99
if _, err := UnmarshalWeightedQuorumCert(badKind); !errors.Is(err, ErrQCWireCorrupt) {
t.Fatalf("bad-kind err = %v, want ErrQCWireCorrupt", err)
}
}
func TestQuorumCert_MixedSchemeRoundTrip(t *testing.T) {
signers := []*testSigner{
newMLDSASigner(t, 0x01, 40),
newSLHDSASigner(t, 0x02, 40),
newMLDSASigner(t, 0x03, 40),
}
sc := buildScenario(t, signers, 100, nil)
wire, err := sc.cert.MarshalBinary()
if err != nil {
t.Fatalf("marshal mixed: %v", err)
}
got, err := UnmarshalWeightedQuorumCert(wire)
if err != nil {
t.Fatalf("unmarshal mixed: %v", err)
}
if !sc.cert.Equal(got) {
t.Fatal("mixed-scheme round-trip not byte-stable")
}
if err := got.Verify(sc.message, sc.cfg); err != nil {
t.Fatalf("decoded mixed cert failed verify: %v", err)
}
}
// ----------------------------------------------------------------------------
// QuasarCert wiring
// ----------------------------------------------------------------------------
func TestQuasarCert_WeightedQuorumLeg(t *testing.T) {
sc := fourMLDSA(t)
qc, err := BuildQuasarCertFromWeightedQuorum(sc.cert)
if err != nil {
t.Fatalf("build QuasarCert: %v", err)
}
if !qc.HasWeightedQuorumLeg() {
t.Fatal("QuasarCert does not report a weighted quorum leg")
}
if qc.Epoch != sc.cert.Epoch {
t.Fatalf("QuasarCert epoch = %d, want %d", qc.Epoch, sc.cert.Epoch)
}
if qc.Validators != int(sc.cert.SignerCount) {
t.Fatalf("QuasarCert validators = %d, want %d", qc.Validators, sc.cert.SignerCount)
}
// The clean Verify entry point over the QuasarCert.
if err := qc.VerifyWeightedQuorumLeg(sc.message, sc.cfg); err != nil {
t.Fatalf("VerifyWeightedQuorumLeg rejected a valid leg: %v", err)
}
// Extract round-trips to the same cert.
extracted, err := ExtractWeightedQuorumLeg(qc.MLDSARollup)
if err != nil {
t.Fatalf("extract: %v", err)
}
if !sc.cert.Equal(extracted) {
t.Fatal("extracted leg != original cert")
}
// The whole QuasarCert must survive its own MarshalBinary/UnmarshalBinary.
qcWire, err := qc.MarshalBinary()
if err != nil {
t.Fatalf("QuasarCert marshal: %v", err)
}
var qc2 QuasarCert
if err := qc2.UnmarshalBinary(qcWire); err != nil {
t.Fatalf("QuasarCert unmarshal: %v", err)
}
if err := qc2.VerifyWeightedQuorumLeg(sc.message, sc.cfg); err != nil {
t.Fatalf("round-tripped QuasarCert leg failed verify: %v", err)
}
}
func TestQuasarCert_LegDiscriminator(t *testing.T) {
// A non-WQC MLDSARollup (e.g. a STARK proof blob) must be cleanly
// distinguished, not misparsed as a WQC.
starkish := []byte{0x10, 0x01, 0x02, 0x03} // does not start with the WQC tag
if _, err := ExtractWeightedQuorumLeg(starkish); !errors.Is(err, ErrMLDSARollupNotWQC) {
t.Fatalf("non-WQC leg err = %v, want ErrMLDSARollupNotWQC", err)
}
qc := &QuasarCert{MLDSARollup: starkish}
if qc.HasWeightedQuorumLeg() {
t.Fatal("non-WQC leg reported as a weighted quorum leg")
}
}
func TestQuasarCert_AttachLegPreservesOtherLegs(t *testing.T) {
sc := fourMLDSA(t)
base := &QuasarCert{
BLS: []byte{0xAA, 0xBB},
Pulsar: []byte{0xCC, 0xDD},
Epoch: sc.cert.Epoch,
}
out, err := base.AttachWeightedQuorumLeg(sc.cert)
if err != nil {
t.Fatalf("attach: %v", err)
}
if string(out.BLS) != string(base.BLS) || string(out.Pulsar) != string(base.Pulsar) {
t.Fatal("attach clobbered other legs")
}
if !out.HasWeightedQuorumLeg() {
t.Fatal("attach did not install the weighted quorum leg")
}
if err := out.VerifyWeightedQuorumLeg(sc.message, sc.cfg); err != nil {
t.Fatalf("attached leg failed verify: %v", err)
}
}
// ----------------------------------------------------------------------------
// Message function: QuorumMessageForCert derives from cert fields
// ----------------------------------------------------------------------------
func TestQuorumMessage_ForCertMatches(t *testing.T) {
sc := fourMLDSA(t)
// Build the message from the cert's own fields + the chain envelope.
msg, err := QuorumMessageForCert(sc.env, sc.cert)
if err != nil {
t.Fatalf("QuorumMessageForCert: %v", err)
}
// Must equal the message the signers actually signed (the scenario built
// the message from the same envelope before the cert existed).
if string(msg) != string(sc.message) {
t.Fatal("QuorumMessageForCert != the message signers signed")
}
if err := sc.cert.Verify(msg, sc.cfg); err != nil {
t.Fatalf("cert failed verify under QuorumMessageForCert output: %v", err)
}
}
func TestQuorumMessage_RefusesZeroEnvelope(t *testing.T) {
// QuorumConsensusMessage must propagate ComputeRoundDigest's refusal of
// zero-value security-relevant inputs (fail-closed).
var env QuorumMessageEnvelope // all zero
if _, err := QuorumConsensusMessage(env); err == nil {
t.Fatal("zero envelope produced a message; want ComputeRoundDigest refusal")
}
}
// TestProofBackendIDs pins the new wire IDs so a future renumber trips this
// test (mirrors the existing ID-pinning discipline in pq_mode_test.go).
func TestProofBackend_DirectWeightedQuorumIDs(t *testing.T) {
if config.ProofBackendDirectWeightedQuorum != 0x30 {
t.Fatalf("ProofBackendDirectWeightedQuorum = 0x%02x, want 0x30", uint8(config.ProofBackendDirectWeightedQuorum))
}
if config.ProofFormatDirectWeightedQuorumV1 != 0x30 {
t.Fatalf("ProofFormatDirectWeightedQuorumV1 = 0x%02x, want 0x30", uint8(config.ProofFormatDirectWeightedQuorumV1))
}
if config.VerifierDirectWeightedQuorumPQ != 0x0030 {
t.Fatalf("VerifierDirectWeightedQuorumPQ = 0x%04x, want 0x0030", uint16(config.VerifierDirectWeightedQuorumPQ))
}
if !config.ProofBackendDirectWeightedQuorum.IsProductionPQ() {
t.Fatal("direct weighted quorum backend must be production-PQ")
}
if !config.ProofBackendDirectWeightedQuorum.IsDirectVerifiable() {
t.Fatal("direct weighted quorum backend must report IsDirectVerifiable")
}
if config.ProofBackendDirectWeightedQuorum.IsForbiddenInPQMode() {
t.Fatal("direct weighted quorum backend must not be forbidden in PQ mode")
}
}
+186
View File
@@ -0,0 +1,186 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// quorum_message.go — the domain-separated consensus message a weighted
// quorum certificate's signers sign over.
//
// One message function. It binds EVERY consensus envelope axis so a single
// signature is non-transferable across domains: a signature produced for
// (chain A, epoch e, height h, round r, value v, qc_type prepare) MUST NOT
// verify for any other (chain, epoch, height, round, value, qc_type) tuple,
// nor across the proof-backend / verifier / profile axes.
//
// The message is built ON TOP of ComputeRoundDigest (round_digest.go) — the
// canonical round-digest machinery that already binds the proof backend,
// profile, hash suite, identity/finality schemes, network/chain ids, epoch,
// height, round, and every state/validator/committee root via TupleHash256
// under "QUASAR-ROUND-DIGEST". This wrapper adds the two axes a quorum cert
// needs that the round digest expresses positionally — qc_type and the
// value/block hash — and re-frames the result under a quorum-specific
// customization tag so a round-digest byte string can never be replayed as
// a quorum message and vice versa.
package quasar
import (
"encoding/binary"
"github.com/luxfi/consensus/config"
)
// quorumMessageCustomization is the cSHAKE256 customization tag for the
// quorum-certificate signing message. Distinct from the round-digest tag so
// the two message spaces are domain-separated. Wire-stable cryptographic
// constant.
const quorumMessageCustomization = "QUASAR-WQC-MESSAGE-V1"
// quorumMessageProtocolTag is the in-band redundant protocol tag for the
// quorum message.
const quorumMessageProtocolTag = "Quasar/WQC/Message"
// QuorumMessageEnvelope carries every axis the quorum message binds. The
// consensus-position fields (chain_id, epoch, height, round, value_hash,
// qc_type, validator_set_root) MUST equal the matching fields of the cert
// that will carry the resulting signatures — the cert verifier rebuilds the
// message from its own fields, so any mismatch makes every signature fail.
//
// The envelope-axis fields (profile, hash suite, schemes, proof backend,
// format, verifier, network) are the round-digest envelope; they pin the
// security posture into the signed transcript so a cert produced under one
// posture cannot be re-presented under another.
type QuorumMessageEnvelope struct {
// Round-digest envelope axes (see ComputeRoundDigest).
ProfileID uint32
HashSuite config.HashSuiteID
IdentityScheme config.IdentitySchemeID
FinalityScheme config.SigSchemeID
ProofPolicy config.ProofPolicyID
ProofBackend config.ProofBackendID
ProofFormat config.ProofFormatID
VerifierID config.VerifierID
EffectivePolicy uint8
NetworkID uint32
// Consensus position — the quorum-cert binding axes.
ChainID uint32
Epoch uint64
Height uint64
Round uint32
ValueHash [32]byte // block_id / value being finalised
QCType uint8 // certificate role (prepare/commit/finality/…)
ValidatorSetRoot [48]byte // weighted-validator-set commitment
// Additional round-digest roots. These default to the value hash / set
// root when a caller has nothing more specific, but are exposed so a
// deployment that runs the full Q-Block envelope can bind its real
// committee / DKG / group-key / DA / state roots too.
ParentQBlockHash [32]byte
DARoot [48]byte
SourceStateRoot [48]byte
ZChainStateRoot [48]byte
CommitteeRoot [48]byte
DKGTranscriptRoot [48]byte
GroupPublicKeyHash [48]byte
SignerSetCommit [48]byte
}
// QuorumConsensusMessage builds the domain-separated message the signers
// sign over. It is the SINGLE message function for weighted quorum certs;
// the prover hands it to each signer, and the verifier rebuilds it from the
// cert's fields. Identical inputs yield identical bytes.
//
// Construction:
// 1. ComputeRoundDigest binds the full envelope (proof backend, profile,
// schemes, network/chain, epoch, height, round, and the roots). The
// value hash is bound as the round digest's payload_root so a different
// block under the same position yields a different digest.
// 2. The 32-byte round digest is re-framed under
// "QUASAR-WQC-MESSAGE-V1" together with qc_type and the value hash
// (bound a second time, explicitly) to produce the final message. The
// re-framing guarantees a round-digest byte string is never itself a
// valid quorum message.
//
// Returns the message bytes and any error from ComputeRoundDigest (which
// refuses zero-value security-relevant inputs — so a caller cannot build a
// message under an unset profile / backend / chain).
func QuorumConsensusMessage(env QuorumMessageEnvelope) ([]byte, error) {
// Bind the value hash into the round digest's payload_root slot. The
// payload_root is [48]byte; left-pad the 32-byte value hash into it
// deterministically (high 16 bytes zero) so the binding is canonical.
var payloadRoot [48]byte
copy(payloadRoot[16:], env.ValueHash[:])
digest, err := ComputeRoundDigest(
env.ProfileID,
env.HashSuite,
env.IdentityScheme,
env.FinalityScheme,
env.ProofPolicy,
env.ProofBackend,
env.ProofFormat,
env.VerifierID,
env.EffectivePolicy,
env.NetworkID,
env.ChainID,
env.Epoch,
env.Height,
env.Round,
env.ParentQBlockHash,
payloadRoot,
env.DARoot,
env.SourceStateRoot,
env.ZChainStateRoot,
env.ValidatorSetRoot,
env.CommitteeRoot,
env.DKGTranscriptRoot,
env.GroupPublicKeyHash,
env.SignerSetCommit,
)
if err != nil {
return nil, err
}
// Re-frame the round digest under the quorum message tag together with
// qc_type and the value hash. This is the seam that makes a quorum
// message non-interchangeable with a bare round digest.
var u32 [4]byte
binary.BigEndian.PutUint32(u32[:], env.ChainID)
chainBytes := append([]byte(nil), u32[:]...)
parts := [][]byte{
[]byte(quorumMessageProtocolTag),
digest[:],
{env.QCType},
env.ValueHash[:],
chainBytes,
env.ValidatorSetRoot[:],
}
out := tupleHash256RoundDigest(parts, 32, quorumMessageCustomization)
return out, nil
}
// QuorumMessageForCert is the verifier-side convenience that rebuilds the
// quorum message from a certificate's consensus-position fields plus the
// envelope axes the chain pins. The caller supplies the envelope axes (from
// the chain security profile) and the cert; this fills the position fields
// from the cert so the two can never disagree.
//
// This is the function a verifier SHOULD use: it guarantees the message is
// derived from the SAME (chain_id, epoch, height, round, value_hash,
// qc_type, validator_set_root) the cert claims, so a cert whose signers
// signed a different message fails verification, and a cert that lies about
// its position fails because the rebuilt message no longer matches the
// signatures.
func QuorumMessageForCert(envelope QuorumMessageEnvelope, cert *WeightedQuorumCert) ([]byte, error) {
if cert == nil {
return nil, ErrQCNil
}
env := envelope
env.ChainID = cert.ChainID
env.Epoch = cert.Epoch
env.Height = cert.Height
env.Round = cert.Round
env.ValueHash = cert.ValueHash
env.QCType = cert.QCType
env.ValidatorSetRoot = cert.ValidatorSetRoot
return QuorumConsensusMessage(env)
}
+168
View File
@@ -0,0 +1,168 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// quorum_quasarcert.go — wiring the weighted quorum certificate into the
// QuasarCert envelope as the honest, full-node-verifiable realization of
// the per-validator-signature legs.
//
// A QuasarCert's MLDSARollup leg is, semantically, "a proof that every one
// of N validators produced a valid independent ML-DSA-65 signature over the
// round digest" (types.go). The STARK/Groth16 backend realizes that as a
// succinct proof. The WeightedQuorumCert realizes the SAME claim DIRECTLY:
// the cert carries the per-validator records and a full node verifies them
// itself (no trusted setup, no succinctness assumption). This is the
// "full-node-verifiable mode" — the conservative production posture.
//
// Likewise the Magnetar leg is a magnetar.ValidatorAggregateCert (N
// independent SLH-DSA signatures). A mixed-scheme WeightedQuorumCert can
// carry ML-DSA and SLH-DSA signer records side by side, so it is also the
// honest realization of a combined ML-DSA ∧ SLH-DSA per-validator surface.
//
// To keep the MLDSARollup slot unambiguous (it may carry a STARK proof OR a
// direct WQC), the embedded bytes are tagged with a 1-byte discriminator.
// Decoders dispatch on the tag; a STARK verifier and a WQC verifier never
// confuse each other's bytes.
package quasar
import (
"errors"
"fmt"
)
// mldsaRollupTagWQC is the 1-byte discriminator prefix that marks the
// MLDSARollup leg as carrying a direct WeightedQuorumCert (the
// full-node-verifiable realization) rather than a STARK/Groth16 succinct
// proof. The value 0x57 ('W') is chosen to be visually distinct and to lie
// outside the typical leading bytes of a STARK proof container; decoders
// MUST check this tag before parsing.
const mldsaRollupTagWQC byte = 0x57
var (
// ErrMLDSARollupNotWQC is returned by ExtractWeightedQuorumLeg when the
// MLDSARollup leg does not carry a WQC discriminator (e.g. it holds a
// STARK proof or per-validator sig concatenation instead).
ErrMLDSARollupNotWQC = errors.New("quasar: MLDSARollup leg is not a direct weighted quorum cert")
// ErrQuasarCertNoWQCLeg is returned by VerifyWeightedQuorumLeg when the
// cert carries no MLDSARollup leg at all.
ErrQuasarCertNoWQCLeg = errors.New("quasar: QuasarCert has no MLDSARollup leg to verify")
)
// EmbedWeightedQuorumLeg encodes a WeightedQuorumCert as the MLDSARollup
// leg of a QuasarCert, tagged with the WQC discriminator. The cert is
// emitted in FULL form (records included) when it carries records, so the
// leg is self-contained and a full node can verify it without a DA fetch;
// pass a Compact() cert to emit the commitment-only leg (DA-hybrid mode),
// in which case verification requires re-attaching records first.
//
// Returns the leg bytes (discriminator || wqc-wire) to assign to
// QuasarCert.MLDSARollup. Pure function; no secrets.
func EmbedWeightedQuorumLeg(cert *WeightedQuorumCert) ([]byte, error) {
if cert == nil {
return nil, ErrQCNil
}
wire, err := cert.MarshalBinary()
if err != nil {
return nil, err
}
out := make([]byte, 0, 1+len(wire))
out = append(out, mldsaRollupTagWQC)
out = append(out, wire...)
return out, nil
}
// ExtractWeightedQuorumLeg decodes the WeightedQuorumCert from a QuasarCert
// MLDSARollup leg. Returns ErrMLDSARollupNotWQC if the leg is empty or does
// not carry the WQC discriminator (so a STARK-rollup leg is cleanly
// distinguished, not misparsed).
func ExtractWeightedQuorumLeg(mldsaRollup []byte) (*WeightedQuorumCert, error) {
if len(mldsaRollup) == 0 || mldsaRollup[0] != mldsaRollupTagWQC {
return nil, ErrMLDSARollupNotWQC
}
return UnmarshalWeightedQuorumCert(mldsaRollup[1:])
}
// HasWeightedQuorumLeg reports whether a QuasarCert's MLDSARollup leg
// carries a direct WeightedQuorumCert (vs a STARK proof or nothing).
func (c *QuasarCert) HasWeightedQuorumLeg() bool {
if c == nil {
return false
}
return len(c.MLDSARollup) > 0 && c.MLDSARollup[0] == mldsaRollupTagWQC
}
// VerifyWeightedQuorumLeg verifies the direct weighted quorum certificate
// carried in a QuasarCert's MLDSARollup leg against message under cfg. This
// is the clean Verify entry point for the full-node-verifiable mode.
//
// It returns nil iff the leg is present, decodes as a WQC, and passes the
// full weighted-quorum predicate (per-signer FIPS verify + Merkle inclusion
// + weight threshold + aggregate/commitment checks). Any failure is a typed
// error — never a panic, never unbounded work.
//
// message MUST be the domain-separated consensus message the signers signed
// (build it with QuorumMessageForCert from the chain's envelope axes and the
// extracted cert, so the position binding is canonical).
func (c *QuasarCert) VerifyWeightedQuorumLeg(message []byte, cfg QuorumVerifierConfig) error {
if c == nil {
return ErrQCNil
}
if len(c.MLDSARollup) == 0 {
return ErrQuasarCertNoWQCLeg
}
cert, err := ExtractWeightedQuorumLeg(c.MLDSARollup)
if err != nil {
return err
}
return cert.Verify(message, cfg)
}
// BuildQuasarCertFromWeightedQuorum builds a QuasarCert whose MLDSARollup
// leg is the direct full-node-verifiable WeightedQuorumCert. The other legs
// (BLS / Corona / Pulsar / Magnetar) are left empty — this is the pure
// "direct weighted quorum" posture where the per-validator-signature
// surface IS the finality evidence, with no threshold-group or classical
// fast-path leg layered on. Compose with the lower-tier ComposePulsar /
// ComposeAurora / ComposePolaris functions when a hybrid posture is wanted;
// those operate on the threshold legs, which are orthogonal to this one.
//
// Epoch / Validators / Finality are copied from the quorum cert so the
// QuasarCert header reflects the same epoch and signer count.
func BuildQuasarCertFromWeightedQuorum(cert *WeightedQuorumCert) (*QuasarCert, error) {
if cert == nil {
return nil, ErrQCNil
}
leg, err := EmbedWeightedQuorumLeg(cert)
if err != nil {
return nil, err
}
return &QuasarCert{
MLDSARollup: leg,
Epoch: cert.Epoch,
Validators: int(cert.SignerCount),
}, nil
}
// AttachWeightedQuorumLeg returns a copy of the QuasarCert with its
// MLDSARollup leg replaced by the direct WeightedQuorumCert, preserving the
// other legs. Use when layering the full-node-verifiable per-validator
// surface onto an already-composed cert (e.g. one that already carries the
// BLS classical fast-path or a Pulsar threshold leg).
func (c *QuasarCert) AttachWeightedQuorumLeg(cert *WeightedQuorumCert) (*QuasarCert, error) {
if c == nil {
return nil, errors.New("quasar: nil QuasarCert")
}
leg, err := EmbedWeightedQuorumLeg(cert)
if err != nil {
return nil, fmt.Errorf("attach weighted quorum leg: %w", err)
}
cp := *c
cp.MLDSARollup = leg
if cp.Epoch == 0 {
cp.Epoch = cert.Epoch
}
if cp.Validators == 0 {
cp.Validators = int(cert.SignerCount)
}
return &cp, nil
}
+200
View File
@@ -0,0 +1,200 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// quorum_scheme.go — the per-signer scheme axis of a weighted quorum
// certificate, plus the pluggable FIPS verifier registry.
//
// A weighted quorum certificate is scheme-agnostic: it proves that a
// quorum-weight subset of validators each produced a valid INDEPENDENT
// signature over the same domain-separated consensus message. "Independent"
// is the whole point — there is no key sharing, no reconstruction, no seed /
// WOTS+ / FORS / secret material combined. Each signer record names the
// scheme it signed under; the verifier dispatches to the unmodified FIPS
// verifier for that scheme.
//
// This is threshold CERTIFICATION, not threshold signing. The contrast with
// the Pulsar / Corona threshold legs of QuasarCert is deliberate: those run
// a DKG and emit ONE group signature; this runs no ceremony and emits N
// per-validator signatures that any peer can check with a stock FIPS
// verifier and no Lux dependency.
package quasar
import (
"errors"
"fmt"
"github.com/luxfi/crypto/mldsa"
magnetar "github.com/luxfi/magnetar/ref/go/pkg/magnetar"
)
// QuorumSchemeID names the signature scheme a single quorum-certificate
// signer record was produced under. Orthogonal to config.SigSchemeID (the
// threshold-finality axis): a quorum cert's signer signs INDEPENDENTLY, so
// this axis names the per-signer FIPS relation the verifier dispatches to.
//
// The byte values intentionally MIRROR the canonical Lux wire bytes for the
// same primitives so a reader sees consistent numbering across the codebase:
//
// 0x41..0x43 — ML-DSA per FIPS 204 (matches config.SigSchemeID 0x40 block)
// 0x05..0x07 — SLH-DSA per FIPS 205 (matches config.RecoverySchemeID block)
//
// New schemes (e.g. an independent-signature Corona) claim the next free
// integer in their family block and MUST register a verifier in
// defaultQuorumVerifiers before any cert references them. Never reuse a
// retired id.
type QuorumSchemeID uint8
const (
QuorumSchemeNone QuorumSchemeID = 0x00
// FIPS 204 ML-DSA (Module-LWE lattice signature). Independent per-signer.
QuorumSchemeMLDSA44 QuorumSchemeID = 0x41
QuorumSchemeMLDSA65 QuorumSchemeID = 0x42 // production identity default
QuorumSchemeMLDSA87 QuorumSchemeID = 0x43
// FIPS 205 SLH-DSA (hash-based stateless signature). Independent
// per-signer; the cross-family backstop. Byte values match
// config.RecoverySchemeID's SLH-DSA block.
QuorumSchemeSLHDSA192s QuorumSchemeID = 0x06 // Magnetar production (SHAKE-192s)
QuorumSchemeSLHDSA192f QuorumSchemeID = 0x16 // SHAKE-192f
QuorumSchemeSLHDSA256s QuorumSchemeID = 0x07 // SHAKE-256s
)
// String returns the canonical wire name.
func (s QuorumSchemeID) String() string {
switch s {
case QuorumSchemeNone:
return "none"
case QuorumSchemeMLDSA44:
return "ml-dsa-44"
case QuorumSchemeMLDSA65:
return "ml-dsa-65"
case QuorumSchemeMLDSA87:
return "ml-dsa-87"
case QuorumSchemeSLHDSA192s:
return "slh-dsa-192s"
case QuorumSchemeSLHDSA192f:
return "slh-dsa-192f"
case QuorumSchemeSLHDSA256s:
return "slh-dsa-256s"
default:
return fmt.Sprintf("quorum-scheme(0x%02x)", uint8(s))
}
}
// FIPS reports the FIPS standard the scheme's verifier implements. "204"
// for ML-DSA, "205" for SLH-DSA, "" for unknown. The certificate's NIST
// posture rests on these being unmodified FIPS verifiers.
func (s QuorumSchemeID) FIPS() string {
switch s {
case QuorumSchemeMLDSA44, QuorumSchemeMLDSA65, QuorumSchemeMLDSA87:
return "204"
case QuorumSchemeSLHDSA192s, QuorumSchemeSLHDSA192f, QuorumSchemeSLHDSA256s:
return "205"
default:
return ""
}
}
var (
// ErrUnknownQuorumScheme is returned when a signer record names a
// scheme with no registered verifier. Treated as a per-record INVALID
// (not a fatal/DoS) by the certificate verifier.
ErrUnknownQuorumScheme = errors.New("quasar: unknown quorum signature scheme")
// ErrSchemeNotAllowed is returned (as a per-record invalidation) when a
// signer record's scheme is not in the verifier's allowed set.
ErrSchemeNotAllowed = errors.New("quasar: quorum signature scheme not in allowed set")
)
// quorumVerifyFn is the pluggable per-signature verification function. It
// MUST be a thin dispatch over an UNMODIFIED FIPS verifier — adding logic
// here breaks the wire-identity property that lets any peer verify a record
// with a stock FIPS library and no Lux dependency.
//
// Contract:
// - pubKey is the signer's canonical public-key bytes (exactly as bound
// into the validator-set leaf).
// - message is the domain-separated consensus message (the round digest
// subject — see quorum_message.go).
// - ctx is the FIPS context string (≤255 bytes), bound at sign time.
// - returns true iff the signature verifies; never panics.
type quorumVerifyFn func(pubKey, message, ctx, sig []byte) bool
// defaultQuorumVerifiers is the canonical scheme→verifier registry. Each
// entry is a thin dispatch over a stock FIPS verifier:
//
// ML-DSA → github.com/luxfi/crypto/mldsa PublicKey.VerifySignatureCtx (FIPS 204)
// SLH-DSA → github.com/luxfi/magnetar slhdsa.Verify via VerifyCtx (FIPS 205)
//
// The map is the single source of truth for which schemes a quorum cert
// can carry. A QuorumVerifierConfig may restrict to a subset but never
// adds schemes the registry does not know.
var defaultQuorumVerifiers = map[QuorumSchemeID]quorumVerifyFn{
QuorumSchemeMLDSA44: mldsaVerifier(mldsa.MLDSA44),
QuorumSchemeMLDSA65: mldsaVerifier(mldsa.MLDSA65),
QuorumSchemeMLDSA87: mldsaVerifier(mldsa.MLDSA87),
QuorumSchemeSLHDSA192s: slhdsaVerifier(magnetar.ModeM192s),
QuorumSchemeSLHDSA192f: slhdsaVerifier(magnetar.ModeM192f),
QuorumSchemeSLHDSA256s: slhdsaVerifier(magnetar.ModeM256s),
}
// mldsaVerifier builds a quorumVerifyFn over the unmodified FIPS 204
// ML-DSA verifier for a fixed parameter set. The public key is parsed
// per-call from its canonical bytes; a parse failure or wrong-length key is
// a clean false (the record is INVALID, not fatal).
func mldsaVerifier(mode mldsa.Mode) quorumVerifyFn {
return func(pubKey, message, ctx, sig []byte) bool {
pk, err := mldsa.PublicKeyFromBytes(pubKey, mode)
if err != nil || pk == nil {
return false
}
// VerifySignatureCtx is the stock FIPS 204 verify with a context
// string per §5.4; an empty ctx (nil) is the empty-context case.
return pk.VerifySignatureCtx(message, sig, ctx)
}
}
// slhdsaVerifier builds a quorumVerifyFn over the unmodified FIPS 205
// SLH-DSA verifier (via magnetar's thin VerifyCtx dispatch over
// circl/slhdsa) for a fixed parameter set.
func slhdsaVerifier(mode magnetar.Mode) quorumVerifyFn {
params, perr := magnetar.ParamsFor(mode)
return func(pubKey, message, ctx, sig []byte) bool {
if perr != nil || params == nil {
return false
}
if len(pubKey) != params.PublicKeySize || len(sig) != params.SignatureSize {
return false
}
pk := &magnetar.PublicKey{Mode: mode, Bytes: append([]byte(nil), pubKey...)}
s := &magnetar.Signature{Mode: mode, Bytes: append([]byte(nil), sig...)}
// VerifyCtx returns nil iff the signature is valid under
// FIPS 205 SLH-DSA.Verify; any error => invalid record.
return magnetar.VerifyCtx(params, pk, message, ctx, s) == nil
}
}
// expectedPubKeyLen returns the canonical public-key byte length for a
// scheme, or (0, false) if the scheme is unknown. Used by the certificate
// verifier to reject a record whose embedded key length cannot match the
// scheme before any signature dispatch.
func expectedPubKeyLen(scheme QuorumSchemeID) (int, bool) {
switch scheme {
case QuorumSchemeMLDSA44:
return mldsa.GetPublicKeySize(mldsa.MLDSA44), true
case QuorumSchemeMLDSA65:
return mldsa.GetPublicKeySize(mldsa.MLDSA65), true
case QuorumSchemeMLDSA87:
return mldsa.GetPublicKeySize(mldsa.MLDSA87), true
case QuorumSchemeSLHDSA192s:
return magnetar.ParamsM192s.PublicKeySize, true
case QuorumSchemeSLHDSA192f:
return magnetar.ParamsM192f.PublicKeySize, true
case QuorumSchemeSLHDSA256s:
return magnetar.ParamsM256s.PublicKeySize, true
default:
return 0, false
}
}
+497
View File
@@ -0,0 +1,497 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// weighted_merkle.go — the weighted validator-set commitment.
//
// A canonical, second-preimage-resistant Merkle commitment over the epoch
// validator set whose leaf binds EVERY accountability field: the validator
// identity, its canonical public key, its voting weight, the parameter set
// it signs under, and its key version. The resulting 48-byte root is the
// validator_set_root that ComputeRoundDigest (round_digest.go) binds into
// the consensus message, and that a Q-Block carries in ValidatorSetRoot.
//
// Why this exists separately from the block-hash Merkle in epoch.go /
// grouped_threshold.go:
//
// - Those trees commit to BLOCK hashes for ordering; they pad an odd
// level by DUPLICATING the last node. Duplication is the classic
// CVE-2012-2459 second-preimage hazard: a different leaf multiset can
// yield the same root. For a commitment whose leaves decide WHO signed
// and WITH WHAT WEIGHT, that is a forgery surface (an attacker could
// present an inclusion proof for a weight/key the committee never
// committed). This tree refuses to duplicate.
//
// - This tree is domain-separated: leaf hashing and internal-node hashing
// use DISTINCT cSHAKE256 customization tags, so no internal-node digest
// can ever be reinterpreted as a leaf digest (and vice versa). That is
// the standard RFC 6962 / Certificate-Transparency defense against the
// duplication and node/leaf-confusion second-preimage attacks.
//
// - The leaf binds the (epoch, parameter_set_id, key_version) accountability
// fields the block-hash trees have no notion of.
//
// One leaf encoding, one root function, one inclusion-proof verifier. This
// is the only weighted-validator-set commitment in the codebase.
package quasar
import (
"encoding/binary"
"errors"
"fmt"
"sort"
)
// weightedLeafCustomization is the SP 800-185 cSHAKE256 customization tag
// for a weighted-validator-set LEAF. Distinct from the node tag so a
// leaf digest can never collide with an internal-node digest — the
// node/leaf-confusion second-preimage defense. Wire-stable cryptographic
// constant: never rename.
const weightedLeafCustomization = "QUASAR-WVSET-LEAF-V1"
// weightedNodeCustomization is the SP 800-185 cSHAKE256 customization tag
// for a weighted-validator-set INTERNAL node. Distinct from the leaf tag.
// Wire-stable cryptographic constant: never rename.
const weightedNodeCustomization = "QUASAR-WVSET-NODE-V1"
// weightedLeafProtocolTag is the in-band redundant protocol tag bound as
// the first TupleHash part of every leaf. Defence-in-depth so a
// cross-customization-collision attacker also has to forge the leading part.
const weightedLeafProtocolTag = "Quasar/WVSet/Leaf"
// weightedNodeProtocolTag is the in-band redundant protocol tag bound as
// the first TupleHash part of every internal node.
const weightedNodeProtocolTag = "Quasar/WVSet/Node"
var (
// ErrWVSetEmpty is returned when a build is attempted over an empty
// validator set. A quorum certificate over zero validators is
// meaningless; the caller must supply ≥ 1 leaf.
ErrWVSetEmpty = errors.New("quasar: weighted validator set is empty")
// ErrWVSetDuplicateID is returned when two leaves share a validator_id.
// The validator set is a SET: a repeated identity is either a
// duplicate-count attack or a caller bug. Refused at build, not papered
// over by dedup.
ErrWVSetDuplicateID = errors.New("quasar: duplicate validator_id in weighted validator set")
// ErrWVSetZeroWeight is returned when a leaf carries zero voting weight.
// A zero-weight signer contributes nothing to a quorum and is almost
// always a misconfiguration; refusing it closes a silent
// "phantom signer inflates the signer count but not the weight" surface.
ErrWVSetZeroWeight = errors.New("quasar: validator has zero voting weight")
// ErrWVSetEmptyPubKey is returned when a leaf carries an empty public
// key. A leaf with no key cannot be the subject of a signature
// verification and must not be committed.
ErrWVSetEmptyPubKey = errors.New("quasar: validator has empty public key")
// ErrWVSetProofShape is returned when an inclusion proof's structural
// invariants do not hold (leaf index out of range, path length does
// not match the tree, etc.).
ErrWVSetProofShape = errors.New("quasar: weighted-set inclusion proof malformed")
)
// WeightedValidatorLeaf is the canonical leaf of the weighted
// validator-set commitment. Every field is an accountability axis bound
// into the leaf digest; flipping any byte yields a different leaf and
// therefore a different root.
//
// The (Epoch) under which the set is committed is NOT a leaf field — it is
// a tree-wide parameter passed to BuildWeightedValidatorSet and folded
// into every leaf digest, so the same (validator_id, pubkey, weight) in a
// different epoch produces a different commitment. This binds the set to
// the epoch without repeating the epoch in every leaf struct.
type WeightedValidatorLeaf struct {
// ValidatorID is the validator's canonical 32-byte identifier. Leaves
// are sorted by this field; duplicates are refused.
ValidatorID [32]byte
// PublicKey is the validator's canonical signature public key bytes
// (e.g. ML-DSA-65 or SLH-DSA packed public key). Bound verbatim — the
// verifier checks a signature under THIS key after proving inclusion.
PublicKey []byte
// VotingWeight is the validator's stake-weight for quorum accounting.
// Must be non-zero.
VotingWeight uint64
// ParameterSetID is the wire byte naming the signature parameter set
// this validator signs under (config.SigSchemeID / IdentitySchemeID
// byte — e.g. 0x42 = ML-DSA-65). Bound so a key cannot be silently
// reinterpreted under a different parameter set.
ParameterSetID uint8
// KeyVersion is the monotonically increasing key-rotation counter for
// this validator. Bound so a retired key (lower version) cannot be
// substituted for the current one under the same identity.
KeyVersion uint32
}
// WeightedValidatorSet is the built commitment: the canonical sorted
// leaves plus their precomputed leaf digests and the root. Immutable after
// BuildWeightedValidatorSet returns; the prover queries it for inclusion
// proofs and the root is what gets bound into the round digest.
type WeightedValidatorSet struct {
epoch uint64
leaves []WeightedValidatorLeaf // sorted by ValidatorID, no duplicates
leafHashes [][48]byte // leafHashes[i] = hash of leaves[i]
root [48]byte
}
// Root returns the 48-byte weighted-validator-set root. This is the value
// bound into ComputeRoundDigest's validatorSetRoot and carried in
// QBlock.ValidatorSetRoot.
func (s *WeightedValidatorSet) Root() [48]byte { return s.root }
// Epoch returns the epoch this set was committed under.
func (s *WeightedValidatorSet) Epoch() uint64 { return s.epoch }
// Len returns the number of validators in the set.
func (s *WeightedValidatorSet) Len() int { return len(s.leaves) }
// Leaves returns a copy of the canonical sorted leaves. Defensive copy so
// callers cannot mutate the committed set.
func (s *WeightedValidatorSet) Leaves() []WeightedValidatorLeaf {
out := make([]WeightedValidatorLeaf, len(s.leaves))
copy(out, s.leaves)
for i := range out {
out[i].PublicKey = append([]byte(nil), s.leaves[i].PublicKey...)
}
return out
}
// computeWeightedLeafHash returns the 48-byte digest of one leaf under the
// epoch. The encoding is canonical and length-framed via TupleHash256, so
// no boundary-shifting attack can move bytes between fields.
//
// Layout (TupleHash256, customization "QUASAR-WVSET-LEAF-V1"):
//
// parts[0] = "Quasar/WVSet/Leaf" (in-band protocol tag)
// parts[1] = epoch (8 BE)
// parts[2] = validator_id ([32]byte)
// parts[3] = canonical_pubkey (variable, length-framed)
// parts[4] = voting_weight (8 BE)
// parts[5] = parameter_set_id (1)
// parts[6] = key_version (4 BE)
func computeWeightedLeafHash(epoch uint64, leaf WeightedValidatorLeaf) [48]byte {
var u64 [8]byte
var u32 [4]byte
binary.BigEndian.PutUint64(u64[:], epoch)
epochBytes := append([]byte(nil), u64[:]...)
binary.BigEndian.PutUint64(u64[:], leaf.VotingWeight)
weightBytes := append([]byte(nil), u64[:]...)
binary.BigEndian.PutUint32(u32[:], leaf.KeyVersion)
keyVerBytes := append([]byte(nil), u32[:]...)
parts := [][]byte{
[]byte(weightedLeafProtocolTag),
epochBytes,
leaf.ValidatorID[:],
leaf.PublicKey,
weightBytes,
{leaf.ParameterSetID},
keyVerBytes,
}
out := tupleHash256RoundDigest(parts, 48, weightedLeafCustomization)
var h [48]byte
copy(h[:], out)
return h
}
// computeWeightedNodeHash returns the 48-byte digest of an internal node
// combining a left and right child. Distinct customization from the leaf
// hash guarantees node/leaf domain separation.
//
// Layout (TupleHash256, customization "QUASAR-WVSET-NODE-V1"):
//
// parts[0] = "Quasar/WVSet/Node" (in-band protocol tag)
// parts[1] = left ([48]byte)
// parts[2] = right ([48]byte)
func computeWeightedNodeHash(left, right [48]byte) [48]byte {
parts := [][]byte{
[]byte(weightedNodeProtocolTag),
left[:],
right[:],
}
out := tupleHash256RoundDigest(parts, 48, weightedNodeCustomization)
var h [48]byte
copy(h[:], out)
return h
}
// BuildWeightedValidatorSet builds the commitment over the epoch validator
// set. Leaves are canonicalised by sorting on ValidatorID; a duplicate
// validator_id, a zero weight, or an empty public key is a hard error
// (fail-closed — a malformed set must never silently produce a root).
//
// The function takes ownership of nothing: it defensively copies the leaf
// public keys so a later mutation of the caller's slice cannot move the
// committed set underneath an already-produced root.
func BuildWeightedValidatorSet(epoch uint64, leaves []WeightedValidatorLeaf) (*WeightedValidatorSet, error) {
if len(leaves) == 0 {
return nil, ErrWVSetEmpty
}
// Defensive copy + canonical sort by validator_id.
sorted := make([]WeightedValidatorLeaf, len(leaves))
copy(sorted, leaves)
for i := range sorted {
if len(sorted[i].PublicKey) == 0 {
return nil, fmt.Errorf("%w: index %d", ErrWVSetEmptyPubKey, i)
}
if sorted[i].VotingWeight == 0 {
return nil, fmt.Errorf("%w: index %d", ErrWVSetZeroWeight, i)
}
sorted[i].PublicKey = append([]byte(nil), sorted[i].PublicKey...)
}
sort.Slice(sorted, func(i, j int) bool {
return bytesLess(sorted[i].ValidatorID[:], sorted[j].ValidatorID[:])
})
// Reject duplicate validator_id after sorting (adjacency check).
for i := 1; i < len(sorted); i++ {
if sorted[i].ValidatorID == sorted[i-1].ValidatorID {
return nil, fmt.Errorf("%w: %x", ErrWVSetDuplicateID, sorted[i].ValidatorID[:])
}
}
leafHashes := make([][48]byte, len(sorted))
for i := range sorted {
leafHashes[i] = computeWeightedLeafHash(epoch, sorted[i])
}
root := weightedMerkleRoot(leafHashes)
return &WeightedValidatorSet{
epoch: epoch,
leaves: sorted,
leafHashes: leafHashes,
root: root,
}, nil
}
// weightedMerkleRoot folds the leaf digests into a single 48-byte root.
//
// Odd levels PROMOTE the unpaired node unchanged to the next level — they
// do NOT duplicate it. Promotion is sound here precisely because leaf and
// internal-node hashes are domain-separated and the per-level node count
// is determined by the (fixed) leaf count: there is no second multiset of
// leaves that yields the same root, because no leaf digest can ever be
// confused with an internal-node digest, and the structure is a function
// of n alone.
//
// A single-leaf set roots at that leaf's digest (the standard convention);
// the leaf-vs-node domain separation means even this degenerate root
// cannot be confused with an internal node.
func weightedMerkleRoot(leafHashes [][48]byte) [48]byte {
if len(leafHashes) == 0 {
return [48]byte{}
}
level := make([][48]byte, len(leafHashes))
copy(level, leafHashes)
for len(level) > 1 {
next := make([][48]byte, 0, (len(level)+1)/2)
i := 0
for ; i+1 < len(level); i += 2 {
next = append(next, computeWeightedNodeHash(level[i], level[i+1]))
}
if i < len(level) {
// Odd tail: promote unchanged. No duplication.
next = append(next, level[i])
}
level = next
}
return level[0]
}
// WeightedInclusionProof is a Merkle authentication path proving a single
// leaf is committed under a WeightedValidatorSet root. The proof carries
// the sibling digests bottom-up plus a left/right orientation bit per
// level. Promotion levels (where the leaf's subtree was the odd tail) carry
// NO sibling — recorded as a skip so verification mirrors construction
// exactly.
type WeightedInclusionProof struct {
// LeafIndex is the position of the proven leaf in the canonical sorted
// order. Bound so a verifier can re-derive the orientation independently
// of the Siblings orientation bits (defence-in-depth cross-check).
LeafIndex uint32
// LeafCount is the total number of leaves in the committed set. Bound so
// the verifier reconstructs the exact tree shape (which levels promote).
LeafCount uint32
// Steps is the bottom-up authentication path.
Steps []WeightedProofStep
}
// WeightedProofStep is one level of an inclusion proof.
type WeightedProofStep struct {
// Promoted is true at a level where the running node was the odd tail
// and was promoted unchanged (no sibling combined). Sibling/IsRight are
// ignored when Promoted is true.
Promoted bool
// Sibling is the 48-byte sibling digest combined at this level.
Sibling [48]byte
// SiblingIsRight reports whether Sibling is the RIGHT child (i.e. the
// running node is the LEFT child) at this level.
SiblingIsRight bool
}
// InclusionProof returns the authentication path for the leaf at the given
// canonical-sorted index. Returns ErrWVSetProofShape if the index is out of
// range.
func (s *WeightedValidatorSet) InclusionProof(leafIndex int) (*WeightedInclusionProof, error) {
n := len(s.leafHashes)
if leafIndex < 0 || leafIndex >= n {
return nil, fmt.Errorf("%w: index %d not in [0,%d)", ErrWVSetProofShape, leafIndex, n)
}
steps := make([]WeightedProofStep, 0)
level := make([][48]byte, n)
copy(level, s.leafHashes)
idx := leafIndex
for len(level) > 1 {
next := make([][48]byte, 0, (len(level)+1)/2)
i := 0
for ; i+1 < len(level); i += 2 {
next = append(next, computeWeightedNodeHash(level[i], level[i+1]))
}
hasOddTail := i < len(level)
if hasOddTail {
next = append(next, level[i])
}
if idx == len(level)-1 && hasOddTail {
// Our node is the promoted odd tail: no sibling this level.
steps = append(steps, WeightedProofStep{Promoted: true})
idx = len(next) - 1
} else {
// Our node was combined with its pair sibling.
if idx%2 == 0 {
// We are the left child; sibling is the right child.
steps = append(steps, WeightedProofStep{
Sibling: level[idx+1],
SiblingIsRight: true,
})
} else {
// We are the right child; sibling is the left child.
steps = append(steps, WeightedProofStep{
Sibling: level[idx-1],
SiblingIsRight: false,
})
}
idx = idx / 2
}
level = next
}
return &WeightedInclusionProof{
LeafIndex: uint32(leafIndex),
LeafCount: uint32(n),
Steps: steps,
}, nil
}
// VerifyWeightedInclusion recomputes the root from a leaf and an inclusion
// proof and reports whether it equals the expected root. Fail-closed: any
// structural mismatch (path length inconsistent with LeafCount, index out
// of range) returns false, never a partial accept.
//
// The verifier independently re-derives the per-level orientation from
// LeafIndex/LeafCount and cross-checks it against the proof's orientation
// bits, so a proof that lies about left/right is rejected even if its
// recomputed root happened to match.
func VerifyWeightedInclusion(root [48]byte, epoch uint64, leaf WeightedValidatorLeaf, proof *WeightedInclusionProof) bool {
if proof == nil {
return false
}
n := int(proof.LeafCount)
idx := int(proof.LeafIndex)
if n <= 0 || idx < 0 || idx >= n {
return false
}
// Re-derive the expected step shape from (idx, n) and confirm the
// proof's step count and promotion/orientation flags match exactly.
expSteps := weightedProofShape(idx, n)
if len(expSteps) != len(proof.Steps) {
return false
}
running := computeWeightedLeafHash(epoch, leaf)
for level, exp := range expSteps {
got := proof.Steps[level]
if got.Promoted != exp.promoted {
return false
}
if exp.promoted {
// Promotion: running node carries up unchanged.
continue
}
if got.SiblingIsRight != exp.siblingIsRight {
return false
}
if exp.siblingIsRight {
running = computeWeightedNodeHash(running, got.Sibling)
} else {
running = computeWeightedNodeHash(got.Sibling, running)
}
}
return running == root
}
// weightedProofShapeStep is the verifier-derived expectation for one level.
type weightedProofShapeStep struct {
promoted bool
siblingIsRight bool
}
// weightedProofShape returns the canonical per-level orientation/promotion
// sequence for a leaf at index idx in a tree of n leaves. This is the
// single source of truth for tree geometry, shared between construction
// (InclusionProof) intent and verification (VerifyWeightedInclusion) so the
// two can never disagree about which levels promote.
func weightedProofShape(idx, n int) []weightedProofShapeStep {
steps := make([]weightedProofShapeStep, 0)
levelLen := n
for levelLen > 1 {
pairedLen := levelLen - (levelLen % 2) // number of nodes that pair up
hasOddTail := levelLen%2 == 1
if idx == levelLen-1 && hasOddTail {
steps = append(steps, weightedProofShapeStep{promoted: true})
// Promoted node becomes the last node of the next level.
idx = (pairedLen / 2)
} else {
steps = append(steps, weightedProofShapeStep{
siblingIsRight: idx%2 == 0,
})
idx = idx / 2
}
nextLen := pairedLen / 2
if hasOddTail {
nextLen++
}
levelLen = nextLen
}
return steps
}
// bytesLess is a constant-shape lexicographic comparison of equal-length
// byte slices (validator IDs are [32]byte). Not constant-time — validator
// IDs are public, so a data-dependent branch here leaks nothing secret.
func bytesLess(a, b []byte) bool {
for i := 0; i < len(a) && i < len(b); i++ {
if a[i] != b[i] {
return a[i] < b[i]
}
}
return len(a) < len(b)
}
+228
View File
@@ -0,0 +1,228 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package quasar
import (
"testing"
)
// mkLeaf builds a deterministic test leaf with a distinct id/pubkey/weight.
func mkLeaf(idByte byte, weight uint64, param uint8, keyVer uint32) WeightedValidatorLeaf {
var id [32]byte
for i := range id {
id[i] = idByte
}
pk := make([]byte, 16)
for i := range pk {
pk[i] = idByte ^ byte(i)
}
return WeightedValidatorLeaf{
ValidatorID: id,
PublicKey: pk,
VotingWeight: weight,
ParameterSetID: param,
KeyVersion: keyVer,
}
}
func TestWeightedMerkle_BuildAndRootDeterminism(t *testing.T) {
leaves := []WeightedValidatorLeaf{
mkLeaf(0x03, 10, 0x42, 1),
mkLeaf(0x01, 20, 0x42, 1),
mkLeaf(0x02, 30, 0x42, 1),
}
s1, err := BuildWeightedValidatorSet(7, leaves)
if err != nil {
t.Fatalf("build: %v", err)
}
// Re-order input: root must be identical (canonical sort by id).
reordered := []WeightedValidatorLeaf{leaves[2], leaves[0], leaves[1]}
s2, err := BuildWeightedValidatorSet(7, reordered)
if err != nil {
t.Fatalf("build reordered: %v", err)
}
if s1.Root() != s2.Root() {
t.Fatalf("root not invariant under input ordering: %x vs %x", s1.Root(), s2.Root())
}
var zero [48]byte
if s1.Root() == zero {
t.Fatal("root is zero for non-empty set")
}
}
func TestWeightedMerkle_EpochBinding(t *testing.T) {
leaves := []WeightedValidatorLeaf{mkLeaf(0x01, 10, 0x42, 1), mkLeaf(0x02, 10, 0x42, 1)}
a, _ := BuildWeightedValidatorSet(7, leaves)
b, _ := BuildWeightedValidatorSet(8, leaves)
if a.Root() == b.Root() {
t.Fatal("epoch is not bound into the root (different epochs collided)")
}
}
func TestWeightedMerkle_WeightBinding(t *testing.T) {
// The whole point of the task: bind WEIGHT. Flip a single weight, expect
// a different root.
base := []WeightedValidatorLeaf{mkLeaf(0x01, 10, 0x42, 1), mkLeaf(0x02, 20, 0x42, 1)}
mut := []WeightedValidatorLeaf{mkLeaf(0x01, 10, 0x42, 1), mkLeaf(0x02, 21, 0x42, 1)}
a, _ := BuildWeightedValidatorSet(7, base)
b, _ := BuildWeightedValidatorSet(7, mut)
if a.Root() == b.Root() {
t.Fatal("voting weight is not bound into the leaf (weight flip collided)")
}
}
func TestWeightedMerkle_ParamAndKeyVersionBinding(t *testing.T) {
base := []WeightedValidatorLeaf{mkLeaf(0x01, 10, 0x42, 1)}
param := []WeightedValidatorLeaf{mkLeaf(0x01, 10, 0x43, 1)}
keyv := []WeightedValidatorLeaf{mkLeaf(0x01, 10, 0x42, 2)}
a, _ := BuildWeightedValidatorSet(7, base)
b, _ := BuildWeightedValidatorSet(7, param)
c, _ := BuildWeightedValidatorSet(7, keyv)
if a.Root() == b.Root() {
t.Fatal("parameter_set_id not bound into leaf")
}
if a.Root() == c.Root() {
t.Fatal("key_version not bound into leaf")
}
}
func TestWeightedMerkle_RejectsBadInput(t *testing.T) {
if _, err := BuildWeightedValidatorSet(7, nil); err != ErrWVSetEmpty {
t.Fatalf("empty set err = %v, want ErrWVSetEmpty", err)
}
// Duplicate id.
dup := []WeightedValidatorLeaf{mkLeaf(0x01, 10, 0x42, 1), mkLeaf(0x01, 20, 0x42, 1)}
if _, err := BuildWeightedValidatorSet(7, dup); err == nil {
t.Fatal("duplicate validator_id accepted, want ErrWVSetDuplicateID")
}
// Zero weight.
zw := []WeightedValidatorLeaf{mkLeaf(0x01, 0, 0x42, 1)}
if _, err := BuildWeightedValidatorSet(7, zw); err == nil {
t.Fatal("zero weight accepted, want ErrWVSetZeroWeight")
}
// Empty pubkey.
ep := []WeightedValidatorLeaf{{ValidatorID: [32]byte{1}, VotingWeight: 10, ParameterSetID: 0x42}}
if _, err := BuildWeightedValidatorSet(7, ep); err == nil {
t.Fatal("empty pubkey accepted, want ErrWVSetEmptyPubKey")
}
}
func TestWeightedMerkle_InclusionSoundness(t *testing.T) {
// Every leaf at every set size must produce a verifying inclusion proof,
// and a NON-member must be rejected. Exercise sizes 1..9 to cover both
// even and odd levels (the promotion path).
for n := 1; n <= 9; n++ {
leaves := make([]WeightedValidatorLeaf, n)
for i := 0; i < n; i++ {
leaves[i] = mkLeaf(byte(i+1), uint64(10*(i+1)), 0x42, 1)
}
set, err := BuildWeightedValidatorSet(7, leaves)
if err != nil {
t.Fatalf("n=%d build: %v", n, err)
}
root := set.Root()
sorted := set.Leaves()
for idx := 0; idx < n; idx++ {
proof, err := set.InclusionProof(idx)
if err != nil {
t.Fatalf("n=%d idx=%d proof: %v", n, idx, err)
}
if !VerifyWeightedInclusion(root, 7, sorted[idx], proof) {
t.Fatalf("n=%d idx=%d: valid inclusion proof rejected", n, idx)
}
// Wrong epoch must fail.
if VerifyWeightedInclusion(root, 8, sorted[idx], proof) {
t.Fatalf("n=%d idx=%d: inclusion accepted under wrong epoch", n, idx)
}
// Non-member leaf (mutated weight) must fail under the same path.
bad := sorted[idx]
bad.VotingWeight++
if VerifyWeightedInclusion(root, 7, bad, proof) {
t.Fatalf("n=%d idx=%d: non-member (mutated weight) accepted", n, idx)
}
}
}
}
func TestWeightedMerkle_TamperedPathRejected(t *testing.T) {
leaves := make([]WeightedValidatorLeaf, 5)
for i := range leaves {
leaves[i] = mkLeaf(byte(i+1), 10, 0x42, 1)
}
set, _ := BuildWeightedValidatorSet(7, leaves)
root := set.Root()
sorted := set.Leaves()
proof, _ := set.InclusionProof(2)
if !VerifyWeightedInclusion(root, 7, sorted[2], proof) {
t.Fatal("baseline proof must verify")
}
// Flip a sibling byte → reject.
if len(proof.Steps) > 0 {
tampered := *proof
tampered.Steps = append([]WeightedProofStep(nil), proof.Steps...)
for i := range tampered.Steps {
if !tampered.Steps[i].Promoted {
tampered.Steps[i].Sibling[0] ^= 0xFF
break
}
}
if VerifyWeightedInclusion(root, 7, sorted[2], &tampered) {
t.Fatal("tampered sibling accepted")
}
}
// Flip an orientation bit → reject (verifier re-derives orientation).
flip := *proof
flip.Steps = append([]WeightedProofStep(nil), proof.Steps...)
for i := range flip.Steps {
if !flip.Steps[i].Promoted {
flip.Steps[i].SiblingIsRight = !flip.Steps[i].SiblingIsRight
break
}
}
if VerifyWeightedInclusion(root, 7, sorted[2], &flip) {
t.Fatal("flipped orientation accepted")
}
// Lie about LeafIndex/LeafCount → reject (shape mismatch).
wrongShape := *proof
wrongShape.LeafCount = 99
if VerifyWeightedInclusion(root, 7, sorted[2], &wrongShape) {
t.Fatal("wrong LeafCount accepted")
}
// Out-of-range index in InclusionProof → error.
if _, err := set.InclusionProof(5); err == nil {
t.Fatal("out-of-range inclusion index accepted")
}
}
// TestWeightedMerkle_NoDuplicationSecondPreimage guards the CVE-2012-2459
// class: with an odd number of leaves, a tree that DUPLICATES the last leaf
// makes the (n) and (n with last leaf repeated) multisets share a root.
// This tree PROMOTES instead, so appending a duplicate of the last leaf is
// rejected at build (duplicate id), and even constructing the doubled leaf
// hash list directly yields a different root than the n-leaf set.
func TestWeightedMerkle_NoDuplicationSecondPreimage(t *testing.T) {
leaves := []WeightedValidatorLeaf{
mkLeaf(0x01, 10, 0x42, 1),
mkLeaf(0x02, 10, 0x42, 1),
mkLeaf(0x03, 10, 0x42, 1),
}
set, _ := BuildWeightedValidatorSet(7, leaves)
// Hand-roll the "duplicate last leaf" root the vulnerable construction
// would produce, and confirm it does NOT equal our promotion root.
h := make([][48]byte, 0, 4)
for _, l := range leaves {
h = append(h, computeWeightedLeafHash(7, l))
}
dup := append(append([][48]byte(nil), h...), h[len(h)-1]) // 4 leaves, last duplicated
dupRoot := weightedMerkleRoot(dup)
if dupRoot == set.Root() {
t.Fatal("promotion root collides with last-leaf-duplication root (CVE-2012-2459 surface)")
}
}