mirror of
https://github.com/luxfi/consensus.git
synced 2026-09-24 00:26:58 +00:00
quasar: verifiable weighted quorum certificate (threshold certification, not signing)
The production PQ consensus threshold object: each validator signs the
domain-separated round message INDEPENDENTLY with its own FIPS 204 ML-DSA /
FIPS 205 SLH-DSA / Corona key; the certificate proves a quorum predicate over
those independent signatures — NO key sharing, DKG, seed/WOTS+/FORS
reconstruction, or per-leaf secret exposure. This is the safe, leaderless,
permissionless, NIST-clean realization of QuasarCert's non-threshold path
(theory: cheap black-box threshold hash-based signing is impossible in the
no-dealer model — Kondi-Kumar-Vanegas; only independent-sig certification or
expensive generic MPC remain).
- weighted_merkle.go: epoch validator-set Merkle commitment; leaf binds
(epoch, validator_id, pubkey, voting_weight, param_set_id, key_version),
sorted, second-preimage-guarded.
- quorum_message.go: one domain-separated consensus message binding
chain/epoch/height/round/value/qc_type — no cross-domain validity.
- quorum_scheme.go: pluggable per-signer verify via UNMODIFIED FIPS 204/205
(+ Corona) verification; one framework, three schemes.
- quorum_cert.go: prover (permissionless, no secrets) + verifier predicate —
Merkle inclusion, strictly-increasing distinct signers, Σweight ≥ threshold,
aggregate-weight + signer-commitment binding; unknown validator = invalid
(not fatal). Compact (commitment-only) + full (records) DA encodings.
- quorum_quasarcert.go: QuasarCert weighted-quorum leg + discriminator.
- config: VerifierDirectWeightedQuorumPQ = 0x0030 (carries no trusted setup).
40 tests: independent-verifier interop, distinct-signer, weight-threshold,
cross-domain non-transferability, Merkle soundness, tamper rejection,
mixed-scheme, compact↔full round-trip. (go.sum: correct stale luxfi/threshold
v1.9.4 hash to the published tag.)
This commit is contained in:
+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 {
|
||||
|
||||
@@ -202,8 +202,8 @@ github.com/luxfi/runtime v1.1.0 h1:6TrvzAmZVCTVbR1ebntHTO3/kVBaogPUSkxdDMnrTiw=
|
||||
github.com/luxfi/runtime v1.1.0/go.mod h1:Mfv2zlXqvfRFMS+/zXgG1TieyP9VnvtVzOGB437+o4Y=
|
||||
github.com/luxfi/sampler v1.1.0 h1:u3iRDl7V06ARh0e85h3HT+aZ1saCFo2yMMsh+dCJbqk=
|
||||
github.com/luxfi/sampler v1.1.0/go.mod h1:kJa53S3tC9+VSbuV3RFu68MmbCCBlr2UM39LOClQ/Hs=
|
||||
github.com/luxfi/threshold v1.9.4 h1:H69e9QkvygDtWkni1FkD5ztLdiTasmpJXcuVzgebdxo=
|
||||
github.com/luxfi/threshold v1.9.4/go.mod h1:o50rsc4H9fIXHuZXSf93qDFfRTXncXaXf1Q+6JAD1vU=
|
||||
github.com/luxfi/threshold v1.9.4 h1:/TsgIzo/e/DIx++J0+9eNuS7HkpSaXbVk+HvhlUOsmE=
|
||||
github.com/luxfi/threshold v1.9.4/go.mod h1:yFEqzc07h+olG8CpJ+i2FjSLxCznk/GcISRfxMuDBvE=
|
||||
github.com/luxfi/utils v1.1.5 h1:6q85557b7djbZ/OF6S1k5wyGddozWnsoUuhokPHQRvo=
|
||||
github.com/luxfi/utils v1.1.5/go.mod h1:T2OCKT1xG9jtKR/gyJQoSkticzrE9WFQ8eohJHGu9Fg=
|
||||
github.com/luxfi/validators v1.2.0 h1:VygpiBqBAdGrfkb7xzE2yrVmnXaqE+hm8FLWdGXO7G8=
|
||||
|
||||
@@ -0,0 +1,907 @@
|
||||
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
// quorum_cert.go — the verifiable weighted quorum certificate.
|
||||
//
|
||||
// A WeightedQuorumCert proves: a quorum-WEIGHT subset of the epoch
|
||||
// validator set each produced a valid INDEPENDENT FIPS 204 ML-DSA /
|
||||
// FIPS 205 SLH-DSA signature over the same domain-separated consensus
|
||||
// message. The proof is the certificate itself — there is no succinct
|
||||
// (STARK/SNARK) layer here; verification is direct (full-node-verifiable
|
||||
// mode):
|
||||
//
|
||||
// for each signer record:
|
||||
// (a) the record's leaf is included under validator_set_root (Merkle)
|
||||
// (b) the record's parameter set is in the allowed set
|
||||
// (c) the record's signature verifies under the stock FIPS verifier
|
||||
// (d) validator_ids are STRICTLY INCREASING (distinct, canonical,
|
||||
// anti-double-count)
|
||||
// accumulate voting weight; then
|
||||
// Σ weight ≥ quorum_threshold
|
||||
// Σ weight == aggregate_weight
|
||||
// commit(signer_ids) == signer_commitment
|
||||
//
|
||||
// Security posture (NIST Class N1): soundness rests ONLY on (i) unmodified
|
||||
// FIPS 204/205 verification — each signer's signature is byte-identical to
|
||||
// what cloudflare/circl, the NIST reference, or openssl-pq accept; and (ii)
|
||||
// the weighted-validator-set Merkle commitment's second-preimage resistance
|
||||
// (weighted_merkle.go). No private key material is shared, reconstructed,
|
||||
// or combined. This is threshold CERTIFICATION, NOT threshold ML-DSA /
|
||||
// SLH-DSA signing: there is no DKG, no seed / WOTS+ / FORS / secret share
|
||||
// ever combined or exposed. A STARK compression layer over the SAME
|
||||
// relation is a SEPARATE future backend (ProofBackendID 0x10/0x20 block);
|
||||
// it is out of scope here.
|
||||
//
|
||||
// Permissionless: building a cert needs NO secrets — a prover collects
|
||||
// already-produced signer records, sorts them, and assembles. Leaderless:
|
||||
// any node with the records can build the identical cert.
|
||||
package quasar
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/luxfi/consensus/config"
|
||||
)
|
||||
|
||||
// QuorumCertVersion is the wire/struct version of the weighted quorum
|
||||
// certificate. Bound into the signer commitment and the encodings so a
|
||||
// version bump is non-malleable.
|
||||
const QuorumCertVersion uint16 = 1
|
||||
|
||||
// signerCommitmentCustomization is the cSHAKE256 customization tag for the
|
||||
// signer-set commitment. Wire-stable cryptographic constant.
|
||||
const signerCommitmentCustomization = "QUASAR-WQC-SIGNERS-V1"
|
||||
|
||||
// signerCommitmentProtocolTag is the in-band redundant protocol tag for the
|
||||
// signer-set commitment.
|
||||
const signerCommitmentProtocolTag = "Quasar/WQC/Signers"
|
||||
|
||||
// QuorumSignerRecord is one signer's contribution to a weighted quorum
|
||||
// certificate: their identity, the public key + accountability fields that
|
||||
// MUST match the validator-set leaf, the Merkle path proving that leaf is
|
||||
// committed, and their independent signature over the consensus message.
|
||||
//
|
||||
// Every field except MerklePath and Signature is also a validator-set leaf
|
||||
// field; the verifier reconstructs the leaf from these and checks the path.
|
||||
// A record whose embedded fields do not match a committed leaf fails the
|
||||
// Merkle check — that is how an unknown validator / pubkey-mismatch /
|
||||
// out-of-set record is rejected (cleanly, never a panic).
|
||||
type QuorumSignerRecord struct {
|
||||
// ValidatorID is the signer's canonical 32-byte identifier. Records are
|
||||
// sorted strictly-increasing by this field.
|
||||
ValidatorID [32]byte
|
||||
|
||||
// PublicKey is the signer's canonical public-key bytes. Bound into the
|
||||
// leaf AND used as the verification key — both must agree.
|
||||
PublicKey []byte
|
||||
|
||||
// VotingWeight is the signer's stake weight. Bound into the leaf and
|
||||
// accumulated toward the quorum threshold.
|
||||
VotingWeight uint64
|
||||
|
||||
// Scheme names the signature scheme this record was produced under
|
||||
// (ML-DSA / SLH-DSA per FIPS 204 / 205). The verifier dispatches to the
|
||||
// stock FIPS verifier for this scheme.
|
||||
Scheme QuorumSchemeID
|
||||
|
||||
// ParamSetID is the parameter-set byte bound into the validator-set
|
||||
// leaf. It MUST equal byte(Scheme) — the leaf commits to the parameter
|
||||
// set, so a record cannot claim one scheme while the leaf pins another.
|
||||
ParamSetID uint8
|
||||
|
||||
// KeyVersion is the signer's key-rotation counter, bound into the leaf.
|
||||
KeyVersion uint32
|
||||
|
||||
// MerklePath proves leaf(ValidatorID, PublicKey, VotingWeight,
|
||||
// ParamSetID, KeyVersion) is included under validator_set_root.
|
||||
MerklePath *WeightedInclusionProof
|
||||
|
||||
// Signature is the signer's independent signature over the consensus
|
||||
// message. Verified by the stock FIPS verifier for Scheme.
|
||||
Signature []byte
|
||||
}
|
||||
|
||||
// leaf reconstructs the validator-set leaf this record claims.
|
||||
func (r *QuorumSignerRecord) leaf() WeightedValidatorLeaf {
|
||||
return WeightedValidatorLeaf{
|
||||
ValidatorID: r.ValidatorID,
|
||||
PublicKey: r.PublicKey,
|
||||
VotingWeight: r.VotingWeight,
|
||||
ParameterSetID: r.ParamSetID,
|
||||
KeyVersion: r.KeyVersion,
|
||||
}
|
||||
}
|
||||
|
||||
// WeightedQuorumCert is the certificate. It is NOT a signature: there is no
|
||||
// "aggregate_signature" field, because nothing is aggregated — the cert
|
||||
// carries (in full mode) the per-signer records, or (in compact mode) just
|
||||
// the commitment to them. Verification is the predicate documented at the
|
||||
// top of this file.
|
||||
type WeightedQuorumCert struct {
|
||||
// Version pins the cert format.
|
||||
Version uint16
|
||||
|
||||
// Consensus position — the message-binding axes. These MUST equal the
|
||||
// values the signers' message was derived from (quorum_message.go).
|
||||
ChainID uint32
|
||||
Epoch uint64
|
||||
Height uint64
|
||||
Round uint32
|
||||
|
||||
// ValueHash is the block_id / value hash this quorum finalises (the
|
||||
// payload anchor of the consensus message).
|
||||
ValueHash [32]byte
|
||||
|
||||
// QCType names the certificate's semantic role (prepare / commit /
|
||||
// finality / …) so a signature for one role cannot be replayed as
|
||||
// another. Bound into the consensus message.
|
||||
QCType uint8
|
||||
|
||||
// ValidatorSetRoot is the weighted-validator-set commitment every
|
||||
// signer's leaf is proven against (weighted_merkle.go). Equals the
|
||||
// epoch's committed root.
|
||||
ValidatorSetRoot [48]byte
|
||||
|
||||
// QuorumThreshold is the minimum total voting weight required for the
|
||||
// cert to be valid. Σ signer weight must meet or exceed it.
|
||||
QuorumThreshold uint64
|
||||
|
||||
// AggregateWeight is the prover's claimed total signer weight. The
|
||||
// verifier recomputes Σ weight and asserts equality — a tampered
|
||||
// aggregate is rejected.
|
||||
AggregateWeight uint64
|
||||
|
||||
// SignerCommitment is the cSHAKE256 commitment to the ordered signer-id
|
||||
// set (+ version + position). The verifier recomputes it from the
|
||||
// records and asserts equality — closes signer-set malleability.
|
||||
SignerCommitment [48]byte
|
||||
|
||||
// SignerCount is the number of signer records (the cardinality the
|
||||
// compact encoding still commits to even when records are omitted).
|
||||
SignerCount uint32
|
||||
|
||||
// Signers is the per-signer records. Populated in FULL mode (records in
|
||||
// the certificate); EMPTY in COMPACT mode (records live in the DA layer,
|
||||
// the header carries only the commitment). A compact cert is verified
|
||||
// by re-attaching records from DA and calling Verify.
|
||||
Signers []QuorumSignerRecord
|
||||
}
|
||||
|
||||
// Typed verification errors. Each maps 1:1 to one predicate clause so a
|
||||
// caller / test can name the exact failure. Every one is a CLEAN rejection
|
||||
// (the cert is invalid); none is a panic or an unbounded-work DoS.
|
||||
var (
|
||||
ErrQCNil = errors.New("quasar: nil weighted quorum cert")
|
||||
ErrQCVersion = errors.New("quasar: weighted quorum cert version mismatch")
|
||||
ErrQCNoSigners = errors.New("quasar: weighted quorum cert has no signer records")
|
||||
ErrQCCompactNoRecords = errors.New("quasar: compact cert carries no records; re-attach from DA before Verify")
|
||||
ErrQCSignerCountMismatch = errors.New("quasar: signer record count does not match SignerCount")
|
||||
ErrQCNotStrictlyIncreasing = errors.New("quasar: signer validator_ids are not strictly increasing (duplicate or unsorted)")
|
||||
ErrQCSchemeNotAllowed = errors.New("quasar: signer scheme not in allowed set")
|
||||
ErrQCParamSetMismatch = errors.New("quasar: signer ParamSetID does not equal scheme byte")
|
||||
ErrQCPubKeyLen = errors.New("quasar: signer public key length wrong for its scheme")
|
||||
ErrQCMerkleInclusion = errors.New("quasar: signer leaf not included under validator_set_root")
|
||||
ErrQCSigInvalid = errors.New("quasar: signer signature failed FIPS verification")
|
||||
ErrQCBelowThreshold = errors.New("quasar: total signer weight below quorum threshold")
|
||||
ErrQCAggregateWeight = errors.New("quasar: recomputed signer weight != AggregateWeight")
|
||||
ErrQCSignerCommitment = errors.New("quasar: recomputed signer commitment != SignerCommitment")
|
||||
ErrQCWeightOverflow = errors.New("quasar: signer weight sum overflows uint64")
|
||||
ErrQCThresholdZero = errors.New("quasar: quorum threshold is zero")
|
||||
)
|
||||
|
||||
// QuorumVerifierConfig pins the policy axes the verifier enforces. Kept
|
||||
// separate from the cert so verification policy (what's allowed) and the
|
||||
// cert (what's claimed) are not braided.
|
||||
type QuorumVerifierConfig struct {
|
||||
// AllowedSchemes is the set of per-signer schemes admissible on this
|
||||
// chain. A record whose scheme is absent fails (ErrQCSchemeNotAllowed).
|
||||
// Empty means "every scheme the registry knows" — convenient for tests;
|
||||
// production callers pin an explicit set via the chain profile.
|
||||
AllowedSchemes map[QuorumSchemeID]bool
|
||||
|
||||
// Context is the FIPS context string passed to every per-signer verify.
|
||||
// It MUST match what signers bound at sign time. Length ≤ 255.
|
||||
Context []byte
|
||||
}
|
||||
|
||||
// schemeAllowed reports whether scheme is admissible under this config.
|
||||
func (c *QuorumVerifierConfig) schemeAllowed(scheme QuorumSchemeID) bool {
|
||||
if len(c.AllowedSchemes) == 0 {
|
||||
_, known := defaultQuorumVerifiers[scheme]
|
||||
return known
|
||||
}
|
||||
return c.AllowedSchemes[scheme]
|
||||
}
|
||||
|
||||
// Verify checks the certificate against message under cfg. Returns nil iff
|
||||
// every predicate clause holds; otherwise a typed error naming the first
|
||||
// failure. NEVER panics and NEVER does unbounded work — an adversarial cert
|
||||
// (unknown validator, bad signature, tampered weight) yields a clean error,
|
||||
// not a node crash.
|
||||
//
|
||||
// message is the domain-separated consensus message the signers signed —
|
||||
// build it with QuorumConsensusMessage so the binding to
|
||||
// (chain_id, epoch, height, round, value_hash, qc_type, validator_set_root)
|
||||
// is canonical and matches what the cert claims.
|
||||
//
|
||||
// The verifier cross-checks the cert's own position fields against the
|
||||
// supplied message via the caller's discipline: the caller derives message
|
||||
// from the SAME (chain_id, epoch, …) it expects, so a cert whose signers
|
||||
// signed a different message fails clause (c) for every record. The
|
||||
// position fields are additionally bound into SignerCommitment, so a cert
|
||||
// that lies about its own position fails clause (commitment) too.
|
||||
func (c *WeightedQuorumCert) Verify(message []byte, cfg QuorumVerifierConfig) error {
|
||||
if c == nil {
|
||||
return ErrQCNil
|
||||
}
|
||||
if c.Version != QuorumCertVersion {
|
||||
return fmt.Errorf("%w: got %d want %d", ErrQCVersion, c.Version, QuorumCertVersion)
|
||||
}
|
||||
if c.QuorumThreshold == 0 {
|
||||
return ErrQCThresholdZero
|
||||
}
|
||||
if c.SignerCount == 0 {
|
||||
return ErrQCNoSigners
|
||||
}
|
||||
if len(c.Signers) == 0 {
|
||||
// Compact cert: records were stripped to the DA layer. Cannot verify
|
||||
// the predicate without them. Caller must re-attach records first.
|
||||
return ErrQCCompactNoRecords
|
||||
}
|
||||
if uint32(len(c.Signers)) != c.SignerCount {
|
||||
return fmt.Errorf("%w: records=%d SignerCount=%d",
|
||||
ErrQCSignerCountMismatch, len(c.Signers), c.SignerCount)
|
||||
}
|
||||
|
||||
var total uint64
|
||||
var prev [32]byte
|
||||
havePrev := false
|
||||
|
||||
for i := range c.Signers {
|
||||
rec := &c.Signers[i]
|
||||
|
||||
// Clause (d): strictly increasing validator_ids. Distinct +
|
||||
// canonical order; closes duplicate-signer double counting and
|
||||
// signer-set malleability (re-ordering a valid cert).
|
||||
if havePrev && !bytesLess(prev[:], rec.ValidatorID[:]) {
|
||||
return fmt.Errorf("%w: record %d", ErrQCNotStrictlyIncreasing, i)
|
||||
}
|
||||
prev = rec.ValidatorID
|
||||
havePrev = true
|
||||
|
||||
// Clause (b): scheme in the allowed set.
|
||||
if !cfg.schemeAllowed(rec.Scheme) {
|
||||
return fmt.Errorf("%w: record %d scheme %s", ErrQCSchemeNotAllowed, i, rec.Scheme)
|
||||
}
|
||||
|
||||
// ParamSetID must equal the scheme byte — the leaf commits to the
|
||||
// parameter set, so a record cannot claim ML-DSA-65 while its leaf
|
||||
// pins ML-DSA-44 (cross-parameter confusion).
|
||||
if rec.ParamSetID != uint8(rec.Scheme) {
|
||||
return fmt.Errorf("%w: record %d param=0x%02x scheme=0x%02x",
|
||||
ErrQCParamSetMismatch, i, rec.ParamSetID, uint8(rec.Scheme))
|
||||
}
|
||||
|
||||
// Public-key length must match the scheme before any dispatch.
|
||||
wantLen, known := expectedPubKeyLen(rec.Scheme)
|
||||
if !known {
|
||||
return fmt.Errorf("%w: record %d scheme %s", ErrQCSchemeNotAllowed, i, rec.Scheme)
|
||||
}
|
||||
if len(rec.PublicKey) != wantLen {
|
||||
return fmt.Errorf("%w: record %d scheme %s have=%d want=%d",
|
||||
ErrQCPubKeyLen, i, rec.Scheme, len(rec.PublicKey), wantLen)
|
||||
}
|
||||
|
||||
// Clause (a): Merkle inclusion under validator_set_root. This is
|
||||
// where an unknown validator / pubkey-mismatch / out-of-set record
|
||||
// is rejected — its reconstructed leaf is not in the committed tree.
|
||||
if !VerifyWeightedInclusion(c.ValidatorSetRoot, c.Epoch, rec.leaf(), rec.MerklePath) {
|
||||
return fmt.Errorf("%w: record %d", ErrQCMerkleInclusion, i)
|
||||
}
|
||||
|
||||
// Clause (c): unmodified FIPS verify. The dispatch is a thin call
|
||||
// into the stock FIPS 204 / 205 verifier for the scheme.
|
||||
verify := defaultQuorumVerifiers[rec.Scheme]
|
||||
if verify == nil || !verify(rec.PublicKey, message, cfg.Context, rec.Signature) {
|
||||
return fmt.Errorf("%w: record %d scheme %s", ErrQCSigInvalid, i, rec.Scheme)
|
||||
}
|
||||
|
||||
// Accumulate weight (overflow-checked).
|
||||
if total+rec.VotingWeight < total {
|
||||
return ErrQCWeightOverflow
|
||||
}
|
||||
total += rec.VotingWeight
|
||||
}
|
||||
|
||||
// Σ weight ≥ quorum_threshold.
|
||||
if total < c.QuorumThreshold {
|
||||
return fmt.Errorf("%w: have %d need %d", ErrQCBelowThreshold, total, c.QuorumThreshold)
|
||||
}
|
||||
|
||||
// Σ weight == aggregate_weight (tamper check).
|
||||
if total != c.AggregateWeight {
|
||||
return fmt.Errorf("%w: recomputed %d claimed %d", ErrQCAggregateWeight, total, c.AggregateWeight)
|
||||
}
|
||||
|
||||
// commit(signer_ids) == signer_commitment (signer-set malleability).
|
||||
want := c.computeSignerCommitment()
|
||||
if want != c.SignerCommitment {
|
||||
return ErrQCSignerCommitment
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// computeSignerCommitment recomputes the cSHAKE256 commitment over the
|
||||
// ordered signer-id set plus the cert's version and consensus position.
|
||||
// Binding the position fields here means a cert that lies about its own
|
||||
// (chain_id, epoch, height, round, value_hash, qc_type, validator_set_root,
|
||||
// quorum_threshold) fails the commitment check even before the per-signer
|
||||
// message binding catches it — defence-in-depth against header tampering.
|
||||
//
|
||||
// Layout (TupleHash256, customization "QUASAR-WQC-SIGNERS-V1"):
|
||||
//
|
||||
// parts[0] = "Quasar/WQC/Signers"
|
||||
// parts[1] = version (2 BE)
|
||||
// parts[2] = chain_id (4 BE)
|
||||
// parts[3] = epoch (8 BE)
|
||||
// parts[4] = height (8 BE)
|
||||
// parts[5] = round (4 BE)
|
||||
// parts[6] = value_hash ([32]byte)
|
||||
// parts[7] = qc_type (1)
|
||||
// parts[8] = validator_set_root ([48]byte)
|
||||
// parts[9] = quorum_threshold (8 BE)
|
||||
// parts[10] = signer_count (4 BE)
|
||||
// parts[11+i] = validator_id[i] ([32]byte), in record order
|
||||
func (c *WeightedQuorumCert) computeSignerCommitment() [48]byte {
|
||||
var u16 [2]byte
|
||||
var u32 [4]byte
|
||||
var u64 [8]byte
|
||||
|
||||
binary.BigEndian.PutUint16(u16[:], c.Version)
|
||||
verBytes := append([]byte(nil), u16[:]...)
|
||||
binary.BigEndian.PutUint32(u32[:], c.ChainID)
|
||||
chainBytes := append([]byte(nil), u32[:]...)
|
||||
binary.BigEndian.PutUint64(u64[:], c.Epoch)
|
||||
epochBytes := append([]byte(nil), u64[:]...)
|
||||
binary.BigEndian.PutUint64(u64[:], c.Height)
|
||||
heightBytes := append([]byte(nil), u64[:]...)
|
||||
binary.BigEndian.PutUint32(u32[:], c.Round)
|
||||
roundBytes := append([]byte(nil), u32[:]...)
|
||||
binary.BigEndian.PutUint64(u64[:], c.QuorumThreshold)
|
||||
threshBytes := append([]byte(nil), u64[:]...)
|
||||
binary.BigEndian.PutUint32(u32[:], c.SignerCount)
|
||||
countBytes := append([]byte(nil), u32[:]...)
|
||||
|
||||
parts := make([][]byte, 0, 11+len(c.Signers))
|
||||
parts = append(parts,
|
||||
[]byte(signerCommitmentProtocolTag),
|
||||
verBytes,
|
||||
chainBytes,
|
||||
epochBytes,
|
||||
heightBytes,
|
||||
roundBytes,
|
||||
c.ValueHash[:],
|
||||
[]byte{c.QCType},
|
||||
c.ValidatorSetRoot[:],
|
||||
threshBytes,
|
||||
countBytes,
|
||||
)
|
||||
for i := range c.Signers {
|
||||
parts = append(parts, c.Signers[i].ValidatorID[:])
|
||||
}
|
||||
|
||||
out := tupleHash256RoundDigest(parts, 48, signerCommitmentCustomization)
|
||||
var h [48]byte
|
||||
copy(h[:], out)
|
||||
return h
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Prover (permissionless — needs NO secrets)
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
// QuorumCertParams names the consensus position + policy a cert is built
|
||||
// for. Separate from the records so the prover's two inputs (what we're
|
||||
// certifying / who signed) are not braided.
|
||||
type QuorumCertParams struct {
|
||||
ChainID uint32
|
||||
Epoch uint64
|
||||
Height uint64
|
||||
Round uint32
|
||||
ValueHash [32]byte
|
||||
QCType uint8
|
||||
ValidatorSetRoot [48]byte
|
||||
QuorumThreshold uint64
|
||||
}
|
||||
|
||||
// BuildWeightedQuorumCert assembles a FULL (records-included) certificate
|
||||
// from already-produced signer records. It is permissionless and
|
||||
// deterministic: NO secrets, NO randomness, NO hidden state. Given the same
|
||||
// records and params, the same cert bytes come out.
|
||||
//
|
||||
// The prover sorts records strictly-increasing by validator_id (rejecting
|
||||
// duplicates), computes aggregate_weight, and computes the signer
|
||||
// commitment. It does NOT verify the records' signatures — assembly is
|
||||
// orthogonal to verification (a relaying node assembles; verifiers verify).
|
||||
// It DOES reject a structurally impossible cert (no records, duplicate ids,
|
||||
// zero threshold) so a malformed cert is never produced.
|
||||
func BuildWeightedQuorumCert(params QuorumCertParams, records []QuorumSignerRecord) (*WeightedQuorumCert, error) {
|
||||
if params.QuorumThreshold == 0 {
|
||||
return nil, ErrQCThresholdZero
|
||||
}
|
||||
if len(records) == 0 {
|
||||
return nil, ErrQCNoSigners
|
||||
}
|
||||
|
||||
sorted := make([]QuorumSignerRecord, len(records))
|
||||
copy(sorted, records)
|
||||
// Defensive copy of variable-length fields so a later caller mutation
|
||||
// cannot move the assembled cert underneath us.
|
||||
for i := range sorted {
|
||||
sorted[i].PublicKey = append([]byte(nil), sorted[i].PublicKey...)
|
||||
sorted[i].Signature = append([]byte(nil), sorted[i].Signature...)
|
||||
}
|
||||
sortSignerRecords(sorted)
|
||||
|
||||
var total uint64
|
||||
for i := range sorted {
|
||||
if i > 0 && sorted[i].ValidatorID == sorted[i-1].ValidatorID {
|
||||
return nil, fmt.Errorf("%w: %x", ErrWVSetDuplicateID, sorted[i].ValidatorID[:])
|
||||
}
|
||||
if total+sorted[i].VotingWeight < total {
|
||||
return nil, ErrQCWeightOverflow
|
||||
}
|
||||
total += sorted[i].VotingWeight
|
||||
}
|
||||
|
||||
cert := &WeightedQuorumCert{
|
||||
Version: QuorumCertVersion,
|
||||
ChainID: params.ChainID,
|
||||
Epoch: params.Epoch,
|
||||
Height: params.Height,
|
||||
Round: params.Round,
|
||||
ValueHash: params.ValueHash,
|
||||
QCType: params.QCType,
|
||||
ValidatorSetRoot: params.ValidatorSetRoot,
|
||||
QuorumThreshold: params.QuorumThreshold,
|
||||
AggregateWeight: total,
|
||||
SignerCount: uint32(len(sorted)),
|
||||
Signers: sorted,
|
||||
}
|
||||
cert.SignerCommitment = cert.computeSignerCommitment()
|
||||
return cert, nil
|
||||
}
|
||||
|
||||
// sortSignerRecords sorts records strictly by validator_id ascending.
|
||||
func sortSignerRecords(recs []QuorumSignerRecord) {
|
||||
// insertion-free stable sort via the std sort on a closure would alloc;
|
||||
// validator IDs are public so a simple comparison sort is fine.
|
||||
for i := 1; i < len(recs); i++ {
|
||||
for j := i; j > 0 && bytesLess(recs[j].ValidatorID[:], recs[j-1].ValidatorID[:]); j-- {
|
||||
recs[j], recs[j-1] = recs[j-1], recs[j]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Compact ↔ full (DA hybrid mode)
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
// Compact returns a copy of the cert with the per-signer records stripped —
|
||||
// the commitment-only form a block header carries. The raw signer records
|
||||
// live in the data-availability layer; a verifier re-attaches them with
|
||||
// WithRecords before calling Verify. The SignerCommitment + SignerCount are
|
||||
// retained, so the header still binds exactly which signer set the cert
|
||||
// claims (a DA layer cannot substitute a different record set without
|
||||
// failing the commitment check on re-attach).
|
||||
func (c *WeightedQuorumCert) Compact() *WeightedQuorumCert {
|
||||
cp := *c
|
||||
cp.Signers = nil
|
||||
return &cp
|
||||
}
|
||||
|
||||
// IsCompact reports whether the cert is in commitment-only form (no
|
||||
// records). A compact cert cannot be verified until records are re-attached.
|
||||
func (c *WeightedQuorumCert) IsCompact() bool {
|
||||
return len(c.Signers) == 0 && c.SignerCount > 0
|
||||
}
|
||||
|
||||
// WithRecords re-attaches signer records to a compact cert (records fetched
|
||||
// from the DA layer). It does NOT trust the records: WithRecords binds them
|
||||
// in, and a subsequent Verify recomputes the signer commitment — if the DA
|
||||
// layer served a different record set than the header committed to, Verify
|
||||
// returns ErrQCSignerCommitment. Returns a copy; the receiver is unmodified.
|
||||
func (c *WeightedQuorumCert) WithRecords(records []QuorumSignerRecord) *WeightedQuorumCert {
|
||||
cp := *c
|
||||
cp.Signers = make([]QuorumSignerRecord, len(records))
|
||||
copy(cp.Signers, records)
|
||||
return &cp
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Wire codec — full and compact encodings
|
||||
// ----------------------------------------------------------------------------
|
||||
//
|
||||
// Compact header layout (deterministic, big-endian):
|
||||
//
|
||||
// kind:1 = wqcKindCompact (0x00) | wqcKindFull (0x01)
|
||||
// version:2
|
||||
// chain_id:4
|
||||
// epoch:8
|
||||
// height:8
|
||||
// round:4
|
||||
// value_hash:32
|
||||
// qc_type:1
|
||||
// validator_set_root:48
|
||||
// quorum_threshold:8
|
||||
// aggregate_weight:8
|
||||
// signer_commitment:48
|
||||
// signer_count:4
|
||||
//
|
||||
// Full layout = compact header (kind=0x01) followed by signer_count signer
|
||||
// records, each:
|
||||
//
|
||||
// validator_id:32
|
||||
// scheme:1
|
||||
// param_set_id:1
|
||||
// key_version:4
|
||||
// voting_weight:8
|
||||
// pubkey_len:4 pubkey:pubkey_len
|
||||
// sig_len:4 sig:sig_len
|
||||
// merkle_leaf_index:4
|
||||
// merkle_leaf_count:4
|
||||
// merkle_step_count:4
|
||||
// step[i]: flags:1 (bit0=promoted, bit1=sibling_is_right) [+ sibling:48 if !promoted]
|
||||
|
||||
const (
|
||||
wqcKindCompact byte = 0x00
|
||||
wqcKindFull byte = 0x01
|
||||
)
|
||||
|
||||
// wqcHeaderSize is the fixed compact-header byte length.
|
||||
const wqcHeaderSize = 1 + 2 + 4 + 8 + 8 + 4 + 32 + 1 + 48 + 8 + 8 + 48 + 4
|
||||
|
||||
// ErrQCWireCorrupt is returned by the decoders on any structural defect.
|
||||
var ErrQCWireCorrupt = errors.New("quasar: weighted quorum cert wire corrupt")
|
||||
|
||||
// MarshalBinary encodes the cert. If records are present it emits the FULL
|
||||
// form; otherwise the COMPACT form. Deterministic: equal certs encode to
|
||||
// equal bytes.
|
||||
func (c *WeightedQuorumCert) MarshalBinary() ([]byte, error) {
|
||||
if c == nil {
|
||||
return nil, ErrQCNil
|
||||
}
|
||||
full := len(c.Signers) > 0
|
||||
kind := wqcKindCompact
|
||||
if full {
|
||||
kind = wqcKindFull
|
||||
}
|
||||
|
||||
buf := make([]byte, 0, wqcHeaderSize)
|
||||
buf = append(buf, kind)
|
||||
buf = appendU16(buf, c.Version)
|
||||
buf = appendU32(buf, c.ChainID)
|
||||
buf = appendU64(buf, c.Epoch)
|
||||
buf = appendU64(buf, c.Height)
|
||||
buf = appendU32(buf, c.Round)
|
||||
buf = append(buf, c.ValueHash[:]...)
|
||||
buf = append(buf, c.QCType)
|
||||
buf = append(buf, c.ValidatorSetRoot[:]...)
|
||||
buf = appendU64(buf, c.QuorumThreshold)
|
||||
buf = appendU64(buf, c.AggregateWeight)
|
||||
buf = append(buf, c.SignerCommitment[:]...)
|
||||
buf = appendU32(buf, c.SignerCount)
|
||||
|
||||
if !full {
|
||||
return buf, nil
|
||||
}
|
||||
|
||||
for i := range c.Signers {
|
||||
rec := &c.Signers[i]
|
||||
buf = append(buf, rec.ValidatorID[:]...)
|
||||
buf = append(buf, byte(rec.Scheme))
|
||||
buf = append(buf, rec.ParamSetID)
|
||||
buf = appendU32(buf, rec.KeyVersion)
|
||||
buf = appendU64(buf, rec.VotingWeight)
|
||||
buf = appendU32(buf, uint32(len(rec.PublicKey)))
|
||||
buf = append(buf, rec.PublicKey...)
|
||||
buf = appendU32(buf, uint32(len(rec.Signature)))
|
||||
buf = append(buf, rec.Signature...)
|
||||
|
||||
if rec.MerklePath == nil {
|
||||
return nil, fmt.Errorf("%w: record %d nil merkle path", ErrQCWireCorrupt, i)
|
||||
}
|
||||
buf = appendU32(buf, rec.MerklePath.LeafIndex)
|
||||
buf = appendU32(buf, rec.MerklePath.LeafCount)
|
||||
buf = appendU32(buf, uint32(len(rec.MerklePath.Steps)))
|
||||
for _, st := range rec.MerklePath.Steps {
|
||||
var flags byte
|
||||
if st.Promoted {
|
||||
flags |= 0x01
|
||||
}
|
||||
if st.SiblingIsRight {
|
||||
flags |= 0x02
|
||||
}
|
||||
buf = append(buf, flags)
|
||||
if !st.Promoted {
|
||||
buf = append(buf, st.Sibling[:]...)
|
||||
}
|
||||
}
|
||||
}
|
||||
return buf, nil
|
||||
}
|
||||
|
||||
// UnmarshalWeightedQuorumCert is the inverse of MarshalBinary. Strict
|
||||
// trailing-bytes policy: any byte after the declared frame rejects the
|
||||
// input. Fail-closed on every short read.
|
||||
func UnmarshalWeightedQuorumCert(data []byte) (*WeightedQuorumCert, error) {
|
||||
r := &qcReader{buf: data}
|
||||
kind, err := r.u8()
|
||||
if err != nil {
|
||||
return nil, ErrQCWireCorrupt
|
||||
}
|
||||
if kind != wqcKindCompact && kind != wqcKindFull {
|
||||
return nil, fmt.Errorf("%w: bad kind 0x%02x", ErrQCWireCorrupt, kind)
|
||||
}
|
||||
|
||||
c := &WeightedQuorumCert{}
|
||||
if c.Version, err = r.u16(); err != nil {
|
||||
return nil, ErrQCWireCorrupt
|
||||
}
|
||||
if c.ChainID, err = r.u32(); err != nil {
|
||||
return nil, ErrQCWireCorrupt
|
||||
}
|
||||
if c.Epoch, err = r.u64(); err != nil {
|
||||
return nil, ErrQCWireCorrupt
|
||||
}
|
||||
if c.Height, err = r.u64(); err != nil {
|
||||
return nil, ErrQCWireCorrupt
|
||||
}
|
||||
if c.Round, err = r.u32(); err != nil {
|
||||
return nil, ErrQCWireCorrupt
|
||||
}
|
||||
if err = r.read32(&c.ValueHash); err != nil {
|
||||
return nil, ErrQCWireCorrupt
|
||||
}
|
||||
if c.QCType, err = r.u8(); err != nil {
|
||||
return nil, ErrQCWireCorrupt
|
||||
}
|
||||
if err = r.read48(&c.ValidatorSetRoot); err != nil {
|
||||
return nil, ErrQCWireCorrupt
|
||||
}
|
||||
if c.QuorumThreshold, err = r.u64(); err != nil {
|
||||
return nil, ErrQCWireCorrupt
|
||||
}
|
||||
if c.AggregateWeight, err = r.u64(); err != nil {
|
||||
return nil, ErrQCWireCorrupt
|
||||
}
|
||||
if err = r.read48(&c.SignerCommitment); err != nil {
|
||||
return nil, ErrQCWireCorrupt
|
||||
}
|
||||
if c.SignerCount, err = r.u32(); err != nil {
|
||||
return nil, ErrQCWireCorrupt
|
||||
}
|
||||
|
||||
if kind == wqcKindCompact {
|
||||
if len(r.buf) != 0 {
|
||||
return nil, fmt.Errorf("%w: %d trailing bytes (compact)", ErrQCWireCorrupt, len(r.buf))
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// Full: decode signer_count records.
|
||||
c.Signers = make([]QuorumSignerRecord, 0, c.SignerCount)
|
||||
for i := uint32(0); i < c.SignerCount; i++ {
|
||||
var rec QuorumSignerRecord
|
||||
if err = r.read32(&rec.ValidatorID); err != nil {
|
||||
return nil, ErrQCWireCorrupt
|
||||
}
|
||||
sch, err := r.u8()
|
||||
if err != nil {
|
||||
return nil, ErrQCWireCorrupt
|
||||
}
|
||||
rec.Scheme = QuorumSchemeID(sch)
|
||||
if rec.ParamSetID, err = r.u8(); err != nil {
|
||||
return nil, ErrQCWireCorrupt
|
||||
}
|
||||
if rec.KeyVersion, err = r.u32(); err != nil {
|
||||
return nil, ErrQCWireCorrupt
|
||||
}
|
||||
if rec.VotingWeight, err = r.u64(); err != nil {
|
||||
return nil, ErrQCWireCorrupt
|
||||
}
|
||||
if rec.PublicKey, err = r.lenPrefixed(); err != nil {
|
||||
return nil, ErrQCWireCorrupt
|
||||
}
|
||||
if rec.Signature, err = r.lenPrefixed(); err != nil {
|
||||
return nil, ErrQCWireCorrupt
|
||||
}
|
||||
|
||||
var path WeightedInclusionProof
|
||||
if path.LeafIndex, err = r.u32(); err != nil {
|
||||
return nil, ErrQCWireCorrupt
|
||||
}
|
||||
if path.LeafCount, err = r.u32(); err != nil {
|
||||
return nil, ErrQCWireCorrupt
|
||||
}
|
||||
stepCount, err := r.u32()
|
||||
if err != nil {
|
||||
return nil, ErrQCWireCorrupt
|
||||
}
|
||||
// Bound the step count: a Merkle path over N leaves is ≤ ceil(log2 N)
|
||||
// + a small constant; cap at 64 (supports 2^64 leaves) so a malicious
|
||||
// header cannot force a huge allocation.
|
||||
if stepCount > 64 {
|
||||
return nil, fmt.Errorf("%w: step count %d too large", ErrQCWireCorrupt, stepCount)
|
||||
}
|
||||
path.Steps = make([]WeightedProofStep, 0, stepCount)
|
||||
for s := uint32(0); s < stepCount; s++ {
|
||||
flags, err := r.u8()
|
||||
if err != nil {
|
||||
return nil, ErrQCWireCorrupt
|
||||
}
|
||||
step := WeightedProofStep{
|
||||
Promoted: flags&0x01 != 0,
|
||||
SiblingIsRight: flags&0x02 != 0,
|
||||
}
|
||||
if !step.Promoted {
|
||||
if err = r.read48(&step.Sibling); err != nil {
|
||||
return nil, ErrQCWireCorrupt
|
||||
}
|
||||
}
|
||||
path.Steps = append(path.Steps, step)
|
||||
}
|
||||
rec.MerklePath = &path
|
||||
c.Signers = append(c.Signers, rec)
|
||||
}
|
||||
|
||||
if len(r.buf) != 0 {
|
||||
return nil, fmt.Errorf("%w: %d trailing bytes (full)", ErrQCWireCorrupt, len(r.buf))
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// qcReader is a bounds-checked sequential reader for the cert codec.
|
||||
type qcReader struct{ buf []byte }
|
||||
|
||||
func (r *qcReader) need(n int) bool { return len(r.buf) >= n }
|
||||
|
||||
func (r *qcReader) u8() (uint8, error) {
|
||||
if !r.need(1) {
|
||||
return 0, ErrQCWireCorrupt
|
||||
}
|
||||
v := r.buf[0]
|
||||
r.buf = r.buf[1:]
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func (r *qcReader) u16() (uint16, error) {
|
||||
if !r.need(2) {
|
||||
return 0, ErrQCWireCorrupt
|
||||
}
|
||||
v := binary.BigEndian.Uint16(r.buf[:2])
|
||||
r.buf = r.buf[2:]
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func (r *qcReader) u32() (uint32, error) {
|
||||
if !r.need(4) {
|
||||
return 0, ErrQCWireCorrupt
|
||||
}
|
||||
v := binary.BigEndian.Uint32(r.buf[:4])
|
||||
r.buf = r.buf[4:]
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func (r *qcReader) u64() (uint64, error) {
|
||||
if !r.need(8) {
|
||||
return 0, ErrQCWireCorrupt
|
||||
}
|
||||
v := binary.BigEndian.Uint64(r.buf[:8])
|
||||
r.buf = r.buf[8:]
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func (r *qcReader) read32(dst *[32]byte) error {
|
||||
if !r.need(32) {
|
||||
return ErrQCWireCorrupt
|
||||
}
|
||||
copy(dst[:], r.buf[:32])
|
||||
r.buf = r.buf[32:]
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *qcReader) read48(dst *[48]byte) error {
|
||||
if !r.need(48) {
|
||||
return ErrQCWireCorrupt
|
||||
}
|
||||
copy(dst[:], r.buf[:48])
|
||||
r.buf = r.buf[48:]
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *qcReader) lenPrefixed() ([]byte, error) {
|
||||
n, err := r.u32()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if uint64(n) > uint64(len(r.buf)) {
|
||||
return nil, ErrQCWireCorrupt
|
||||
}
|
||||
out := make([]byte, n)
|
||||
copy(out, r.buf[:n])
|
||||
r.buf = r.buf[n:]
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Equal reports structural equality of two certs (used in round-trip
|
||||
// tests). Compares all fields including the per-signer records.
|
||||
func (c *WeightedQuorumCert) Equal(o *WeightedQuorumCert) bool {
|
||||
if c == nil || o == nil {
|
||||
return c == o
|
||||
}
|
||||
if c.Version != o.Version || c.ChainID != o.ChainID || c.Epoch != o.Epoch ||
|
||||
c.Height != o.Height || c.Round != o.Round || c.ValueHash != o.ValueHash ||
|
||||
c.QCType != o.QCType || c.ValidatorSetRoot != o.ValidatorSetRoot ||
|
||||
c.QuorumThreshold != o.QuorumThreshold || c.AggregateWeight != o.AggregateWeight ||
|
||||
c.SignerCommitment != o.SignerCommitment || c.SignerCount != o.SignerCount ||
|
||||
len(c.Signers) != len(o.Signers) {
|
||||
return false
|
||||
}
|
||||
for i := range c.Signers {
|
||||
a, b := &c.Signers[i], &o.Signers[i]
|
||||
if a.ValidatorID != b.ValidatorID || a.Scheme != b.Scheme ||
|
||||
a.ParamSetID != b.ParamSetID || a.KeyVersion != b.KeyVersion ||
|
||||
a.VotingWeight != b.VotingWeight ||
|
||||
!bytes.Equal(a.PublicKey, b.PublicKey) || !bytes.Equal(a.Signature, b.Signature) {
|
||||
return false
|
||||
}
|
||||
if !equalProof(a.MerklePath, b.MerklePath) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func equalProof(a, b *WeightedInclusionProof) bool {
|
||||
if a == nil || b == nil {
|
||||
return a == b
|
||||
}
|
||||
if a.LeafIndex != b.LeafIndex || a.LeafCount != b.LeafCount || len(a.Steps) != len(b.Steps) {
|
||||
return false
|
||||
}
|
||||
for i := range a.Steps {
|
||||
if a.Steps[i].Promoted != b.Steps[i].Promoted ||
|
||||
a.Steps[i].SiblingIsRight != b.Steps[i].SiblingIsRight ||
|
||||
a.Steps[i].Sibling != b.Steps[i].Sibling {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ProofBackendID returns the canonical wire ProofBackendID for a direct
|
||||
// weighted quorum certificate. A caller assembling the round-digest
|
||||
// envelope (quorum_message.go) MUST stamp this backend so the signed
|
||||
// transcript binds the full-node-verifiable trust model — a cert produced
|
||||
// under this backend cannot be re-presented under a STARK backend's
|
||||
// envelope.
|
||||
func (c *WeightedQuorumCert) ProofBackendID() config.ProofBackendID {
|
||||
return config.ProofBackendDirectWeightedQuorum
|
||||
}
|
||||
|
||||
// ProofFormatID returns the canonical wire ProofFormatID for the full
|
||||
// (records-included) weighted quorum cert encoding.
|
||||
func (c *WeightedQuorumCert) ProofFormatID() config.ProofFormatID {
|
||||
return config.ProofFormatDirectWeightedQuorumV1
|
||||
}
|
||||
|
||||
// VerifierID returns the canonical pinned VerifierID for the direct
|
||||
// weighted quorum predicate.
|
||||
func (c *WeightedQuorumCert) VerifierID() config.VerifierID {
|
||||
return config.VerifierDirectWeightedQuorumPQ
|
||||
}
|
||||
@@ -0,0 +1,599 @@
|
||||
// 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
|
||||
|
||||
// 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,
|
||||
cfg: QuorumVerifierConfig{Context: ctx},
|
||||
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.message, 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.message, 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.message, 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.message, QuorumVerifierConfig{Context: ctxA}); err != nil {
|
||||
t.Fatalf("ctx-A cert rejected under ctx-A: %v", err)
|
||||
}
|
||||
if err := sc.cert.Verify(sc.message, QuorumVerifierConfig{Context: []byte("ctx-B")}); 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.message, 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.message, 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.message, 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.message, 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
|
||||
err := sc.cert.Verify(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)
|
||||
}
|
||||
if err := sc.cert.Verify(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.message, 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.message, 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.message, 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.message, sc.cfg)
|
||||
if !errors.Is(err, ErrQCSignerCommitment) {
|
||||
t.Fatalf("commitment tamper err = %v, want ErrQCSignerCommitment", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuorumCert_HeaderPositionTamperFailsCommitment(t *testing.T) {
|
||||
// Flipping a position field the commitment binds (but NOT re-signing /
|
||||
// re-committing) must fail the commitment check.
|
||||
sc := fourMLDSA(t)
|
||||
sc.cert.Height++ // commitment was computed over the old height
|
||||
err := sc.cert.Verify(sc.message, sc.cfg)
|
||||
if !errors.Is(err, ErrQCSignerCommitment) {
|
||||
t.Fatalf("position tamper err = %v, want ErrQCSignerCommitment", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Scheme allow-list
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
func TestQuorumCert_SchemeNotAllowed(t *testing.T) {
|
||||
sc := fourMLDSA(t)
|
||||
// Allow only SLH-DSA; the cert is all ML-DSA → rejected.
|
||||
cfg := QuorumVerifierConfig{
|
||||
Context: nil,
|
||||
AllowedSchemes: map[QuorumSchemeID]bool{QuorumSchemeSLHDSA192s: true},
|
||||
}
|
||||
err := sc.cert.Verify(sc.message, 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.message, 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.message, 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.message, 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.message, 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.message, sc.cfg); !errors.Is(err, ErrQCSignerCountMismatch) {
|
||||
t.Fatalf("count mismatch err = %v, want ErrQCSignerCountMismatch", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
// 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.message, 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.message, 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.message, 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.message, 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.message, 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.message, 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.message, 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.message, 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")
|
||||
}
|
||||
if err := sc.cert.Verify(msg, sc.cfg); err != nil {
|
||||
t.Fatalf("cert failed verify under QuorumMessageForCert output: %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,186 @@
|
||||
// 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
|
||||
|
||||
// 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 and the value hash. This is the seam that makes a quorum
|
||||
// message non-interchangeable with a bare round digest.
|
||||
var u32 [4]byte
|
||||
binary.BigEndian.PutUint32(u32[:], env.ChainID)
|
||||
chainBytes := append([]byte(nil), u32[:]...)
|
||||
|
||||
parts := [][]byte{
|
||||
[]byte(quorumMessageProtocolTag),
|
||||
digest[:],
|
||||
{env.QCType},
|
||||
env.ValueHash[:],
|
||||
chainBytes,
|
||||
env.ValidatorSetRoot[:],
|
||||
}
|
||||
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) the cert claims, so a cert whose signers
|
||||
// signed a different message fails verification, and a cert that lies about
|
||||
// its position 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
|
||||
return QuorumConsensusMessage(env)
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
// 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 message 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 (per-signer FIPS verify + Merkle inclusion
|
||||
// + weight threshold + aggregate/commitment checks). Any failure is a typed
|
||||
// error — never a panic, never unbounded work.
|
||||
//
|
||||
// message MUST be the domain-separated consensus message the signers signed
|
||||
// (build it with QuorumMessageForCert from the chain's envelope axes and the
|
||||
// extracted cert, so the position binding is canonical).
|
||||
func (c *QuasarCert) VerifyWeightedQuorumLeg(message []byte, 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(message, 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,200 @@
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
// expectedPubKeyLen returns the canonical public-key byte length for a
|
||||
// scheme, or (0, false) if the scheme is unknown. Used by the certificate
|
||||
// verifier to reject a record whose embedded key length cannot match the
|
||||
// scheme before any signature dispatch.
|
||||
func expectedPubKeyLen(scheme QuorumSchemeID) (int, bool) {
|
||||
switch scheme {
|
||||
case QuorumSchemeMLDSA44:
|
||||
return mldsa.GetPublicKeySize(mldsa.MLDSA44), true
|
||||
case QuorumSchemeMLDSA65:
|
||||
return mldsa.GetPublicKeySize(mldsa.MLDSA65), true
|
||||
case QuorumSchemeMLDSA87:
|
||||
return mldsa.GetPublicKeySize(mldsa.MLDSA87), true
|
||||
case QuorumSchemeSLHDSA192s:
|
||||
return magnetar.ParamsM192s.PublicKeySize, true
|
||||
case QuorumSchemeSLHDSA192f:
|
||||
return magnetar.ParamsM192f.PublicKeySize, true
|
||||
case QuorumSchemeSLHDSA256s:
|
||||
return magnetar.ParamsM256s.PublicKeySize, true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,497 @@
|
||||
// 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. The encoding is canonical and length-framed via TupleHash256, so
|
||||
// no boundary-shifting attack can move bytes between fields.
|
||||
//
|
||||
// Layout (TupleHash256, customization "QUASAR-WVSET-LEAF-V1"):
|
||||
//
|
||||
// parts[0] = "Quasar/WVSet/Leaf" (in-band protocol tag)
|
||||
// parts[1] = epoch (8 BE)
|
||||
// parts[2] = validator_id ([32]byte)
|
||||
// parts[3] = canonical_pubkey (variable, length-framed)
|
||||
// parts[4] = voting_weight (8 BE)
|
||||
// parts[5] = parameter_set_id (1)
|
||||
// parts[6] = key_version (4 BE)
|
||||
func computeWeightedLeafHash(epoch uint64, leaf WeightedValidatorLeaf) [48]byte {
|
||||
var u64 [8]byte
|
||||
var u32 [4]byte
|
||||
|
||||
binary.BigEndian.PutUint64(u64[:], epoch)
|
||||
epochBytes := append([]byte(nil), u64[:]...)
|
||||
|
||||
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,
|
||||
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))
|
||||
for i := range sorted {
|
||||
leafHashes[i] = computeWeightedLeafHash(epoch, 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
|
||||
}
|
||||
|
||||
running := computeWeightedLeafHash(epoch, 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,228 @@
|
||||
// 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_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.
|
||||
h := make([][48]byte, 0, 4)
|
||||
for _, l := range leaves {
|
||||
h = append(h, computeWeightedLeafHash(7, l))
|
||||
}
|
||||
dup := append(append([][48]byte(nil), h...), h[len(h)-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)")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user