mirror of
https://github.com/luxfi/consensus.git
synced 2026-11-03 07:13:43 +00:00
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.
173 lines
7.0 KiB
Go
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
|
|
}
|