fix(pulsar): hard-cap PartyID before signer-bitmap allocation (DoS)

Item7(a) library-side defense-in-depth, on SHIPPED pulsar v1.9.0 (the tag
cloud's consensus v1.35.30 pins). Non-breaking: CanonicalSignerSet's
2-arg signature is UNCHANGED so the existing consensus consumer compiles
and runs unmodified.

CanonicalSignerSet sized the signer bitmap at make([]byte, maxID/8+1)
with maxID taken straight from an attacker-controlled uint32 Partial.
PartyID and no upper bound — a single Partial naming PartyID near
math.MaxUint32 drives a ~512MiB allocation (memory-exhaustion DoS).

Added MaxCanonicalPartyID = 1<<20 (bitmap <= 128 KiB) and a typed
ErrPartyIDOutOfRange; CanonicalSignerSet now rejects any partial in
`valid` whose PartyID >= the cap BEFORE the sort or the allocation. The
whole slice is checked, not just the chosen subset, so a hostile PartyID
cannot hide behind the threshold cut. The cap sits far above any real
validator set (Lux runs hundreds to low thousands) so legitimate inputs
are never rejected.

This is orthogonal to — not a replacement for — the consumer's own
exact-ValidatorSetSize bound (consensus PulsarRoundSigner.Finalize, its
companion commit): the library needs no external context and stays
signature-stable, so this hardening protects any future caller lacking
that upstream guard.

New test: TestCanonicalSignerSet_RejectsOversizedPartyID (MaxUint32,
excluded-from-chosen, at-cap boundary, and the just-below-cap happy path
with a bounded bitmap). No go.mod/go.sum change.
This commit is contained in:
Hanzo AI
2026-07-08 07:34:25 -07:00
parent c18abc71ba
commit 5a162cf03f
2 changed files with 76 additions and 1 deletions
+27
View File
@@ -48,14 +48,41 @@ type RoundSigner interface {
var ErrInsufficientSigners = errors.New("pulsar: fewer valid partials than threshold")
// ErrPartyIDOutOfRange means a Partial names a PartyID above MaxCanonicalPartyID,
// i.e. one that would drive an absurd signer-bitmap allocation.
var ErrPartyIDOutOfRange = errors.New("pulsar: partyID exceeds MaxCanonicalPartyID")
// MaxCanonicalPartyID is a hard defensive ceiling on PartyID. The signer
// bitmap is sized at (maxPartyID/8+1) bytes, so an unbounded uint32 PartyID
// (up to ~512MiB from a single MaxUint32) is a memory-exhaustion DoS.
//
// This cap bounds the bitmap allocation to at most 128 KiB regardless of
// input. It is deliberately far above any real validator set — Lux consensus
// runs hundreds to low thousands of validators — so it never rejects
// legitimate PartyIDs, while killing the DoS. It is NOT a substitute for the
// consumer's own validator-set bound (consensus PulsarRoundSigner.Finalize
// bounds PartyID by the exact ValidatorSetSize before calling here); this is
// orthogonal library-level defense-in-depth that needs no external context
// and keeps CanonicalSignerSet's signature stable for existing callers.
const MaxCanonicalPartyID = 1 << 20 // 1,048,576 -> bitmap <= 128 KiB
// CanonicalSignerSet picks the deterministic first-threshold valid partials
// (sorted by PartyID) so an aggregator cannot grind the signer subset — hence
// z, the hint, and the final signature bytes — by choosing among valid sets.
// Returns the chosen partials and the signer bitmap.
//
// Every partial in valid is checked against MaxCanonicalPartyID BEFORE the
// sort or the bitmap allocation, so a hostile out-of-range PartyID is
// rejected up front and can never size an allocation.
func CanonicalSignerSet(valid []Partial, threshold int) ([]Partial, []byte, error) {
if len(valid) < threshold {
return nil, nil, ErrInsufficientSigners
}
for _, p := range valid {
if p.PartyID >= MaxCanonicalPartyID {
return nil, nil, ErrPartyIDOutOfRange
}
}
cp := append([]Partial(nil), valid...)
sort.Slice(cp, func(i, j int) bool { return cp[i].PartyID < cp[j].PartyID })
chosen := cp[:threshold]
+49 -1
View File
@@ -1,6 +1,9 @@
package pulsar
import "testing"
import (
"math"
"testing"
)
func TestCanonicalSignerSetDeterministicAndAntiGrind(t *testing.T) {
mk := func(ids ...uint32) []Partial {
@@ -31,3 +34,48 @@ func TestCanonicalSignerSetDeterministicAndAntiGrind(t *testing.T) {
t.Fatalf("below threshold must error, got %v", err)
}
}
// TestCanonicalSignerSet_RejectsOversizedPartyID is the Item7(a) library-side
// defense-in-depth lock: a Partial naming a PartyID above MaxCanonicalPartyID
// must be rejected with ErrPartyIDOutOfRange BEFORE any PartyID-sized bitmap
// allocation. Prior to this the bitmap was make([]byte, maxID/8+1) with maxID
// straight from an attacker-controlled uint32 — a single MaxUint32 sized a
// ~512MiB allocation.
func TestCanonicalSignerSet_RejectsOversizedPartyID(t *testing.T) {
mk := func(ids ...uint32) []Partial {
ps := make([]Partial, len(ids))
for i, id := range ids {
ps[i] = Partial{PartyID: id, ZShare: []byte{byte(id)}}
}
return ps
}
// Hostile PartyID == MaxUint32 among legitimate small IDs.
if _, _, err := CanonicalSignerSet(mk(1, 2, math.MaxUint32), 2); err != ErrPartyIDOutOfRange {
t.Fatalf("expected ErrPartyIDOutOfRange for PartyID=MaxUint32, got %v", err)
}
// Rejected even when the oversized PartyID would be excluded from the
// chosen (smallest-threshold) subset — the whole valid slice is checked.
if _, _, err := CanonicalSignerSet(mk(1, 2, 3, 4, math.MaxUint32), 2); err != ErrPartyIDOutOfRange {
t.Fatalf("expected ErrPartyIDOutOfRange even when oversized PartyID is excluded from chosen, got %v", err)
}
// Exactly at the cap is out of range (valid indices are [0, cap)).
if _, _, err := CanonicalSignerSet(mk(0, 1, MaxCanonicalPartyID), 2); err != ErrPartyIDOutOfRange {
t.Fatalf("expected ErrPartyIDOutOfRange for PartyID==MaxCanonicalPartyID, got %v", err)
}
// Just below the cap is accepted, and the resulting bitmap is bounded
// to at most 128 KiB (never the ~512MiB an unbounded uint32 would give).
chosen, bitmap, err := CanonicalSignerSet(mk(1, MaxCanonicalPartyID-1), 2)
if err != nil {
t.Fatalf("PartyID just below the cap must be accepted, got %v", err)
}
if len(chosen) != 2 {
t.Fatalf("expected 2 chosen, got %d", len(chosen))
}
if len(bitmap) > MaxCanonicalPartyID/8+1 {
t.Fatalf("bitmap length %d exceeds the MaxCanonicalPartyID ceiling", len(bitmap))
}
}