Files
consensus/protocol/quasar/quorum_codec_test.go
zooqueen fd3e1e88f4 quasar: harden weighted quorum cert envelope binding (5 red-team findings)
The crypto core (TupleHash256, FIPS 204/205 verify, weighted Merkle,
leaf binding, distinct-signer ordering, weight overflow guards) was
sound. The ENVELOPE binding was not. Fixes, smallest correct change,
no new fail-open paths:

F1 (CRITICAL) sub-quorum finality forgery — QuorumThreshold was
   unbound: Verify checked Sum(weight) >= attacker-chosen
   c.QuorumThreshold with no floor and no signature binding. Two-part
   fix:
   (a) bind quorum_threshold into the SIGNED message. Added
       QuorumThreshold to QuorumMessageEnvelope and into the
       QuorumConsensusMessage re-frame parts (quorum_message.go) — the
       quorum-cert-specific layer where qc_type / value_hash already
       live. NOT added to the shared ComputeRoundDigest (that round
       digest is consumed by non-quorum consensus rounds; threshold is
       not one of its axes). QuorumMessageForCert now fills the
       threshold from the cert, so honest signatures over the real
       quorum cannot be re-framed under a lowered one.
   (b) MANDATORY verifier floor: QuorumVerifierConfig.MinThreshold.
       Verify FAILS CLOSED if MinThreshold is unset (zero) and rejects
       any cert whose QuorumThreshold < MinThreshold. A cert may not
       assert its own security parameter.

F2 (HIGH) decode DoS — UnmarshalWeightedQuorumCert reserved capacity
   for an attacker-controlled uint32 SignerCount (0xFFFFFFFF ~= 446 GB).
   Cap SignerCount against the remaining buffer (wqcMinRecordBytes = 66)
   before the make, mirroring the existing step_count cap. O(1)
   rejection, no allocation.

F3 (HIGH) opaque-message trust / role-replay — Verify took a
   caller-supplied message. It now takes the chain envelope and rebuilds
   the position binding ITSELF via QuorumMessageForCert; the raw-message
   form is demoted to the internal verifyWithMessage helper. Folds in
   the LOW finding: Verify asserts envelope.ProofBackend ==
   cert.ProofBackendID() before building the message. QCType
   prepare->commit role-replay and every position axis are now caught at
   the rebuilt-message signature check. VerifyWeightedQuorumLeg updated
   to take the envelope.

F4 (MED) weighted-Merkle proof malleability — weightedProofShape gives
   identical shapes for different LeafCount (e.g. (0,3) and (0,4) both
   "RR"), so a record had multiple valid (LeafIndex,LeafCount) encodings
   -> byte-distinct certs, breaking dedup/equivocation. Bind each
   signer's (LeafIndex,LeafCount) into computeSignerCommitment; any
   malleation now changes the commitment and is rejected.

F5 (MED) mixed-scheme FIPS-context asymmetry — magnetar.ValidatorSign
   signs the empty FIPS-205 context while ML-DSA signs cfg.Context;
   Verify applied one context to all, so mixed certs only worked with
   ctx=nil. Added contextForScheme: ML-DSA -> cfg.Context, SLH-DSA ->
   empty context (the round-digest message carries the domain binding).

Tests: new quorum_redteam_test.go proves each PoC is now REJECTED
((a) forged below-floor threshold, (b) threshold-swap message binding,
(c) 0xFFFFFFFF SignerCount fast-reject, (d) position/QCType/value_hash
tamper, (e) non-canonical (LeafIndex,LeafCount)). Existing tests
updated for the envelope-based Verify signature + mandatory
MinThreshold. config: ProofFormatDirectWeightedQuorumV1 (0x30) and
VerifierDirectWeightedQuorumPQ (0x0030) wire constants for the cert.

Verify: go build ./protocol/quasar/... ok; go test
'Quorum|WeightedMerkle|QuasarCert|RoundDigest|Redteam' 154 PASS / 0
FAIL; gofmt clean; go vet clean; framework fail-open gate ok.
2026-06-21 14:08:05 -07:00

275 lines
9.5 KiB
Go

// 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.env, 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.env, 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.env, 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.env, 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.env, 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.env, 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.env, 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.env, 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")
}
// The rebuilt message bytes drive a passing predicate (this is exactly the
// message the public Verify constructs internally).
if err := sc.cert.verifyWithMessage(msg, sc.cfg); err != nil {
t.Fatalf("cert failed verify under QuorumMessageForCert output: %v", err)
}
// And the public envelope-based Verify (which rebuilds the message itself)
// accepts the same cert.
if err := sc.cert.Verify(sc.env, sc.cfg); err != nil {
t.Fatalf("cert failed envelope-based Verify: %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")
}
}