feat(quasar): ConsensusCert envelope — policy-gated threshold certification

Canonical finality cert: one policy-gated envelope (VerifyConsensusCert) with
per-leg evidence modes — ThresholdSig (lattice O(1)), WeightedSigSet (independent
FIPS sigs + weighted-Merkle quorum; the only mode for SLH-DSA, never
seed-reconstructed), StarkCompressedSigSet (P3Q, same predicate, audit-gated),
ClassicalAggregate (BLS, never satisfies a PQ leg). Required legs are
policy-derived (CertPolicy.RequiredLegs), never cert bytes (Red H3 anti-downgrade);
full-tuple domain binding. WeightedQuorumCert foundation: policy-as-parameter,
message recomputed internally, strictly-increasing IDs, checked weight.

27 named envelope tests + F1-F5 + 6 CTO attacks PASS; verifier total (fuzz, zero
panics); a fuzz-found threshold-sig flags-byte malleability fixed. Fixed go.sum
[email protected] to canonical transparency-log hashes. Scrubbed ~54MB tracked binaries.
This commit is contained in:
zooqueen
2026-06-21 20:39:11 -07:00
parent 9e2a063658
commit d01dcfa1c1
25 changed files with 6655 additions and 4 deletions
+6
View File
@@ -103,3 +103,9 @@ LLM.md
QWEN.md
.AGENTS.md
GEMINI.md
quasar_full
test.bin
*.bin
examples/quasar_full/quasar_full
examples/01-simple-bridge/01-simple-bridge
examples/02-ai-payment/02-ai-payment
+17 -1
View File
@@ -33,7 +33,23 @@ post-quantum finality. All sub-protocols live in `protocol/`.
**DAG (Nebula)**: Photon -> Wave (per frontier vertex) -> Flare (cert/skip) -> Horizon (safe prefix) -> Field (commit) -> Committer
### Quasar Certificate
### ConsensusCert envelope (CANONICAL — supersedes the Groth16 `QuasarCert` narrative below)
The finality certificate is threshold **CERTIFICATION**, not threshold signing.
One policy-gated envelope (`protocol/quasar/consensus_cert.go`,
`VerifyConsensusCert`) with per-leg evidence modes:
`EvidenceThresholdSig` (lattice O(1) — origin's `VerifyWithRealKeys`),
`EvidenceWeightedSigSet` (independent FIPS sigs + weighted quorum — the
`WeightedQuorumCert`; the **only** mode for SLH-DSA, never seed-reconstructed),
`EvidenceStarkCompressedSigSet` (P3Q, same predicate, audit-gated stub), and
`EvidenceClassicalAggregate` (BLS, classical-only, never satisfies a PQ leg).
Required legs are **policy-derived** (`CertPolicy.RequiredLegs`), never cert
bytes (the Red H3 anti-downgrade). One-sentence spec: *policy defines the
required legs; cert bytes provide per-leg evidence; each mode proves the same
weighted-quorum predicate over the same domain-separated message.* The
`MLDSAProof`/Groth16 3-tuple below is the SUPERSEDED prior design.
### Quasar Certificate (superseded — historical)
`QuasarCert` (see `protocol/quasar/types.go:40`) is a 3-tuple:
+42 -1
View File
@@ -503,6 +503,16 @@ func (p ProofPolicyID) IsForbiddenInPQMode() bool {
// 0x22 = P3Q_STARK_FRI_SHA3_PQ Lux P3Q (Plonky3 fork, cSHAKE256 Merkle)
// 0x23 = STONE_CAIRO_STARK_PQ Cairo / Stone backend
// 0x24 = STWO_CIRCLE_STARK_PQ Stwo Circle STARK
// 0x30..0x3F — Direct full-node verification (no succinct proof). The
// "proof" is the certificate itself: each signer's
// unmodified FIPS 204/205 signature, a weighted-validator-set
// Merkle inclusion path, and a weight-threshold predicate.
// Soundness rests ONLY on the underlying FIPS verifier plus
// Merkle second-preimage resistance — there is no
// succinctness layer and therefore no STARK/SNARK trusted
// component to audit. A STARK compression backend over the
// SAME relation is a separate id in the 0x10/0x20 block.
// 0x30 = DIRECT_WEIGHTED_QUORUM_PQ per-signer FIPS verify + Merkle + weight predicate
// 0x70..0x7F — Dev-only / non-production (CI, testnet, audit fuzzing)
// 0x70 = RISC0_RAW_STARK_DEV
// 0x71 = SP1_CORE_STARK_DEV
@@ -519,6 +529,18 @@ const (
ProofBackendStoneCairoSTARK ProofBackendID = 0x23
ProofBackendStwoCircleSTARK ProofBackendID = 0x24
// ProofBackendDirectWeightedQuorum is the direct full-node-verifiable
// weighted quorum certificate backend (HIP-0079 §"full-node-verifiable
// mode"). Verification is direct: for each signer record, run unmodified
// FIPS 204 (ML-DSA) or FIPS 205 (SLH-DSA) Verify over the
// domain-separated consensus message, check the weighted-validator-set
// Merkle inclusion path, then assert the accumulated voting weight meets
// the quorum threshold. No succinct proof, no recursion, no trusted
// setup; the per-signer signatures ARE the proof. This is threshold
// CERTIFICATION, not threshold signing — no key material is shared,
// reconstructed, or combined. See protocol/quasar/quorum_cert.go.
ProofBackendDirectWeightedQuorum ProofBackendID = 0x30
// Dev-only — never produced in production strict-PQ deployments.
ProofBackendRISC0RawSTARKDev ProofBackendID = 0x70
ProofBackendSP1CoreSTARKDev ProofBackendID = 0x71
@@ -545,6 +567,8 @@ func (b ProofBackendID) String() string {
return "stone-cairo-stark-pq"
case ProofBackendStwoCircleSTARK:
return "stwo-circle-stark-pq"
case ProofBackendDirectWeightedQuorum:
return "direct-weighted-quorum-pq"
case ProofBackendRISC0RawSTARKDev:
return "risc0-raw-stark-dev"
case ProofBackendSP1CoreSTARKDev:
@@ -561,18 +585,35 @@ func (b ProofBackendID) String() string {
// IsProductionPQ reports whether this backend is acceptable in strict-PQ
// production mode. Dev backends (0x70 block) and forbidden backends
// (0x80 block) return false.
//
// ProofBackendDirectWeightedQuorum qualifies: its soundness rests on
// unmodified FIPS 204/205 verification plus Merkle second-preimage
// resistance, which is strictly inside the NIST-approved algorithm set
// (no classical EC, no trusted setup). It is the most conservative
// production-PQ posture — there is no succinctness assumption to break.
func (b ProofBackendID) IsProductionPQ() bool {
switch b {
case ProofBackendRISC0SuccinctSTARK,
ProofBackendSP1CompressedSTARK,
ProofBackendP3QSTARKFRISHA3,
ProofBackendStoneCairoSTARK,
ProofBackendStwoCircleSTARK:
ProofBackendStwoCircleSTARK,
ProofBackendDirectWeightedQuorum:
return true
}
return false
}
// IsDirectVerifiable reports whether this backend is verified directly by
// a full node — no succinct proof, no recursion, no trusted setup. The
// certificate carries the per-signer FIPS signatures and a Merkle/weight
// predicate; the verifier replays them. Distinguishes the
// "full-node-verifiable mode" from the succinct (STARK/SNARK) backends so
// audit tooling can assert which trust model a cert envelope rides under.
func (b ProofBackendID) IsDirectVerifiable() bool {
return b == ProofBackendDirectWeightedQuorum
}
// IsForbiddenInPQMode mirrors ProofPolicyID.IsForbiddenInPQMode at the
// backend layer.
func (b ProofBackendID) IsForbiddenInPQMode() bool {
+21
View File
@@ -114,6 +114,9 @@ func (p ProfileID) String() string {
// 0x22 = P3Q_BINARY_V1 Lux P3Q (Plonky3 fork) native
// 0x23 = STONE_CAIRO_BINARY_V1 Stone / Cairo native
// 0x24 = STWO_CIRCLE_BINARY_V1 Stwo Circle STARK native
// 0x30..0x3F — Direct full-node-verifiable layouts (no succinct proof)
// 0x30 = DIRECT_WEIGHTED_QUORUM_V1 weighted quorum cert wire
// (signer records: per-signer FIPS sig + Merkle path)
// 0x70..0x7F — Dev-only formats (CI / fuzzing)
// 0x80..0xFF — Forbidden in strict-PQ mode (mirrors classical-wrapper IDs)
// 0x80 = GROTH16_WRAPPED_BINARY classical wrapper format
@@ -129,6 +132,12 @@ const (
ProofFormatStoneCairoBinaryV1 ProofFormatID = 0x23
ProofFormatStwoCircleBinaryV1 ProofFormatID = 0x24
// ProofFormatDirectWeightedQuorumV1 is the wire layout of the
// direct full-node-verifiable weighted quorum certificate (the full,
// records-included encoding). See protocol/quasar/quorum_cert.go
// MarshalBinary / the QC version byte.
ProofFormatDirectWeightedQuorumV1 ProofFormatID = 0x30
ProofFormatGroth16WrappedForbid ProofFormatID = 0x80
ProofFormatKZGWrappedForbid ProofFormatID = 0x81
)
@@ -150,6 +159,8 @@ func (f ProofFormatID) String() string {
return "stone-cairo-binary-v1"
case ProofFormatStwoCircleBinaryV1:
return "stwo-circle-binary-v1"
case ProofFormatDirectWeightedQuorumV1:
return "direct-weighted-quorum-v1"
case ProofFormatGroth16WrappedForbid:
return "groth16-wrapped-classical-forbidden-in-pq"
case ProofFormatKZGWrappedForbid:
@@ -200,6 +211,14 @@ const (
VerifierStoneCairoSTARKPQ VerifierID = 0x0023
VerifierStwoCircleSTARKPQ VerifierID = 0x0024
// VerifierDirectWeightedQuorumPQ is the pinned verifier for the direct
// full-node-verifiable weighted quorum certificate. Its "program" is the
// fixed predicate in protocol/quasar/quorum_cert.go (per-signer FIPS
// 204/205 Verify + weighted-validator-set Merkle inclusion + weight
// threshold). It carries no verifier key or trusted setup — the manifest
// for this id pins the consensus source commit and the predicate version.
VerifierDirectWeightedQuorumPQ VerifierID = 0x0030
// Dev-only verifiers.
VerifierSP1CoreSTARKDev VerifierID = 0x0070
VerifierRISC0RawSTARKDev VerifierID = 0x0071
@@ -224,6 +243,8 @@ func (v VerifierID) String() string {
return "stone-cairo-stark-pq"
case VerifierStwoCircleSTARKPQ:
return "stwo-circle-stark-pq"
case VerifierDirectWeightedQuorumPQ:
return "direct-weighted-quorum-pq"
case VerifierSP1CoreSTARKDev:
return "sp1-core-stark-dev"
case VerifierRISC0RawSTARKDev:
Binary file not shown.
Binary file not shown.
Binary file not shown.
+2 -2
View File
@@ -164,8 +164,8 @@ github.com/luxfi/crypto/ipa v1.2.4 h1:6xfwhI9/HrcDkF3Ti5/NxsNQIWbwYDJmRSNIHRQ/xf
github.com/luxfi/crypto/ipa v1.2.4/go.mod h1:43J6f6rcfUMrZt4cQectMOZb6Ps+fAEj8ZTPC3Kk+gE=
github.com/luxfi/database v1.19.0 h1:sySH5eS8ihJtyc+yb0LgTignGwJ9H4W9bv+nboCuIm0=
github.com/luxfi/database v1.19.0/go.mod h1:o1ccjgVKMYbVBfi+6+sDvx4swGFgGnCGFQ4gL/oJ+kY=
github.com/luxfi/geth v1.17.11 h1:8hkMHTpd1Wz/P8KAiZLtw9y/KOFux/Wja88kcAaEo1Q=
github.com/luxfi/geth v1.17.11/go.mod h1:EZPzzYoH+Io08/8+0DJCvH+53jnoi3zKsEWAxwzlOL0=
github.com/luxfi/geth v1.17.11 h1:opyVqYiH3t4qglAdMymudxKIO09O1LWB9tKGc57o9sA=
github.com/luxfi/geth v1.17.11/go.mod h1:3vQfQJd9JC+AVBjxNXa9PYQOqpbE2dKu8E3jqhPZ3LU=
github.com/luxfi/ids v1.2.15 h1:omE+E4+0Poj9DzM11ejSFgteaSQ3KDHi5g54iH6jcxI=
github.com/luxfi/ids v1.2.15/go.mod h1:Fj73K5xcblvdE0SxU/ip+jE8VqNdu+80548su5KJ7xI=
github.com/luxfi/lattice/v7 v7.1.4 h1:hQR02M6cHTAV5+joOPi9gb9Gm+z/hKJnhJF4IlciIJs=
+797
View File
@@ -0,0 +1,797 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// consensus_cert.go — the ConsensusCert ENVELOPE.
//
// ConsensusCert is a policy-gated aggregate quorum certificate: policy defines
// the required cryptographic legs; certificate bytes provide per-leg evidence;
// each evidence mode must prove the same weighted-quorum predicate over the
// same domain-separated consensus message.
//
// It sits ONE level above the evidence verifiers. The WeightedSigSet evidence
// mode IS the already-green WeightedQuorumCert (quorum_cert.go); the
// ThresholdSig evidence mode wires origin's threshold-leg verifiers
// (cert_policy_verify.go); the StarkCompressedSigSet evidence mode is the
// audit-gated succinct compression of the SAME weighted-quorum predicate; the
// ClassicalAggregate evidence mode is the classical-only backstop. The envelope
// decides WHICH legs are required (from policy, never from cert bytes), binds
// the full consensus tuple into one domain-separated message, and dispatches
// each required leg to its evidence verifier.
//
// Decomplected (church of Hickey):
//
// - Leg KIND (LegKind) names WHAT cryptographic requirement a leg satisfies
// (Pulsar / Corona / Magnetar / Classical). It is a POLICY property.
// - Evidence MODE (EvidenceMode) names HOW that requirement is proven
// (ThresholdSig / WeightedSigSet / StarkCompressedSigSet /
// ClassicalAggregate). It is a PROOF property.
//
// Kind and mode are orthogonal axes and are NEVER braided. No lifecycle word
// (Migration / Legacy / Fallback / Compat / Experimental) appears anywhere: a
// leg is named by its ROLE, never by where it sits in a rollout.
//
// ====================================================================
// THE 13 INVARIANTS (each is enforced in code below; the parenthetical
// names the clause / typed error that enforces it).
// ====================================================================
//
// I1. Required legs come from POLICY ONLY, never from certificate bytes.
// (VerifyConsensusCert step 2: required := policy.RequiredLegs(); the
// cert's RequiredLegsRoot is CHECKED against HashRequiredLegs(required),
// never trusted as the source — ErrRequiredLegsRootMismatch.)
//
// I2. The certificate may not assert its own required-leg set: the
// RequiredLegsRoot is recomputed from policy and any mismatch is a hard
// reject. (ErrRequiredLegsRootMismatch.)
//
// I3. The validator set is pinned by the verifier, not the cert: the cert's
// ValidatorSetRoot is checked against validators.Root().
// (ErrValidatorSetRootMismatch.)
//
// I4. The signed domain message binds the FULL consensus tuple
// (chain_id, epoch, height, round, block_hash, validator_set_root,
// policy_id, required_legs_root, signer_root, cert_profile) — NEVER the
// block hash alone. (consensusCertMessage; cross-domain replay closed.)
//
// I5. Every required leg MUST have evidence in the cert; a missing leg is a
// hard reject. (ErrMissingRequiredLeg.)
//
// I6. Every leg's (kind, mode, param-set) triple MUST be permitted by policy
// before any signature math. (ErrDisallowedEvidenceMode.)
//
// I7. SLH-DSA is NEVER threshold-signed in production: a Magnetar leg whose
// evidence mode is ThresholdSig is a hard reject.
// (ErrSLHDSAThresholdSigForbidden.)
//
// I8. A threshold-signature leg that verifies but lacks signer
// ACCOUNTABILITY (signer root / aggregate weight / policy / session
// binding) MUST be rejected. (ErrThresholdSigWithoutAccountability,
// ErrSignerRootMismatch, ErrInsufficientWeight.)
//
// I9. The StarkCompressedSigSet evidence mode proves the SAME predicate as
// VerifyWeightedSigSet — not a different / "close-enough" verifier. Until
// the Keccak-AIR backend is audit-gated it returns a clear error and
// NEVER silently accepts. (ErrStarkBackendNotAuditGated;
// TestStarkCompressedAndWeightedSigSetSamePredicate.)
//
// I10. Classical evidence can satisfy ONLY a LegClassical requirement; it can
// NEVER stand in for a required PQ leg. (ErrClassicalCannotSatisfyPQLeg.)
//
// I11. A classical-only cert is rejected under any policy that requires a PQ
// leg; classical is accepted only once EVERY required PQ leg has already
// been satisfied by PQ evidence. (ErrClassicalOnlyForbidden /
// ErrMissingRequiredPQLeg — enforced by the ENVELOPE, not the helper.)
//
// I12. Every required leg MUST be seen exactly once; the count of satisfied
// legs equals the count of required legs. (ErrMissingRequiredLeg via the
// final seenLegs == required cardinality check.)
//
// I13. Malformed envelope / evidence bytes yield a typed error, NEVER a panic
// and NEVER unbounded work. (every decode is bounds-checked and
// fail-closed; covered by the table + fuzz target.)
package quasar
import (
"encoding/binary"
"errors"
"fmt"
coronaThreshold "github.com/luxfi/threshold/protocols/corona"
)
// consensusCertVersion pins the envelope struct/wire version. A bump is
// non-malleable: it is bound into the domain message.
const consensusCertVersion uint16 = 1
// consensusCertDomainTag is the domain-separation prefix for the ConsensusCert
// signing message. Wire-stable cryptographic constant: never rename.
const consensusCertDomainTag = "Lux/ConsensusCert/v1"
// consensusCertMessageCustomization is the cSHAKE256 customization tag for the
// envelope's domain message. Distinct from the WeightedSigSet message tag so an
// envelope message can never be replayed as an inner-cert message.
const consensusCertMessageCustomization = "LUX-CONSENSUSCERT-MESSAGE-V1"
// requiredLegsCustomization is the cSHAKE256 customization tag for the
// commitment over the policy-derived required-leg set.
const requiredLegsCustomization = "LUX-CONSENSUSCERT-REQUIRED-LEGS-V1"
// requiredLegsProtocolTag is the in-band redundant protocol tag for the
// required-legs commitment.
const requiredLegsProtocolTag = "Lux/ConsensusCert/RequiredLegs"
// ----------------------------------------------------------------------------
// The two orthogonal axes: leg KIND (what) and evidence MODE (how).
// ----------------------------------------------------------------------------
// EvidenceMode names HOW a leg's requirement is proven. Orthogonal to LegKind
// (what is required). No lifecycle word appears: a mode is named by the proof
// structure it denotes, never by a rollout phase.
type EvidenceMode uint8
const (
// EvidenceThresholdSig — one O(1) aggregate threshold signature (Pulsar /
// Corona) over the message, PLUS a signer-accountability binding. A bare
// threshold signature has no signer-set binding, so accountability is
// MANDATORY (see VerifyThresholdSigLeg).
EvidenceThresholdSig EvidenceMode = iota + 1
// EvidenceWeightedSigSet — N independent stock FIPS 204/205 signatures with
// a weighted-validator-set Merkle quorum (the WeightedQuorumCert in
// quorum_cert.go). This is the already-green foundation.
EvidenceWeightedSigSet
// EvidenceStarkCompressedSigSet — a succinct STARK proof that the SAME
// weighted-quorum predicate VerifyWeightedSigSet checks holds. It proves an
// identical public statement; it is NOT a different verifier. Audit-gated:
// rejected until the Keccak-AIR backend lands (never silently accepted).
EvidenceStarkCompressedSigSet
// EvidenceClassicalAggregate — a classical aggregate (e.g. BLS-12-381). It
// can satisfy ONLY a LegClassical requirement and never a PQ leg.
EvidenceClassicalAggregate
)
// String returns the canonical wire name of the evidence mode.
func (m EvidenceMode) String() string {
switch m {
case EvidenceThresholdSig:
return "threshold-sig"
case EvidenceWeightedSigSet:
return "weighted-sig-set"
case EvidenceStarkCompressedSigSet:
return "stark-compressed-sig-set"
case EvidenceClassicalAggregate:
return "classical-aggregate"
default:
return fmt.Sprintf("evidence-mode(0x%02x)", uint8(m))
}
}
// LegKind names WHAT cryptographic requirement a leg satisfies. Orthogonal to
// EvidenceMode (how it is proven). The byte values mirror config.LegName for
// the PQ legs so a reader sees consistent numbering; LegClassical is the
// classical backstop.
type LegKind uint8
const (
// LegPulsarMLDSA — a Module-LWE (Pulsar / FIPS 204 ML-DSA) requirement.
LegPulsarMLDSA LegKind = iota + 1
// LegCoronaLattice — a Ring-LWE (Corona) lattice requirement.
LegCoronaLattice
// LegMagnetarSLHDSA — a hash-based (Magnetar / FIPS 205 SLH-DSA) cross-family
// requirement. NEVER satisfiable by EvidenceThresholdSig (no aggregatable
// threshold structure; production-forbidden).
LegMagnetarSLHDSA
// LegClassical — a classical-aggregate (e.g. BLS-12-381) requirement. The
// only kind EvidenceClassicalAggregate may satisfy.
LegClassical
)
// String returns the canonical wire name of the leg kind.
func (k LegKind) String() string {
switch k {
case LegPulsarMLDSA:
return "pulsar-mldsa"
case LegCoronaLattice:
return "corona-lattice"
case LegMagnetarSLHDSA:
return "magnetar-slhdsa"
case LegClassical:
return "classical"
default:
return fmt.Sprintf("leg-kind(0x%02x)", uint8(k))
}
}
// IsPostQuantum reports whether this leg kind is a post-quantum requirement.
// LegClassical is the only non-PQ kind.
func (k LegKind) IsPostQuantum() bool {
switch k {
case LegPulsarMLDSA, LegCoronaLattice, LegMagnetarSLHDSA:
return true
default:
return false
}
}
// ----------------------------------------------------------------------------
// Classical scheme axis (the only schemes a LegClassical evidence may name).
// ----------------------------------------------------------------------------
// ClassicalScheme names a classical aggregate signature scheme carried by an
// EvidenceClassicalAggregate payload. Kept distinct from the PQ scheme axis so
// a classical scheme can never be confused with a FIPS 204/205 parameter set.
type ClassicalScheme uint8
const (
// ClassicalSchemeNone is the zero value (no classical scheme).
ClassicalSchemeNone ClassicalScheme = 0x00
// ClassicalSchemeBLS12381 is a BLS-12-381 aggregate over the message.
ClassicalSchemeBLS12381 ClassicalScheme = 0x01
)
// String returns the canonical wire name of the classical scheme.
func (s ClassicalScheme) String() string {
switch s {
case ClassicalSchemeNone:
return "none"
case ClassicalSchemeBLS12381:
return "bls12-381"
default:
return fmt.Sprintf("classical-scheme(0x%02x)", uint8(s))
}
}
// ----------------------------------------------------------------------------
// Certificate structure.
// ----------------------------------------------------------------------------
// LegSpec names one leg's requirement axes: its kind plus the parameter-set
// byte the evidence is produced under (e.g. 0x42 = ML-DSA-65). The (kind,
// mode, param-set) triple is what policy.Allows gates.
type LegSpec struct {
// Kind is WHAT requirement this leg satisfies.
Kind LegKind
// ParamSetID is the parameter-set byte (config.SigSchemeID / IdentitySchemeID
// byte for PQ legs; ClassicalScheme byte for LegClassical) the evidence is
// produced under. Gated by policy.Allows so a leg cannot silently downgrade
// its parameter set.
ParamSetID uint8
}
// LegEvidence is one leg's contribution to a ConsensusCert: its requirement
// (LegSpec), the evidence mode (how it is proven), and the opaque per-mode
// payload the matching evidence verifier decodes.
type LegEvidence struct {
// Leg names the requirement this evidence claims to satisfy.
Leg LegSpec
// Mode names how the requirement is proven; selects the evidence verifier.
Mode EvidenceMode
// Payload is the mode-specific evidence bytes (a marshalled
// WeightedQuorumCert, a ThresholdSigPayload, a StarkCompressedPayload, or a
// ClassicalAggregatePayload). The matching verifier owns its decoding;
// every decode is bounds-checked and fail-closed.
Payload []byte
}
// ParamSet returns the parameter-set byte of this evidence's leg. Convenience
// for the policy gate.
func (e LegEvidence) ParamSet() uint8 { return e.Leg.ParamSetID }
// ConsensusCert is the policy-gated aggregate quorum certificate. The header
// pins the full consensus position + policy/validator-set/signer commitments;
// Evidence carries one entry per leg.
//
// CRITICAL: the header's RequiredLegsRoot is NOT the source of which legs are
// required — it is a value the verifier CHECKS against the policy-derived set
// (I1/I2). A cert cannot weaken itself by claiming a smaller required set.
type ConsensusCert struct {
// Version pins the envelope format.
Version uint16
// Profile is the chain security profile byte bound into the domain message
// (so a cert produced under one posture cannot be re-presented under
// another).
Profile uint8
// Consensus position.
ChainID uint32
Epoch uint64
Height uint64
Round uint32
// BlockHash is the block / value being finalised. Bound into the message —
// but NEVER alone (I4).
BlockHash [32]byte
// ValidatorSetRoot is the weighted-validator-set commitment the verifier
// pins against validators.Root() (I3).
ValidatorSetRoot [48]byte
// PolicyID identifies the policy record the verifier loads from the policy
// store. Bound into the message.
PolicyID uint32
// RequiredLegsRoot is the cert's CLAIMED commitment to the required-leg set.
// CHECKED against HashRequiredLegs(policy.RequiredLegs()) — never trusted as
// the source of the required set (I1/I2).
RequiredLegsRoot [32]byte
// SignerRoot is the commitment to the signer set across the evidence. Bound
// into the message and cross-checked by the threshold-sig accountability
// clause (I8).
SignerRoot [32]byte
// AggregateWeight is the cert's claimed total signer weight (header-level;
// each evidence mode independently re-establishes weight against the
// threshold).
AggregateWeight uint64
// Evidence is the per-leg evidence, one entry per leg the cert provides.
Evidence []LegEvidence
}
// ----------------------------------------------------------------------------
// Policy + validator-set interfaces (the decomplected inputs).
// ----------------------------------------------------------------------------
// ConsensusCertPolicy is the chain's cert posture, supplied to the verifier.
// It is the SINGLE source of which legs are required and which (kind, mode,
// param-set) triples are permitted. The cert never supplies any of this.
type ConsensusCertPolicy interface {
// RequiredLegs returns the legs the cert MUST satisfy. POLICY-derived,
// deterministic, and the sole source of the required set (I1).
RequiredLegs() []LegSpec
// Allows reports whether a leg may be proven by the given evidence mode
// under the given parameter-set byte. Gates the (kind, mode, param-set)
// triple before any signature math (I6).
Allows(leg LegSpec, mode EvidenceMode, paramSet uint8) bool
// ThresholdWeight is the minimum aggregate signer weight any leg's evidence
// must establish — the chain BFT quorum floor. A threshold-sig leg whose
// accountability asserts less is rejected (I8); a weighted-sig-set leg's own
// MinThreshold floor is pinned to this value.
ThresholdWeight() uint64
// AllowsClassicalScheme reports whether a classical aggregate under the
// named scheme is admissible (gates the classical leg's scheme byte).
AllowsClassicalScheme(scheme ClassicalScheme) bool
}
// ConsensusCertPolicyStore resolves the policy for a (chain, epoch, policy-id).
// The verifier loads the policy from here — never from the cert.
type ConsensusCertPolicyStore interface {
Policy(chainID uint32, epoch uint64, policyID uint32) (ConsensusCertPolicy, error)
}
// ConsensusValidatorSet is the committed epoch validator set the verifier pins
// the cert against. Root() is checked against the cert's ValidatorSetRoot (I3);
// the per-leg key accessors supply the trusted verification keys for the
// threshold-sig / weighted-sig-set / classical legs.
type ConsensusValidatorSet interface {
// Root returns the 48-byte weighted-validator-set commitment.
Root() [48]byte
// Epoch returns the epoch this set was committed under (folded into the
// weighted-Merkle leaf digests; passed to the WeightedSigSet verifier).
Epoch() uint64
// WeightedConfig returns the QuorumVerifierConfig the WeightedSigSet
// evidence mode verifies under (allowed schemes, FIPS context, and the
// MinThreshold floor). The floor MUST be the chain BFT quorum.
WeightedConfig() QuorumVerifierConfig
// WeightedEnvelope returns the round-digest posture axes (profile, hash
// suite, schemes, proof backend/format/verifier, network) the inner
// WeightedQuorumCert verifies under. The position fields are filled from the
// inner cert by QuorumMessageForCert, so a stale position here is harmless;
// the posture axes pin the security model into the inner cert's message.
WeightedEnvelope() QuorumMessageEnvelope
// ThresholdGroupKey returns the threshold-signature group public key for a
// leg kind (Pulsar group key bytes / Corona group key), or (nil, false) if
// the set has no group key for that kind. Used by the threshold-sig leg.
ThresholdGroupKey(kind LegKind) (ThresholdGroupKey, bool)
// ClassicalAggregateKey returns the classical aggregate verification key for
// a scheme (e.g. the BLS aggregate public key), or (nil, false). Used by the
// classical leg.
ClassicalAggregateKey(scheme ClassicalScheme) ([]byte, bool)
}
// ThresholdGroupKey is the trusted group public key the threshold-sig leg
// verifies against. It carries the leg kind it belongs to and the raw key
// material the per-kind verifier (Corona group key / Pulsar group-key bytes)
// consumes.
type ThresholdGroupKey struct {
// Kind is the leg kind this group key verifies. A key whose Kind does not
// match the evidence's leg kind is a hard reject (defence-in-depth against a
// cross-kind key swap).
Kind LegKind
// CoronaGroupKey is the decoded Corona (Ring-LWE) threshold group key, set
// iff Kind == LegCoronaLattice.
CoronaGroupKey *coronaThreshold.GroupKey
// PulsarGroupKey is the Pulsar (Module-LWE) threshold group public-key
// bytes, set iff Kind == LegPulsarMLDSA.
PulsarGroupKey []byte
}
// ----------------------------------------------------------------------------
// Per-mode payloads.
// ----------------------------------------------------------------------------
// ThresholdSigPayload is the EvidenceThresholdSig payload: one O(1) aggregate
// threshold signature PLUS the signer accountability that a bare threshold
// signature lacks. Both are mandatory — a threshold sig with no accountability
// is rejected (I8).
//
// SOUNDNESS of the accountability binding: the envelope's domain message (I4)
// already binds signer_root. The threshold signers signed over THAT message, so
// a verifying threshold signature attests the quorum committed to this
// signer_root. Accountability.SignerRoot is the cert's echo of that value; the
// clause SignerRoot == msg.SignerRoot ties the cert's claim to what was signed,
// and AggregateWeight >= ThresholdWeight() is the weight floor the threshold sig
// alone cannot express.
type ThresholdSigPayload struct {
// Signature is the O(1) aggregate threshold signature bytes (Corona
// CORS-framed signature / Pulsar PULS-framed signature) over the message.
Signature []byte
// Accountability binds the signer set + weight + session to the leg. MUST be
// present and well-formed; absence is ErrThresholdSigWithoutAccountability.
Accountability *ThresholdAccountability
}
// ThresholdAccountability is the signer-set / weight / session binding a
// threshold-signature leg MUST carry so the cert is attributable. Without it a
// threshold sig proves "some quorum signed" but binds nothing about WHO or HOW
// MUCH weight — unacceptable for an accountable finality cert.
type ThresholdAccountability struct {
// SignerRoot MUST equal the envelope's SignerRoot (which is bound into the
// signed message). A mismatch is ErrSignerRootMismatch.
SignerRoot [32]byte
// AggregateWeight MUST be >= policy.ThresholdWeight(). Below floor is
// ErrInsufficientWeight.
AggregateWeight uint64
}
// StarkCompressedPayload is the EvidenceStarkCompressedSigSet payload. It is a
// succinct proof of the SAME weighted-quorum public statement
// VerifyWeightedSigSet checks (I9). The PublicInputs field pins, in the clear,
// the exact statement the circuit proves so the predicate identity is
// inspectable and test-pinnable; the Proof is the (audit-gated) succinct bytes.
type StarkCompressedPayload struct {
// PublicInputs is the circuit's public statement — byte-identical to the
// WeightedSigSet predicate's bound inputs (validator_set_root ‖
// quorum_threshold ‖ aggregate_weight ‖ signer_commitment ‖ message). The
// circuit proves VerifyWeightedSigSet(...) == nil over EXACTLY these inputs.
PublicInputs []byte
// Proof is the succinct STARK proof bytes. Verified only once the Keccak-AIR
// backend is audit-gated; until then the mode fails closed.
Proof []byte
}
// ClassicalAggregatePayload is the EvidenceClassicalAggregate payload: a
// classical aggregate signature under a named scheme. Satisfies ONLY a
// LegClassical requirement (I10).
type ClassicalAggregatePayload struct {
// Scheme names the classical aggregate scheme (gated by
// policy.AllowsClassicalScheme).
Scheme ClassicalScheme
// Payload is the classical aggregate signature bytes over the message.
Payload []byte
}
// ----------------------------------------------------------------------------
// Typed envelope errors. Each maps 1:1 to one invariant clause so a caller /
// test can name the exact failure. Every one is a CLEAN rejection — never a
// panic, never unbounded work.
// ----------------------------------------------------------------------------
var (
ErrConsensusCertNil = errors.New("quasar: nil consensus cert")
ErrConsensusCertVersion = errors.New("quasar: consensus cert version mismatch")
ErrConsensusCertPolicyLoad = errors.New("quasar: policy store could not resolve policy")
ErrConsensusCertNoPolicyLegs = errors.New("quasar: policy declares no required legs")
// I1/I2 — required legs are policy-derived; the cert's claimed root must match.
ErrRequiredLegsRootMismatch = errors.New("quasar: cert RequiredLegsRoot != hash of policy-derived required legs (required legs are policy-derived, never cert-derived)")
// I3 — validator set is verifier-pinned.
ErrValidatorSetRootMismatch = errors.New("quasar: cert ValidatorSetRoot != committed validator set root")
// I5/I12 — every required leg must have evidence, exactly once.
ErrMissingRequiredLeg = errors.New("quasar: a policy-required leg has no evidence in the cert")
ErrDuplicateLegKind = errors.New("quasar: duplicate evidence for the same leg kind")
// I6 — (kind, mode, param-set) must be policy-permitted.
ErrDisallowedEvidenceMode = errors.New("quasar: policy does not allow this (leg, evidence-mode, param-set) triple")
ErrUnknownEvidenceMode = errors.New("quasar: unknown evidence mode")
// I7 — SLH-DSA never threshold-signed.
ErrSLHDSAThresholdSigForbidden = errors.New("quasar: SLH-DSA (Magnetar) may not be proven by threshold-sig evidence — no aggregatable threshold structure exists; production-forbidden")
// I8 — threshold sig accountability.
ErrThresholdSigWithoutAccountability = errors.New("quasar: threshold-sig leg carries no signer accountability — a threshold sig that verifies but binds no signer set/weight is rejected")
ErrSignerRootMismatch = errors.New("quasar: threshold-sig accountability SignerRoot != cert SignerRoot")
ErrInsufficientWeight = errors.New("quasar: threshold-sig accountability AggregateWeight is below the policy threshold weight")
ErrThresholdGroupKeyMissing = errors.New("quasar: no threshold group key for this leg kind")
ErrThresholdSigInvalid = errors.New("quasar: threshold aggregate signature failed verification")
ErrThresholdGroupKeyKindMismatch = errors.New("quasar: threshold group key kind does not match the leg kind")
// I9 — STARK proves the SAME predicate; audit-gated until Keccak-AIR lands.
ErrStarkBackendNotAuditGated = errors.New("quasar: stark-compressed-sig-set backend is not audit-gated (Keccak-AIR unbuilt) — fail closed, never silently accept")
ErrStarkPublicInputsMismatch = errors.New("quasar: stark-compressed public inputs do not equal the WeightedSigSet predicate statement")
// I10/I11 — classical leg.
ErrClassicalCannotSatisfyPQLeg = errors.New("quasar: classical-aggregate evidence cannot satisfy a post-quantum leg requirement")
ErrClassicalOnlyForbidden = errors.New("quasar: classical evidence present but policy requires a PQ leg that no PQ evidence satisfies (classical-only is forbidden under a PQ policy)")
ErrClassicalAggregateDisallowed = errors.New("quasar: classical aggregate scheme is not permitted by policy")
ErrMissingRequiredPQLeg = errors.New("quasar: a policy-required PQ leg is unsatisfied")
ErrClassicalAggregateInvalid = errors.New("quasar: classical aggregate signature failed verification")
// I13 — malformed evidence bytes.
ErrEvidenceWireCorrupt = errors.New("quasar: consensus cert evidence payload is malformed")
)
// ----------------------------------------------------------------------------
// HashRequiredLegs — the policy-derived required-leg commitment (I1/I2).
// ----------------------------------------------------------------------------
// HashRequiredLegs computes the 32-byte commitment over a required-leg set.
// Canonical: legs are bound in their given order (RequiredLegs() returns a
// deterministic order), each as (kind, param_set) so a relabel of either is
// caught. This is the value VerifyConsensusCert recomputes from policy and
// checks against the cert's RequiredLegsRoot — the cert never supplies it.
func HashRequiredLegs(legs []LegSpec) [32]byte {
parts := make([][]byte, 0, 1+2*len(legs))
parts = append(parts, []byte(requiredLegsProtocolTag))
var n [4]byte
binary.BigEndian.PutUint32(n[:], uint32(len(legs)))
parts = append(parts, append([]byte(nil), n[:]...))
for _, l := range legs {
parts = append(parts, []byte{byte(l.Kind), l.ParamSetID})
}
out := tupleHash256RoundDigest(parts, 32, requiredLegsCustomization)
var h [32]byte
copy(h[:], out)
return h
}
// ----------------------------------------------------------------------------
// consensusCertMessage — the FULL-tuple domain message (I4).
// ----------------------------------------------------------------------------
// consensusCertMessage builds the domain-separated message the evidence legs
// anchor to. It binds the FULL consensus tuple — NEVER the block hash alone:
//
// domain_tag ‖ version ‖ cert_profile ‖ chain_id ‖ epoch ‖ height ‖ round ‖
// block_hash ‖ validator_set_root ‖ policy_id ‖ required_legs_root ‖ signer_root
//
// under a quorum-cert-distinct customization tag. A cert whose signers signed a
// different tuple fails every leg's signature check; a cert that lies about any
// tuple field fails because the rebuilt message no longer matches.
func consensusCertMessage(cert *ConsensusCert, requiredLegsRoot [32]byte) []byte {
var u16 [2]byte
var u32 [4]byte
var u64 [8]byte
binary.BigEndian.PutUint16(u16[:], cert.Version)
verBytes := append([]byte(nil), u16[:]...)
binary.BigEndian.PutUint32(u32[:], cert.ChainID)
chainBytes := append([]byte(nil), u32[:]...)
binary.BigEndian.PutUint64(u64[:], cert.Epoch)
epochBytes := append([]byte(nil), u64[:]...)
binary.BigEndian.PutUint64(u64[:], cert.Height)
heightBytes := append([]byte(nil), u64[:]...)
binary.BigEndian.PutUint32(u32[:], cert.Round)
roundBytes := append([]byte(nil), u32[:]...)
binary.BigEndian.PutUint32(u32[:], cert.PolicyID)
policyBytes := append([]byte(nil), u32[:]...)
parts := [][]byte{
[]byte(consensusCertDomainTag),
verBytes,
{cert.Profile},
chainBytes,
epochBytes,
heightBytes,
roundBytes,
cert.BlockHash[:],
cert.ValidatorSetRoot[:],
policyBytes,
requiredLegsRoot[:],
cert.SignerRoot[:],
}
return tupleHash256RoundDigest(parts, 32, consensusCertMessageCustomization)
}
// ----------------------------------------------------------------------------
// VerifyConsensusCert — the envelope verifier.
// ----------------------------------------------------------------------------
// VerifyConsensusCert verifies a ConsensusCert against the chain policy and the
// committed validator set. Returns nil iff every invariant holds; otherwise a
// typed error naming the first failure. NEVER panics, NEVER does unbounded work.
//
// The 6-step contract (each enforcing the invariants above):
//
// 1. Load the policy from the store (NEVER from the cert).
// 2. required := policy.RequiredLegs(); reject if cert.RequiredLegsRoot !=
// HashRequiredLegs(required) (I1/I2). Required legs are POLICY-derived.
// 3. Reject if cert.ValidatorSetRoot != validators.Root() (I3).
// 4. Build the domain message binding the FULL tuple (I4).
// 5. For each required leg: locate its evidence (I5), gate the (kind, mode,
// param-set) triple (I6), and dispatch to the matching evidence verifier.
// 6. Reject unless every required leg was satisfied exactly once (I12), and —
// the envelope, not any helper — reject a classical-only cert under a PQ
// policy (I11).
func VerifyConsensusCert(store ConsensusCertPolicyStore, validators ConsensusValidatorSet, cert *ConsensusCert) error {
if cert == nil {
return ErrConsensusCertNil
}
if validators == nil || store == nil {
return ErrConsensusCertNil
}
if cert.Version != consensusCertVersion {
return fmt.Errorf("%w: got %d want %d", ErrConsensusCertVersion, cert.Version, consensusCertVersion)
}
// Step 1 — policy from the store. NEVER from the cert.
policy, err := store.Policy(cert.ChainID, cert.Epoch, cert.PolicyID)
if err != nil {
return fmt.Errorf("%w: %v", ErrConsensusCertPolicyLoad, err)
}
if policy == nil {
return ErrConsensusCertPolicyLoad
}
// Step 2 — required legs are POLICY-derived; the cert's claimed root is
// CHECKED, never trusted as the source (I1/I2).
required := policy.RequiredLegs()
if len(required) == 0 {
return ErrConsensusCertNoPolicyLegs
}
wantLegsRoot := HashRequiredLegs(required)
if cert.RequiredLegsRoot != wantLegsRoot {
return ErrRequiredLegsRootMismatch
}
// Step 3 — validator set is verifier-pinned (I3).
if cert.ValidatorSetRoot != validators.Root() {
return ErrValidatorSetRootMismatch
}
// Step 4 — domain message binding the FULL tuple (I4). Built from the
// policy-derived required-legs root, NOT the cert's claimed root (they are
// already proven equal in step 2, but binding the policy-derived value makes
// the message construction independent of cert bytes).
msg := consensusCertMessage(cert, wantLegsRoot)
// Evidence is one-to-one with leg kind: reject duplicate evidence entries for
// the same kind up front so a second (e.g. forged) entry can never shadow or
// be silently ignored behind the first. This makes evidenceFor's "first
// match" unambiguous.
evKinds := make(map[LegKind]bool, len(cert.Evidence))
for i := range cert.Evidence {
k := cert.Evidence[i].Leg.Kind
if evKinds[k] {
return fmt.Errorf("%w: kind %s", ErrDuplicateLegKind, k)
}
evKinds[k] = true
}
// Step 5 — per required leg: evidence present, triple allowed, dispatch.
// Track which leg kinds were satisfied (for the cardinality + classical-only
// checks). A required-leg set is small (<= 4), so the linear scan is O(1).
seen := make(map[LegKind]bool, len(required))
satisfiedPQ := false
for _, leg := range required {
ev, ok := cert.evidenceFor(leg.Kind)
if !ok {
return fmt.Errorf("%w: kind %s", ErrMissingRequiredLeg, leg.Kind)
}
if seen[leg.Kind] {
return fmt.Errorf("%w: kind %s", ErrDuplicateLegKind, leg.Kind)
}
// The evidence's own leg spec must name the SAME requirement the policy
// requires (kind), and the policy must permit the (kind, mode, param-set)
// triple BEFORE any signature math (I6).
if ev.Leg.Kind != leg.Kind {
return fmt.Errorf("%w: evidence kind %s != required kind %s",
ErrDisallowedEvidenceMode, ev.Leg.Kind, leg.Kind)
}
if !policy.Allows(ev.Leg, ev.Mode, ev.ParamSet()) {
return fmt.Errorf("%w: kind %s mode %s param 0x%02x",
ErrDisallowedEvidenceMode, ev.Leg.Kind, ev.Mode, ev.ParamSet())
}
// Dispatch to the matching evidence verifier.
if err := dispatchEvidence(policy, validators, cert, msg, ev); err != nil {
return err
}
seen[leg.Kind] = true
if leg.Kind.IsPostQuantum() {
satisfiedPQ = true
}
}
// Step 6 — cardinality (I12): every required leg seen exactly once. The
// duplicate guard above plus this count makes "seen == required" total.
if len(seen) != len(required) {
return fmt.Errorf("%w: satisfied %d of %d required", ErrMissingRequiredLeg, len(seen), len(required))
}
// Step 6 (I11) — classical-only is forbidden under a PQ policy. The
// ENVELOPE enforces this, NOT the classical helper: by here every required
// leg (PQ included) has been satisfied by its OWN evidence, so if the policy
// required any PQ leg, that PQ leg's PQ evidence verified. A policy that
// requires a PQ leg therefore cannot be satisfied by classical evidence
// alone. We assert it explicitly as defence-in-depth: if any required leg is
// PQ, at least one PQ leg must have been satisfied.
if requiresPQ(required) && !satisfiedPQ {
return ErrMissingRequiredPQLeg
}
return nil
}
// requiresPQ reports whether the required-leg set contains any PQ leg.
func requiresPQ(required []LegSpec) bool {
for _, l := range required {
if l.Kind.IsPostQuantum() {
return true
}
}
return false
}
// evidenceFor returns the cert's evidence for a leg kind, or (zero, false).
// EvidenceFor is the exported spelling the directive names; evidenceFor is the
// internal one used on the hot path.
func (c *ConsensusCert) evidenceFor(kind LegKind) (LegEvidence, bool) {
for i := range c.Evidence {
if c.Evidence[i].Leg.Kind == kind {
return c.Evidence[i], true
}
}
return LegEvidence{}, false
}
// EvidenceFor returns the cert's evidence for a leg kind, or (zero, false). The
// directive names this accessor in the per-leg loop.
func (c *ConsensusCert) EvidenceFor(kind LegKind) (LegEvidence, bool) {
return c.evidenceFor(kind)
}
// dispatchEvidence routes one leg's evidence to its mode verifier (I6 dispatch).
func dispatchEvidence(policy ConsensusCertPolicy, validators ConsensusValidatorSet, cert *ConsensusCert, msg []byte, ev LegEvidence) error {
switch ev.Mode {
case EvidenceThresholdSig:
return VerifyThresholdSigLeg(policy, validators, cert, msg, ev)
case EvidenceWeightedSigSet:
return VerifyWeightedSigSet(policy, validators, cert, msg, ev)
case EvidenceStarkCompressedSigSet:
return VerifyStarkCompressedSigSet(policy, validators, cert, msg, ev)
case EvidenceClassicalAggregate:
return VerifyClassicalAggregateLeg(policy, validators, cert, msg, ev)
default:
return fmt.Errorf("%w: 0x%02x", ErrUnknownEvidenceMode, uint8(ev.Mode))
}
}
+149
View File
@@ -0,0 +1,149 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// consensus_cert_codec.go — deterministic, fail-closed wire codecs for the
// ConsensusCert envelope and its per-leg evidence payloads.
//
// Every decoder is bounds-checked and rejects trailing bytes (strict canonical
// language): a malformed payload yields a typed error, NEVER a panic and NEVER
// unbounded work (I13). The encoders are deterministic — equal values encode to
// equal bytes — so a cert has exactly one canonical encoding.
//
// The fixed-frame readers reuse qcReader (quorum_cert.go): one bounds-checked
// sequential reader in the package, never two.
package quasar
import (
"github.com/luxfi/crypto/bls"
)
// ----------------------------------------------------------------------------
// Per-leg evidence payload codecs.
// ----------------------------------------------------------------------------
// encodeThresholdSigPayload encodes a ThresholdSigPayload deterministically:
//
// flags:1 bit0 = accountability present
// sig_len:4 sig:N
// [ if accountability present ]
// signer_root:32
// aggregate_weight:8
func encodeThresholdSigPayload(p *ThresholdSigPayload) []byte {
buf := make([]byte, 0, 1+4+len(p.Signature)+32+8)
var flags byte
if p.Accountability != nil {
flags |= 0x01
}
buf = append(buf, flags)
buf = appendU32(buf, uint32(len(p.Signature)))
buf = append(buf, p.Signature...)
if p.Accountability != nil {
buf = append(buf, p.Accountability.SignerRoot[:]...)
buf = appendU64(buf, p.Accountability.AggregateWeight)
}
return buf
}
// decodeThresholdSigPayload is the strict inverse. A truncated frame, a
// length-prefix overrun, or trailing bytes is ErrEvidenceWireCorrupt.
func decodeThresholdSigPayload(data []byte) (*ThresholdSigPayload, error) {
r := &qcReader{buf: data}
flags, err := r.u8()
if err != nil {
return nil, ErrEvidenceWireCorrupt
}
// Strict canonical: only bit0 (accountability-present) is defined. Any other
// bit set is a non-canonical frame (it would re-encode to a different flags
// byte) — reject so the encoding is one-to-one (no malleability).
if flags&^byte(0x01) != 0 {
return nil, ErrEvidenceWireCorrupt
}
sig, err := r.lenPrefixed()
if err != nil {
return nil, ErrEvidenceWireCorrupt
}
p := &ThresholdSigPayload{Signature: sig}
if flags&0x01 != 0 {
var acct ThresholdAccountability
if err := r.read32(&acct.SignerRoot); err != nil {
return nil, ErrEvidenceWireCorrupt
}
if acct.AggregateWeight, err = r.u64(); err != nil {
return nil, ErrEvidenceWireCorrupt
}
p.Accountability = &acct
}
if len(r.buf) != 0 {
return nil, ErrEvidenceWireCorrupt
}
return p, nil
}
// encodeStarkCompressedPayload encodes a StarkCompressedPayload deterministically:
//
// public_inputs_len:4 public_inputs:N
// proof_len:4 proof:M
func encodeStarkCompressedPayload(p *StarkCompressedPayload) []byte {
buf := make([]byte, 0, 4+len(p.PublicInputs)+4+len(p.Proof))
buf = appendU32(buf, uint32(len(p.PublicInputs)))
buf = append(buf, p.PublicInputs...)
buf = appendU32(buf, uint32(len(p.Proof)))
buf = append(buf, p.Proof...)
return buf
}
// decodeStarkCompressedPayload is the strict inverse.
func decodeStarkCompressedPayload(data []byte) (*StarkCompressedPayload, error) {
r := &qcReader{buf: data}
pub, err := r.lenPrefixed()
if err != nil {
return nil, ErrEvidenceWireCorrupt
}
proof, err := r.lenPrefixed()
if err != nil {
return nil, ErrEvidenceWireCorrupt
}
if len(r.buf) != 0 {
return nil, ErrEvidenceWireCorrupt
}
return &StarkCompressedPayload{PublicInputs: pub, Proof: proof}, nil
}
// encodeClassicalAggregatePayload encodes a ClassicalAggregatePayload:
//
// scheme:1
// payload_len:4 payload:N
func encodeClassicalAggregatePayload(p *ClassicalAggregatePayload) []byte {
buf := make([]byte, 0, 1+4+len(p.Payload))
buf = append(buf, byte(p.Scheme))
buf = appendU32(buf, uint32(len(p.Payload)))
buf = append(buf, p.Payload...)
return buf
}
// decodeClassicalAggregatePayload is the strict inverse.
func decodeClassicalAggregatePayload(data []byte) (*ClassicalAggregatePayload, error) {
r := &qcReader{buf: data}
sch, err := r.u8()
if err != nil {
return nil, ErrEvidenceWireCorrupt
}
pl, err := r.lenPrefixed()
if err != nil {
return nil, ErrEvidenceWireCorrupt
}
if len(r.buf) != 0 {
return nil, ErrEvidenceWireCorrupt
}
return &ClassicalAggregatePayload{Scheme: ClassicalScheme(sch), Payload: pl}, nil
}
// ----------------------------------------------------------------------------
// bls helper — thin wrapper so the classical leg does not import bls directly
// (keeps the leg file's imports minimal; one constructor in one place).
// ----------------------------------------------------------------------------
// blsPublicKeyFromBytes parses a compressed BLS-12-381 aggregate public key.
func blsPublicKeyFromBytes(keyBytes []byte) (*bls.PublicKey, error) {
return bls.PublicKeyFromCompressedBytes(keyBytes)
}
+283
View File
@@ -0,0 +1,283 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// consensus_cert_legs.go — the four evidence-mode verifiers the ConsensusCert
// envelope dispatches to. Each verifies ONE evidence mode and stays in its
// lane: it never decides whether its leg is required or allowed (the envelope
// does that), only whether the evidence it was handed proves the weighted-
// quorum predicate over the domain-separated message.
//
// Decomplected from the envelope (consensus_cert.go):
//
// VerifyThresholdSigLeg — O(1) aggregate threshold sig + accountability
// VerifyWeightedSigSet — the already-green WeightedQuorumCert
// VerifyStarkCompressedSigSet — audit-gated succinct proof of the SAME predicate
// VerifyClassicalAggregateLeg — classical aggregate, LegClassical only
package quasar
import (
"bytes"
"encoding/binary"
"fmt"
)
// ----------------------------------------------------------------------------
// VerifyThresholdSigLeg — EvidenceThresholdSig.
// ----------------------------------------------------------------------------
// VerifyThresholdSigLeg verifies a threshold-signature leg: an O(1) aggregate
// threshold signature (Pulsar / Corona) over the message, PLUS the signer
// accountability a bare threshold signature lacks.
//
// I7 — a Magnetar (SLH-DSA) leg in threshold-sig mode is a hard reject:
// SLH-DSA has no aggregatable threshold structure (THBS-SE reconstructs
// the FIPS-205 seed — research-only, production-forbidden).
// I8 — the leg MUST bind signer root / aggregate weight / policy / session.
// A threshold sig that verifies but lacks accountability is rejected;
// Accountability.SignerRoot must equal cert.SignerRoot (which is bound
// into the signed message), and AggregateWeight must meet the policy
// threshold weight.
//
// SOUNDNESS: the envelope's domain message binds signer_root (I4). The threshold
// signers signed over THAT message, so a verifying signature attests the quorum
// committed to this signer_root. The accountability echo + the weight floor are
// the envelope's cross-checks that the cert's CLAIMED signer set and weight
// match what was signed — a bare threshold sig expresses neither.
func VerifyThresholdSigLeg(policy ConsensusCertPolicy, validators ConsensusValidatorSet, cert *ConsensusCert, msg []byte, ev LegEvidence) error {
// I7 — SLH-DSA is never threshold-signed in production.
if ev.Leg.Kind == LegMagnetarSLHDSA {
return ErrSLHDSAThresholdSigForbidden
}
payload, err := decodeThresholdSigPayload(ev.Payload)
if err != nil {
return err
}
// I8 (presence) — accountability is mandatory. A threshold sig with no
// signer-set/weight binding is unacceptable for an accountable cert.
if payload.Accountability == nil {
return ErrThresholdSigWithoutAccountability
}
// I8 (signer root) — the accountability's signer root MUST equal the cert's
// signer root, which is bound into the signed message. A mismatch means the
// cert is claiming a different signer set than the one whose commitment the
// quorum signed.
if payload.Accountability.SignerRoot != cert.SignerRoot {
return ErrSignerRootMismatch
}
// I8 (weight floor) — the accountability's aggregate weight MUST meet the
// policy threshold weight. A threshold signature alone cannot express the
// weight that signed; the accountability asserts it and the envelope pins
// the floor.
if payload.Accountability.AggregateWeight < policy.ThresholdWeight() {
return fmt.Errorf("%w: have %d need %d",
ErrInsufficientWeight, payload.Accountability.AggregateWeight, policy.ThresholdWeight())
}
// Fetch the trusted group key for this leg kind (verifier-supplied, never
// cert bytes).
gk, ok := validators.ThresholdGroupKey(ev.Leg.Kind)
if !ok {
return fmt.Errorf("%w: kind %s", ErrThresholdGroupKeyMissing, ev.Leg.Kind)
}
// Defence-in-depth: the group key must belong to the same kind as the leg
// (no cross-kind key swap).
if gk.Kind != ev.Leg.Kind {
return fmt.Errorf("%w: key kind %s leg kind %s",
ErrThresholdGroupKeyKindMismatch, gk.Kind, ev.Leg.Kind)
}
// Verify the O(1) aggregate threshold signature against the group key over
// the recomputed message, routing through origin's per-leg verifiers
// (cert_policy_verify.go / polaris.go) — the SAME verifiers QuasarCert uses.
var verified bool
switch ev.Leg.Kind {
case LegCoronaLattice:
if gk.CoronaGroupKey == nil {
return fmt.Errorf("%w: corona key nil", ErrThresholdGroupKeyMissing)
}
verified = verifyCoronaLeg(msg, gk.CoronaGroupKey, payload.Signature)
case LegPulsarMLDSA:
if len(gk.PulsarGroupKey) == 0 {
return fmt.Errorf("%w: pulsar key empty", ErrThresholdGroupKeyMissing)
}
verified = verifyPulsarLeg(msg, gk.PulsarGroupKey, payload.Signature)
default:
// LegClassical / unknown can never be a threshold-sig leg.
return fmt.Errorf("%w: kind %s cannot be a threshold-sig leg", ErrDisallowedEvidenceMode, ev.Leg.Kind)
}
if !verified {
return fmt.Errorf("%w: kind %s", ErrThresholdSigInvalid, ev.Leg.Kind)
}
return nil
}
// ----------------------------------------------------------------------------
// VerifyWeightedSigSet — EvidenceWeightedSigSet (the already-green foundation).
// ----------------------------------------------------------------------------
// VerifyWeightedSigSet verifies a weighted-sig-set leg: the evidence payload is
// a marshalled WeightedQuorumCert (quorum_cert.go), the already-green
// foundation. The envelope's job here is to BIND the inner cert to the same
// consensus position the envelope claims and to pin the threshold floor — the
// inner cert then proves the full weighted-quorum predicate itself (N
// independent FIPS verifies + weighted-Merkle quorum + threshold floor).
//
// The inner WeightedQuorumCert recomputes ITS OWN domain-separated FIPS message
// from a round-digest envelope; that is correct — a weighted-sig-set cert is a
// complete, self-binding quorum cert. The envelope cross-checks the inner cert's
// position (chain/epoch/height/round/value/validator-set-root) against its own
// header so the inner cert cannot certify a different block or set than the
// envelope claims, and pins MinThreshold == policy.ThresholdWeight().
func VerifyWeightedSigSet(policy ConsensusCertPolicy, validators ConsensusValidatorSet, cert *ConsensusCert, msg []byte, ev LegEvidence) error {
inner, err := UnmarshalWeightedQuorumCert(ev.Payload)
if err != nil {
// A malformed inner cert is a clean evidence-corrupt rejection (I13).
return fmt.Errorf("%w: weighted-sig-set inner cert: %v", ErrEvidenceWireCorrupt, err)
}
// Bind the inner cert to the envelope's consensus position. The inner cert
// MUST certify the SAME block + position + validator set the envelope claims;
// otherwise a valid quorum cert for a DIFFERENT block could be smuggled in as
// evidence for this one.
if inner.ChainID != cert.ChainID ||
inner.Epoch != cert.Epoch ||
inner.Height != cert.Height ||
inner.Round != cert.Round ||
inner.ValueHash != cert.BlockHash ||
inner.ValidatorSetRoot != cert.ValidatorSetRoot {
return fmt.Errorf("%w: inner weighted-sig-set cert position does not match the envelope", ErrEvidenceWireCorrupt)
}
// Pin the threshold floor to the policy's quorum weight (fail-closed; the
// inner Verify rejects a zero floor). The allowed-scheme / context axes come
// from the validator set's weighted config.
cfg := validators.WeightedConfig()
cfg.MinThreshold = policy.ThresholdWeight()
// Build the inner cert's round-digest envelope from the validator set's
// posture axes; the position fields are filled from the inner cert by
// QuorumMessageForCert inside Verify.
innerEnv := validators.WeightedEnvelope()
if err := inner.Verify(innerEnv, cfg); err != nil {
// Surface the inner predicate's typed error verbatim — it already names
// the exact clause (Merkle / FIPS / threshold / commitment).
return err
}
return nil
}
// ----------------------------------------------------------------------------
// VerifyStarkCompressedSigSet — EvidenceStarkCompressedSigSet (audit-gated).
// ----------------------------------------------------------------------------
// VerifyStarkCompressedSigSet verifies a stark-compressed-sig-set leg. By
// CONSTRUCTION this mode proves the SAME public statement VerifyWeightedSigSet
// checks: the circuit's public inputs are byte-identical to the WeightedSigSet
// predicate's bound inputs, and the circuit proves VerifyWeightedSigSet(...) ==
// nil over EXACTLY those inputs. It is NOT a different / "close-enough"
// verifier (I9).
//
// Until the Keccak-AIR backend is audit-gated, this returns a clear error and
// NEVER silently accepts — a succinct backend that has not been reviewed must
// fail closed, exactly like an unregistered verifier.
//
// The predicate-identity check below is the part that IS live today: it pins
// that the public inputs the proof commits to equal the WeightedSigSet
// statement for this cert (so when the backend lands, it can only ever attest
// the same relation). The proof BYTES are not yet checked — the function fails
// closed on the gate.
func VerifyStarkCompressedSigSet(policy ConsensusCertPolicy, validators ConsensusValidatorSet, cert *ConsensusCert, msg []byte, ev LegEvidence) error {
payload, err := decodeStarkCompressedPayload(ev.Payload)
if err != nil {
return err
}
// Predicate identity (I9): the proof's public inputs MUST equal the
// WeightedSigSet statement for this cert. This is what makes the compressed
// mode prove the SAME predicate rather than a different one — the public
// statement is pinned and inspectable. Computed independently of the payload.
want := starkPublicStatement(cert, msg)
if !bytes.Equal(payload.PublicInputs, want) {
return ErrStarkPublicInputsMismatch
}
// Audit gate (I9): the succinct backend (Keccak-AIR) is not yet audit-gated.
// Fail CLOSED — never silently accept an unreviewed succinct proof.
return ErrStarkBackendNotAuditGated
}
// starkPublicStatement returns the canonical public statement a
// stark-compressed-sig-set proof MUST commit to: it is, by construction, the
// WeightedSigSet predicate's bound inputs for this cert. The succinct circuit's
// public statement equals this; the WeightedSigSet verifier checks the same
// relation directly. This is the single source of truth both
// VerifyStarkCompressedSigSet (input check) and the equivalence test pin to.
//
// Layout (the inputs VerifyWeightedSigSet binds): the envelope domain message
// (which itself binds validator_set_root, signer_root, threshold via the full
// tuple), the cert's validator_set_root, signer_root, and aggregate_weight.
func starkPublicStatement(cert *ConsensusCert, msg []byte) []byte {
var u64 [8]byte
binary.BigEndian.PutUint64(u64[:], cert.AggregateWeight)
parts := [][]byte{
[]byte("Lux/ConsensusCert/StarkPublicStatement/v1"),
msg,
cert.ValidatorSetRoot[:],
cert.SignerRoot[:],
append([]byte(nil), u64[:]...),
}
return tupleHash256RoundDigest(parts, 32, "LUX-CONSENSUSCERT-STARK-STATEMENT-V1")
}
// ----------------------------------------------------------------------------
// VerifyClassicalAggregateLeg — EvidenceClassicalAggregate (LegClassical only).
// ----------------------------------------------------------------------------
// VerifyClassicalAggregateLeg verifies a classical-aggregate leg. It is KEPT
// DUMB on purpose: it checks ONLY that the leg kind is LegClassical (a classical
// aggregate can never satisfy a PQ leg — I10), then verifies the classical
// aggregate under the policy-permitted scheme. Whether classical is allowed or
// SUFFICIENT for the chain is the ENVELOPE's call, not this helper's: by the
// time the envelope dispatches here it has already verified every required PQ
// leg with PQ evidence (I11).
func VerifyClassicalAggregateLeg(policy ConsensusCertPolicy, validators ConsensusValidatorSet, cert *ConsensusCert, msg []byte, ev LegEvidence) error {
// I10 — classical evidence can ONLY satisfy a LegClassical requirement.
if ev.Leg.Kind != LegClassical {
return fmt.Errorf("%w: leg kind %s", ErrClassicalCannotSatisfyPQLeg, ev.Leg.Kind)
}
payload, err := decodeClassicalAggregatePayload(ev.Payload)
if err != nil {
return err
}
// Scheme gate — the classical scheme must be policy-permitted.
if !policy.AllowsClassicalScheme(payload.Scheme) {
return fmt.Errorf("%w: scheme %s", ErrClassicalAggregateDisallowed, payload.Scheme)
}
// Fetch the trusted classical aggregate key (verifier-supplied).
keyBytes, ok := validators.ClassicalAggregateKey(payload.Scheme)
if !ok || len(keyBytes) == 0 {
return fmt.Errorf("%w: no key for scheme %s", ErrClassicalAggregateDisallowed, payload.Scheme)
}
// Verify the classical aggregate over the recomputed message.
switch payload.Scheme {
case ClassicalSchemeBLS12381:
pub, perr := blsPublicKeyFromBytes(keyBytes)
if perr != nil {
return fmt.Errorf("%w: bls key parse: %v", ErrClassicalAggregateDisallowed, perr)
}
if !verifyBLSLeg(msg, pub, payload.Payload) {
return ErrClassicalAggregateInvalid
}
return nil
default:
return fmt.Errorf("%w: unhandled scheme %s", ErrClassicalAggregateDisallowed, payload.Scheme)
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+625
View File
@@ -0,0 +1,625 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package quasar
import (
"bytes"
"crypto/rand"
"errors"
"testing"
"github.com/luxfi/consensus/config"
"github.com/luxfi/crypto/mldsa"
magnetar "github.com/luxfi/magnetar/ref/go/pkg/magnetar"
)
// ----------------------------------------------------------------------------
// Test scaffold: a real validator set with real ML-DSA / SLH-DSA keys,
// real independent signatures over a real domain-separated message, and a
// real assembled cert. Every cryptographic operation is the production path
// — no mocks, no stubs.
// ----------------------------------------------------------------------------
// testSigner is one validator's full key material + accountability fields.
type testSigner struct {
id [32]byte
scheme QuorumSchemeID
weight uint64
keyVer uint32
pubBytes []byte
// exactly one of these is set per scheme
mldsaPriv *mldsa.PrivateKey
slhPriv *magnetar.PrivateKey
}
// sign produces this signer's independent signature over message under ctx.
func (s *testSigner) sign(t *testing.T, message, ctx []byte) []byte {
t.Helper()
switch {
case s.mldsaPriv != nil:
sig, err := s.mldsaPriv.SignCtx(rand.Reader, message, ctx)
if err != nil {
t.Fatalf("ml-dsa sign: %v", err)
}
return sig
case s.slhPriv != nil:
// magnetar.ValidatorSign binds the empty FIPS context; tests that
// use SLH-DSA records therefore pass ctx=nil at verify time.
sig, err := magnetar.ValidatorSign(s.slhPriv, rand.Reader, message)
if err != nil {
t.Fatalf("slh-dsa sign: %v", err)
}
return sig
default:
t.Fatal("testSigner has no key")
return nil
}
}
// newMLDSASigner builds a real ML-DSA-65 signer at the given id/weight.
func newMLDSASigner(t *testing.T, idByte byte, weight uint64) *testSigner {
t.Helper()
priv, err := mldsa.GenerateKey(rand.Reader, mldsa.MLDSA65)
if err != nil {
t.Fatalf("ml-dsa keygen: %v", err)
}
pub := priv.Public().(*mldsa.PublicKey)
var id [32]byte
for i := range id {
id[i] = idByte
}
return &testSigner{
id: id,
scheme: QuorumSchemeMLDSA65,
weight: weight,
keyVer: 1,
pubBytes: pub.Bytes(),
mldsaPriv: priv,
}
}
// newSLHDSASigner builds a real SLH-DSA-192s signer at the given id/weight.
func newSLHDSASigner(t *testing.T, idByte byte, weight uint64) *testSigner {
t.Helper()
priv, err := magnetar.GenerateKey(magnetar.ParamsM192s, rand.Reader)
if err != nil {
t.Fatalf("slh-dsa keygen: %v", err)
}
var id [32]byte
for i := range id {
id[i] = idByte
}
return &testSigner{
id: id,
scheme: QuorumSchemeSLHDSA192s,
weight: weight,
keyVer: 1,
pubBytes: priv.Public().Bytes,
slhPriv: priv,
}
}
// testEnv builds the canonical message envelope for the scenario.
func testEnv() QuorumMessageEnvelope {
return QuorumMessageEnvelope{
ProfileID: 0xC57E0001,
HashSuite: config.HashSuiteSHA3NIST,
IdentityScheme: config.IdentitySchemeMLDSA65,
FinalityScheme: config.SigSchemePulsar65,
ProofPolicy: config.ProofPolicySTARKFRISHA3PQ,
ProofBackend: config.ProofBackendDirectWeightedQuorum,
ProofFormat: config.ProofFormatDirectWeightedQuorumV1,
VerifierID: config.VerifierDirectWeightedQuorumPQ,
EffectivePolicy: 1,
NetworkID: 0xC0DE0001,
ChainID: 0xDEADBEEF,
Epoch: 42,
Height: 1000,
Round: 3,
QCType: 2,
}
}
// scenario is a fully-built, valid cert + everything needed to verify it.
type scenario struct {
env QuorumMessageEnvelope
cfg QuorumVerifierConfig
message []byte
cert *WeightedQuorumCert
set *WeightedValidatorSet
signers []*testSigner
}
// buildScenario assembles a real, valid cert from real signers. threshold is
// the quorum-weight floor; ctx is the FIPS context (nil for the SLH-DSA
// path). The value hash is fixed and bound into the message + cert.
func buildScenario(t *testing.T, signers []*testSigner, threshold uint64, ctx []byte) scenario {
t.Helper()
env := testEnv()
var valueHash [32]byte
for i := range valueHash {
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))
for i, s := range signers {
leaves[i] = WeightedValidatorLeaf{
ValidatorID: s.id,
PublicKey: s.pubBytes,
VotingWeight: s.weight,
ParameterSetID: uint8(s.scheme),
KeyVersion: s.keyVer,
}
}
set, err := BuildWeightedValidatorSet(env.Epoch, leaves)
if err != nil {
t.Fatalf("build set: %v", err)
}
env.ValidatorSetRoot = set.Root()
// The domain-separated consensus message.
message, err := QuorumConsensusMessage(env)
if err != nil {
t.Fatalf("build message: %v", err)
}
// Each signer signs INDEPENDENTLY; build records with Merkle paths.
sorted := set.Leaves()
idxOf := func(id [32]byte) int {
for i := range sorted {
if sorted[i].ValidatorID == id {
return i
}
}
t.Fatalf("signer id %x not in set", id[:])
return -1
}
records := make([]QuorumSignerRecord, 0, len(signers))
for _, s := range signers {
proof, err := set.InclusionProof(idxOf(s.id))
if err != nil {
t.Fatalf("inclusion proof: %v", err)
}
records = append(records, QuorumSignerRecord{
ValidatorID: s.id,
PublicKey: s.pubBytes,
VotingWeight: s.weight,
Scheme: s.scheme,
ParamSetID: uint8(s.scheme),
KeyVersion: s.keyVer,
MerklePath: proof,
Signature: s.sign(t, message, ctx),
})
}
cert, err := BuildWeightedQuorumCert(QuorumCertParams{
ChainID: env.ChainID,
Epoch: env.Epoch,
Height: env.Height,
Round: env.Round,
ValueHash: env.ValueHash,
QCType: env.QCType,
ValidatorSetRoot: set.Root(),
QuorumThreshold: threshold,
}, records)
if err != nil {
t.Fatalf("build cert: %v", err)
}
return scenario{
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,
signers: signers,
}
}
// fourMLDSA builds a standard 4-signer ML-DSA scenario, weights 25 each,
// threshold 100 (== Σ weight). ctx = nil.
func fourMLDSA(t *testing.T) scenario {
signers := []*testSigner{
newMLDSASigner(t, 0x01, 25),
newMLDSASigner(t, 0x02, 25),
newMLDSASigner(t, 0x03, 25),
newMLDSASigner(t, 0x04, 25),
}
return buildScenario(t, signers, 100, nil)
}
// ----------------------------------------------------------------------------
// Happy path + independent-verifier interop
// ----------------------------------------------------------------------------
func TestQuorumCert_HappyPath(t *testing.T) {
sc := fourMLDSA(t)
if err := sc.cert.Verify(sc.env, sc.cfg); err != nil {
t.Fatalf("valid cert rejected: %v", err)
}
}
// TestQuorumCert_IndependentVerifierInterop proves each record's signature
// verifies under the STOCK FIPS verifier directly — no Lux cert machinery,
// just mldsa.PublicKey.VerifySignatureCtx. This is the wire-identity claim:
// any peer can verify a signer's contribution with a stock FIPS library.
func TestQuorumCert_IndependentVerifierInterop(t *testing.T) {
sc := fourMLDSA(t)
for i := range sc.cert.Signers {
rec := &sc.cert.Signers[i]
pk, err := mldsa.PublicKeyFromBytes(rec.PublicKey, mldsa.MLDSA65)
if err != nil {
t.Fatalf("record %d: parse pub: %v", i, err)
}
if !pk.VerifySignatureCtx(sc.message, rec.Signature, sc.cfg.Context) {
t.Fatalf("record %d: stock FIPS 204 verifier rejected the signature", i)
}
}
}
// TestQuorumCert_SLHDSAInterop proves the SLH-DSA records verify under the
// stock FIPS 205 verifier (magnetar's thin VerifyCtx over circl/slhdsa).
func TestQuorumCert_SLHDSAInterop(t *testing.T) {
signers := []*testSigner{
newSLHDSASigner(t, 0x01, 60),
newSLHDSASigner(t, 0x02, 60),
}
sc := buildScenario(t, signers, 100, 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 {
rec := &sc.cert.Signers[i]
params := magnetar.ParamsM192s
pk := &magnetar.PublicKey{Mode: magnetar.ModeM192s, Bytes: rec.PublicKey}
sig := &magnetar.Signature{Mode: magnetar.ModeM192s, Bytes: rec.Signature}
if err := magnetar.VerifyCtx(params, pk, sc.message, sc.cfg.Context, sig); err != nil {
t.Fatalf("record %d: stock FIPS 205 verifier rejected: %v", i, err)
}
}
}
// TestQuorumCert_MixedScheme proves a single cert can carry ML-DSA and
// SLH-DSA signer records side by side (the honest realization of a combined
// per-validator surface).
func TestQuorumCert_MixedScheme(t *testing.T) {
signers := []*testSigner{
newMLDSASigner(t, 0x01, 40),
newSLHDSASigner(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("valid mixed-scheme cert rejected: %v", err)
}
}
// TestQuorumCert_MLDSAContextBinding proves the FIPS context plumbing works:
// signing under ctx="A" and verifying under ctx="A" succeeds, under ctx="B"
// fails.
func TestQuorumCert_MLDSAContextBinding(t *testing.T) {
ctxA := []byte("lux-quasar-wqc-ctx-A")
signers := []*testSigner{
newMLDSASigner(t, 0x01, 60),
newMLDSASigner(t, 0x02, 60),
}
sc := buildScenario(t, signers, 100, ctxA)
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.env, QuorumVerifierConfig{Context: []byte("ctx-B"), MinThreshold: 100}); err == nil {
t.Fatal("cert signed under ctx-A verified under ctx-B (context not bound)")
}
}
// ----------------------------------------------------------------------------
// Distinct-signer enforcement
// ----------------------------------------------------------------------------
func TestQuorumCert_RejectsUnsorted(t *testing.T) {
sc := fourMLDSA(t)
// Swap two records so they are no longer strictly increasing.
sc.cert.Signers[0], sc.cert.Signers[1] = sc.cert.Signers[1], sc.cert.Signers[0]
// 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.env, sc.cfg)
if !errors.Is(err, ErrQCNotStrictlyIncreasing) {
t.Fatalf("unsorted cert err = %v, want ErrQCNotStrictlyIncreasing", err)
}
}
func TestQuorumCert_RejectsDuplicateSigner(t *testing.T) {
sc := fourMLDSA(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.env, sc.cfg)
if !errors.Is(err, ErrQCNotStrictlyIncreasing) {
t.Fatalf("duplicate-signer cert err = %v, want ErrQCNotStrictlyIncreasing", err)
}
}
func TestQuorumCert_ProverRejectsDuplicate(t *testing.T) {
// The prover itself must refuse to assemble a cert with duplicate ids.
s := newMLDSASigner(t, 0x01, 50)
rec := QuorumSignerRecord{
ValidatorID: s.id,
PublicKey: s.pubBytes,
VotingWeight: s.weight,
Scheme: s.scheme,
ParamSetID: uint8(s.scheme),
KeyVersion: s.keyVer,
MerklePath: &WeightedInclusionProof{LeafCount: 1},
Signature: []byte{0x00},
}
_, err := BuildWeightedQuorumCert(QuorumCertParams{
ChainID: 1, Epoch: 1, QuorumThreshold: 1, ValidatorSetRoot: [48]byte{1},
}, []QuorumSignerRecord{rec, rec})
if !errors.Is(err, ErrWVSetDuplicateID) {
t.Fatalf("prover duplicate err = %v, want ErrWVSetDuplicateID", err)
}
}
// ----------------------------------------------------------------------------
// Weight threshold
// ----------------------------------------------------------------------------
func TestQuorumCert_BelowThresholdRejected(t *testing.T) {
// Σ weight = 100; set threshold to 101 → below threshold.
signers := []*testSigner{
newMLDSASigner(t, 0x01, 25),
newMLDSASigner(t, 0x02, 25),
newMLDSASigner(t, 0x03, 25),
newMLDSASigner(t, 0x04, 25),
}
sc := buildScenario(t, signers, 101, nil)
err := sc.cert.Verify(sc.env, sc.cfg)
if !errors.Is(err, ErrQCBelowThreshold) {
t.Fatalf("below-threshold err = %v, want ErrQCBelowThreshold", err)
}
}
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.env, sc.cfg); err != nil {
t.Fatalf("at-threshold cert rejected: %v", err)
}
}
// ----------------------------------------------------------------------------
// Wrong message / domain / cross-epoch / cross-chain
// ----------------------------------------------------------------------------
func TestQuorumCert_WrongMessageRejected(t *testing.T) {
sc := fourMLDSA(t)
wrong := append([]byte(nil), sc.message...)
wrong[0] ^= 0xFF
// 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)
}
}
// crossAxis re-derives a message with a single envelope axis changed and
// confirms the original cert's signatures do NOT verify against it.
func crossAxis(t *testing.T, name string, mutate func(*QuorumMessageEnvelope)) {
t.Helper()
sc := fourMLDSA(t)
env := sc.env
mutate(&env)
msg2, err := QuorumConsensusMessage(env)
if err != nil {
t.Fatalf("%s: rebuild message: %v", name, err)
}
if bytes.Equal(msg2, sc.message) {
t.Fatalf("%s: message unchanged after mutating axis (no binding!)", name)
}
// 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)
}
}
func TestQuorumCert_CrossDomainNonTransferability(t *testing.T) {
crossAxis(t, "chain_id", func(e *QuorumMessageEnvelope) { e.ChainID++ })
crossAxis(t, "epoch", func(e *QuorumMessageEnvelope) { e.Epoch++ })
crossAxis(t, "height", func(e *QuorumMessageEnvelope) { e.Height++ })
crossAxis(t, "round", func(e *QuorumMessageEnvelope) { e.Round++ })
crossAxis(t, "qc_type", func(e *QuorumMessageEnvelope) { e.QCType++ })
crossAxis(t, "value_hash", func(e *QuorumMessageEnvelope) { e.ValueHash[0] ^= 0xFF })
crossAxis(t, "network_id", func(e *QuorumMessageEnvelope) { e.NetworkID++ })
crossAxis(t, "proof_backend", func(e *QuorumMessageEnvelope) { e.ProofBackend = config.ProofBackendP3QSTARKFRISHA3 })
crossAxis(t, "profile_id", func(e *QuorumMessageEnvelope) { e.ProfileID++ })
}
// ----------------------------------------------------------------------------
// Merkle-inclusion soundness (non-member rejected) at the cert layer
// ----------------------------------------------------------------------------
func TestQuorumCert_NonMemberRejected(t *testing.T) {
sc := fourMLDSA(t)
// Mutate a record's weight so its reconstructed leaf is no longer the
// committed one — its Merkle path no longer proves inclusion. Re-sign
// nothing (the signature is over the message, not the leaf); the failure
// must be the Merkle clause.
sc.cert.Signers[1].VotingWeight = 999
// Keep aggregate consistent with the records so we reach the Merkle check
// (otherwise we'd trip the aggregate-weight clause first — but here the
// per-record Merkle check runs before the aggregate, so adjust both to be
// 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.env, sc.cfg)
if !errors.Is(err, ErrQCMerkleInclusion) {
t.Fatalf("non-member err = %v, want ErrQCMerkleInclusion", err)
}
}
func TestQuorumCert_UnknownValidatorNotFatal(t *testing.T) {
// A record for a validator NOT in the committed set must make the cert
// INVALID — but cleanly (a typed error), never a panic / DoS. We build a
// fresh signer not in the set, give it a syntactically-valid but bogus
// Merkle path, and confirm Verify returns ErrQCMerkleInclusion without
// panicking.
sc := fourMLDSA(t)
stranger := newMLDSASigner(t, 0xFE, 25)
// A path shaped for a 4-leaf tree at index 0, with a random sibling.
var sib [48]byte
for i := range sib {
sib[i] = 0x77
}
bogus := &WeightedInclusionProof{
LeafIndex: 0,
LeafCount: 4,
Steps: []WeightedProofStep{
{Sibling: sib, SiblingIsRight: true},
{Sibling: sib, SiblingIsRight: true},
},
}
// Replace the lowest-id record so ordering stays strictly increasing.
rec := QuorumSignerRecord{
ValidatorID: stranger.id, // 0xFE.. is the largest id → goes last
PublicKey: stranger.pubBytes,
VotingWeight: stranger.weight,
Scheme: stranger.scheme,
ParamSetID: uint8(stranger.scheme),
KeyVersion: stranger.keyVer,
MerklePath: bogus,
Signature: stranger.sign(t, sc.message, nil),
}
sc.cert.Signers = append(sc.cert.Signers, rec)
sc.cert.SignerCount++
sc.cert.AggregateWeight += rec.VotingWeight
sc.cert.SignerCommitment = sc.cert.computeSignerCommitment()
// Must not panic; must return the Merkle clause error.
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)
}
}
// ----------------------------------------------------------------------------
// Aggregate-weight / signer-commitment tamper rejection
// ----------------------------------------------------------------------------
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.env, sc.cfg)
if !errors.Is(err, ErrQCAggregateWeight) {
t.Fatalf("aggregate tamper err = %v, want ErrQCAggregateWeight", err)
}
}
func TestQuorumCert_SignerCommitmentTamper(t *testing.T) {
sc := fourMLDSA(t)
sc.cert.SignerCommitment[0] ^= 0xFF
err := sc.cert.Verify(sc.env, sc.cfg)
if !errors.Is(err, ErrQCSignerCommitment) {
t.Fatalf("commitment tamper err = %v, want ErrQCSignerCommitment", err)
}
}
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++ // 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)
}
}
// ----------------------------------------------------------------------------
// Scheme allow-list
// ----------------------------------------------------------------------------
func TestQuorumCert_SchemeNotAllowed(t *testing.T) {
sc := fourMLDSA(t)
// 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.env, cfg)
if !errors.Is(err, ErrQCSchemeNotAllowed) {
t.Fatalf("scheme-not-allowed err = %v, want ErrQCSchemeNotAllowed", err)
}
}
func TestQuorumCert_ParamSetMismatch(t *testing.T) {
sc := fourMLDSA(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.env, sc.cfg)
if !errors.Is(err, ErrQCParamSetMismatch) {
t.Fatalf("param mismatch err = %v, want ErrQCParamSetMismatch", err)
}
}
// ----------------------------------------------------------------------------
// Structural / fail-closed
// ----------------------------------------------------------------------------
func TestQuorumCert_StructuralRejections(t *testing.T) {
sc := fourMLDSA(t)
var nilCert *WeightedQuorumCert
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.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.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.env, sc.cfg); !errors.Is(err, ErrQCSignerCountMismatch) {
t.Fatalf("count mismatch err = %v, want ErrQCSignerCountMismatch", err)
}
}
+274
View File
@@ -0,0 +1,274 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package quasar
import (
"errors"
"testing"
"github.com/luxfi/consensus/config"
)
// ----------------------------------------------------------------------------
// Compact ↔ full encoding round-trip
// ----------------------------------------------------------------------------
func TestQuorumCert_FullRoundTrip(t *testing.T) {
sc := fourMLDSA(t)
wire, err := sc.cert.MarshalBinary()
if err != nil {
t.Fatalf("marshal full: %v", err)
}
got, err := UnmarshalWeightedQuorumCert(wire)
if err != nil {
t.Fatalf("unmarshal full: %v", err)
}
if !sc.cert.Equal(got) {
t.Fatal("full round-trip not byte-stable")
}
// And the decoded cert must still verify with real signatures.
if err := got.Verify(sc.env, sc.cfg); err != nil {
t.Fatalf("decoded full cert failed verify: %v", err)
}
}
func TestQuorumCert_CompactRoundTrip(t *testing.T) {
sc := fourMLDSA(t)
compact := sc.cert.Compact()
if !compact.IsCompact() {
t.Fatal("Compact() did not produce a compact cert")
}
wire, err := compact.MarshalBinary()
if err != nil {
t.Fatalf("marshal compact: %v", err)
}
got, err := UnmarshalWeightedQuorumCert(wire)
if err != nil {
t.Fatalf("unmarshal compact: %v", err)
}
if !compact.Equal(got) {
t.Fatal("compact round-trip not byte-stable")
}
// A compact cert cannot be verified until records are re-attached.
if err := got.Verify(sc.env, sc.cfg); !errors.Is(err, ErrQCCompactNoRecords) {
t.Fatalf("compact verify err = %v, want ErrQCCompactNoRecords", err)
}
}
// TestQuorumCert_CompactToFullViaDA proves the DA-hybrid flow: header carries
// the compact commitment; records are fetched from DA and re-attached; the
// re-attached cert verifies. A DA layer that serves a DIFFERENT record set
// is caught by the commitment recomputation.
func TestQuorumCert_CompactToFullViaDA(t *testing.T) {
sc := fourMLDSA(t)
compact := sc.cert.Compact()
// Honest DA: the real records re-attach and verify.
reattached := compact.WithRecords(sc.cert.Signers)
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.env, sc.cfg)
// SignerCount (4) != records (3) is caught first; either count-mismatch
// or commitment-mismatch is an acceptable clean rejection.
if err == nil {
t.Fatal("malicious DA record substitution accepted")
}
if !errors.Is(err, ErrQCSignerCountMismatch) && !errors.Is(err, ErrQCSignerCommitment) {
t.Fatalf("malicious DA err = %v, want count or commitment mismatch", err)
}
}
func TestQuorumCert_DecoderFailClosed(t *testing.T) {
sc := fourMLDSA(t)
wire, _ := sc.cert.MarshalBinary()
// Truncation at every prefix length must error, never panic.
for n := 0; n < len(wire); n++ {
if _, err := UnmarshalWeightedQuorumCert(wire[:n]); err == nil {
t.Fatalf("truncated-to-%d decoded without error", n)
}
}
// Trailing garbage must error.
withTrailer := append(append([]byte(nil), wire...), 0xFF)
if _, err := UnmarshalWeightedQuorumCert(withTrailer); !errors.Is(err, ErrQCWireCorrupt) {
t.Fatalf("trailing byte err = %v, want ErrQCWireCorrupt", err)
}
// Bad kind byte.
badKind := append([]byte(nil), wire...)
badKind[0] = 0x99
if _, err := UnmarshalWeightedQuorumCert(badKind); !errors.Is(err, ErrQCWireCorrupt) {
t.Fatalf("bad-kind err = %v, want ErrQCWireCorrupt", err)
}
}
func TestQuorumCert_MixedSchemeRoundTrip(t *testing.T) {
signers := []*testSigner{
newMLDSASigner(t, 0x01, 40),
newSLHDSASigner(t, 0x02, 40),
newMLDSASigner(t, 0x03, 40),
}
sc := buildScenario(t, signers, 100, nil)
wire, err := sc.cert.MarshalBinary()
if err != nil {
t.Fatalf("marshal mixed: %v", err)
}
got, err := UnmarshalWeightedQuorumCert(wire)
if err != nil {
t.Fatalf("unmarshal mixed: %v", err)
}
if !sc.cert.Equal(got) {
t.Fatal("mixed-scheme round-trip not byte-stable")
}
if err := got.Verify(sc.env, sc.cfg); err != nil {
t.Fatalf("decoded mixed cert failed verify: %v", err)
}
}
// ----------------------------------------------------------------------------
// QuasarCert wiring
// ----------------------------------------------------------------------------
func TestQuasarCert_WeightedQuorumLeg(t *testing.T) {
sc := fourMLDSA(t)
qc, err := BuildQuasarCertFromWeightedQuorum(sc.cert)
if err != nil {
t.Fatalf("build QuasarCert: %v", err)
}
if !qc.HasWeightedQuorumLeg() {
t.Fatal("QuasarCert does not report a weighted quorum leg")
}
if qc.Epoch != sc.cert.Epoch {
t.Fatalf("QuasarCert epoch = %d, want %d", qc.Epoch, sc.cert.Epoch)
}
if qc.Validators != int(sc.cert.SignerCount) {
t.Fatalf("QuasarCert validators = %d, want %d", qc.Validators, sc.cert.SignerCount)
}
// The clean Verify entry point over the QuasarCert.
if err := qc.VerifyWeightedQuorumLeg(sc.env, sc.cfg); err != nil {
t.Fatalf("VerifyWeightedQuorumLeg rejected a valid leg: %v", err)
}
// Extract round-trips to the same cert.
extracted, err := ExtractWeightedQuorumLeg(qc.MLDSARollup)
if err != nil {
t.Fatalf("extract: %v", err)
}
if !sc.cert.Equal(extracted) {
t.Fatal("extracted leg != original cert")
}
// The whole QuasarCert must survive its own MarshalBinary/UnmarshalBinary.
qcWire, err := qc.MarshalBinary()
if err != nil {
t.Fatalf("QuasarCert marshal: %v", err)
}
var qc2 QuasarCert
if err := qc2.UnmarshalBinary(qcWire); err != nil {
t.Fatalf("QuasarCert unmarshal: %v", err)
}
if err := qc2.VerifyWeightedQuorumLeg(sc.env, sc.cfg); err != nil {
t.Fatalf("round-tripped QuasarCert leg failed verify: %v", err)
}
}
func TestQuasarCert_LegDiscriminator(t *testing.T) {
// A non-WQC MLDSARollup (e.g. a STARK proof blob) must be cleanly
// distinguished, not misparsed as a WQC.
starkish := []byte{0x10, 0x01, 0x02, 0x03} // does not start with the WQC tag
if _, err := ExtractWeightedQuorumLeg(starkish); !errors.Is(err, ErrMLDSARollupNotWQC) {
t.Fatalf("non-WQC leg err = %v, want ErrMLDSARollupNotWQC", err)
}
qc := &QuasarCert{MLDSARollup: starkish}
if qc.HasWeightedQuorumLeg() {
t.Fatal("non-WQC leg reported as a weighted quorum leg")
}
}
func TestQuasarCert_AttachLegPreservesOtherLegs(t *testing.T) {
sc := fourMLDSA(t)
base := &QuasarCert{
BLS: []byte{0xAA, 0xBB},
Pulsar: []byte{0xCC, 0xDD},
Epoch: sc.cert.Epoch,
}
out, err := base.AttachWeightedQuorumLeg(sc.cert)
if err != nil {
t.Fatalf("attach: %v", err)
}
if string(out.BLS) != string(base.BLS) || string(out.Pulsar) != string(base.Pulsar) {
t.Fatal("attach clobbered other legs")
}
if !out.HasWeightedQuorumLeg() {
t.Fatal("attach did not install the weighted quorum leg")
}
if err := out.VerifyWeightedQuorumLeg(sc.env, sc.cfg); err != nil {
t.Fatalf("attached leg failed verify: %v", err)
}
}
// ----------------------------------------------------------------------------
// Message function: QuorumMessageForCert derives from cert fields
// ----------------------------------------------------------------------------
func TestQuorumMessage_ForCertMatches(t *testing.T) {
sc := fourMLDSA(t)
// Build the message from the cert's own fields + the chain envelope.
msg, err := QuorumMessageForCert(sc.env, sc.cert)
if err != nil {
t.Fatalf("QuorumMessageForCert: %v", err)
}
// Must equal the message the signers actually signed (the scenario built
// the message from the same envelope before the cert existed).
if string(msg) != string(sc.message) {
t.Fatal("QuorumMessageForCert != the message signers signed")
}
// 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) {
// QuorumConsensusMessage must propagate ComputeRoundDigest's refusal of
// zero-value security-relevant inputs (fail-closed).
var env QuorumMessageEnvelope // all zero
if _, err := QuorumConsensusMessage(env); err == nil {
t.Fatal("zero envelope produced a message; want ComputeRoundDigest refusal")
}
}
// TestProofBackendIDs pins the new wire IDs so a future renumber trips this
// test (mirrors the existing ID-pinning discipline in pq_mode_test.go).
func TestProofBackend_DirectWeightedQuorumIDs(t *testing.T) {
if config.ProofBackendDirectWeightedQuorum != 0x30 {
t.Fatalf("ProofBackendDirectWeightedQuorum = 0x%02x, want 0x30", uint8(config.ProofBackendDirectWeightedQuorum))
}
if config.ProofFormatDirectWeightedQuorumV1 != 0x30 {
t.Fatalf("ProofFormatDirectWeightedQuorumV1 = 0x%02x, want 0x30", uint8(config.ProofFormatDirectWeightedQuorumV1))
}
if config.VerifierDirectWeightedQuorumPQ != 0x0030 {
t.Fatalf("VerifierDirectWeightedQuorumPQ = 0x%04x, want 0x0030", uint16(config.VerifierDirectWeightedQuorumPQ))
}
if !config.ProofBackendDirectWeightedQuorum.IsProductionPQ() {
t.Fatal("direct weighted quorum backend must be production-PQ")
}
if !config.ProofBackendDirectWeightedQuorum.IsDirectVerifiable() {
t.Fatal("direct weighted quorum backend must report IsDirectVerifiable")
}
if config.ProofBackendDirectWeightedQuorum.IsForbiddenInPQMode() {
t.Fatal("direct weighted quorum backend must not be forbidden in PQ mode")
}
}
+458
View File
@@ -0,0 +1,458 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// quorum_cto_attacks_test.go — the supplemental adversarial corpus the
// weighted-quorum design must withstand beyond the F1-F5 envelope findings.
// Each test reproduces a specific attack from the consensus-threshold-cert
// directive and asserts a CLEAN rejection (typed error, never a panic, never
// unbounded work). These close the gaps the F-series did not name explicitly:
//
// weight overflow Σ voting weight wrapping uint64
// wrong validator_set_root a cert whose signers are committed under a
// different epoch set than its envelope pins
// malformed cert / no panic a fuzz + table sweep over arbitrary wire bytes
//
// All are threshold-CERTIFICATION attacks: the adversary controls cert bytes
// but cannot break a stock FIPS 204/205 verifier or the weighted-Merkle
// commitment, and the verifier owns message construction and the policy axes.
package quasar
import (
"crypto/rand"
"encoding/binary"
"errors"
"math"
"testing"
"github.com/luxfi/crypto/mldsa"
)
// ----------------------------------------------------------------------------
// Weight overflow — Σ voting weight must never wrap.
// ----------------------------------------------------------------------------
// TestCTO_WeightOverflowRejected drives a cert whose per-signer weights, summed
// in order, overflow uint64. The verifier's checked accumulation must reject at
// the overflow guard (ErrQCWeightOverflow) rather than wrapping to a small
// total that silently satisfies (or silently fails) the threshold. A wrapped
// accumulator is a finality-forgery primitive: an attacker who could make Σ
// wrap to exactly the threshold would forge a quorum out of phantom weight.
//
// BuildWeightedQuorumCert ALSO guards overflow at assembly, so to exercise the
// VERIFIER's own guard we build a valid two-signer cert with modest weights,
// then mutate the SECOND record's in-cert weight (and the aggregate) to values
// whose running sum wraps — the per-signer Merkle leaf still authenticates
// because we rebuild the committed set with the mutated weight, and the
// commitment is recomputed, so the rejection is provably the overflow clause.
func TestCTO_WeightOverflowRejected(t *testing.T) {
// First signer carries MaxUint64; second carries a positive weight, so the
// running sum overflows on the SECOND accumulation. Commit the set with
// exactly these weights so each leaf authenticates. Kept as runtime vars so
// the wrapping sum is computed at runtime (a constant w1+w2 would be a
// compile-time overflow error).
var w1 uint64 = math.MaxUint64
var w2 uint64 = 100
s1 := newMLDSASigner(t, 0x01, w1)
s2 := newMLDSASigner(t, 0x02, w2)
// Build the committed set + message + records by hand (buildScenario would
// route through BuildWeightedQuorumCert's assembly overflow guard).
env := testEnv()
var vh [32]byte
for i := range vh {
vh[i] = 0xAB
}
env.ValueHash = vh
// Threshold below the wrapped value so the floor/threshold clauses are not
// what trips — isolate the overflow guard. Σ would be w1+w2 (overflow); the
// honest semantics is "huge", so any small threshold is met IF no overflow.
env.QuorumThreshold = 50
leaves := []WeightedValidatorLeaf{
{ValidatorID: s1.id, PublicKey: s1.pubBytes, VotingWeight: w1, ParameterSetID: uint8(s1.scheme), KeyVersion: s1.keyVer},
{ValidatorID: s2.id, PublicKey: s2.pubBytes, VotingWeight: w2, ParameterSetID: uint8(s2.scheme), KeyVersion: s2.keyVer},
}
set, err := BuildWeightedValidatorSet(env.Epoch, leaves)
if err != nil {
t.Fatalf("build set: %v", err)
}
env.ValidatorSetRoot = set.Root()
msg, err := QuorumConsensusMessage(env)
if err != nil {
t.Fatalf("build message: %v", err)
}
sorted := set.Leaves()
idxOf := func(id [32]byte) int {
for i := range sorted {
if sorted[i].ValidatorID == id {
return i
}
}
t.Fatal("id not in set")
return -1
}
records := make([]QuorumSignerRecord, 0, 2)
for _, s := range []*testSigner{s1, s2} {
proof, err := set.InclusionProof(idxOf(s.id))
if err != nil {
t.Fatalf("inclusion proof: %v", err)
}
records = append(records, QuorumSignerRecord{
ValidatorID: s.id,
PublicKey: s.pubBytes,
VotingWeight: s.weight,
Scheme: s.scheme,
ParamSetID: uint8(s.scheme),
KeyVersion: s.keyVer,
MerklePath: proof,
Signature: s.sign(t, msg, nil),
})
}
// Assemble the cert WITHOUT the prover's overflow guard (hand-build the
// struct). AggregateWeight is set to the wrapped value an attacker claims.
cert := &WeightedQuorumCert{
Version: QuorumCertVersion,
ChainID: env.ChainID,
Epoch: env.Epoch,
Height: env.Height,
Round: env.Round,
ValueHash: env.ValueHash,
QCType: env.QCType,
ValidatorSetRoot: set.Root(),
QuorumThreshold: env.QuorumThreshold,
AggregateWeight: w1 + w2, // wraps at runtime; the value the attacker claims
SignerCount: 2,
Signers: records,
}
cert.SignerCommitment = cert.computeSignerCommitment()
cfg := QuorumVerifierConfig{MinThreshold: env.QuorumThreshold}
err = cert.Verify(env, cfg)
if !errors.Is(err, ErrQCWeightOverflow) {
t.Fatalf("overflowing weight sum err = %v, want ErrQCWeightOverflow", err)
}
}
// TestCTO_ProverRejectsWeightOverflow proves the prover (assembly) ALSO
// refuses to build a cert whose weights overflow — defence in depth so a
// malformed overflowing cert is never even produced by an honest relayer.
func TestCTO_ProverRejectsWeightOverflow(t *testing.T) {
s1 := newMLDSASigner(t, 0x01, math.MaxUint64)
s2 := newMLDSASigner(t, 0x02, 1)
rec := func(s *testSigner) QuorumSignerRecord {
return QuorumSignerRecord{
ValidatorID: s.id,
PublicKey: s.pubBytes,
VotingWeight: s.weight,
Scheme: s.scheme,
ParamSetID: uint8(s.scheme),
KeyVersion: s.keyVer,
MerklePath: &WeightedInclusionProof{LeafCount: 2},
Signature: []byte{0x00},
}
}
_, err := BuildWeightedQuorumCert(QuorumCertParams{
ChainID: 1, Epoch: 1, QuorumThreshold: 1, ValidatorSetRoot: [48]byte{1},
}, []QuorumSignerRecord{rec(s1), rec(s2)})
if !errors.Is(err, ErrQCWeightOverflow) {
t.Fatalf("prover overflow err = %v, want ErrQCWeightOverflow", err)
}
}
// ----------------------------------------------------------------------------
// Wrong validator_set_root — signers committed under a different set.
// ----------------------------------------------------------------------------
// TestCTO_WrongValidatorSetRootRejected reproduces a cross-set attack: a cert
// whose signers were committed under set A is re-presented with its
// ValidatorSetRoot swapped to an unrelated set B's root. Because the root is
// (a) bound into the signed message (so the original signatures no longer
// verify) AND (b) the per-signer Merkle paths were authenticated against set
// A's root, the swapped root cannot reproduce inclusion. The verifier must
// reject — a quorum certified over one validator set may not be laundered as a
// quorum over a different set. We recompute the signer commitment so the
// rejection is provably the Merkle clause / signature binding, not the
// commitment field.
func TestCTO_WrongValidatorSetRootRejected(t *testing.T) {
sc := fourMLDSA(t)
// An unrelated validator set B (different ids/keys) → a different root.
other := []*testSigner{
newMLDSASigner(t, 0xA1, 25),
newMLDSASigner(t, 0xA2, 25),
newMLDSASigner(t, 0xA3, 25),
newMLDSASigner(t, 0xA4, 25),
}
leavesB := make([]WeightedValidatorLeaf, len(other))
for i, s := range other {
leavesB[i] = WeightedValidatorLeaf{
ValidatorID: s.id,
PublicKey: s.pubBytes,
VotingWeight: s.weight,
ParameterSetID: uint8(s.scheme),
KeyVersion: s.keyVer,
}
}
setB, err := BuildWeightedValidatorSet(sc.env.Epoch, leavesB)
if err != nil {
t.Fatalf("build set B: %v", err)
}
if setB.Root() == sc.cert.ValidatorSetRoot {
t.Fatal("precondition: set B root collides with set A root")
}
// Swap the cert's committed root to set B's root and recompute the
// commitment (which binds the root) so the commitment clause passes and the
// failure is the per-signer Merkle inclusion / rebuilt-message signature.
sc.cert.ValidatorSetRoot = setB.Root()
sc.cert.SignerCommitment = sc.cert.computeSignerCommitment()
err = sc.cert.Verify(sc.env, sc.cfg)
// The verifier rebuilds the message with the swapped root (signatures fail)
// AND the original set-A Merkle paths cannot authenticate against set B's
// root — either clean clause rejection is acceptable; both are typed.
if !errors.Is(err, ErrQCMerkleInclusion) && !errors.Is(err, ErrQCSigInvalid) {
t.Fatalf("wrong validator_set_root err = %v, want Merkle or signature rejection", err)
}
}
// ----------------------------------------------------------------------------
// Malformed cert bytes — typed error, NEVER a panic / unbounded work.
// ----------------------------------------------------------------------------
// FuzzUnmarshalWeightedQuorumCert asserts the decoder never panics on
// arbitrary input and, when it does decode, the result re-encodes to bytes the
// decoder accepts again (idempotent on the accepted-language). The seed corpus
// includes a real full cert, a real compact cert, and pathological headers.
func FuzzUnmarshalWeightedQuorumCert(f *testing.F) {
// Seeds: a real full + compact cert, plus adversarial fragments.
full := buildFuzzSeedCert(f)
if wire, err := full.MarshalBinary(); err == nil {
f.Add(wire)
}
if wire, err := full.Compact().MarshalBinary(); err == nil {
f.Add(wire)
}
f.Add([]byte{}) // empty
f.Add([]byte{wqcKindFull}) // kind only, truncated
f.Add([]byte{wqcKindCompact}) // compact kind only, truncated
f.Add([]byte{0x99}) // bad kind
// A full header claiming an enormous signer_count with no records.
hdr := make([]byte, wqcHeaderSize)
hdr[0] = wqcKindFull
binary.BigEndian.PutUint32(hdr[wqcHeaderSize-4:], 0xFFFFFFFF)
f.Add(hdr)
f.Fuzz(func(t *testing.T, data []byte) {
// Must never panic, never hang. A decode either errors or yields a cert.
cert, err := UnmarshalWeightedQuorumCert(data)
if err != nil {
if cert != nil {
t.Fatalf("decoder returned a cert alongside error %v", err)
}
return
}
// Accepted: re-encoding must succeed and round-trip (the decoder accepts
// a canonical sub-language; a cert it produced must re-encode to bytes it
// accepts and that Equal the cert).
wire, merr := cert.MarshalBinary()
if merr != nil {
t.Fatalf("re-marshal of accepted cert failed: %v", merr)
}
again, aerr := UnmarshalWeightedQuorumCert(wire)
if aerr != nil {
t.Fatalf("re-decode of re-marshaled accepted cert failed: %v", aerr)
}
if !cert.Equal(again) {
t.Fatal("decode∘encode∘decode not stable on an accepted cert")
}
})
}
// TestCTO_MalformedNeverPanics is the deterministic companion to the fuzzer: a
// table of hostile byte strings that each must yield a typed error and never a
// panic. Runs in normal `go test` (no -fuzz needed) so CI always exercises it.
func TestCTO_MalformedNeverPanics(t *testing.T) {
full := buildFuzzSeedCert(t)
good, err := full.MarshalBinary()
if err != nil {
t.Fatalf("marshal seed: %v", err)
}
cases := map[string][]byte{
"nil": nil,
"empty": {},
"kind_full_only": {wqcKindFull},
"kind_compact_only": {wqcKindCompact},
"bad_kind": {0x99, 0x00, 0x01},
"single_zero": {0x00},
"header_minus_one": good[:wqcHeaderSize-1],
"good_plus_trailer": append(append([]byte(nil), good...), 0xDE, 0xAD),
"all_0xFF_short": bytesRepeat(0xFF, 16),
"all_0xFF_headerlen": bytesRepeat(0xFF, wqcHeaderSize),
"all_0x00_headerlen": bytesRepeat(0x00, wqcHeaderSize),
}
// Plus every truncation prefix of a valid full cert.
for n := 0; n < len(good); n++ {
cases[truncName(n)] = good[:n]
}
for name, data := range cases {
t.Run(name, func(t *testing.T) {
// The only requirement: no panic. We do not assert error-ness for the
// full-length valid prefix (n == len(good)) since that is the cert
// itself; every shorter/garbled input must error, and none may panic.
cert, err := UnmarshalWeightedQuorumCert(data)
if err == nil && !bytesEq(data, good) {
// Accepted something other than the exact valid cert — only OK if
// it genuinely round-trips (the decoder's accepted language).
wire, merr := cert.MarshalBinary()
if merr != nil || !bytesEq(wire, data) {
// A non-canonical accept would be a malleability bug; surface it.
// (The strict trailing-byte policy should prevent this.)
if !bytesEq(data, good) {
t.Fatalf("accepted non-canonical input %q without round-trip", name)
}
}
}
})
}
}
// ----------------------------------------------------------------------------
// local helpers (kept here so this file is self-contained alongside the
// shared scenario harness in quorum_cert_test.go)
// ----------------------------------------------------------------------------
// buildFuzzSeedCert builds a small real cert usable as a fuzz/table seed. It
// takes testing.TB so both Fuzz (*testing.F) and Test (*testing.T) can use it.
// It builds two real ML-DSA-65 signers inline (the shared scenario harness in
// quorum_cert_test.go is *testing.T-only and cannot serve the fuzzer).
func buildFuzzSeedCert(tb testing.TB) *WeightedQuorumCert {
tb.Helper()
type kp struct {
id [32]byte
priv *mldsa.PrivateKey
pub []byte
}
mk := func(idByte byte) kp {
priv, err := mldsa.GenerateKey(rand.Reader, mldsa.MLDSA65)
if err != nil {
tb.Fatalf("seed keygen: %v", err)
}
var id [32]byte
for i := range id {
id[i] = idByte
}
return kp{id: id, priv: priv, pub: priv.Public().(*mldsa.PublicKey).Bytes()}
}
k1, k2 := mk(0x01), mk(0x02)
env := testEnv()
var vh [32]byte
for i := range vh {
vh[i] = 0xAB
}
env.ValueHash = vh
env.QuorumThreshold = 100
leaves := []WeightedValidatorLeaf{
{ValidatorID: k1.id, PublicKey: k1.pub, VotingWeight: 50, ParameterSetID: uint8(QuorumSchemeMLDSA65), KeyVersion: 1},
{ValidatorID: k2.id, PublicKey: k2.pub, VotingWeight: 50, ParameterSetID: uint8(QuorumSchemeMLDSA65), KeyVersion: 1},
}
set, err := BuildWeightedValidatorSet(env.Epoch, leaves)
if err != nil {
tb.Fatalf("seed build set: %v", err)
}
env.ValidatorSetRoot = set.Root()
msg, err := QuorumConsensusMessage(env)
if err != nil {
tb.Fatalf("seed message: %v", err)
}
sorted := set.Leaves()
idxOf := func(id [32]byte) int {
for i := range sorted {
if sorted[i].ValidatorID == id {
return i
}
}
tb.Fatal("seed id not in set")
return -1
}
recs := make([]QuorumSignerRecord, 0, 2)
for _, k := range []kp{k1, k2} {
proof, err := set.InclusionProof(idxOf(k.id))
if err != nil {
tb.Fatalf("seed proof: %v", err)
}
sig, err := k.priv.SignCtx(rand.Reader, msg, nil)
if err != nil {
tb.Fatalf("seed sign: %v", err)
}
recs = append(recs, QuorumSignerRecord{
ValidatorID: k.id,
PublicKey: k.pub,
VotingWeight: 50,
Scheme: QuorumSchemeMLDSA65,
ParamSetID: uint8(QuorumSchemeMLDSA65),
KeyVersion: 1,
MerklePath: proof,
Signature: sig,
})
}
cert, err := BuildWeightedQuorumCert(QuorumCertParams{
ChainID: env.ChainID,
Epoch: env.Epoch,
Height: env.Height,
Round: env.Round,
ValueHash: env.ValueHash,
QCType: env.QCType,
ValidatorSetRoot: set.Root(),
QuorumThreshold: 100,
}, recs)
if err != nil {
tb.Fatalf("seed build cert: %v", err)
}
return cert
}
func bytesRepeat(b byte, n int) []byte {
out := make([]byte, n)
for i := range out {
out[i] = b
}
return out
}
func bytesEq(a, b []byte) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
func truncName(n int) string {
return "trunc_" + itoa(n)
}
func itoa(n int) string {
if n == 0 {
return "0"
}
var b [20]byte
i := len(b)
for n > 0 {
i--
b[i] = byte('0' + n%10)
n /= 10
}
return string(b[i:])
}
+204
View File
@@ -0,0 +1,204 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// quorum_message.go — the domain-separated consensus message a weighted
// quorum certificate's signers sign over.
//
// One message function. It binds EVERY consensus envelope axis so a single
// signature is non-transferable across domains: a signature produced for
// (chain A, epoch e, height h, round r, value v, qc_type prepare) MUST NOT
// verify for any other (chain, epoch, height, round, value, qc_type) tuple,
// nor across the proof-backend / verifier / profile axes.
//
// The message is built ON TOP of ComputeRoundDigest (round_digest.go) — the
// canonical round-digest machinery that already binds the proof backend,
// profile, hash suite, identity/finality schemes, network/chain ids, epoch,
// height, round, and every state/validator/committee root via TupleHash256
// under "QUASAR-ROUND-DIGEST". This wrapper adds the two axes a quorum cert
// needs that the round digest expresses positionally — qc_type and the
// value/block hash — and re-frames the result under a quorum-specific
// customization tag so a round-digest byte string can never be replayed as
// a quorum message and vice versa.
package quasar
import (
"encoding/binary"
"github.com/luxfi/consensus/config"
)
// quorumMessageCustomization is the cSHAKE256 customization tag for the
// quorum-certificate signing message. Distinct from the round-digest tag so
// the two message spaces are domain-separated. Wire-stable cryptographic
// constant.
const quorumMessageCustomization = "QUASAR-WQC-MESSAGE-V1"
// quorumMessageProtocolTag is the in-band redundant protocol tag for the
// quorum message.
const quorumMessageProtocolTag = "Quasar/WQC/Message"
// QuorumMessageEnvelope carries every axis the quorum message binds. The
// consensus-position fields (chain_id, epoch, height, round, value_hash,
// qc_type, validator_set_root) MUST equal the matching fields of the cert
// that will carry the resulting signatures — the cert verifier rebuilds the
// message from its own fields, so any mismatch makes every signature fail.
//
// The envelope-axis fields (profile, hash suite, schemes, proof backend,
// format, verifier, network) are the round-digest envelope; they pin the
// security posture into the signed transcript so a cert produced under one
// posture cannot be re-presented under another.
type QuorumMessageEnvelope struct {
// Round-digest envelope axes (see ComputeRoundDigest).
ProfileID uint32
HashSuite config.HashSuiteID
IdentityScheme config.IdentitySchemeID
FinalityScheme config.SigSchemeID
ProofPolicy config.ProofPolicyID
ProofBackend config.ProofBackendID
ProofFormat config.ProofFormatID
VerifierID config.VerifierID
EffectivePolicy uint8
NetworkID uint32
// Consensus position — the quorum-cert binding axes.
ChainID uint32
Epoch uint64
Height uint64
Round uint32
ValueHash [32]byte // block_id / value being finalised
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
// committee / DKG / group-key / DA / state roots too.
ParentQBlockHash [32]byte
DARoot [48]byte
SourceStateRoot [48]byte
ZChainStateRoot [48]byte
CommitteeRoot [48]byte
DKGTranscriptRoot [48]byte
GroupPublicKeyHash [48]byte
SignerSetCommit [48]byte
}
// QuorumConsensusMessage builds the domain-separated message the signers
// sign over. It is the SINGLE message function for weighted quorum certs;
// the prover hands it to each signer, and the verifier rebuilds it from the
// cert's fields. Identical inputs yield identical bytes.
//
// Construction:
// 1. ComputeRoundDigest binds the full envelope (proof backend, profile,
// schemes, network/chain, epoch, height, round, and the roots). The
// value hash is bound as the round digest's payload_root so a different
// block under the same position yields a different digest.
// 2. The 32-byte round digest is re-framed under
// "QUASAR-WQC-MESSAGE-V1" together with qc_type and the value hash
// (bound a second time, explicitly) to produce the final message. The
// re-framing guarantees a round-digest byte string is never itself a
// valid quorum message.
//
// Returns the message bytes and any error from ComputeRoundDigest (which
// refuses zero-value security-relevant inputs — so a caller cannot build a
// message under an unset profile / backend / chain).
func QuorumConsensusMessage(env QuorumMessageEnvelope) ([]byte, error) {
// Bind the value hash into the round digest's payload_root slot. The
// payload_root is [48]byte; left-pad the 32-byte value hash into it
// deterministically (high 16 bytes zero) so the binding is canonical.
var payloadRoot [48]byte
copy(payloadRoot[16:], env.ValueHash[:])
digest, err := ComputeRoundDigest(
env.ProfileID,
env.HashSuite,
env.IdentityScheme,
env.FinalityScheme,
env.ProofPolicy,
env.ProofBackend,
env.ProofFormat,
env.VerifierID,
env.EffectivePolicy,
env.NetworkID,
env.ChainID,
env.Epoch,
env.Height,
env.Round,
env.ParentQBlockHash,
payloadRoot,
env.DARoot,
env.SourceStateRoot,
env.ZChainStateRoot,
env.ValidatorSetRoot,
env.CommitteeRoot,
env.DKGTranscriptRoot,
env.GroupPublicKeyHash,
env.SignerSetCommit,
)
if err != nil {
return nil, err
}
// Re-frame the round digest under the quorum message tag together with
// 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[:],
{env.QCType},
env.ValueHash[:],
chainBytes,
env.ValidatorSetRoot[:],
threshBytes,
}
out := tupleHash256RoundDigest(parts, 32, quorumMessageCustomization)
return out, nil
}
// QuorumMessageForCert is the verifier-side convenience that rebuilds the
// quorum message from a certificate's consensus-position fields plus the
// envelope axes the chain pins. The caller supplies the envelope axes (from
// the chain security profile) and the cert; this fills the position fields
// from the cert so the two can never disagree.
//
// 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, 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
}
env := envelope
env.ChainID = cert.ChainID
env.Epoch = cert.Epoch
env.Height = cert.Height
env.Round = cert.Round
env.ValueHash = cert.ValueHash
env.QCType = cert.QCType
env.ValidatorSetRoot = cert.ValidatorSetRoot
env.QuorumThreshold = cert.QuorumThreshold
return QuorumConsensusMessage(env)
}
+172
View File
@@ -0,0 +1,172 @@
// 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
}
+384
View File
@@ -0,0 +1,384 @@
// 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 the proof's promotion/orientation
// sequence alone cannot distinguish a (0,3) record from a (0,4) record with the
// same sibling bytes. Left unbound, the same logical cert would have
// byte-distinct encodings (breaking dedup / equivocation detection).
//
// RED-1 upgrade: the canonicality is now enforced CRYPTOGRAPHICALLY in the
// weighted-Merkle commitment — LeafCount is folded into every leaf digest
// (computeWeightedLeafHash), so relabeling LeafCount 3→4 recomputes a different
// leaf digest and FAILS the Merkle inclusion clause against validator_set_root.
// The malleation is caught at the Merkle clause itself (ErrQCMerkleInclusion),
// strictly stronger than the prior commitment-only defence (which is retained
// as defence-in-depth). Exactly ONE (LeafIndex,LeafCount) verifies per tree.
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 proof shape alone cannot
// distinguish them. This is exactly why LeafCount must be bound into the
// leaf digest (the root) — the structural cross-check is insufficient.
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. Before RED-1 this slipped past the Merkle clause (same
// shape, same siblings → same root). Now LeafCount is bound into the leaf
// digest, so the recomputed root no longer matches validator_set_root.
sc.cert.Signers[rec0].MerklePath.LeafCount = 4
// The Merkle clause must now FAIL for this record: the count-binding makes
// the recomputed leaf digest — and therefore the root — depend on the
// claimed count, so the (0,4) relabel cannot reproduce the (0,3) root.
if VerifyWeightedInclusion(
sc.cert.ValidatorSetRoot, sc.cert.Epoch,
sc.cert.Signers[rec0].leaf(), sc.cert.Signers[rec0].MerklePath,
) {
t.Fatal("malleated (0,4) proof still satisfies the Merkle clause — count not bound into the root")
}
// Full cert Verify rejects the malleated cert at the Merkle inclusion
// clause (record iteration reaches clause (a) before the commitment check).
err := sc.cert.Verify(sc.env, sc.cfg)
if !errors.Is(err, ErrQCMerkleInclusion) {
t.Fatalf("malleated LeafCount err = %v, want ErrQCMerkleInclusion", 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)
}
}
+224
View File
@@ -0,0 +1,224 @@
// 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
}
}
@@ -0,0 +1,2 @@
go test fuzz v1
[]byte("0\x00\x00\x00\x010")
+527
View File
@@ -0,0 +1,527 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// weighted_merkle.go — the weighted validator-set commitment.
//
// A canonical, second-preimage-resistant Merkle commitment over the epoch
// validator set whose leaf binds EVERY accountability field: the validator
// identity, its canonical public key, its voting weight, the parameter set
// it signs under, and its key version. The resulting 48-byte root is the
// validator_set_root that ComputeRoundDigest (round_digest.go) binds into
// the consensus message, and that a Q-Block carries in ValidatorSetRoot.
//
// Why this exists separately from the block-hash Merkle in epoch.go /
// grouped_threshold.go:
//
// - Those trees commit to BLOCK hashes for ordering; they pad an odd
// level by DUPLICATING the last node. Duplication is the classic
// CVE-2012-2459 second-preimage hazard: a different leaf multiset can
// yield the same root. For a commitment whose leaves decide WHO signed
// and WITH WHAT WEIGHT, that is a forgery surface (an attacker could
// present an inclusion proof for a weight/key the committee never
// committed). This tree refuses to duplicate.
//
// - This tree is domain-separated: leaf hashing and internal-node hashing
// use DISTINCT cSHAKE256 customization tags, so no internal-node digest
// can ever be reinterpreted as a leaf digest (and vice versa). That is
// the standard RFC 6962 / Certificate-Transparency defense against the
// duplication and node/leaf-confusion second-preimage attacks.
//
// - The leaf binds the (epoch, parameter_set_id, key_version) accountability
// fields the block-hash trees have no notion of.
//
// One leaf encoding, one root function, one inclusion-proof verifier. This
// is the only weighted-validator-set commitment in the codebase.
package quasar
import (
"encoding/binary"
"errors"
"fmt"
"sort"
)
// weightedLeafCustomization is the SP 800-185 cSHAKE256 customization tag
// for a weighted-validator-set LEAF. Distinct from the node tag so a
// leaf digest can never collide with an internal-node digest — the
// node/leaf-confusion second-preimage defense. Wire-stable cryptographic
// constant: never rename.
const weightedLeafCustomization = "QUASAR-WVSET-LEAF-V1"
// weightedNodeCustomization is the SP 800-185 cSHAKE256 customization tag
// for a weighted-validator-set INTERNAL node. Distinct from the leaf tag.
// Wire-stable cryptographic constant: never rename.
const weightedNodeCustomization = "QUASAR-WVSET-NODE-V1"
// weightedLeafProtocolTag is the in-band redundant protocol tag bound as
// the first TupleHash part of every leaf. Defence-in-depth so a
// cross-customization-collision attacker also has to forge the leading part.
const weightedLeafProtocolTag = "Quasar/WVSet/Leaf"
// weightedNodeProtocolTag is the in-band redundant protocol tag bound as
// the first TupleHash part of every internal node.
const weightedNodeProtocolTag = "Quasar/WVSet/Node"
var (
// ErrWVSetEmpty is returned when a build is attempted over an empty
// validator set. A quorum certificate over zero validators is
// meaningless; the caller must supply ≥ 1 leaf.
ErrWVSetEmpty = errors.New("quasar: weighted validator set is empty")
// ErrWVSetDuplicateID is returned when two leaves share a validator_id.
// The validator set is a SET: a repeated identity is either a
// duplicate-count attack or a caller bug. Refused at build, not papered
// over by dedup.
ErrWVSetDuplicateID = errors.New("quasar: duplicate validator_id in weighted validator set")
// ErrWVSetZeroWeight is returned when a leaf carries zero voting weight.
// A zero-weight signer contributes nothing to a quorum and is almost
// always a misconfiguration; refusing it closes a silent
// "phantom signer inflates the signer count but not the weight" surface.
ErrWVSetZeroWeight = errors.New("quasar: validator has zero voting weight")
// ErrWVSetEmptyPubKey is returned when a leaf carries an empty public
// key. A leaf with no key cannot be the subject of a signature
// verification and must not be committed.
ErrWVSetEmptyPubKey = errors.New("quasar: validator has empty public key")
// ErrWVSetProofShape is returned when an inclusion proof's structural
// invariants do not hold (leaf index out of range, path length does
// not match the tree, etc.).
ErrWVSetProofShape = errors.New("quasar: weighted-set inclusion proof malformed")
)
// WeightedValidatorLeaf is the canonical leaf of the weighted
// validator-set commitment. Every field is an accountability axis bound
// into the leaf digest; flipping any byte yields a different leaf and
// therefore a different root.
//
// The (Epoch) under which the set is committed is NOT a leaf field — it is
// a tree-wide parameter passed to BuildWeightedValidatorSet and folded
// into every leaf digest, so the same (validator_id, pubkey, weight) in a
// different epoch produces a different commitment. This binds the set to
// the epoch without repeating the epoch in every leaf struct.
type WeightedValidatorLeaf struct {
// ValidatorID is the validator's canonical 32-byte identifier. Leaves
// are sorted by this field; duplicates are refused.
ValidatorID [32]byte
// PublicKey is the validator's canonical signature public key bytes
// (e.g. ML-DSA-65 or SLH-DSA packed public key). Bound verbatim — the
// verifier checks a signature under THIS key after proving inclusion.
PublicKey []byte
// VotingWeight is the validator's stake-weight for quorum accounting.
// Must be non-zero.
VotingWeight uint64
// ParameterSetID is the wire byte naming the signature parameter set
// this validator signs under (config.SigSchemeID / IdentitySchemeID
// byte — e.g. 0x42 = ML-DSA-65). Bound so a key cannot be silently
// reinterpreted under a different parameter set.
ParameterSetID uint8
// KeyVersion is the monotonically increasing key-rotation counter for
// this validator. Bound so a retired key (lower version) cannot be
// substituted for the current one under the same identity.
KeyVersion uint32
}
// WeightedValidatorSet is the built commitment: the canonical sorted
// leaves plus their precomputed leaf digests and the root. Immutable after
// BuildWeightedValidatorSet returns; the prover queries it for inclusion
// proofs and the root is what gets bound into the round digest.
type WeightedValidatorSet struct {
epoch uint64
leaves []WeightedValidatorLeaf // sorted by ValidatorID, no duplicates
leafHashes [][48]byte // leafHashes[i] = hash of leaves[i]
root [48]byte
}
// Root returns the 48-byte weighted-validator-set root. This is the value
// bound into ComputeRoundDigest's validatorSetRoot and carried in
// QBlock.ValidatorSetRoot.
func (s *WeightedValidatorSet) Root() [48]byte { return s.root }
// Epoch returns the epoch this set was committed under.
func (s *WeightedValidatorSet) Epoch() uint64 { return s.epoch }
// Len returns the number of validators in the set.
func (s *WeightedValidatorSet) Len() int { return len(s.leaves) }
// Leaves returns a copy of the canonical sorted leaves. Defensive copy so
// callers cannot mutate the committed set.
func (s *WeightedValidatorSet) Leaves() []WeightedValidatorLeaf {
out := make([]WeightedValidatorLeaf, len(s.leaves))
copy(out, s.leaves)
for i := range out {
out[i].PublicKey = append([]byte(nil), s.leaves[i].PublicKey...)
}
return out
}
// computeWeightedLeafHash returns the 48-byte digest of one leaf under the
// epoch and the total leaf count. The encoding is canonical and length-framed
// via TupleHash256, so no boundary-shifting attack can move bytes between
// fields.
//
// leafCount is the total number of leaves in the committed set. It is a
// tree-wide parameter folded into EVERY leaf digest — exactly as epoch is —
// so the same (validator_id, pubkey, weight) under a different claimed count
// produces a different leaf digest and therefore a different root. This is
// what makes the inclusion proof's (LeafIndex, LeafCount) canonical: the
// weighted proof SHAPE alone is many-to-one in the count (the collision
// classes {3,4}, {5,6,7,8}, … all share a shape), so without binding the
// count cryptographically into the root, an attacker could relabel LeafCount
// within its shape-class, keep the same siblings, and recompute an IDENTICAL
// root — a second byte-distinct cert over the same signer set (QUASAR-C5
// non-malleability break, see quorum_cert.go). Binding the count here makes
// the recomputed leaf digest depend on it, so any relabel changes the root
// and is rejected: exactly ONE (LeafIndex, LeafCount) verifies per real tree.
//
// Layout (TupleHash256, customization "QUASAR-WVSET-LEAF-V1"):
//
// parts[0] = "Quasar/WVSet/Leaf" (in-band protocol tag)
// parts[1] = epoch (8 BE)
// parts[2] = leaf_count (4 BE)
// parts[3] = validator_id ([32]byte)
// parts[4] = canonical_pubkey (variable, length-framed)
// parts[5] = voting_weight (8 BE)
// parts[6] = parameter_set_id (1)
// parts[7] = key_version (4 BE)
func computeWeightedLeafHash(epoch uint64, leafCount uint32, leaf WeightedValidatorLeaf) [48]byte {
var u64 [8]byte
var u32 [4]byte
binary.BigEndian.PutUint64(u64[:], epoch)
epochBytes := append([]byte(nil), u64[:]...)
binary.BigEndian.PutUint32(u32[:], leafCount)
countBytes := append([]byte(nil), u32[:]...)
binary.BigEndian.PutUint64(u64[:], leaf.VotingWeight)
weightBytes := append([]byte(nil), u64[:]...)
binary.BigEndian.PutUint32(u32[:], leaf.KeyVersion)
keyVerBytes := append([]byte(nil), u32[:]...)
parts := [][]byte{
[]byte(weightedLeafProtocolTag),
epochBytes,
countBytes,
leaf.ValidatorID[:],
leaf.PublicKey,
weightBytes,
{leaf.ParameterSetID},
keyVerBytes,
}
out := tupleHash256RoundDigest(parts, 48, weightedLeafCustomization)
var h [48]byte
copy(h[:], out)
return h
}
// computeWeightedNodeHash returns the 48-byte digest of an internal node
// combining a left and right child. Distinct customization from the leaf
// hash guarantees node/leaf domain separation.
//
// Layout (TupleHash256, customization "QUASAR-WVSET-NODE-V1"):
//
// parts[0] = "Quasar/WVSet/Node" (in-band protocol tag)
// parts[1] = left ([48]byte)
// parts[2] = right ([48]byte)
func computeWeightedNodeHash(left, right [48]byte) [48]byte {
parts := [][]byte{
[]byte(weightedNodeProtocolTag),
left[:],
right[:],
}
out := tupleHash256RoundDigest(parts, 48, weightedNodeCustomization)
var h [48]byte
copy(h[:], out)
return h
}
// BuildWeightedValidatorSet builds the commitment over the epoch validator
// set. Leaves are canonicalised by sorting on ValidatorID; a duplicate
// validator_id, a zero weight, or an empty public key is a hard error
// (fail-closed — a malformed set must never silently produce a root).
//
// The function takes ownership of nothing: it defensively copies the leaf
// public keys so a later mutation of the caller's slice cannot move the
// committed set underneath an already-produced root.
func BuildWeightedValidatorSet(epoch uint64, leaves []WeightedValidatorLeaf) (*WeightedValidatorSet, error) {
if len(leaves) == 0 {
return nil, ErrWVSetEmpty
}
// Defensive copy + canonical sort by validator_id.
sorted := make([]WeightedValidatorLeaf, len(leaves))
copy(sorted, leaves)
for i := range sorted {
if len(sorted[i].PublicKey) == 0 {
return nil, fmt.Errorf("%w: index %d", ErrWVSetEmptyPubKey, i)
}
if sorted[i].VotingWeight == 0 {
return nil, fmt.Errorf("%w: index %d", ErrWVSetZeroWeight, i)
}
sorted[i].PublicKey = append([]byte(nil), sorted[i].PublicKey...)
}
sort.Slice(sorted, func(i, j int) bool {
return bytesLess(sorted[i].ValidatorID[:], sorted[j].ValidatorID[:])
})
// Reject duplicate validator_id after sorting (adjacency check).
for i := 1; i < len(sorted); i++ {
if sorted[i].ValidatorID == sorted[i-1].ValidatorID {
return nil, fmt.Errorf("%w: %x", ErrWVSetDuplicateID, sorted[i].ValidatorID[:])
}
}
leafHashes := make([][48]byte, len(sorted))
count := uint32(len(sorted))
for i := range sorted {
leafHashes[i] = computeWeightedLeafHash(epoch, count, sorted[i])
}
root := weightedMerkleRoot(leafHashes)
return &WeightedValidatorSet{
epoch: epoch,
leaves: sorted,
leafHashes: leafHashes,
root: root,
}, nil
}
// weightedMerkleRoot folds the leaf digests into a single 48-byte root.
//
// Odd levels PROMOTE the unpaired node unchanged to the next level — they
// do NOT duplicate it. Promotion is sound here precisely because leaf and
// internal-node hashes are domain-separated and the per-level node count
// is determined by the (fixed) leaf count: there is no second multiset of
// leaves that yields the same root, because no leaf digest can ever be
// confused with an internal-node digest, and the structure is a function
// of n alone.
//
// A single-leaf set roots at that leaf's digest (the standard convention);
// the leaf-vs-node domain separation means even this degenerate root
// cannot be confused with an internal node.
func weightedMerkleRoot(leafHashes [][48]byte) [48]byte {
if len(leafHashes) == 0 {
return [48]byte{}
}
level := make([][48]byte, len(leafHashes))
copy(level, leafHashes)
for len(level) > 1 {
next := make([][48]byte, 0, (len(level)+1)/2)
i := 0
for ; i+1 < len(level); i += 2 {
next = append(next, computeWeightedNodeHash(level[i], level[i+1]))
}
if i < len(level) {
// Odd tail: promote unchanged. No duplication.
next = append(next, level[i])
}
level = next
}
return level[0]
}
// WeightedInclusionProof is a Merkle authentication path proving a single
// leaf is committed under a WeightedValidatorSet root. The proof carries
// the sibling digests bottom-up plus a left/right orientation bit per
// level. Promotion levels (where the leaf's subtree was the odd tail) carry
// NO sibling — recorded as a skip so verification mirrors construction
// exactly.
type WeightedInclusionProof struct {
// LeafIndex is the position of the proven leaf in the canonical sorted
// order. Bound so a verifier can re-derive the orientation independently
// of the Siblings orientation bits (defence-in-depth cross-check).
LeafIndex uint32
// LeafCount is the total number of leaves in the committed set. Bound so
// the verifier reconstructs the exact tree shape (which levels promote).
LeafCount uint32
// Steps is the bottom-up authentication path.
Steps []WeightedProofStep
}
// WeightedProofStep is one level of an inclusion proof.
type WeightedProofStep struct {
// Promoted is true at a level where the running node was the odd tail
// and was promoted unchanged (no sibling combined). Sibling/IsRight are
// ignored when Promoted is true.
Promoted bool
// Sibling is the 48-byte sibling digest combined at this level.
Sibling [48]byte
// SiblingIsRight reports whether Sibling is the RIGHT child (i.e. the
// running node is the LEFT child) at this level.
SiblingIsRight bool
}
// InclusionProof returns the authentication path for the leaf at the given
// canonical-sorted index. Returns ErrWVSetProofShape if the index is out of
// range.
func (s *WeightedValidatorSet) InclusionProof(leafIndex int) (*WeightedInclusionProof, error) {
n := len(s.leafHashes)
if leafIndex < 0 || leafIndex >= n {
return nil, fmt.Errorf("%w: index %d not in [0,%d)", ErrWVSetProofShape, leafIndex, n)
}
steps := make([]WeightedProofStep, 0)
level := make([][48]byte, n)
copy(level, s.leafHashes)
idx := leafIndex
for len(level) > 1 {
next := make([][48]byte, 0, (len(level)+1)/2)
i := 0
for ; i+1 < len(level); i += 2 {
next = append(next, computeWeightedNodeHash(level[i], level[i+1]))
}
hasOddTail := i < len(level)
if hasOddTail {
next = append(next, level[i])
}
if idx == len(level)-1 && hasOddTail {
// Our node is the promoted odd tail: no sibling this level.
steps = append(steps, WeightedProofStep{Promoted: true})
idx = len(next) - 1
} else {
// Our node was combined with its pair sibling.
if idx%2 == 0 {
// We are the left child; sibling is the right child.
steps = append(steps, WeightedProofStep{
Sibling: level[idx+1],
SiblingIsRight: true,
})
} else {
// We are the right child; sibling is the left child.
steps = append(steps, WeightedProofStep{
Sibling: level[idx-1],
SiblingIsRight: false,
})
}
idx = idx / 2
}
level = next
}
return &WeightedInclusionProof{
LeafIndex: uint32(leafIndex),
LeafCount: uint32(n),
Steps: steps,
}, nil
}
// VerifyWeightedInclusion recomputes the root from a leaf and an inclusion
// proof and reports whether it equals the expected root. Fail-closed: any
// structural mismatch (path length inconsistent with LeafCount, index out
// of range) returns false, never a partial accept.
//
// The verifier independently re-derives the per-level orientation from
// LeafIndex/LeafCount and cross-checks it against the proof's orientation
// bits, so a proof that lies about left/right is rejected even if its
// recomputed root happened to match.
func VerifyWeightedInclusion(root [48]byte, epoch uint64, leaf WeightedValidatorLeaf, proof *WeightedInclusionProof) bool {
if proof == nil {
return false
}
n := int(proof.LeafCount)
idx := int(proof.LeafIndex)
if n <= 0 || idx < 0 || idx >= n {
return false
}
// Re-derive the expected step shape from (idx, n) and confirm the
// proof's step count and promotion/orientation flags match exactly.
expSteps := weightedProofShape(idx, n)
if len(expSteps) != len(proof.Steps) {
return false
}
// Fold the CLAIMED leaf count into the leaf digest, exactly as the
// builder folds the real count. The weighted proof shape is many-to-one
// in the count (collision classes {3,4}, {5,6,7,8}, … share a shape), so
// the step-count/orientation cross-check above cannot distinguish an
// alternate count within the same class. Binding the count into the leaf
// digest here makes the recomputed root depend on it: a cert that relabels
// LeafCount within its shape-class yields a DIFFERENT digest and fails
// against the real root, so exactly ONE (LeafIndex, LeafCount) verifies
// per committed tree (QUASAR-C5 non-malleability).
running := computeWeightedLeafHash(epoch, uint32(n), leaf)
for level, exp := range expSteps {
got := proof.Steps[level]
if got.Promoted != exp.promoted {
return false
}
if exp.promoted {
// Promotion: running node carries up unchanged.
continue
}
if got.SiblingIsRight != exp.siblingIsRight {
return false
}
if exp.siblingIsRight {
running = computeWeightedNodeHash(running, got.Sibling)
} else {
running = computeWeightedNodeHash(got.Sibling, running)
}
}
return running == root
}
// weightedProofShapeStep is the verifier-derived expectation for one level.
type weightedProofShapeStep struct {
promoted bool
siblingIsRight bool
}
// weightedProofShape returns the canonical per-level orientation/promotion
// sequence for a leaf at index idx in a tree of n leaves. This is the
// single source of truth for tree geometry, shared between construction
// (InclusionProof) intent and verification (VerifyWeightedInclusion) so the
// two can never disagree about which levels promote.
func weightedProofShape(idx, n int) []weightedProofShapeStep {
steps := make([]weightedProofShapeStep, 0)
levelLen := n
for levelLen > 1 {
pairedLen := levelLen - (levelLen % 2) // number of nodes that pair up
hasOddTail := levelLen%2 == 1
if idx == levelLen-1 && hasOddTail {
steps = append(steps, weightedProofShapeStep{promoted: true})
// Promoted node becomes the last node of the next level.
idx = (pairedLen / 2)
} else {
steps = append(steps, weightedProofShapeStep{
siblingIsRight: idx%2 == 0,
})
idx = idx / 2
}
nextLen := pairedLen / 2
if hasOddTail {
nextLen++
}
levelLen = nextLen
}
return steps
}
// bytesLess is a constant-shape lexicographic comparison of equal-length
// byte slices (validator IDs are [32]byte). Not constant-time — validator
// IDs are public, so a data-dependent branch here leaks nothing secret.
func bytesLess(a, b []byte) bool {
for i := 0; i < len(a) && i < len(b); i++ {
if a[i] != b[i] {
return a[i] < b[i]
}
}
return len(a) < len(b)
}
+319
View File
@@ -0,0 +1,319 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package quasar
import (
"testing"
)
// mkLeaf builds a deterministic test leaf with a distinct id/pubkey/weight.
func mkLeaf(idByte byte, weight uint64, param uint8, keyVer uint32) WeightedValidatorLeaf {
var id [32]byte
for i := range id {
id[i] = idByte
}
pk := make([]byte, 16)
for i := range pk {
pk[i] = idByte ^ byte(i)
}
return WeightedValidatorLeaf{
ValidatorID: id,
PublicKey: pk,
VotingWeight: weight,
ParameterSetID: param,
KeyVersion: keyVer,
}
}
func TestWeightedMerkle_BuildAndRootDeterminism(t *testing.T) {
leaves := []WeightedValidatorLeaf{
mkLeaf(0x03, 10, 0x42, 1),
mkLeaf(0x01, 20, 0x42, 1),
mkLeaf(0x02, 30, 0x42, 1),
}
s1, err := BuildWeightedValidatorSet(7, leaves)
if err != nil {
t.Fatalf("build: %v", err)
}
// Re-order input: root must be identical (canonical sort by id).
reordered := []WeightedValidatorLeaf{leaves[2], leaves[0], leaves[1]}
s2, err := BuildWeightedValidatorSet(7, reordered)
if err != nil {
t.Fatalf("build reordered: %v", err)
}
if s1.Root() != s2.Root() {
t.Fatalf("root not invariant under input ordering: %x vs %x", s1.Root(), s2.Root())
}
var zero [48]byte
if s1.Root() == zero {
t.Fatal("root is zero for non-empty set")
}
}
func TestWeightedMerkle_EpochBinding(t *testing.T) {
leaves := []WeightedValidatorLeaf{mkLeaf(0x01, 10, 0x42, 1), mkLeaf(0x02, 10, 0x42, 1)}
a, _ := BuildWeightedValidatorSet(7, leaves)
b, _ := BuildWeightedValidatorSet(8, leaves)
if a.Root() == b.Root() {
t.Fatal("epoch is not bound into the root (different epochs collided)")
}
}
func TestWeightedMerkle_WeightBinding(t *testing.T) {
// The whole point of the task: bind WEIGHT. Flip a single weight, expect
// a different root.
base := []WeightedValidatorLeaf{mkLeaf(0x01, 10, 0x42, 1), mkLeaf(0x02, 20, 0x42, 1)}
mut := []WeightedValidatorLeaf{mkLeaf(0x01, 10, 0x42, 1), mkLeaf(0x02, 21, 0x42, 1)}
a, _ := BuildWeightedValidatorSet(7, base)
b, _ := BuildWeightedValidatorSet(7, mut)
if a.Root() == b.Root() {
t.Fatal("voting weight is not bound into the leaf (weight flip collided)")
}
}
func TestWeightedMerkle_ParamAndKeyVersionBinding(t *testing.T) {
base := []WeightedValidatorLeaf{mkLeaf(0x01, 10, 0x42, 1)}
param := []WeightedValidatorLeaf{mkLeaf(0x01, 10, 0x43, 1)}
keyv := []WeightedValidatorLeaf{mkLeaf(0x01, 10, 0x42, 2)}
a, _ := BuildWeightedValidatorSet(7, base)
b, _ := BuildWeightedValidatorSet(7, param)
c, _ := BuildWeightedValidatorSet(7, keyv)
if a.Root() == b.Root() {
t.Fatal("parameter_set_id not bound into leaf")
}
if a.Root() == c.Root() {
t.Fatal("key_version not bound into leaf")
}
}
func TestWeightedMerkle_RejectsBadInput(t *testing.T) {
if _, err := BuildWeightedValidatorSet(7, nil); err != ErrWVSetEmpty {
t.Fatalf("empty set err = %v, want ErrWVSetEmpty", err)
}
// Duplicate id.
dup := []WeightedValidatorLeaf{mkLeaf(0x01, 10, 0x42, 1), mkLeaf(0x01, 20, 0x42, 1)}
if _, err := BuildWeightedValidatorSet(7, dup); err == nil {
t.Fatal("duplicate validator_id accepted, want ErrWVSetDuplicateID")
}
// Zero weight.
zw := []WeightedValidatorLeaf{mkLeaf(0x01, 0, 0x42, 1)}
if _, err := BuildWeightedValidatorSet(7, zw); err == nil {
t.Fatal("zero weight accepted, want ErrWVSetZeroWeight")
}
// Empty pubkey.
ep := []WeightedValidatorLeaf{{ValidatorID: [32]byte{1}, VotingWeight: 10, ParameterSetID: 0x42}}
if _, err := BuildWeightedValidatorSet(7, ep); err == nil {
t.Fatal("empty pubkey accepted, want ErrWVSetEmptyPubKey")
}
}
func TestWeightedMerkle_InclusionSoundness(t *testing.T) {
// Every leaf at every set size must produce a verifying inclusion proof,
// and a NON-member must be rejected. Exercise sizes 1..9 to cover both
// even and odd levels (the promotion path).
for n := 1; n <= 9; n++ {
leaves := make([]WeightedValidatorLeaf, n)
for i := 0; i < n; i++ {
leaves[i] = mkLeaf(byte(i+1), uint64(10*(i+1)), 0x42, 1)
}
set, err := BuildWeightedValidatorSet(7, leaves)
if err != nil {
t.Fatalf("n=%d build: %v", n, err)
}
root := set.Root()
sorted := set.Leaves()
for idx := 0; idx < n; idx++ {
proof, err := set.InclusionProof(idx)
if err != nil {
t.Fatalf("n=%d idx=%d proof: %v", n, idx, err)
}
if !VerifyWeightedInclusion(root, 7, sorted[idx], proof) {
t.Fatalf("n=%d idx=%d: valid inclusion proof rejected", n, idx)
}
// Wrong epoch must fail.
if VerifyWeightedInclusion(root, 8, sorted[idx], proof) {
t.Fatalf("n=%d idx=%d: inclusion accepted under wrong epoch", n, idx)
}
// Non-member leaf (mutated weight) must fail under the same path.
bad := sorted[idx]
bad.VotingWeight++
if VerifyWeightedInclusion(root, 7, bad, proof) {
t.Fatalf("n=%d idx=%d: non-member (mutated weight) accepted", n, idx)
}
}
}
}
func TestWeightedMerkle_TamperedPathRejected(t *testing.T) {
leaves := make([]WeightedValidatorLeaf, 5)
for i := range leaves {
leaves[i] = mkLeaf(byte(i+1), 10, 0x42, 1)
}
set, _ := BuildWeightedValidatorSet(7, leaves)
root := set.Root()
sorted := set.Leaves()
proof, _ := set.InclusionProof(2)
if !VerifyWeightedInclusion(root, 7, sorted[2], proof) {
t.Fatal("baseline proof must verify")
}
// Flip a sibling byte → reject.
if len(proof.Steps) > 0 {
tampered := *proof
tampered.Steps = append([]WeightedProofStep(nil), proof.Steps...)
for i := range tampered.Steps {
if !tampered.Steps[i].Promoted {
tampered.Steps[i].Sibling[0] ^= 0xFF
break
}
}
if VerifyWeightedInclusion(root, 7, sorted[2], &tampered) {
t.Fatal("tampered sibling accepted")
}
}
// Flip an orientation bit → reject (verifier re-derives orientation).
flip := *proof
flip.Steps = append([]WeightedProofStep(nil), proof.Steps...)
for i := range flip.Steps {
if !flip.Steps[i].Promoted {
flip.Steps[i].SiblingIsRight = !flip.Steps[i].SiblingIsRight
break
}
}
if VerifyWeightedInclusion(root, 7, sorted[2], &flip) {
t.Fatal("flipped orientation accepted")
}
// Lie about LeafIndex/LeafCount → reject (shape mismatch).
wrongShape := *proof
wrongShape.LeafCount = 99
if VerifyWeightedInclusion(root, 7, sorted[2], &wrongShape) {
t.Fatal("wrong LeafCount accepted")
}
// Out-of-range index in InclusionProof → error.
if _, err := set.InclusionProof(5); err == nil {
t.Fatal("out-of-range inclusion index accepted")
}
}
// TestWeightedMerkle_LeafCountCanonical is the RED-1 regression: the weighted
// proof SHAPE is many-to-one in the leaf count (collision classes {3,4},
// {5,6,7,8}, {9..16}, {17..32}, … all share a shape per leaf index), so the
// step-count/orientation cross-check alone cannot distinguish an alternate
// LeafCount within a class. Before binding the count into the leaf digest, an
// attacker could relabel LeafCount within its shape-class, keep the same
// siblings, and recompute an IDENTICAL root — a second byte-distinct cert over
// the same signer set (a QUASAR-C5 non-malleability break and a dedup/
// equivocation hazard). This test asserts that across every (n, idx) up to 32,
// ONLY the canonical (idx, n) verifies and EVERY alternate count is rejected,
// including same-shape-class counts that defeat the structural guards.
func TestWeightedMerkle_LeafCountCanonical(t *testing.T) {
const maxN = 32
// shapeKey serialises weightedProofShape so we can detect when an alternate
// count lands in the SAME shape class as the honest one (the dangerous
// case the structural cross-check cannot catch).
shapeKey := func(idx, n int) string {
var b []byte
for _, s := range weightedProofShape(idx, n) {
switch {
case s.promoted:
b = append(b, 'P')
case s.siblingIsRight:
b = append(b, 'R')
default:
b = append(b, 'L')
}
}
return string(b)
}
sawSameClassForgeryAttempt := false
for n := 1; n <= maxN; n++ {
leaves := make([]WeightedValidatorLeaf, n)
for i := 0; i < n; i++ {
leaves[i] = mkLeaf(byte(i+1), uint64(10*(i+1)), 0x42, 1)
}
set, err := BuildWeightedValidatorSet(7, leaves)
if err != nil {
t.Fatalf("n=%d build: %v", n, err)
}
root := set.Root()
sorted := set.Leaves()
for idx := 0; idx < n; idx++ {
proof, err := set.InclusionProof(idx)
if err != nil {
t.Fatalf("n=%d idx=%d proof: %v", n, idx, err)
}
// Canonical proof must verify.
if !VerifyWeightedInclusion(root, 7, sorted[idx], proof) {
t.Fatalf("n=%d idx=%d: canonical proof rejected", n, idx)
}
honestShape := shapeKey(idx, n)
// Forge by relabeling LeafCount to every other valid count, keeping
// the honest leaf and the honest Steps (siblings + flags). EVERY
// alternate count must be rejected.
for np := 1; np <= maxN; np++ {
if np == n || idx >= np {
continue
}
forged := *proof
forged.LeafCount = uint32(np)
if shapeKey(idx, np) == honestShape {
// Same-class relabel: step count + orientation flags STILL
// match expSteps, so only the count-binding can reject it.
sawSameClassForgeryAttempt = true
}
if VerifyWeightedInclusion(root, 7, sorted[idx], &forged) {
t.Fatalf("n=%d idx=%d: forged LeafCount=%d (shape %q vs honest %q) ACCEPTED — malleability open",
n, idx, np, shapeKey(idx, np), honestShape)
}
}
}
}
// Guard the test itself: confirm we actually exercised the dangerous
// same-shape-class relabel (e.g. (idx=0,n=3) vs (idx=0,n=4) both "RR"),
// not just trivially-different shapes the length check already caught.
if !sawSameClassForgeryAttempt {
t.Fatal("test did not exercise a same-shape-class LeafCount relabel; " +
"the regression would pass vacuously")
}
}
// TestWeightedMerkle_NoDuplicationSecondPreimage guards the CVE-2012-2459
// class: with an odd number of leaves, a tree that DUPLICATES the last leaf
// makes the (n) and (n with last leaf repeated) multisets share a root.
// This tree PROMOTES instead, so appending a duplicate of the last leaf is
// rejected at build (duplicate id), and even constructing the doubled leaf
// hash list directly yields a different root than the n-leaf set.
func TestWeightedMerkle_NoDuplicationSecondPreimage(t *testing.T) {
leaves := []WeightedValidatorLeaf{
mkLeaf(0x01, 10, 0x42, 1),
mkLeaf(0x02, 10, 0x42, 1),
mkLeaf(0x03, 10, 0x42, 1),
}
set, _ := BuildWeightedValidatorSet(7, leaves)
// Hand-roll the "duplicate last leaf" root the vulnerable construction
// would produce, and confirm it does NOT equal our promotion root. The
// vulnerable construction has FOUR leaf slots, so it binds leaf_count=4;
// the honest set binds leaf_count=3. The roots therefore differ both by
// tree structure (promotion vs duplication) AND by the bound count.
dup := make([][48]byte, 0, 4)
for _, l := range leaves {
dup = append(dup, computeWeightedLeafHash(7, 4, l))
}
dup = append(dup, dup[len(dup)-1]) // 4 leaves, last duplicated
dupRoot := weightedMerkleRoot(dup)
if dupRoot == set.Root() {
t.Fatal("promotion root collides with last-leaf-duplication root (CVE-2012-2459 surface)")
}
}
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.