fix(quasar/pulsar): DoS bound is 1-based (PartyID <= ValidatorSetSize)

v1.35.31's bound rejected PartyID >= ValidatorSetSize, but party IDs are
1-based (threshold convention: Shamir x=0 is the secret), so validators are
1..N and the Nth validator (PartyID == ValidatorSetSize) was wrongly dropped —
breaking every real N-of-N ceremony (quorum never reached). The bound is a
strict UPPER bound now (> ValidatorSetSize): DoS-safe (rejects unbounded
PartyID) and 1-based-correct. TestFinalize_BoundaryPartyIDEqualsValidatorSetSize
locks it. The v1.35.31 tag is left untouched (immutable); this is v1.35.32.
This commit is contained in:
Hanzo AI
2026-07-08 07:56:23 -07:00
parent 6272501f69
commit acc6c218a7
2 changed files with 69 additions and 5 deletions
@@ -151,3 +151,60 @@ func TestFinalize_InRangePartyIDsSucceed(t *testing.T) {
t.Fatalf("bitmap length %d exceeds ValidatorSetSize-bounded maximum", len(cert.SignerBitmap))
}
}
// TestFinalize_BoundaryPartyIDEqualsValidatorSetSize is the regression for the
// 1-based off-by-one: party IDs are 1-based, so the Nth validator has
// PartyID == ValidatorSetSize and MUST be admitted. An earlier `>=` bound
// wrongly rejected it, which broke every real N-of-N ceremony (the last voter's
// leg was dropped, so quorum was never reached). The bound is a strict upper
// bound (`> ValidatorSetSize`); PartyID == ValidatorSetSize is in range.
func TestFinalize_BoundaryPartyIDEqualsValidatorSetSize(t *testing.T) {
pool, nonceID := makePool()
sess := fullSession(pool.Root())
sid := sess.SessionID()
const vss = 7 // matches the controlplane's N=7, 1-based party IDs 1..7
s := &PulsarRoundSigner{
Session: sess,
Pool: pool,
Threshold: 2,
ValidatorSetSize: vss,
L: 5,
}
canonical, _ := pool.At(pulsarlib.CanonicalNonceIndex(sid, pool.Root(), pool.Size()))
r1, err := s.Round1(sid, nonceID, canonical)
if err != nil {
t.Fatalf("Round1: %v", err)
}
var partials []pulsarlib.Partial
// Include the boundary party PartyID == ValidatorSetSize (7).
for _, party := range []uint32{uint32(vss), uint32(vss) - 1} {
p, err := s.Round2(r1, pulsarlib.PartialInput{
PartyID: party,
ZShare: bytes.Repeat([]byte{byte(party)}, 5*256*4),
})
if err != nil {
t.Fatalf("Round2 party %d: %v", party, err)
}
partials = append(partials, p)
}
// Must reach the (expected) missing-core failure, NOT a bound rejection:
// the boundary PartyID must be admitted through the DoS bound.
if _, _, err := s.Finalize(r1, partials); err == ErrPartyIDOutOfRange {
t.Fatal("PartyID == ValidatorSetSize wrongly rejected (1-based off-by-one regression)")
}
// And PartyID == ValidatorSetSize+1 (out of the 1-based range) is rejected.
over, err := s.Round2(r1, pulsarlib.PartialInput{
PartyID: uint32(vss) + 1,
ZShare: bytes.Repeat([]byte{9}, 5*256*4),
})
if err != nil {
t.Fatalf("Round2 over-boundary: %v", err)
}
if _, _, err := s.Finalize(r1, append(partials, over)); err != ErrPartyIDOutOfRange {
t.Fatalf("PartyID > ValidatorSetSize must be rejected, got %v", err)
}
}
+12 -5
View File
@@ -178,11 +178,18 @@ func (s *PulsarRoundSigner) Finalize(
// chosen subset) so a hostile PartyID cannot hide behind the
// threshold cut, and we do it here (rather than in pulsarlib) to
// keep CanonicalSignerSet's published signature non-breaking.
// Party IDs are 1-based (the threshold convention: a Shamir share at x=0
// would be the secret itself, so validators are indexed 1..ValidatorSetSize).
// The valid domain is therefore [1, ValidatorSetSize] and the bound is a
// strict upper bound: reject PartyID > ValidatorSetSize. (An earlier `>=`
// wrongly rejected the Nth validator, PartyID == ValidatorSetSize.) This is
// purely a DoS ceiling on the bitmap allocation, not exact validation —
// upstream registration already binds each PartyID to a registered voter.
if s.ValidatorSetSize <= 0 {
return pulsarlib.Aggregate{}, pulsarlib.ConsensusCert{}, ErrPartyIDOutOfRange
}
for _, p := range partials {
if p.PartyID >= uint32(s.ValidatorSetSize) {
if p.PartyID > uint32(s.ValidatorSetSize) {
return pulsarlib.Aggregate{}, pulsarlib.ConsensusCert{}, ErrPartyIDOutOfRange
}
}
@@ -247,14 +254,14 @@ func (s *PulsarRoundSigner) Finalize(
// singletonBitmap returns a one-bit bitmap with only partyID set, sized to
// hold that bit. MergeAggregates unions these into the full signer bitmap.
//
// validatorSetSize bounds partyID (must be in [0, validatorSetSize)) and is
// checked BEFORE the PartyID-sized allocation. By the time Finalize calls
// this it has already rejected any out-of-range PartyID in the whole
// validatorSetSize bounds partyID (1-based: must be in [1, validatorSetSize])
// and is checked BEFORE the PartyID-sized allocation. By the time Finalize
// calls this it has already rejected any out-of-range PartyID in the whole
// partials slice, so this is defense-in-depth against a future caller
// reaching singletonBitmap directly — the allocation is never sized by an
// unbounded attacker-controlled value.
func singletonBitmap(partyID uint32, validatorSetSize int) ([]byte, error) {
if validatorSetSize <= 0 || partyID >= uint32(validatorSetSize) {
if validatorSetSize <= 0 || partyID > uint32(validatorSetSize) {
return nil, ErrPartyIDOutOfRange
}
bm := make([]byte, partyID/8+1)