Files
consensus/protocol/quasar/quorum_redteam_test.go
zooqueen d01dcfa1c1 feat(quasar): ConsensusCert envelope — policy-gated threshold certification
Canonical finality cert: one policy-gated envelope (VerifyConsensusCert) with
per-leg evidence modes — ThresholdSig (lattice O(1)), WeightedSigSet (independent
FIPS sigs + weighted-Merkle quorum; the only mode for SLH-DSA, never
seed-reconstructed), StarkCompressedSigSet (P3Q, same predicate, audit-gated),
ClassicalAggregate (BLS, never satisfies a PQ leg). Required legs are
policy-derived (CertPolicy.RequiredLegs), never cert bytes (Red H3 anti-downgrade);
full-tuple domain binding. WeightedQuorumCert foundation: policy-as-parameter,
message recomputed internally, strictly-increasing IDs, checked weight.

27 named envelope tests + F1-F5 + 6 CTO attacks PASS; verifier total (fuzz, zero
panics); a fuzz-found threshold-sig flags-byte malleability fixed. Fixed go.sum
[email protected] to canonical transparency-log hashes. Scrubbed ~54MB tracked binaries.
2026-06-21 20:39:11 -07:00

385 lines
16 KiB
Go

// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// quorum_redteam_test.go — adversarial closures for the red-team findings on
// the weighted quorum certificate's ENVELOPE binding. Each test reproduces a
// specific reported PoC and asserts it is now REJECTED (fail-closed). The
// crypto core (TupleHash256, FIPS verify, Merkle, leaf binding, distinct
// signer, weight guards) is sound and untouched; these tests pin the envelope
// fixes:
//
// F1 (CRITICAL) sub-quorum finality forgery via unbound QuorumThreshold
// F2 (HIGH) decode DoS via attacker-controlled SignerCount
// F3 (HIGH) Verify trusting an opaque caller message (role/position replay)
// F4 (MED) weighted-Merkle (LeafIndex,LeafCount) proof malleability
// F5 (MED) mixed-scheme FIPS-context asymmetry
package quasar
import (
"encoding/binary"
"errors"
"testing"
"time"
"github.com/luxfi/consensus/config"
)
// ----------------------------------------------------------------------------
// F1 (CRITICAL) — QuorumThreshold is bound; sub-quorum finality forgery closed.
// ----------------------------------------------------------------------------
// TestRedteam_F1_ForgedBelowFloorThresholdRejected reproduces the headline
// PoC: a real but SUB-QUORUM signer set (Σweight=40) is assembled into a cert
// that asserts its OWN low QuorumThreshold=40, while the chain BFT quorum is
// 67. The mandatory verifier floor (MinThreshold) rejects it — a cert may not
// assert its own security parameter.
func TestRedteam_F1_ForgedBelowFloorThresholdRejected(t *testing.T) {
// 4 validators, weight 10 each → Σ = 40. The forger sets QuorumThreshold=40.
signers := []*testSigner{
newMLDSASigner(t, 0x01, 10),
newMLDSASigner(t, 0x02, 10),
newMLDSASigner(t, 0x03, 10),
newMLDSASigner(t, 0x04, 10),
}
sc := buildScenario(t, signers, 40, nil) // cert.QuorumThreshold = 40
// The chain's real BFT quorum floor is 67.
cfg := sc.cfg
cfg.MinThreshold = 67
err := sc.cert.Verify(sc.env, cfg)
if !errors.Is(err, ErrQCThresholdBelowFloor) {
t.Fatalf("forged below-floor threshold err = %v, want ErrQCThresholdBelowFloor", err)
}
}
// TestRedteam_F1_MinThresholdUnsetFailsClosed proves the floor is MANDATORY: a
// verifier that forgets to pin MinThreshold (zero) does NOT silently accept —
// it fails closed. This is the defence against a caller accidentally trusting
// the cert's self-asserted threshold.
func TestRedteam_F1_MinThresholdUnsetFailsClosed(t *testing.T) {
sc := fourMLDSA(t) // a perfectly valid cert
cfg := sc.cfg
cfg.MinThreshold = 0 // operator forgot to pin the floor
err := sc.cert.Verify(sc.env, cfg)
if !errors.Is(err, ErrQCMinThresholdUnset) {
t.Fatalf("unset MinThreshold err = %v, want ErrQCMinThresholdUnset (fail closed)", err)
}
}
// TestRedteam_F1_ThresholdBoundIntoSignedMessage proves the SIGNED-message
// half of the fix: a signature produced under threshold T1 does NOT verify
// once the cert claims a different threshold T2. The honest signers signed the
// real quorum (T1); re-presenting their signatures under a lowered T2 makes
// the verifier rebuild the message at T2, and the T1 signatures fail the FIPS
// check. This holds even with NO floor reliance (MinThreshold below both).
func TestRedteam_F1_ThresholdBoundIntoSignedMessage(t *testing.T) {
const t1 = 100 // honest quorum the signers actually signed under
const t2 = 40 // forged lowered threshold
sc := fourMLDSA(t) // signers signed under threshold == 100 (env.QuorumThreshold)
if sc.cert.QuorumThreshold != t1 {
t.Fatalf("precondition: cert threshold = %d, want %d", sc.cert.QuorumThreshold, t1)
}
// Forge: lower the cert's asserted threshold to t2 and recompute the
// commitment so the commitment clause is NOT what trips (we want to prove
// the SIGNATURE binding specifically).
forged := *sc.cert
forged.QuorumThreshold = t2
forged.SignerCommitment = forged.computeSignerCommitment()
// Use a floor at or below t2 so the floor does NOT reject — isolating the
// message-binding defence.
cfg := sc.cfg
cfg.MinThreshold = t2
err := forged.Verify(sc.env, cfg)
if !errors.Is(err, ErrQCSigInvalid) {
t.Fatalf("threshold-swapped cert err = %v, want ErrQCSigInvalid (message binding)", err)
}
// Cross-check the binding at the message layer directly: the message for t1
// and the message for t2 must differ (so a t1 signature can never be a t2
// signature).
envT2 := sc.env
envT2.QuorumThreshold = t2
msgT2, err := QuorumConsensusMessage(envT2)
if err != nil {
t.Fatalf("build t2 message: %v", err)
}
if string(msgT2) == string(sc.message) {
t.Fatal("quorum_threshold is not bound into the signed message (t1 and t2 messages collide)")
}
}
// ----------------------------------------------------------------------------
// F2 (HIGH) — decode DoS via attacker-controlled SignerCount closed.
// ----------------------------------------------------------------------------
// TestRedteam_F2_HugeSignerCountRejectedFast crafts a FULL-kind frame whose
// header declares SignerCount = 0xFFFFFFFF but carries no record bytes. The
// decoder must reject it in O(1) without attempting a ~446 GB reservation.
func TestRedteam_F2_HugeSignerCountRejectedFast(t *testing.T) {
// Build a minimal valid header, then overwrite kind=full and
// signer_count=0xFFFFFFFF, and truncate to exactly the header so there are
// zero record bytes available.
sc := fourMLDSA(t)
wire, err := sc.cert.MarshalBinary()
if err != nil {
t.Fatalf("marshal: %v", err)
}
// Take just the fixed compact header prefix and flip it to a full frame
// with a maxed-out signer count. The header is wqcHeaderSize bytes; its
// last 4 bytes are signer_count.
hdr := append([]byte(nil), wire[:wqcHeaderSize]...)
hdr[0] = wqcKindFull // kind = full
binary.BigEndian.PutUint32(hdr[wqcHeaderSize-4:], 0xFFFFFFFF)
done := make(chan struct{})
var derr error
go func() {
_, derr = UnmarshalWeightedQuorumCert(hdr)
close(done)
}()
select {
case <-done:
// Must be a clean wire-corrupt rejection (the count cannot fit the
// remaining zero bytes), not a panic / OOM.
if !errors.Is(derr, ErrQCWireCorrupt) {
t.Fatalf("huge signer_count err = %v, want ErrQCWireCorrupt", derr)
}
case <-time.After(5 * time.Second):
t.Fatal("decode of 0xFFFFFFFF signer_count did not return promptly (DoS not capped)")
}
}
// TestRedteam_F2_SignerCountExceedingBufferRejected pins the exact cap: a
// signer_count whose minimum on-wire footprint exceeds the remaining buffer is
// rejected before allocation, even for modest-but-impossible counts.
func TestRedteam_F2_SignerCountExceedingBufferRejected(t *testing.T) {
sc := fourMLDSA(t)
wire, _ := sc.cert.MarshalBinary()
hdr := append([]byte(nil), wire[:wqcHeaderSize]...)
hdr[0] = wqcKindFull
// One record needs ≥ wqcMinRecordBytes; with zero trailing bytes even a
// count of 1 is impossible.
binary.BigEndian.PutUint32(hdr[wqcHeaderSize-4:], 1)
if _, err := UnmarshalWeightedQuorumCert(hdr); !errors.Is(err, ErrQCWireCorrupt) {
t.Fatalf("count=1 over empty record region err = %v, want ErrQCWireCorrupt", err)
}
}
// ----------------------------------------------------------------------------
// F3 (HIGH) — Verify rebuilds the position binding itself; opaque-message and
// proof-backend-axis replay closed.
// ----------------------------------------------------------------------------
// TestRedteam_F3_PositionTamperRejected mutates EACH consensus-position axis
// the message binds (chain_id, height, round, qc_type, value_hash), recomputes
// the signer commitment so the commitment clause is satisfied, and confirms
// the envelope-based Verify rejects every one — because it rebuilds the signing
// message from the cert's own (tampered) fields and the original signatures no
// longer verify. A QCType prepare→commit role-replay is included.
func TestRedteam_F3_PositionTamperRejected(t *testing.T) {
cases := []struct {
name string
mutate func(*WeightedQuorumCert)
}{
{"chain_id", func(c *WeightedQuorumCert) { c.ChainID++ }},
{"height", func(c *WeightedQuorumCert) { c.Height++ }},
{"round", func(c *WeightedQuorumCert) { c.Round++ }},
{"qc_type_role_replay", func(c *WeightedQuorumCert) { c.QCType++ }},
{"value_hash", func(c *WeightedQuorumCert) { c.ValueHash[0] ^= 0xFF }},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
sc := fourMLDSA(t)
tc.mutate(sc.cert)
// Satisfy the commitment clause so the rejection is provably the
// rebuilt-message signature check, not the commitment.
sc.cert.SignerCommitment = sc.cert.computeSignerCommitment()
err := sc.cert.Verify(sc.env, sc.cfg)
if !errors.Is(err, ErrQCSigInvalid) {
t.Fatalf("%s tamper err = %v, want ErrQCSigInvalid (verifier rebuilds position binding)", tc.name, err)
}
})
}
}
// TestRedteam_F3_VerifyOwnsMessageConstruction proves Verify never trusts a
// caller-supplied message: there is no public Verify form that accepts raw
// bytes. The envelope-based Verify rebuilds the message from the cert, so a
// caller cannot hand it a message minted for a DIFFERENT position. We assert
// the cert verifies under its real envelope and is rejected under an envelope
// whose ValidatorSetRoot axis the verifier would otherwise have to trust.
func TestRedteam_F3_VerifyOwnsMessageConstruction(t *testing.T) {
sc := fourMLDSA(t)
if err := sc.cert.Verify(sc.env, sc.cfg); err != nil {
t.Fatalf("valid cert rejected under its real envelope: %v", err)
}
// A second, independent validator set the attacker would like the cert to
// be read against. The cert pins its OWN ValidatorSetRoot, so Verify binds
// that root into the rebuilt message regardless of the envelope's stale
// root — but the envelope's *posture* axes still feed the round digest.
// Flip the envelope's NetworkID (a round-digest axis): the rebuilt message
// changes and the signatures fail. The caller cannot smuggle in a message
// for a different network.
wrongEnv := sc.env
wrongEnv.NetworkID++
err := sc.cert.Verify(wrongEnv, sc.cfg)
if !errors.Is(err, ErrQCSigInvalid) {
t.Fatalf("cross-network envelope err = %v, want ErrQCSigInvalid", err)
}
}
// TestRedteam_F3_ProofBackendAxisMismatchRejected folds in the LOW finding:
// Verify asserts the envelope's proof-backend axis equals the cert's
// ProofBackendID(). An envelope naming a different backend is rejected before
// any message is built — a direct weighted-quorum cert cannot be verified
// under a STARK backend's envelope.
func TestRedteam_F3_ProofBackendAxisMismatchRejected(t *testing.T) {
sc := fourMLDSA(t)
wrongEnv := sc.env
wrongEnv.ProofBackend = config.ProofBackendP3QSTARKFRISHA3 // not the cert's backend
err := sc.cert.Verify(wrongEnv, sc.cfg)
if !errors.Is(err, ErrQCProofBackendMismatch) {
t.Fatalf("proof-backend mismatch err = %v, want ErrQCProofBackendMismatch", err)
}
}
// ----------------------------------------------------------------------------
// F4 (MED) — weighted-Merkle (LeafIndex,LeafCount) proof malleability closed.
// ----------------------------------------------------------------------------
// TestRedteam_F4_NonCanonicalLeafEncodingRejected reproduces the proof-shape
// ambiguity: weightedProofShape gives the IDENTICAL shape ("RR") for a leaf at
// index 0 whether LeafCount is 3 or 4, so the proof's promotion/orientation
// sequence alone cannot distinguish a (0,3) record from a (0,4) record with the
// same sibling bytes. Left unbound, the same logical cert would have
// byte-distinct encodings (breaking dedup / equivocation detection).
//
// RED-1 upgrade: the canonicality is now enforced CRYPTOGRAPHICALLY in the
// weighted-Merkle commitment — LeafCount is folded into every leaf digest
// (computeWeightedLeafHash), so relabeling LeafCount 3→4 recomputes a different
// leaf digest and FAILS the Merkle inclusion clause against validator_set_root.
// The malleation is caught at the Merkle clause itself (ErrQCMerkleInclusion),
// strictly stronger than the prior commitment-only defence (which is retained
// as defence-in-depth). Exactly ONE (LeafIndex,LeafCount) verifies per tree.
func TestRedteam_F4_NonCanonicalLeafEncodingRejected(t *testing.T) {
// 3 ML-DSA signers → the committed set has 3 leaves; the index-0 record's
// canonical proof carries LeafCount=3 with shape "RR".
signers := []*testSigner{
newMLDSASigner(t, 0x01, 40),
newMLDSASigner(t, 0x02, 40),
newMLDSASigner(t, 0x03, 40),
}
sc := buildScenario(t, signers, 100, nil)
if err := sc.cert.Verify(sc.env, sc.cfg); err != nil {
t.Fatalf("baseline 3-signer cert rejected: %v", err)
}
// Find the index-0 record (lowest validator id after canonical sort).
rec0 := -1
for i := range sc.cert.Signers {
if sc.cert.Signers[i].MerklePath.LeafIndex == 0 {
rec0 = i
break
}
}
if rec0 < 0 {
t.Fatal("no index-0 record found")
}
if got := sc.cert.Signers[rec0].MerklePath.LeafCount; got != 3 {
t.Fatalf("precondition: index-0 LeafCount = %d, want 3", got)
}
// Confirm the SHAPE ambiguity is real: (0,3) and (0,4) produce the same
// promotion/orientation sequence, so the proof shape alone cannot
// distinguish them. This is exactly why LeafCount must be bound into the
// leaf digest (the root) — the structural cross-check is insufficient.
shape3 := weightedProofShape(0, 3)
shape4 := weightedProofShape(0, 4)
if len(shape3) != len(shape4) {
t.Fatalf("shape lengths differ (%d vs %d); ambiguity premise false", len(shape3), len(shape4))
}
for i := range shape3 {
if shape3[i] != shape4[i] {
t.Fatalf("shapes differ at level %d; ambiguity premise false", i)
}
}
// Malleate: claim LeafCount=4 for the index-0 record WITHOUT touching the
// sibling bytes. Before RED-1 this slipped past the Merkle clause (same
// shape, same siblings → same root). Now LeafCount is bound into the leaf
// digest, so the recomputed root no longer matches validator_set_root.
sc.cert.Signers[rec0].MerklePath.LeafCount = 4
// The Merkle clause must now FAIL for this record: the count-binding makes
// the recomputed leaf digest — and therefore the root — depend on the
// claimed count, so the (0,4) relabel cannot reproduce the (0,3) root.
if VerifyWeightedInclusion(
sc.cert.ValidatorSetRoot, sc.cert.Epoch,
sc.cert.Signers[rec0].leaf(), sc.cert.Signers[rec0].MerklePath,
) {
t.Fatal("malleated (0,4) proof still satisfies the Merkle clause — count not bound into the root")
}
// Full cert Verify rejects the malleated cert at the Merkle inclusion
// clause (record iteration reaches clause (a) before the commitment check).
err := sc.cert.Verify(sc.env, sc.cfg)
if !errors.Is(err, ErrQCMerkleInclusion) {
t.Fatalf("malleated LeafCount err = %v, want ErrQCMerkleInclusion", err)
}
}
// ----------------------------------------------------------------------------
// F5 (MED) — mixed-scheme FIPS-context asymmetry closed.
// ----------------------------------------------------------------------------
// TestRedteam_F5_MixedSchemeNonEmptyContext proves a mixed ML-DSA ∧ SLH-DSA
// cert now verifies even when cfg.Context is NON-empty. The ML-DSA records
// were signed under ctx; the SLH-DSA records were signed under the empty
// FIPS-205 context (magnetar.ValidatorSign). The per-scheme context selection
// (contextForScheme) applies ctx to ML-DSA and the empty context to SLH-DSA,
// so both legs verify — previously a non-nil cfg.Context made the SLH-DSA leg
// reject and mixed certs only worked with ctx=nil.
func TestRedteam_F5_MixedSchemeNonEmptyContext(t *testing.T) {
ctx := []byte("lux-quasar-wqc-mixed-ctx")
// Build a mixed cert. The ML-DSA signers must sign under ctx; the SLH-DSA
// signer signs under the empty context (its testSigner.sign path uses
// ValidatorSign regardless of ctx).
mldsa1 := newMLDSASigner(t, 0x01, 40)
slh := newSLHDSASigner(t, 0x02, 40)
mldsa3 := newMLDSASigner(t, 0x03, 40)
// buildScenario signs each signer over the message under the provided ctx;
// the ML-DSA path binds ctx, the SLH-DSA path ignores it (empty FIPS-205
// context). This mirrors production: a chain may run a non-empty ML-DSA
// context alongside SLH-DSA validators.
sc := buildScenario(t, []*testSigner{mldsa1, slh, mldsa3}, 100, ctx)
cfg := sc.cfg // Context = ctx, MinThreshold = 100
if err := sc.cert.Verify(sc.env, cfg); err != nil {
t.Fatalf("mixed-scheme cert under non-empty ctx rejected: %v", err)
}
// Sanity: contextForScheme routes the empty context to the SLH-DSA leg and
// ctx to the ML-DSA leg.
if got := contextForScheme(cfg, QuorumSchemeSLHDSA192s); got != nil {
t.Fatalf("SLH-DSA context = %v, want nil (empty FIPS-205 ctx)", got)
}
if got := contextForScheme(cfg, QuorumSchemeMLDSA65); string(got) != string(ctx) {
t.Fatalf("ML-DSA context = %q, want %q", got, ctx)
}
}