mirror of
https://github.com/luxfi/consensus.git
synced 2026-09-24 00:26:58 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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.
@@ -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=
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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:])
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
go test fuzz v1
|
||||
[]byte("0\x00\x00\x00\x010")
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
Binary file not shown.
Reference in New Issue
Block a user