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

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

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

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

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

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

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

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

225 lines
9.2 KiB
Go

// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// quorum_scheme.go — the per-signer scheme axis of a weighted quorum
// certificate, plus the pluggable FIPS verifier registry.
//
// A weighted quorum certificate is scheme-agnostic: it proves that a
// quorum-weight subset of validators each produced a valid INDEPENDENT
// signature over the same domain-separated consensus message. "Independent"
// is the whole point — there is no key sharing, no reconstruction, no seed /
// WOTS+ / FORS / secret material combined. Each signer record names the
// scheme it signed under; the verifier dispatches to the unmodified FIPS
// verifier for that scheme.
//
// This is threshold CERTIFICATION, not threshold signing. The contrast with
// the Pulsar / Corona threshold legs of QuasarCert is deliberate: those run
// a DKG and emit ONE group signature; this runs no ceremony and emits N
// per-validator signatures that any peer can check with a stock FIPS
// verifier and no Lux dependency.
package quasar
import (
"errors"
"fmt"
"github.com/luxfi/crypto/mldsa"
magnetar "github.com/luxfi/magnetar/ref/go/pkg/magnetar"
)
// QuorumSchemeID names the signature scheme a single quorum-certificate
// signer record was produced under. Orthogonal to config.SigSchemeID (the
// threshold-finality axis): a quorum cert's signer signs INDEPENDENTLY, so
// this axis names the per-signer FIPS relation the verifier dispatches to.
//
// The byte values intentionally MIRROR the canonical Lux wire bytes for the
// same primitives so a reader sees consistent numbering across the codebase:
//
// 0x41..0x43 — ML-DSA per FIPS 204 (matches config.SigSchemeID 0x40 block)
// 0x05..0x07 — SLH-DSA per FIPS 205 (matches config.RecoverySchemeID block)
//
// New schemes (e.g. an independent-signature Corona) claim the next free
// integer in their family block and MUST register a verifier in
// defaultQuorumVerifiers before any cert references them. Never reuse a
// retired id.
type QuorumSchemeID uint8
const (
QuorumSchemeNone QuorumSchemeID = 0x00
// FIPS 204 ML-DSA (Module-LWE lattice signature). Independent per-signer.
QuorumSchemeMLDSA44 QuorumSchemeID = 0x41
QuorumSchemeMLDSA65 QuorumSchemeID = 0x42 // production identity default
QuorumSchemeMLDSA87 QuorumSchemeID = 0x43
// FIPS 205 SLH-DSA (hash-based stateless signature). Independent
// per-signer; the cross-family backstop. Byte values match
// config.RecoverySchemeID's SLH-DSA block.
QuorumSchemeSLHDSA192s QuorumSchemeID = 0x06 // Magnetar production (SHAKE-192s)
QuorumSchemeSLHDSA192f QuorumSchemeID = 0x16 // SHAKE-192f
QuorumSchemeSLHDSA256s QuorumSchemeID = 0x07 // SHAKE-256s
)
// String returns the canonical wire name.
func (s QuorumSchemeID) String() string {
switch s {
case QuorumSchemeNone:
return "none"
case QuorumSchemeMLDSA44:
return "ml-dsa-44"
case QuorumSchemeMLDSA65:
return "ml-dsa-65"
case QuorumSchemeMLDSA87:
return "ml-dsa-87"
case QuorumSchemeSLHDSA192s:
return "slh-dsa-192s"
case QuorumSchemeSLHDSA192f:
return "slh-dsa-192f"
case QuorumSchemeSLHDSA256s:
return "slh-dsa-256s"
default:
return fmt.Sprintf("quorum-scheme(0x%02x)", uint8(s))
}
}
// FIPS reports the FIPS standard the scheme's verifier implements. "204"
// for ML-DSA, "205" for SLH-DSA, "" for unknown. The certificate's NIST
// posture rests on these being unmodified FIPS verifiers.
func (s QuorumSchemeID) FIPS() string {
switch s {
case QuorumSchemeMLDSA44, QuorumSchemeMLDSA65, QuorumSchemeMLDSA87:
return "204"
case QuorumSchemeSLHDSA192s, QuorumSchemeSLHDSA192f, QuorumSchemeSLHDSA256s:
return "205"
default:
return ""
}
}
var (
// ErrUnknownQuorumScheme is returned when a signer record names a
// scheme with no registered verifier. Treated as a per-record INVALID
// (not a fatal/DoS) by the certificate verifier.
ErrUnknownQuorumScheme = errors.New("quasar: unknown quorum signature scheme")
// ErrSchemeNotAllowed is returned (as a per-record invalidation) when a
// signer record's scheme is not in the verifier's allowed set.
ErrSchemeNotAllowed = errors.New("quasar: quorum signature scheme not in allowed set")
)
// quorumVerifyFn is the pluggable per-signature verification function. It
// MUST be a thin dispatch over an UNMODIFIED FIPS verifier — adding logic
// here breaks the wire-identity property that lets any peer verify a record
// with a stock FIPS library and no Lux dependency.
//
// Contract:
// - pubKey is the signer's canonical public-key bytes (exactly as bound
// into the validator-set leaf).
// - message is the domain-separated consensus message (the round digest
// subject — see quorum_message.go).
// - ctx is the FIPS context string (≤255 bytes), bound at sign time.
// - returns true iff the signature verifies; never panics.
type quorumVerifyFn func(pubKey, message, ctx, sig []byte) bool
// defaultQuorumVerifiers is the canonical scheme→verifier registry. Each
// entry is a thin dispatch over a stock FIPS verifier:
//
// ML-DSA → github.com/luxfi/crypto/mldsa PublicKey.VerifySignatureCtx (FIPS 204)
// SLH-DSA → github.com/luxfi/magnetar slhdsa.Verify via VerifyCtx (FIPS 205)
//
// The map is the single source of truth for which schemes a quorum cert
// can carry. A QuorumVerifierConfig may restrict to a subset but never
// adds schemes the registry does not know.
var defaultQuorumVerifiers = map[QuorumSchemeID]quorumVerifyFn{
QuorumSchemeMLDSA44: mldsaVerifier(mldsa.MLDSA44),
QuorumSchemeMLDSA65: mldsaVerifier(mldsa.MLDSA65),
QuorumSchemeMLDSA87: mldsaVerifier(mldsa.MLDSA87),
QuorumSchemeSLHDSA192s: slhdsaVerifier(magnetar.ModeM192s),
QuorumSchemeSLHDSA192f: slhdsaVerifier(magnetar.ModeM192f),
QuorumSchemeSLHDSA256s: slhdsaVerifier(magnetar.ModeM256s),
}
// mldsaVerifier builds a quorumVerifyFn over the unmodified FIPS 204
// ML-DSA verifier for a fixed parameter set. The public key is parsed
// per-call from its canonical bytes; a parse failure or wrong-length key is
// a clean false (the record is INVALID, not fatal).
func mldsaVerifier(mode mldsa.Mode) quorumVerifyFn {
return func(pubKey, message, ctx, sig []byte) bool {
pk, err := mldsa.PublicKeyFromBytes(pubKey, mode)
if err != nil || pk == nil {
return false
}
// VerifySignatureCtx is the stock FIPS 204 verify with a context
// string per §5.4; an empty ctx (nil) is the empty-context case.
return pk.VerifySignatureCtx(message, sig, ctx)
}
}
// slhdsaVerifier builds a quorumVerifyFn over the unmodified FIPS 205
// SLH-DSA verifier (via magnetar's thin VerifyCtx dispatch over
// circl/slhdsa) for a fixed parameter set.
func slhdsaVerifier(mode magnetar.Mode) quorumVerifyFn {
params, perr := magnetar.ParamsFor(mode)
return func(pubKey, message, ctx, sig []byte) bool {
if perr != nil || params == nil {
return false
}
if len(pubKey) != params.PublicKeySize || len(sig) != params.SignatureSize {
return false
}
pk := &magnetar.PublicKey{Mode: mode, Bytes: append([]byte(nil), pubKey...)}
s := &magnetar.Signature{Mode: mode, Bytes: append([]byte(nil), sig...)}
// VerifyCtx returns nil iff the signature is valid under
// FIPS 205 SLH-DSA.Verify; any error => invalid record.
return magnetar.VerifyCtx(params, pk, message, ctx, s) == nil
}
}
// 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
// scheme before any signature dispatch.
func expectedPubKeyLen(scheme QuorumSchemeID) (int, bool) {
switch scheme {
case QuorumSchemeMLDSA44:
return mldsa.GetPublicKeySize(mldsa.MLDSA44), true
case QuorumSchemeMLDSA65:
return mldsa.GetPublicKeySize(mldsa.MLDSA65), true
case QuorumSchemeMLDSA87:
return mldsa.GetPublicKeySize(mldsa.MLDSA87), true
case QuorumSchemeSLHDSA192s:
return magnetar.ParamsM192s.PublicKeySize, true
case QuorumSchemeSLHDSA192f:
return magnetar.ParamsM192f.PublicKeySize, true
case QuorumSchemeSLHDSA256s:
return magnetar.ParamsM256s.PublicKeySize, true
default:
return 0, false
}
}