mirror of
https://github.com/luxfi/consensus.git
synced 2026-07-27 02:57:46 +00:00
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user