mirror of
https://github.com/luxfi/consensus.git
synced 2026-09-24 00:26:58 +00:00
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:
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user