Files
consensus/protocol/quasar/quorum_quasarcert.go
zooqueen 8bf3334ffa 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

173 lines
7.0 KiB
Go

// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// quorum_quasarcert.go — wiring the weighted quorum certificate into the
// QuasarCert envelope as the honest, full-node-verifiable realization of
// the per-validator-signature legs.
//
// A QuasarCert's MLDSARollup leg is, semantically, "a proof that every one
// of N validators produced a valid independent ML-DSA-65 signature over the
// round digest" (types.go). The STARK/Groth16 backend realizes that as a
// succinct proof. The WeightedQuorumCert realizes the SAME claim DIRECTLY:
// the cert carries the per-validator records and a full node verifies them
// itself (no trusted setup, no succinctness assumption). This is the
// "full-node-verifiable mode" — the conservative production posture.
//
// Likewise the Magnetar leg is a magnetar.ValidatorAggregateCert (N
// independent SLH-DSA signatures). A mixed-scheme WeightedQuorumCert can
// carry ML-DSA and SLH-DSA signer records side by side, so it is also the
// honest realization of a combined ML-DSA ∧ SLH-DSA per-validator surface.
//
// To keep the MLDSARollup slot unambiguous (it may carry a STARK proof OR a
// direct WQC), the embedded bytes are tagged with a 1-byte discriminator.
// Decoders dispatch on the tag; a STARK verifier and a WQC verifier never
// confuse each other's bytes.
package quasar
import (
"errors"
"fmt"
)
// mldsaRollupTagWQC is the 1-byte discriminator prefix that marks the
// MLDSARollup leg as carrying a direct WeightedQuorumCert (the
// full-node-verifiable realization) rather than a STARK/Groth16 succinct
// proof. The value 0x57 ('W') is chosen to be visually distinct and to lie
// outside the typical leading bytes of a STARK proof container; decoders
// MUST check this tag before parsing.
const mldsaRollupTagWQC byte = 0x57
var (
// ErrMLDSARollupNotWQC is returned by ExtractWeightedQuorumLeg when the
// MLDSARollup leg does not carry a WQC discriminator (e.g. it holds a
// STARK proof or per-validator sig concatenation instead).
ErrMLDSARollupNotWQC = errors.New("quasar: MLDSARollup leg is not a direct weighted quorum cert")
// ErrQuasarCertNoWQCLeg is returned by VerifyWeightedQuorumLeg when the
// cert carries no MLDSARollup leg at all.
ErrQuasarCertNoWQCLeg = errors.New("quasar: QuasarCert has no MLDSARollup leg to verify")
)
// EmbedWeightedQuorumLeg encodes a WeightedQuorumCert as the MLDSARollup
// leg of a QuasarCert, tagged with the WQC discriminator. The cert is
// emitted in FULL form (records included) when it carries records, so the
// leg is self-contained and a full node can verify it without a DA fetch;
// pass a Compact() cert to emit the commitment-only leg (DA-hybrid mode),
// in which case verification requires re-attaching records first.
//
// Returns the leg bytes (discriminator || wqc-wire) to assign to
// QuasarCert.MLDSARollup. Pure function; no secrets.
func EmbedWeightedQuorumLeg(cert *WeightedQuorumCert) ([]byte, error) {
if cert == nil {
return nil, ErrQCNil
}
wire, err := cert.MarshalBinary()
if err != nil {
return nil, err
}
out := make([]byte, 0, 1+len(wire))
out = append(out, mldsaRollupTagWQC)
out = append(out, wire...)
return out, nil
}
// ExtractWeightedQuorumLeg decodes the WeightedQuorumCert from a QuasarCert
// MLDSARollup leg. Returns ErrMLDSARollupNotWQC if the leg is empty or does
// not carry the WQC discriminator (so a STARK-rollup leg is cleanly
// distinguished, not misparsed).
func ExtractWeightedQuorumLeg(mldsaRollup []byte) (*WeightedQuorumCert, error) {
if len(mldsaRollup) == 0 || mldsaRollup[0] != mldsaRollupTagWQC {
return nil, ErrMLDSARollupNotWQC
}
return UnmarshalWeightedQuorumCert(mldsaRollup[1:])
}
// HasWeightedQuorumLeg reports whether a QuasarCert's MLDSARollup leg
// carries a direct WeightedQuorumCert (vs a STARK proof or nothing).
func (c *QuasarCert) HasWeightedQuorumLeg() bool {
if c == nil {
return false
}
return len(c.MLDSARollup) > 0 && c.MLDSARollup[0] == mldsaRollupTagWQC
}
// VerifyWeightedQuorumLeg verifies the direct weighted quorum certificate
// 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 (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.
//
// 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
}
if len(c.MLDSARollup) == 0 {
return ErrQuasarCertNoWQCLeg
}
cert, err := ExtractWeightedQuorumLeg(c.MLDSARollup)
if err != nil {
return err
}
return cert.Verify(envelope, cfg)
}
// BuildQuasarCertFromWeightedQuorum builds a QuasarCert whose MLDSARollup
// leg is the direct full-node-verifiable WeightedQuorumCert. The other legs
// (BLS / Corona / Pulsar / Magnetar) are left empty — this is the pure
// "direct weighted quorum" posture where the per-validator-signature
// surface IS the finality evidence, with no threshold-group or classical
// fast-path leg layered on. Compose with the lower-tier ComposePulsar /
// ComposeAurora / ComposePolaris functions when a hybrid posture is wanted;
// those operate on the threshold legs, which are orthogonal to this one.
//
// Epoch / Validators / Finality are copied from the quorum cert so the
// QuasarCert header reflects the same epoch and signer count.
func BuildQuasarCertFromWeightedQuorum(cert *WeightedQuorumCert) (*QuasarCert, error) {
if cert == nil {
return nil, ErrQCNil
}
leg, err := EmbedWeightedQuorumLeg(cert)
if err != nil {
return nil, err
}
return &QuasarCert{
MLDSARollup: leg,
Epoch: cert.Epoch,
Validators: int(cert.SignerCount),
}, nil
}
// AttachWeightedQuorumLeg returns a copy of the QuasarCert with its
// MLDSARollup leg replaced by the direct WeightedQuorumCert, preserving the
// other legs. Use when layering the full-node-verifiable per-validator
// surface onto an already-composed cert (e.g. one that already carries the
// BLS classical fast-path or a Pulsar threshold leg).
func (c *QuasarCert) AttachWeightedQuorumLeg(cert *WeightedQuorumCert) (*QuasarCert, error) {
if c == nil {
return nil, errors.New("quasar: nil QuasarCert")
}
leg, err := EmbedWeightedQuorumLeg(cert)
if err != nil {
return nil, fmt.Errorf("attach weighted quorum leg: %w", err)
}
cp := *c
cp.MLDSARollup = leg
if cp.Epoch == 0 {
cp.Epoch = cert.Epoch
}
if cp.Validators == 0 {
cp.Validators = int(cert.SignerCount)
}
return &cp, nil
}