mirror of
https://github.com/luxfi/consensus.git
synced 2026-10-30 14:45:53 +00:00
The weighted-Merkle proof SHAPE is many-to-one in the leaf count: every
leaf index has collision classes ({3,4}, {5,6,7,8}, {9..16}, {17..32}, …)
that share an identical promotion/orientation sequence. So a cert could
relabel LeafCount within its shape-class, keep the same siblings, and
recompute an IDENTICAL root — a second byte-distinct but fully-valid cert
over the same signer set + signatures. That breaks cert dedup / equivocation
detection and contradicts the canonicality claim at quorum_cert.go and
QUASAR-C5 (not a finality forgery — signer set/weight/value/threshold stay
bound).
Fix: bind LeafCount into every leaf digest (computeWeightedLeafHash), exactly
as epoch is already folded in as a tree-wide parameter. The recomputed root
now depends on the claimed count, so a relabel within a shape-class yields a
different leaf digest and FAILS Merkle inclusion against validator_set_root.
Exactly ONE (LeafIndex,LeafCount) verifies per committed tree. The
(leaf_index,leaf_count) signer-commitment binding is retained as
defence-in-depth. The malleated cert is now caught at the Merkle clause
(ErrQCMerkleInclusion), strictly stronger than the prior commitment-only catch.
Notes on the chosen approach: "canonicalize to class-minimum" was rejected —
the proof shape does not encode enough to recover the real count, so a
class-min rule wrongly rejects honest certs (e.g. a real 4-validator set at
idx 0). Root-level count binding is the only sound fix and accepts exactly
the honest count.
Tests:
- TestWeightedMerkle_LeafCountCanonical: across n up to 32, every leaf index,
ONLY the canonical (idx,count) verifies and every alternate count (incl.
same-shape-class relabels that defeat the structural guard) is rejected;
self-guards that it actually exercised a same-class relabel. Verified to
FAIL against the pre-fix code with exactly the (0,3)->(0,4) attack.
- F4 red-team test updated: malleated (0,4) now fails the Merkle clause
(was: passed Merkle, caught only by commitment).
GOWORK=off go build ./... and go test ./... green; gofmt + vet clean.
320 lines
11 KiB
Go
320 lines
11 KiB
Go
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
package quasar
|
|
|
|
import (
|
|
"testing"
|
|
)
|
|
|
|
// mkLeaf builds a deterministic test leaf with a distinct id/pubkey/weight.
|
|
func mkLeaf(idByte byte, weight uint64, param uint8, keyVer uint32) WeightedValidatorLeaf {
|
|
var id [32]byte
|
|
for i := range id {
|
|
id[i] = idByte
|
|
}
|
|
pk := make([]byte, 16)
|
|
for i := range pk {
|
|
pk[i] = idByte ^ byte(i)
|
|
}
|
|
return WeightedValidatorLeaf{
|
|
ValidatorID: id,
|
|
PublicKey: pk,
|
|
VotingWeight: weight,
|
|
ParameterSetID: param,
|
|
KeyVersion: keyVer,
|
|
}
|
|
}
|
|
|
|
func TestWeightedMerkle_BuildAndRootDeterminism(t *testing.T) {
|
|
leaves := []WeightedValidatorLeaf{
|
|
mkLeaf(0x03, 10, 0x42, 1),
|
|
mkLeaf(0x01, 20, 0x42, 1),
|
|
mkLeaf(0x02, 30, 0x42, 1),
|
|
}
|
|
s1, err := BuildWeightedValidatorSet(7, leaves)
|
|
if err != nil {
|
|
t.Fatalf("build: %v", err)
|
|
}
|
|
// Re-order input: root must be identical (canonical sort by id).
|
|
reordered := []WeightedValidatorLeaf{leaves[2], leaves[0], leaves[1]}
|
|
s2, err := BuildWeightedValidatorSet(7, reordered)
|
|
if err != nil {
|
|
t.Fatalf("build reordered: %v", err)
|
|
}
|
|
if s1.Root() != s2.Root() {
|
|
t.Fatalf("root not invariant under input ordering: %x vs %x", s1.Root(), s2.Root())
|
|
}
|
|
var zero [48]byte
|
|
if s1.Root() == zero {
|
|
t.Fatal("root is zero for non-empty set")
|
|
}
|
|
}
|
|
|
|
func TestWeightedMerkle_EpochBinding(t *testing.T) {
|
|
leaves := []WeightedValidatorLeaf{mkLeaf(0x01, 10, 0x42, 1), mkLeaf(0x02, 10, 0x42, 1)}
|
|
a, _ := BuildWeightedValidatorSet(7, leaves)
|
|
b, _ := BuildWeightedValidatorSet(8, leaves)
|
|
if a.Root() == b.Root() {
|
|
t.Fatal("epoch is not bound into the root (different epochs collided)")
|
|
}
|
|
}
|
|
|
|
func TestWeightedMerkle_WeightBinding(t *testing.T) {
|
|
// The whole point of the task: bind WEIGHT. Flip a single weight, expect
|
|
// a different root.
|
|
base := []WeightedValidatorLeaf{mkLeaf(0x01, 10, 0x42, 1), mkLeaf(0x02, 20, 0x42, 1)}
|
|
mut := []WeightedValidatorLeaf{mkLeaf(0x01, 10, 0x42, 1), mkLeaf(0x02, 21, 0x42, 1)}
|
|
a, _ := BuildWeightedValidatorSet(7, base)
|
|
b, _ := BuildWeightedValidatorSet(7, mut)
|
|
if a.Root() == b.Root() {
|
|
t.Fatal("voting weight is not bound into the leaf (weight flip collided)")
|
|
}
|
|
}
|
|
|
|
func TestWeightedMerkle_ParamAndKeyVersionBinding(t *testing.T) {
|
|
base := []WeightedValidatorLeaf{mkLeaf(0x01, 10, 0x42, 1)}
|
|
param := []WeightedValidatorLeaf{mkLeaf(0x01, 10, 0x43, 1)}
|
|
keyv := []WeightedValidatorLeaf{mkLeaf(0x01, 10, 0x42, 2)}
|
|
a, _ := BuildWeightedValidatorSet(7, base)
|
|
b, _ := BuildWeightedValidatorSet(7, param)
|
|
c, _ := BuildWeightedValidatorSet(7, keyv)
|
|
if a.Root() == b.Root() {
|
|
t.Fatal("parameter_set_id not bound into leaf")
|
|
}
|
|
if a.Root() == c.Root() {
|
|
t.Fatal("key_version not bound into leaf")
|
|
}
|
|
}
|
|
|
|
func TestWeightedMerkle_RejectsBadInput(t *testing.T) {
|
|
if _, err := BuildWeightedValidatorSet(7, nil); err != ErrWVSetEmpty {
|
|
t.Fatalf("empty set err = %v, want ErrWVSetEmpty", err)
|
|
}
|
|
// Duplicate id.
|
|
dup := []WeightedValidatorLeaf{mkLeaf(0x01, 10, 0x42, 1), mkLeaf(0x01, 20, 0x42, 1)}
|
|
if _, err := BuildWeightedValidatorSet(7, dup); err == nil {
|
|
t.Fatal("duplicate validator_id accepted, want ErrWVSetDuplicateID")
|
|
}
|
|
// Zero weight.
|
|
zw := []WeightedValidatorLeaf{mkLeaf(0x01, 0, 0x42, 1)}
|
|
if _, err := BuildWeightedValidatorSet(7, zw); err == nil {
|
|
t.Fatal("zero weight accepted, want ErrWVSetZeroWeight")
|
|
}
|
|
// Empty pubkey.
|
|
ep := []WeightedValidatorLeaf{{ValidatorID: [32]byte{1}, VotingWeight: 10, ParameterSetID: 0x42}}
|
|
if _, err := BuildWeightedValidatorSet(7, ep); err == nil {
|
|
t.Fatal("empty pubkey accepted, want ErrWVSetEmptyPubKey")
|
|
}
|
|
}
|
|
|
|
func TestWeightedMerkle_InclusionSoundness(t *testing.T) {
|
|
// Every leaf at every set size must produce a verifying inclusion proof,
|
|
// and a NON-member must be rejected. Exercise sizes 1..9 to cover both
|
|
// even and odd levels (the promotion path).
|
|
for n := 1; n <= 9; n++ {
|
|
leaves := make([]WeightedValidatorLeaf, n)
|
|
for i := 0; i < n; i++ {
|
|
leaves[i] = mkLeaf(byte(i+1), uint64(10*(i+1)), 0x42, 1)
|
|
}
|
|
set, err := BuildWeightedValidatorSet(7, leaves)
|
|
if err != nil {
|
|
t.Fatalf("n=%d build: %v", n, err)
|
|
}
|
|
root := set.Root()
|
|
sorted := set.Leaves()
|
|
for idx := 0; idx < n; idx++ {
|
|
proof, err := set.InclusionProof(idx)
|
|
if err != nil {
|
|
t.Fatalf("n=%d idx=%d proof: %v", n, idx, err)
|
|
}
|
|
if !VerifyWeightedInclusion(root, 7, sorted[idx], proof) {
|
|
t.Fatalf("n=%d idx=%d: valid inclusion proof rejected", n, idx)
|
|
}
|
|
// Wrong epoch must fail.
|
|
if VerifyWeightedInclusion(root, 8, sorted[idx], proof) {
|
|
t.Fatalf("n=%d idx=%d: inclusion accepted under wrong epoch", n, idx)
|
|
}
|
|
// Non-member leaf (mutated weight) must fail under the same path.
|
|
bad := sorted[idx]
|
|
bad.VotingWeight++
|
|
if VerifyWeightedInclusion(root, 7, bad, proof) {
|
|
t.Fatalf("n=%d idx=%d: non-member (mutated weight) accepted", n, idx)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestWeightedMerkle_TamperedPathRejected(t *testing.T) {
|
|
leaves := make([]WeightedValidatorLeaf, 5)
|
|
for i := range leaves {
|
|
leaves[i] = mkLeaf(byte(i+1), 10, 0x42, 1)
|
|
}
|
|
set, _ := BuildWeightedValidatorSet(7, leaves)
|
|
root := set.Root()
|
|
sorted := set.Leaves()
|
|
|
|
proof, _ := set.InclusionProof(2)
|
|
if !VerifyWeightedInclusion(root, 7, sorted[2], proof) {
|
|
t.Fatal("baseline proof must verify")
|
|
}
|
|
|
|
// Flip a sibling byte → reject.
|
|
if len(proof.Steps) > 0 {
|
|
tampered := *proof
|
|
tampered.Steps = append([]WeightedProofStep(nil), proof.Steps...)
|
|
for i := range tampered.Steps {
|
|
if !tampered.Steps[i].Promoted {
|
|
tampered.Steps[i].Sibling[0] ^= 0xFF
|
|
break
|
|
}
|
|
}
|
|
if VerifyWeightedInclusion(root, 7, sorted[2], &tampered) {
|
|
t.Fatal("tampered sibling accepted")
|
|
}
|
|
}
|
|
|
|
// Flip an orientation bit → reject (verifier re-derives orientation).
|
|
flip := *proof
|
|
flip.Steps = append([]WeightedProofStep(nil), proof.Steps...)
|
|
for i := range flip.Steps {
|
|
if !flip.Steps[i].Promoted {
|
|
flip.Steps[i].SiblingIsRight = !flip.Steps[i].SiblingIsRight
|
|
break
|
|
}
|
|
}
|
|
if VerifyWeightedInclusion(root, 7, sorted[2], &flip) {
|
|
t.Fatal("flipped orientation accepted")
|
|
}
|
|
|
|
// Lie about LeafIndex/LeafCount → reject (shape mismatch).
|
|
wrongShape := *proof
|
|
wrongShape.LeafCount = 99
|
|
if VerifyWeightedInclusion(root, 7, sorted[2], &wrongShape) {
|
|
t.Fatal("wrong LeafCount accepted")
|
|
}
|
|
|
|
// Out-of-range index in InclusionProof → error.
|
|
if _, err := set.InclusionProof(5); err == nil {
|
|
t.Fatal("out-of-range inclusion index accepted")
|
|
}
|
|
}
|
|
|
|
// TestWeightedMerkle_LeafCountCanonical is the RED-1 regression: the weighted
|
|
// proof SHAPE is many-to-one in the leaf count (collision classes {3,4},
|
|
// {5,6,7,8}, {9..16}, {17..32}, … all share a shape per leaf index), so the
|
|
// step-count/orientation cross-check alone cannot distinguish an alternate
|
|
// LeafCount within a class. Before binding the count into the leaf digest, an
|
|
// attacker could relabel LeafCount within its shape-class, keep the same
|
|
// siblings, and recompute an IDENTICAL root — a second byte-distinct cert over
|
|
// the same signer set (a QUASAR-C5 non-malleability break and a dedup/
|
|
// equivocation hazard). This test asserts that across every (n, idx) up to 32,
|
|
// ONLY the canonical (idx, n) verifies and EVERY alternate count is rejected,
|
|
// including same-shape-class counts that defeat the structural guards.
|
|
func TestWeightedMerkle_LeafCountCanonical(t *testing.T) {
|
|
const maxN = 32
|
|
|
|
// shapeKey serialises weightedProofShape so we can detect when an alternate
|
|
// count lands in the SAME shape class as the honest one (the dangerous
|
|
// case the structural cross-check cannot catch).
|
|
shapeKey := func(idx, n int) string {
|
|
var b []byte
|
|
for _, s := range weightedProofShape(idx, n) {
|
|
switch {
|
|
case s.promoted:
|
|
b = append(b, 'P')
|
|
case s.siblingIsRight:
|
|
b = append(b, 'R')
|
|
default:
|
|
b = append(b, 'L')
|
|
}
|
|
}
|
|
return string(b)
|
|
}
|
|
|
|
sawSameClassForgeryAttempt := false
|
|
|
|
for n := 1; n <= maxN; n++ {
|
|
leaves := make([]WeightedValidatorLeaf, n)
|
|
for i := 0; i < n; i++ {
|
|
leaves[i] = mkLeaf(byte(i+1), uint64(10*(i+1)), 0x42, 1)
|
|
}
|
|
set, err := BuildWeightedValidatorSet(7, leaves)
|
|
if err != nil {
|
|
t.Fatalf("n=%d build: %v", n, err)
|
|
}
|
|
root := set.Root()
|
|
sorted := set.Leaves()
|
|
|
|
for idx := 0; idx < n; idx++ {
|
|
proof, err := set.InclusionProof(idx)
|
|
if err != nil {
|
|
t.Fatalf("n=%d idx=%d proof: %v", n, idx, err)
|
|
}
|
|
// Canonical proof must verify.
|
|
if !VerifyWeightedInclusion(root, 7, sorted[idx], proof) {
|
|
t.Fatalf("n=%d idx=%d: canonical proof rejected", n, idx)
|
|
}
|
|
honestShape := shapeKey(idx, n)
|
|
|
|
// Forge by relabeling LeafCount to every other valid count, keeping
|
|
// the honest leaf and the honest Steps (siblings + flags). EVERY
|
|
// alternate count must be rejected.
|
|
for np := 1; np <= maxN; np++ {
|
|
if np == n || idx >= np {
|
|
continue
|
|
}
|
|
forged := *proof
|
|
forged.LeafCount = uint32(np)
|
|
if shapeKey(idx, np) == honestShape {
|
|
// Same-class relabel: step count + orientation flags STILL
|
|
// match expSteps, so only the count-binding can reject it.
|
|
sawSameClassForgeryAttempt = true
|
|
}
|
|
if VerifyWeightedInclusion(root, 7, sorted[idx], &forged) {
|
|
t.Fatalf("n=%d idx=%d: forged LeafCount=%d (shape %q vs honest %q) ACCEPTED — malleability open",
|
|
n, idx, np, shapeKey(idx, np), honestShape)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Guard the test itself: confirm we actually exercised the dangerous
|
|
// same-shape-class relabel (e.g. (idx=0,n=3) vs (idx=0,n=4) both "RR"),
|
|
// not just trivially-different shapes the length check already caught.
|
|
if !sawSameClassForgeryAttempt {
|
|
t.Fatal("test did not exercise a same-shape-class LeafCount relabel; " +
|
|
"the regression would pass vacuously")
|
|
}
|
|
}
|
|
|
|
// TestWeightedMerkle_NoDuplicationSecondPreimage guards the CVE-2012-2459
|
|
// class: with an odd number of leaves, a tree that DUPLICATES the last leaf
|
|
// makes the (n) and (n with last leaf repeated) multisets share a root.
|
|
// This tree PROMOTES instead, so appending a duplicate of the last leaf is
|
|
// rejected at build (duplicate id), and even constructing the doubled leaf
|
|
// hash list directly yields a different root than the n-leaf set.
|
|
func TestWeightedMerkle_NoDuplicationSecondPreimage(t *testing.T) {
|
|
leaves := []WeightedValidatorLeaf{
|
|
mkLeaf(0x01, 10, 0x42, 1),
|
|
mkLeaf(0x02, 10, 0x42, 1),
|
|
mkLeaf(0x03, 10, 0x42, 1),
|
|
}
|
|
set, _ := BuildWeightedValidatorSet(7, leaves)
|
|
|
|
// Hand-roll the "duplicate last leaf" root the vulnerable construction
|
|
// would produce, and confirm it does NOT equal our promotion root. The
|
|
// vulnerable construction has FOUR leaf slots, so it binds leaf_count=4;
|
|
// the honest set binds leaf_count=3. The roots therefore differ both by
|
|
// tree structure (promotion vs duplication) AND by the bound count.
|
|
dup := make([][48]byte, 0, 4)
|
|
for _, l := range leaves {
|
|
dup = append(dup, computeWeightedLeafHash(7, 4, l))
|
|
}
|
|
dup = append(dup, dup[len(dup)-1]) // 4 leaves, last duplicated
|
|
dupRoot := weightedMerkleRoot(dup)
|
|
if dupRoot == set.Root() {
|
|
t.Fatal("promotion root collides with last-leaf-duplication root (CVE-2012-2459 surface)")
|
|
}
|
|
}
|