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.
This commit is contained in:
zooqueen
2026-06-21 14:08:05 -07:00
parent 90e5238592
commit fd3e1e88f4
8 changed files with 675 additions and 79 deletions
+21
View File
@@ -114,6 +114,9 @@ func (p ProfileID) String() string {
// 0x22 = P3Q_BINARY_V1 Lux P3Q (Plonky3 fork) native
// 0x23 = STONE_CAIRO_BINARY_V1 Stone / Cairo native
// 0x24 = STWO_CIRCLE_BINARY_V1 Stwo Circle STARK native
// 0x30..0x3F — Direct full-node-verifiable layouts (no succinct proof)
// 0x30 = DIRECT_WEIGHTED_QUORUM_V1 weighted quorum cert wire
// (signer records: per-signer FIPS sig + Merkle path)
// 0x70..0x7F — Dev-only formats (CI / fuzzing)
// 0x80..0xFF — Forbidden in strict-PQ mode (mirrors classical-wrapper IDs)
// 0x80 = GROTH16_WRAPPED_BINARY classical wrapper format
@@ -129,6 +132,12 @@ const (
ProofFormatStoneCairoBinaryV1 ProofFormatID = 0x23
ProofFormatStwoCircleBinaryV1 ProofFormatID = 0x24
// ProofFormatDirectWeightedQuorumV1 is the wire layout of the
// direct full-node-verifiable weighted quorum certificate (the full,
// records-included encoding). See protocol/quasar/quorum_cert.go
// MarshalBinary / the QC version byte.
ProofFormatDirectWeightedQuorumV1 ProofFormatID = 0x30
ProofFormatGroth16WrappedForbid ProofFormatID = 0x80
ProofFormatKZGWrappedForbid ProofFormatID = 0x81
)
@@ -150,6 +159,8 @@ func (f ProofFormatID) String() string {
return "stone-cairo-binary-v1"
case ProofFormatStwoCircleBinaryV1:
return "stwo-circle-binary-v1"
case ProofFormatDirectWeightedQuorumV1:
return "direct-weighted-quorum-v1"
case ProofFormatGroth16WrappedForbid:
return "groth16-wrapped-classical-forbidden-in-pq"
case ProofFormatKZGWrappedForbid:
@@ -200,6 +211,14 @@ const (
VerifierStoneCairoSTARKPQ VerifierID = 0x0023
VerifierStwoCircleSTARKPQ VerifierID = 0x0024
// VerifierDirectWeightedQuorumPQ is the pinned verifier for the direct
// full-node-verifiable weighted quorum certificate. Its "program" is the
// fixed predicate in protocol/quasar/quorum_cert.go (per-signer FIPS
// 204/205 Verify + weighted-validator-set Merkle inclusion + weight
// threshold). It carries no verifier key or trusted setup — the manifest
// for this id pins the consensus source commit and the predicate version.
VerifierDirectWeightedQuorumPQ VerifierID = 0x0030
// Dev-only verifiers.
VerifierSP1CoreSTARKDev VerifierID = 0x0070
VerifierRISC0RawSTARKDev VerifierID = 0x0071
@@ -224,6 +243,8 @@ func (v VerifierID) String() string {
return "stone-cairo-stark-pq"
case VerifierStwoCircleSTARKPQ:
return "stwo-circle-stark-pq"
case VerifierDirectWeightedQuorumPQ:
return "direct-weighted-quorum-pq"
case VerifierSP1CoreSTARKDev:
return "sp1-core-stark-dev"
case VerifierRISC0RawSTARKDev:
+141 -23
View File
@@ -190,6 +190,9 @@ var (
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")
ErrQCMinThresholdUnset = errors.New("quasar: verifier MinThreshold is unset (zero); a cert may not assert its own security parameter — fail closed")
ErrQCThresholdBelowFloor = errors.New("quasar: cert QuorumThreshold is below the chain BFT quorum floor (MinThreshold)")
ErrQCProofBackendMismatch = errors.New("quasar: envelope proof backend does not match the cert's ProofBackendID")
)
// QuorumVerifierConfig pins the policy axes the verifier enforces. Kept
@@ -204,7 +207,24 @@ type QuorumVerifierConfig struct {
// Context is the FIPS context string passed to every per-signer verify.
// It MUST match what signers bound at sign time. Length ≤ 255.
//
// Applied per-scheme: ML-DSA (FIPS 204) records verify under this context;
// SLH-DSA (FIPS 205) records ALWAYS verify under the empty context,
// because the only SLH-DSA sign path here (magnetar.ValidatorSign) binds
// the empty FIPS-205 context and the round-digest message already carries
// the full domain binding. See contextForScheme.
Context []byte
// MinThreshold is the chain's BFT quorum floor — the minimum voting
// weight any valid cert on this chain MUST assert. Verify FAILS CLOSED if
// MinThreshold is zero (unset): a certificate may not be the sole source
// of its own security parameter, so the verifier MUST pin the floor from
// the chain profile. A cert whose QuorumThreshold is below this floor is
// rejected (ErrQCThresholdBelowFloor) regardless of how many signatures it
// carries — this is the mandatory defence against sub-quorum finality
// forgery, independent of (and in addition to) the threshold's binding
// into the signed message.
MinThreshold uint64
}
// schemeAllowed reports whether scheme is admissible under this config.
@@ -216,33 +236,79 @@ func (c *QuorumVerifierConfig) schemeAllowed(scheme QuorumSchemeID) bool {
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.
// Verify checks the certificate against the chain envelope 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, forged threshold)
// 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.
// envelope carries the chain's pinned posture axes (profile, hash suite,
// schemes, proof backend/format/verifier, network). Verify rebuilds the
// domain-separated signing message ITSELF from the envelope plus the cert's
// own consensus-position fields (via QuorumMessageForCert) — it NEVER trusts
// a caller-supplied opaque message. This closes the role-replay / position
// mismatch surface: a cert whose signers signed a different
// (chain_id, epoch, height, round, value_hash, qc_type, validator_set_root,
// quorum_threshold) fails the per-signer FIPS check for every record, and a
// cert that lies about any of those fails because the rebuilt message no
// longer matches the signatures.
//
// 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 {
// Verify additionally asserts the envelope's proof-backend axis equals the
// cert's own ProofBackendID(), so a cert produced under the direct
// weighted-quorum backend cannot be verified under a different backend's
// envelope (and vice versa).
func (c *WeightedQuorumCert) Verify(envelope QuorumMessageEnvelope, cfg QuorumVerifierConfig) error {
if c == nil {
return ErrQCNil
}
// Bind the verification envelope to the cert's trust model: the caller's
// envelope axis MUST name the same proof backend this cert is produced
// under. Folds the round-digest proof-backend axis check into Verify so a
// cross-backend envelope is rejected before any message is built.
if envelope.ProofBackend != c.ProofBackendID() {
return fmt.Errorf("%w: envelope=0x%02x cert=0x%02x",
ErrQCProofBackendMismatch, uint8(envelope.ProofBackend), uint8(c.ProofBackendID()))
}
// Rebuild the signing message from the envelope + the cert's own position
// fields. The verifier owns message construction — it does not trust a
// caller-supplied message.
message, err := QuorumMessageForCert(envelope, c)
if err != nil {
return err
}
return c.verifyWithMessage(message, cfg)
}
// verifyWithMessage is the core weighted-quorum predicate over an already-
// built domain-separated message. It is INTERNAL: production callers MUST use
// Verify, which constructs the message itself from the chain envelope so a
// caller can never substitute an opaque message that skips the position /
// threshold / proof-backend binding. This helper carries only the predicate
// clauses + the mandatory threshold floor; it is exercised directly by tests
// that need to drive a deliberately-mismatched message.
func (c *WeightedQuorumCert) verifyWithMessage(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)
}
// Mandatory threshold floor (fail-closed). A cert may not be the sole
// source of its own security parameter: the verifier MUST pin the chain's
// BFT quorum floor, and a cert asserting a threshold below it is rejected
// regardless of signature count — the defence-in-depth that closes
// sub-quorum finality forgery even if a signed-message binding were
// somehow bypassed.
if cfg.MinThreshold == 0 {
return ErrQCMinThresholdUnset
}
if c.QuorumThreshold == 0 {
return ErrQCThresholdZero
}
if c.QuorumThreshold < cfg.MinThreshold {
return fmt.Errorf("%w: threshold %d floor %d",
ErrQCThresholdBelowFloor, c.QuorumThreshold, cfg.MinThreshold)
}
if c.SignerCount == 0 {
return ErrQCNoSigners
}
@@ -303,9 +369,14 @@ func (c *WeightedQuorumCert) Verify(message []byte, cfg QuorumVerifierConfig) er
}
// Clause (c): unmodified FIPS verify. The dispatch is a thin call
// into the stock FIPS 204 / 205 verifier for the scheme.
// into the stock FIPS 204 / 205 verifier for the scheme. The FIPS
// context is selected PER SCHEME (contextForScheme): ML-DSA records
// verify under cfg.Context; SLH-DSA records verify under the empty
// FIPS-205 context, matching magnetar.ValidatorSign's empty-ctx sign.
// Without this, a mixed ML-DSA ∧ SLH-DSA cert would only verify when
// cfg.Context is nil (the SLH-DSA leg would reject a non-empty ctx).
verify := defaultQuorumVerifiers[rec.Scheme]
if verify == nil || !verify(rec.PublicKey, message, cfg.Context, rec.Signature) {
if verify == nil || !verify(rec.PublicKey, message, contextForScheme(cfg, rec.Scheme), rec.Signature) {
return fmt.Errorf("%w: record %d scheme %s", ErrQCSigInvalid, i, rec.Scheme)
}
@@ -336,12 +407,21 @@ func (c *WeightedQuorumCert) Verify(message []byte, cfg QuorumVerifierConfig) er
}
// computeSignerCommitment recomputes the cSHAKE256 commitment over the
// ordered signer-id set plus the cert's version and consensus position.
// ordered signer 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.
//
// Per signer it binds validator_id AND the Merkle (leaf_index, leaf_count).
// Without the latter, the weighted-Merkle proof shape can be ambiguous: two
// different (leaf_index, leaf_count) encodings can yield the identical proof
// shape (e.g. (0,3) and (0,4) both produce "RR"), so a single logical record
// would have multiple valid encodings → byte-distinct certs with the same
// signatures, breaking dedup / equivocation detection. Binding (leaf_index,
// leaf_count) makes any such malleation change the commitment → rejected,
// and makes the cert bytes canonical for a given signer set.
//
// Layout (TupleHash256, customization "QUASAR-WQC-SIGNERS-V1"):
//
// parts[0] = "Quasar/WQC/Signers"
@@ -355,7 +435,10 @@ func (c *WeightedQuorumCert) Verify(message []byte, cfg QuorumVerifierConfig) er
// 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
// then, per record i in order, three parts:
// validator_id[i] ([32]byte)
// leaf_index[i] (4 BE)
// leaf_count[i] (4 BE)
func (c *WeightedQuorumCert) computeSignerCommitment() [48]byte {
var u16 [2]byte
var u32 [4]byte
@@ -376,7 +459,7 @@ func (c *WeightedQuorumCert) computeSignerCommitment() [48]byte {
binary.BigEndian.PutUint32(u32[:], c.SignerCount)
countBytes := append([]byte(nil), u32[:]...)
parts := make([][]byte, 0, 11+len(c.Signers))
parts := make([][]byte, 0, 11+3*len(c.Signers))
parts = append(parts,
[]byte(signerCommitmentProtocolTag),
verBytes,
@@ -391,7 +474,21 @@ func (c *WeightedQuorumCert) computeSignerCommitment() [48]byte {
countBytes,
)
for i := range c.Signers {
parts = append(parts, c.Signers[i].ValidatorID[:])
// Bind the Merkle position (leaf_index, leaf_count). A nil path (a
// structurally-incomplete record that will fail the Merkle clause
// anyway) binds the (0,0) sentinel so the commitment is still total.
var leafIdx, leafCount uint32
if p := c.Signers[i].MerklePath; p != nil {
leafIdx, leafCount = p.LeafIndex, p.LeafCount
}
var idxBytes, cntBytes [4]byte
binary.BigEndian.PutUint32(idxBytes[:], leafIdx)
binary.BigEndian.PutUint32(cntBytes[:], leafCount)
parts = append(parts,
c.Signers[i].ValidatorID[:],
append([]byte(nil), idxBytes[:]...),
append([]byte(nil), cntBytes[:]...),
)
}
out := tupleHash256RoundDigest(parts, 48, signerCommitmentCustomization)
@@ -565,6 +662,18 @@ const (
// wqcHeaderSize is the fixed compact-header byte length.
const wqcHeaderSize = 1 + 2 + 4 + 8 + 8 + 4 + 32 + 1 + 48 + 8 + 8 + 48 + 4
// wqcMinRecordBytes is the SMALLEST possible on-wire size of one signer
// record: the fixed fields with a zero-length public key, a zero-length
// signature, and a zero-step Merkle path. Used to cap an attacker-controlled
// signer_count against the remaining buffer BEFORE any allocation, so a
// header claiming signer_count = 0xFFFFFFFF cannot force a multi-hundred-GB
// reservation (decode-DoS). Layout, per the full-record comment above:
//
// validator_id:32 scheme:1 param_set_id:1 key_version:4 voting_weight:8
// pubkey_len:4 sig_len:4 merkle_leaf_index:4 merkle_leaf_count:4
// merkle_step_count:4
const wqcMinRecordBytes = 32 + 1 + 1 + 4 + 8 + 4 + 4 + 4 + 4 + 4 // = 66
// ErrQCWireCorrupt is returned by the decoders on any structural defect.
var ErrQCWireCorrupt = errors.New("quasar: weighted quorum cert wire corrupt")
@@ -693,7 +802,16 @@ func UnmarshalWeightedQuorumCert(data []byte) (*WeightedQuorumCert, error) {
return c, nil
}
// Full: decode signer_count records.
// Full: decode signer_count records. Cap signer_count against the
// remaining buffer BEFORE reserving capacity: each record occupies at
// least wqcMinRecordBytes, so a count whose minimum footprint exceeds the
// bytes that remain is structurally impossible. This rejects an
// adversarial header (signer_count = 0xFFFFFFFF → ~446 GB reservation) in
// O(1) with no allocation — mirrors the step_count cap below.
if uint64(c.SignerCount)*wqcMinRecordBytes > uint64(len(r.buf)) {
return nil, fmt.Errorf("%w: signer_count %d exceeds remaining buffer (%d bytes)",
ErrQCWireCorrupt, c.SignerCount, len(r.buf))
}
c.Signers = make([]QuorumSignerRecord, 0, c.SignerCount)
for i := uint32(0); i < c.SignerCount; i++ {
var rec QuorumSignerRecord
+57 -31
View File
@@ -144,6 +144,10 @@ func buildScenario(t *testing.T, signers []*testSigner, threshold uint64, ctx []
valueHash[i] = 0xAB
}
env.ValueHash = valueHash
// The quorum threshold is bound into the SIGNED message, so it must be set
// on the envelope before the signers sign (and it equals the cert's
// QuorumThreshold below).
env.QuorumThreshold = threshold
// Build the weighted validator set commitment from the signers' leaves.
leaves := make([]WeightedValidatorLeaf, len(signers))
@@ -212,8 +216,11 @@ func buildScenario(t *testing.T, signers []*testSigner, threshold uint64, ctx []
}
return scenario{
env: env,
cfg: QuorumVerifierConfig{Context: ctx},
env: env,
// MinThreshold is the mandatory BFT-quorum floor. The scenario pins it
// to the cert's threshold (the verifier MUST pin a non-zero floor or
// Verify fails closed). Tests that drive a below-floor cert override it.
cfg: QuorumVerifierConfig{Context: ctx, MinThreshold: threshold},
message: message,
cert: cert,
set: set,
@@ -239,7 +246,7 @@ func fourMLDSA(t *testing.T) scenario {
func TestQuorumCert_HappyPath(t *testing.T) {
sc := fourMLDSA(t)
if err := sc.cert.Verify(sc.message, sc.cfg); err != nil {
if err := sc.cert.Verify(sc.env, sc.cfg); err != nil {
t.Fatalf("valid cert rejected: %v", err)
}
}
@@ -270,7 +277,7 @@ func TestQuorumCert_SLHDSAInterop(t *testing.T) {
newSLHDSASigner(t, 0x02, 60),
}
sc := buildScenario(t, signers, 100, nil)
if err := sc.cert.Verify(sc.message, sc.cfg); err != nil {
if err := sc.cert.Verify(sc.env, sc.cfg); err != nil {
t.Fatalf("valid SLH-DSA cert rejected: %v", err)
}
for i := range sc.cert.Signers {
@@ -294,7 +301,7 @@ func TestQuorumCert_MixedScheme(t *testing.T) {
newMLDSASigner(t, 0x03, 40),
}
sc := buildScenario(t, signers, 100, nil)
if err := sc.cert.Verify(sc.message, sc.cfg); err != nil {
if err := sc.cert.Verify(sc.env, sc.cfg); err != nil {
t.Fatalf("valid mixed-scheme cert rejected: %v", err)
}
}
@@ -309,10 +316,10 @@ func TestQuorumCert_MLDSAContextBinding(t *testing.T) {
newMLDSASigner(t, 0x02, 60),
}
sc := buildScenario(t, signers, 100, ctxA)
if err := sc.cert.Verify(sc.message, QuorumVerifierConfig{Context: ctxA}); err != nil {
if err := sc.cert.Verify(sc.env, QuorumVerifierConfig{Context: ctxA, MinThreshold: 100}); 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 {
if err := sc.cert.Verify(sc.env, QuorumVerifierConfig{Context: []byte("ctx-B"), MinThreshold: 100}); err == nil {
t.Fatal("cert signed under ctx-A verified under ctx-B (context not bound)")
}
}
@@ -328,7 +335,7 @@ func TestQuorumCert_RejectsUnsorted(t *testing.T) {
// 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)
err := sc.cert.Verify(sc.env, sc.cfg)
if !errors.Is(err, ErrQCNotStrictlyIncreasing) {
t.Fatalf("unsorted cert err = %v, want ErrQCNotStrictlyIncreasing", err)
}
@@ -339,7 +346,7 @@ func TestQuorumCert_RejectsDuplicateSigner(t *testing.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)
err := sc.cert.Verify(sc.env, sc.cfg)
if !errors.Is(err, ErrQCNotStrictlyIncreasing) {
t.Fatalf("duplicate-signer cert err = %v, want ErrQCNotStrictlyIncreasing", err)
}
@@ -379,7 +386,7 @@ func TestQuorumCert_BelowThresholdRejected(t *testing.T) {
newMLDSASigner(t, 0x04, 25),
}
sc := buildScenario(t, signers, 101, nil)
err := sc.cert.Verify(sc.message, sc.cfg)
err := sc.cert.Verify(sc.env, sc.cfg)
if !errors.Is(err, ErrQCBelowThreshold) {
t.Fatalf("below-threshold err = %v, want ErrQCBelowThreshold", err)
}
@@ -388,7 +395,7 @@ func TestQuorumCert_BelowThresholdRejected(t *testing.T) {
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 {
if err := sc.cert.Verify(sc.env, sc.cfg); err != nil {
t.Fatalf("at-threshold cert rejected: %v", err)
}
}
@@ -401,7 +408,11 @@ 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)
// Drive the internal predicate with a deliberately-corrupted message. The
// public Verify builds the message itself (it cannot be fed a wrong one);
// verifyWithMessage is the demoted raw-message form, exercised here to
// prove a message that does not match the signatures is rejected.
err := sc.cert.verifyWithMessage(wrong, sc.cfg)
if !errors.Is(err, ErrQCSigInvalid) {
t.Fatalf("wrong-message err = %v, want ErrQCSigInvalid", err)
}
@@ -421,7 +432,9 @@ func crossAxis(t *testing.T, name string, mutate func(*QuorumMessageEnvelope)) {
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 {
// Drive the internal predicate with the cross-axis message: the cert's
// signatures (over the original axis values) must not verify against it.
if err := sc.cert.verifyWithMessage(msg2, sc.cfg); err == nil {
t.Fatalf("%s: cert verified against cross-domain message (replay surface)", name)
}
}
@@ -455,7 +468,7 @@ func TestQuorumCert_NonMemberRejected(t *testing.T) {
// 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)
err := sc.cert.Verify(sc.env, sc.cfg)
if !errors.Is(err, ErrQCMerkleInclusion) {
t.Fatalf("non-member err = %v, want ErrQCMerkleInclusion", err)
}
@@ -499,7 +512,7 @@ func TestQuorumCert_UnknownValidatorNotFatal(t *testing.T) {
sc.cert.SignerCommitment = sc.cert.computeSignerCommitment()
// Must not panic; must return the Merkle clause error.
err := sc.cert.Verify(sc.message, sc.cfg)
err := sc.cert.Verify(sc.env, sc.cfg)
if !errors.Is(err, ErrQCMerkleInclusion) {
t.Fatalf("unknown-validator err = %v, want ErrQCMerkleInclusion (clean, not fatal)", err)
}
@@ -513,7 +526,7 @@ 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)
err := sc.cert.Verify(sc.env, sc.cfg)
if !errors.Is(err, ErrQCAggregateWeight) {
t.Fatalf("aggregate tamper err = %v, want ErrQCAggregateWeight", err)
}
@@ -522,20 +535,31 @@ func TestQuorumCert_AggregateWeightTamper(t *testing.T) {
func TestQuorumCert_SignerCommitmentTamper(t *testing.T) {
sc := fourMLDSA(t)
sc.cert.SignerCommitment[0] ^= 0xFF
err := sc.cert.Verify(sc.message, sc.cfg)
err := sc.cert.Verify(sc.env, 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.
func TestQuorumCert_HeaderPositionTamperFailsVerify(t *testing.T) {
// A cert that lies about its own consensus position must be rejected.
// Under the envelope-based Verify, the verifier rebuilds the signing
// message from the cert's OWN position fields (QuorumMessageForCert), so
// flipping the height makes Verify rebuild the message at the tampered
// height — and the signatures (produced over the real height) no longer
// verify. This is a STRONGER guarantee than the old commitment-only catch:
// position tampering fails at the per-signer FIPS check, earlier in the
// predicate. (The commitment layer remains as defence-in-depth against
// signer-set / leaf-position malleation that does not change the rebuilt
// message — see SignerCommitmentTamper and the leaf-encoding test.)
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)
sc.cert.Height++ // signatures were produced over the original height
// Recompute the commitment so we PASS the commitment check and prove the
// rejection is the signature clause specifically, not the commitment.
sc.cert.SignerCommitment = sc.cert.computeSignerCommitment()
err := sc.cert.Verify(sc.env, sc.cfg)
if !errors.Is(err, ErrQCSigInvalid) {
t.Fatalf("position tamper err = %v, want ErrQCSigInvalid", err)
}
}
@@ -545,12 +569,14 @@ func TestQuorumCert_HeaderPositionTamperFailsCommitment(t *testing.T) {
func TestQuorumCert_SchemeNotAllowed(t *testing.T) {
sc := fourMLDSA(t)
// Allow only SLH-DSA; the cert is all ML-DSA → rejected.
// Allow only SLH-DSA; the cert is all ML-DSA → rejected. MinThreshold must
// be set or Verify fails closed before reaching the scheme check.
cfg := QuorumVerifierConfig{
Context: nil,
MinThreshold: 100,
AllowedSchemes: map[QuorumSchemeID]bool{QuorumSchemeSLHDSA192s: true},
}
err := sc.cert.Verify(sc.message, cfg)
err := sc.cert.Verify(sc.env, cfg)
if !errors.Is(err, ErrQCSchemeNotAllowed) {
t.Fatalf("scheme-not-allowed err = %v, want ErrQCSchemeNotAllowed", err)
}
@@ -561,7 +587,7 @@ func TestQuorumCert_ParamSetMismatch(t *testing.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)
err := sc.cert.Verify(sc.env, sc.cfg)
if !errors.Is(err, ErrQCParamSetMismatch) {
t.Fatalf("param mismatch err = %v, want ErrQCParamSetMismatch", err)
}
@@ -575,25 +601,25 @@ func TestQuorumCert_StructuralRejections(t *testing.T) {
sc := fourMLDSA(t)
var nilCert *WeightedQuorumCert
if err := nilCert.Verify(sc.message, sc.cfg); !errors.Is(err, ErrQCNil) {
if err := nilCert.Verify(sc.env, 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) {
if err := (&badVer).Verify(sc.env, 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) {
if err := (&zeroThresh).Verify(sc.env, 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) {
if err := (&countMismatch).Verify(sc.env, sc.cfg); !errors.Is(err, ErrQCSignerCountMismatch) {
t.Fatalf("count mismatch err = %v, want ErrQCSignerCountMismatch", err)
}
}
+16 -9
View File
@@ -28,7 +28,7 @@ func TestQuorumCert_FullRoundTrip(t *testing.T) {
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 {
if err := got.Verify(sc.env, sc.cfg); err != nil {
t.Fatalf("decoded full cert failed verify: %v", err)
}
}
@@ -51,7 +51,7 @@ func TestQuorumCert_CompactRoundTrip(t *testing.T) {
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) {
if err := got.Verify(sc.env, sc.cfg); !errors.Is(err, ErrQCCompactNoRecords) {
t.Fatalf("compact verify err = %v, want ErrQCCompactNoRecords", err)
}
}
@@ -66,13 +66,13 @@ func TestQuorumCert_CompactToFullViaDA(t *testing.T) {
// 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 {
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.message, sc.cfg)
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 {
@@ -124,7 +124,7 @@ func TestQuorumCert_MixedSchemeRoundTrip(t *testing.T) {
if !sc.cert.Equal(got) {
t.Fatal("mixed-scheme round-trip not byte-stable")
}
if err := got.Verify(sc.message, sc.cfg); err != nil {
if err := got.Verify(sc.env, sc.cfg); err != nil {
t.Fatalf("decoded mixed cert failed verify: %v", err)
}
}
@@ -151,7 +151,7 @@ func TestQuasarCert_WeightedQuorumLeg(t *testing.T) {
}
// The clean Verify entry point over the QuasarCert.
if err := qc.VerifyWeightedQuorumLeg(sc.message, sc.cfg); err != nil {
if err := qc.VerifyWeightedQuorumLeg(sc.env, sc.cfg); err != nil {
t.Fatalf("VerifyWeightedQuorumLeg rejected a valid leg: %v", err)
}
@@ -173,7 +173,7 @@ func TestQuasarCert_WeightedQuorumLeg(t *testing.T) {
if err := qc2.UnmarshalBinary(qcWire); err != nil {
t.Fatalf("QuasarCert unmarshal: %v", err)
}
if err := qc2.VerifyWeightedQuorumLeg(sc.message, sc.cfg); err != nil {
if err := qc2.VerifyWeightedQuorumLeg(sc.env, sc.cfg); err != nil {
t.Fatalf("round-tripped QuasarCert leg failed verify: %v", err)
}
}
@@ -208,7 +208,7 @@ func TestQuasarCert_AttachLegPreservesOtherLegs(t *testing.T) {
if !out.HasWeightedQuorumLeg() {
t.Fatal("attach did not install the weighted quorum leg")
}
if err := out.VerifyWeightedQuorumLeg(sc.message, sc.cfg); err != nil {
if err := out.VerifyWeightedQuorumLeg(sc.env, sc.cfg); err != nil {
t.Fatalf("attached leg failed verify: %v", err)
}
}
@@ -229,9 +229,16 @@ func TestQuorumMessage_ForCertMatches(t *testing.T) {
if string(msg) != string(sc.message) {
t.Fatal("QuorumMessageForCert != the message signers signed")
}
if err := sc.cert.Verify(msg, sc.cfg); err != nil {
// 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) {
+24 -6
View File
@@ -69,6 +69,14 @@ type QuorumMessageEnvelope struct {
QCType uint8 // certificate role (prepare/commit/finality/…)
ValidatorSetRoot [48]byte // weighted-validator-set commitment
// QuorumThreshold is the minimum total voting weight the cert asserts as
// its security parameter. Bound into the SIGNED message so a signature is
// only valid for the threshold it was produced under — a cert may not
// re-present signatures under a lowered threshold (sub-quorum finality
// forgery). This is a quorum-cert-specific axis (like qc_type and the
// value hash) layered here, NOT in the shared round digest.
QuorumThreshold uint64
// 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
@@ -140,12 +148,20 @@ func QuorumConsensusMessage(env QuorumMessageEnvelope) ([]byte, error) {
}
// 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.
// qc_type, the value hash, and the quorum threshold. This is the seam
// that makes a quorum message non-interchangeable with a bare round
// digest AND binds the quorum security parameter into the signature: a
// signature produced under threshold T verifies ONLY for threshold T, so
// honest signatures over the chain's real BFT quorum cannot be re-framed
// under a lowered threshold (sub-quorum finality forgery).
var u32 [4]byte
binary.BigEndian.PutUint32(u32[:], env.ChainID)
chainBytes := append([]byte(nil), u32[:]...)
var u64 [8]byte
binary.BigEndian.PutUint64(u64[:], env.QuorumThreshold)
threshBytes := append([]byte(nil), u64[:]...)
parts := [][]byte{
[]byte(quorumMessageProtocolTag),
digest[:],
@@ -153,6 +169,7 @@ func QuorumConsensusMessage(env QuorumMessageEnvelope) ([]byte, error) {
env.ValueHash[:],
chainBytes,
env.ValidatorSetRoot[:],
threshBytes,
}
out := tupleHash256RoundDigest(parts, 32, quorumMessageCustomization)
return out, nil
@@ -166,10 +183,10 @@ func QuorumConsensusMessage(env QuorumMessageEnvelope) ([]byte, error) {
//
// 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.
// qc_type, validator_set_root, quorum_threshold) the cert claims, so a cert
// whose signers signed a different message fails verification, and a cert
// that lies about its position or lowers its threshold fails because the
// rebuilt message no longer matches the signatures.
func QuorumMessageForCert(envelope QuorumMessageEnvelope, cert *WeightedQuorumCert) ([]byte, error) {
if cert == nil {
return nil, ErrQCNil
@@ -182,5 +199,6 @@ func QuorumMessageForCert(envelope QuorumMessageEnvelope, cert *WeightedQuorumCe
env.ValueHash = cert.ValueHash
env.QCType = cert.QCType
env.ValidatorSetRoot = cert.ValidatorSetRoot
env.QuorumThreshold = cert.QuorumThreshold
return QuorumConsensusMessage(env)
}
+14 -10
View File
@@ -92,18 +92,22 @@ func (c *QuasarCert) HasWeightedQuorumLeg() bool {
}
// 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.
// carried in a QuasarCert's MLDSARollup leg against the chain envelope 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.
// full weighted-quorum predicate (proof-backend axis match + per-signer FIPS
// verify + Merkle inclusion + weight threshold floor + 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 {
// envelope carries the chain's pinned posture axes; the extracted cert's
// Verify rebuilds the domain-separated signing message ITSELF from the
// envelope plus the cert's own position fields, so the caller never supplies
// an opaque message that could skip the position / threshold / proof-backend
// binding.
func (c *QuasarCert) VerifyWeightedQuorumLeg(envelope QuorumMessageEnvelope, cfg QuorumVerifierConfig) error {
if c == nil {
return ErrQCNil
}
@@ -114,7 +118,7 @@ func (c *QuasarCert) VerifyWeightedQuorumLeg(message []byte, cfg QuorumVerifierC
if err != nil {
return err
}
return cert.Verify(message, cfg)
return cert.Verify(envelope, cfg)
}
// BuildQuasarCertFromWeightedQuorum builds a QuasarCert whose MLDSARollup
+378
View File
@@ -0,0 +1,378 @@
// 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 a record's (LeafIndex,LeafCount) has
// multiple encodings that verify against the same root with the same sibling
// bytes. Left unbound, the same logical cert would have byte-distinct
// encodings (breaking dedup / equivocation detection). The fix binds
// (LeafIndex,LeafCount) into the signer commitment, so swapping LeafCount
// 3→4 changes the commitment and the cert is rejected.
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 Merkle verifier alone cannot
// distinguish them. This is exactly why the binding must live in the cert
// commitment.
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. The Merkle recomputation still succeeds (same shape, same
// siblings → same root), so this would slip past the Merkle clause — but
// the commitment now binds LeafCount, so recomputing it over the malleated
// record yields a value != the stored commitment.
sc.cert.Signers[rec0].MerklePath.LeafCount = 4
// The Merkle clause must still PASS for this record (proving the malleation
// is NOT caught by the Merkle math — only by the commitment binding).
if !VerifyWeightedInclusion(
sc.cert.ValidatorSetRoot, sc.cert.Epoch,
sc.cert.Signers[rec0].leaf(), sc.cert.Signers[rec0].MerklePath,
) {
t.Fatal("precondition: malleated (0,4) proof must still satisfy the Merkle clause")
}
// Verify with the ORIGINAL (un-recomputed) commitment → the bound
// LeafCount no longer matches → ErrQCSignerCommitment.
err := sc.cert.Verify(sc.env, sc.cfg)
if !errors.Is(err, ErrQCSignerCommitment) {
t.Fatalf("malleated LeafCount err = %v, want ErrQCSignerCommitment", 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)
}
}
+24
View File
@@ -176,6 +176,30 @@ func slhdsaVerifier(mode magnetar.Mode) quorumVerifyFn {
}
}
// contextForScheme returns the FIPS context string to verify a record of the
// given scheme under, resolving the mixed-scheme context asymmetry.
//
// The two FIPS sign paths used here bind DIFFERENT contexts:
// - ML-DSA (FIPS 204) records are signed via mldsa.SignCtx under the
// caller's context, so they MUST verify under cfg.Context.
// - SLH-DSA (FIPS 205) records are signed via magnetar.ValidatorSign, which
// binds the EMPTY FIPS-205 context (the round-digest message already
// carries the full domain binding). They MUST verify under the empty
// context regardless of cfg.Context.
//
// Applying one cfg.Context to every scheme would make a mixed ML-DSA ∧
// SLH-DSA cert verify only when cfg.Context is nil. Selecting the context per
// scheme family removes that footgun: the empty context is forced for the
// SLH-DSA leg, so a mixed cert verifies under any cfg.Context the ML-DSA
// signers used.
func contextForScheme(cfg QuorumVerifierConfig, scheme QuorumSchemeID) []byte {
if scheme.FIPS() == "205" {
// SLH-DSA: empty FIPS-205 context (matches magnetar.ValidatorSign).
return nil
}
return cfg.Context
}
// 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