Skip to content

Commit a864e63

Browse files
pinebitclaudeKaloyanTanev
authored
core: qbft improvements (#4557)
* core: qbft improvements * core/consensus/qbft: pin justification round in test helper Set Round explicitly in signedJustification so the test does not depend on newRandomQBFTMsg's random round being non-zero (verifyMsg rejects round <= 0), matching the adjacent signedBase helper. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * *: qbft improvements (#4560) * Refactor to be closer to the rest of qbft code * Cap incoming consensus message size * Fail fast on timeout * Simplify --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: kalo <24719519+KaloyanTanev@users.noreply.github.com>
1 parent 23bf906 commit a864e63

6 files changed

Lines changed: 361 additions & 3 deletions

File tree

core/consensus/qbft/qbft.go

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,13 @@ import (
3737

3838
type subscriber func(ctx context.Context, duty core.Duty, value proto.Message) error
3939

40+
// maxConsensusMsgSize caps the wire size of an incoming QBFTConsensusMsg, well below
41+
// the 128MB default p2p frame limit. A legitimate message carries at most a handful of
42+
// small justification sub-messages (bounded in handle) plus its values, the largest of
43+
// which is a single block proposal (a few MB on mainnet); 32MB leaves ample margin while
44+
// bounding the receive/decode/allocation cost a malicious peer can inflict per message.
45+
const maxConsensusMsgSize = 32 * 1024 * 1024 // 32 MB.
46+
4047
var supportedCompareDuties = []core.DutyType{core.DutyAttester}
4148

4249
// newDefinition returns a qbft definition (this is constant across all consensus instances).
@@ -365,7 +372,7 @@ func (c *Consensus) SubscribePriority(fn func(ctx context.Context, duty core.Dut
365372
func (c *Consensus) Start(ctx context.Context) {
366373
p2p.RegisterHandler("qbft", c.p2pNode, protocols.QBFTv2ProtocolID,
367374
func() proto.Message { return new(pbv1.QBFTConsensusMsg) },
368-
c.handle)
375+
c.handle, p2p.WithReadLimit(maxConsensusMsgSize))
369376

370377
go func() {
371378
for {
@@ -678,7 +685,18 @@ func (c *Consensus) handle(ctx context.Context, _ peer.ID, req proto.Message) (p
678685
return nil, false, errors.New("invalid duty", z.Any("duty", duty))
679686
}
680687

688+
if err := verifyMsgLimits(pbMsg, len(c.pubkeys)); err != nil {
689+
return nil, false, err
690+
}
691+
681692
for _, justification := range pbMsg.GetJustification() {
693+
// Bail out as soon as the receive deadline fires rather than burning the full
694+
// CPU budget on signature recovery for every justification in a large message.
695+
if ctx.Err() != nil {
696+
return nil, false, errors.Wrap(ctx.Err(), "receive cancelled during justification verification",
697+
z.Any("duty", duty), z.Any("after", time.Since(t0)))
698+
}
699+
682700
if err := verifyMsg(justification, c.pubkeys); err != nil {
683701
return nil, false, errors.Wrap(err, "invalid justification")
684702
}
@@ -776,6 +794,29 @@ func (c *Consensus) getPeerIdx() (int64, error) {
776794
return peerIdx, nil
777795
}
778796

797+
// verifyMsgLimits bounds the justification and value counts of a consensus message
798+
// before any expensive per-element work (each justification requires an ECDSA
799+
// signature recovery, each value a proto unmarshal + hash). Without it a single
800+
// authenticated peer could pack one large message with many sub-messages to exhaust
801+
// CPU/memory on every peer (amplification DoS).
802+
func verifyMsgLimits(pbMsg *pbv1.QBFTConsensusMsg, nodes int) error {
803+
// A legitimate justification set contains at most a quorum of ROUND-CHANGE plus a
804+
// quorum of PREPARE messages (see qbft.getJustifiedQrc), bounded above by 2*nodes.
805+
maxJust := 2 * nodes
806+
if n := len(pbMsg.GetJustification()); n > maxJust {
807+
return errors.New("too many justifications", z.Int("count", n), z.Int("max", maxJust))
808+
}
809+
810+
// Each message (the main message plus each justification) references at most two
811+
// values (value and prepared value), so the values are bounded by 2*(justifications+1).
812+
maxValues := 2 * (len(pbMsg.GetJustification()) + 1)
813+
if n := len(pbMsg.GetValues()); n > maxValues {
814+
return errors.New("too many values", z.Int("count", n), z.Int("max", maxValues))
815+
}
816+
817+
return nil
818+
}
819+
779820
func verifyMsg(msg *pbv1.QBFTMsg, pubkeys map[int64]*k1.PublicKey) error {
780821
if msg == nil || msg.GetDuty() == nil {
781822
return errors.New("invalid consensus message")

core/consensus/qbft/qbft_internal_test.go

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -706,6 +706,129 @@ func TestQBFTConsensusHandle(t *testing.T) {
706706
}
707707
}
708708

709+
// TestQBFTConsensusHandleAmplificationLimits verifies that handle rejects messages
710+
// carrying more justifications or values than a legitimate consensus message ever
711+
// needs, before doing the expensive per-element signature recovery / unmarshalling.
712+
// This caps the CPU/memory amplification a single authenticated peer can inflict.
713+
func TestQBFTConsensusHandleAmplificationLimits(t *testing.T) {
714+
// newConsensus returns a single-node consensus, so max justifications = 2*nodes = 2.
715+
newConsensus := func(t *testing.T) (*Consensus, *k1.PrivateKey) {
716+
t.Helper()
717+
718+
var c Consensus
719+
720+
deadliner := coremocks.NewDeadliner(t)
721+
deadliner.On("Add", mock.Anything).Maybe().Return(core.DeadlineScheduled)
722+
c.deadliner = deadliner
723+
c.gaterFunc = func(core.Duty) bool { return true }
724+
c.mutable.instances = make(map[core.Duty]*instance.IO[Msg])
725+
726+
p2pKey := testutil.GenerateInsecureK1Key(t, 0)
727+
c.pubkeys = make(map[int64]*k1.PublicKey)
728+
c.pubkeys[0] = p2pKey.PubKey()
729+
730+
return &c, p2pKey
731+
}
732+
733+
// signedBase returns a validly-signed main message so verification reaches the limit checks.
734+
signedBase := func(t *testing.T, p2pKey *k1.PrivateKey) *pbv1.QBFTConsensusMsg {
735+
t.Helper()
736+
737+
base := &pbv1.QBFTConsensusMsg{Msg: newRandomQBFTMsg(t)}
738+
base.Msg.PeerIdx = 0
739+
base.Msg.Round = 1
740+
base.Msg.Duty = &pbv1.Duty{Slot: 42, Type: 1}
741+
742+
msgHash, err := hashProto(base.GetMsg())
743+
require.NoError(t, err)
744+
745+
sign, err := k1util.Sign(p2pKey, msgHash[:])
746+
require.NoError(t, err)
747+
748+
base.Msg.Signature = sign
749+
750+
return base
751+
}
752+
753+
// signedJustification returns a validly-signed justification matching the base message's duty.
754+
signedJustification := func(t *testing.T, p2pKey *k1.PrivateKey) *pbv1.QBFTMsg {
755+
t.Helper()
756+
757+
j := newRandomQBFTMsg(t)
758+
j.PeerIdx = 0
759+
j.Round = 1 // verifyMsg requires round > 0, don't rely on the random value.
760+
j.Duty = &pbv1.Duty{Slot: 42, Type: 1}
761+
762+
jHash, err := hashProto(j)
763+
require.NoError(t, err)
764+
765+
j.Signature, err = k1util.Sign(p2pKey, jHash[:])
766+
require.NoError(t, err)
767+
768+
return j
769+
}
770+
771+
t.Run("too many justifications rejected", func(t *testing.T) {
772+
c, p2pKey := newConsensus(t)
773+
base := signedBase(t, p2pKey)
774+
775+
// 3 justifications > 2*nodes (2). Content is irrelevant since the count
776+
// check runs before any per-justification verification.
777+
for range 3 {
778+
base.Justification = append(base.Justification, &pbv1.QBFTMsg{})
779+
}
780+
781+
_, _, err := c.handle(context.Background(), "peerID", base)
782+
require.ErrorContains(t, err, "too many justifications")
783+
})
784+
785+
t.Run("max justifications accepted", func(t *testing.T) {
786+
c, p2pKey := newConsensus(t)
787+
base := signedBase(t, p2pKey)
788+
789+
// Exactly 2*nodes (2) justifications must not be rejected by the count check.
790+
for range 2 {
791+
base.Justification = append(base.Justification, signedJustification(t, p2pKey))
792+
}
793+
794+
_, _, err := c.handle(context.Background(), "peerID", base)
795+
require.NoError(t, err)
796+
})
797+
798+
t.Run("too many values rejected", func(t *testing.T) {
799+
c, p2pKey := newConsensus(t)
800+
base := signedBase(t, p2pKey)
801+
802+
// 0 justifications => max values = 2*(0+1) = 2. Provide 3.
803+
base.Values = []*anypb.Any{{}, {}, {}}
804+
805+
_, _, err := c.handle(context.Background(), "peerID", base)
806+
require.ErrorContains(t, err, "too many values")
807+
})
808+
809+
t.Run("max values accepted", func(t *testing.T) {
810+
c, p2pKey := newConsensus(t)
811+
base := signedBase(t, p2pKey)
812+
813+
// 2 justifications => max values = 2*(2+1) = 6. A message carrying exactly
814+
// the maximum must pass the count check and the rest of handle, guarding
815+
// against the bound being tightened below the legitimate maximum.
816+
for range 2 {
817+
base.Justification = append(base.Justification, signedJustification(t, p2pKey))
818+
}
819+
820+
for i := range 6 {
821+
value, err := anypb.New(&pbv1.Duty{Slot: uint64(i + 1)})
822+
require.NoError(t, err)
823+
824+
base.Values = append(base.Values, value)
825+
}
826+
827+
_, _, err := c.handle(context.Background(), "peerID", base)
828+
require.NoError(t, err)
829+
})
830+
}
831+
709832
func TestInstanceIO_MaybeStart(t *testing.T) {
710833
t.Run("MaybeStart for new instance", func(t *testing.T) {
711834
inst1 := instance.NewIO[Msg]()

core/qbft/qbft.go

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,21 @@ type dedupKey struct {
158158
Round int64
159159
}
160160

161+
// maxDecidedResends bounds the number of MsgDecided rebroadcasts that
162+
// post-decision ROUND-CHANGE messages from a single peer can trigger.
163+
// A lagging peer re-sends ROUND-CHANGE with an increasing round on each
164+
// timeout until it learns the decided value, so a handful of resends is
165+
// ample for liveness, while the cap stops a malicious peer minting
166+
// ever-higher rounds from extracting unlimited large rebroadcasts.
167+
const maxDecidedResends = 16
168+
169+
// decidedResend tracks the MsgDecided rebroadcasts triggered by a peer's
170+
// post-decision ROUND-CHANGE messages.
171+
type decidedResend struct {
172+
Round int64 // Highest round a rebroadcast was triggered for.
173+
Count int // Total rebroadcasts triggered by the peer.
174+
}
175+
161176
// errors
162177
var (
163178
errCompare = errors.New("compare leader value with local value failed")
@@ -211,6 +226,7 @@ func Run[I any, V comparable, C any](ctx context.Context, d Definition[I, V, C],
211226
qCommit []Msg[I, V, C]
212227
buffer = make(map[int64][]Msg[I, V, C])
213228
dedupRules = make(map[dedupKey]bool)
229+
decidedResends = make(map[int64]decidedResend) // Bounds MsgDecided rebroadcasts by peer source.
214230
timerChan <-chan time.Time
215231
stopTimer func()
216232
)
@@ -273,6 +289,25 @@ func Run[I any, V comparable, C any](ctx context.Context, d Definition[I, V, C],
273289
return true
274290
}
275291

292+
// allowDecidedResend reports whether a post-decision ROUND-CHANGE from source at
293+
// round may trigger a MsgDecided rebroadcast, recording it when it does. It permits
294+
// at most one rebroadcast per source per strictly-increasing round, capped at
295+
// maxDecidedResends per source, so duplicate, replayed or maliciously
296+
// round-incremented messages can't repeatedly trigger a large rebroadcast
297+
// (amplification DoS), while a peer advancing to a genuinely new round still gets
298+
// served. Sources are authenticated by the transport, so the tracked set stays
299+
// naturally bounded by the cluster size.
300+
allowDecidedResend := func(incomingSource, incomingRound int64) bool {
301+
resend := decidedResends[incomingSource]
302+
if incomingRound <= resend.Round || resend.Count >= maxDecidedResends {
303+
return false
304+
}
305+
306+
decidedResends[incomingSource] = decidedResend{Round: incomingRound, Count: resend.Count + 1}
307+
308+
return true
309+
}
310+
276311
// changeRound updates round and clears the rule dedup state.
277312
changeRound := func(newRound int64, rule UponRule) {
278313
if round == newRound {
@@ -316,9 +351,12 @@ func Run[I any, V comparable, C any](ctx context.Context, d Definition[I, V, C],
316351
inputValueCh = nil // Don't read from this channel again.
317352

318353
case msg := <-t.Receive:
319-
// Just send Qcommit if consensus already decided
354+
// Just send Qcommit if consensus already decided. The resend is rate-limited
355+
// (see allowDecidedResend) to bound amplification; note this runs before the
356+
// isJustified check, so the ROUND-CHANGE need not even be justified.
320357
if len(qCommit) > 0 {
321-
if msg.Source() != process && msg.Type() == MsgRoundChange { // Algorithm 3:17
358+
if msg.Source() != process && msg.Type() == MsgRoundChange && // Algorithm 3:17
359+
allowDecidedResend(msg.Source(), msg.Round()) {
322360
err = broadcastMsg(MsgDecided, qCommit[0].Value(), qCommit)
323361
}
324362

core/qbft/qbft_internal_test.go

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -620,6 +620,118 @@ func (m msg) Justification() []Msg[int64, int64, int64] {
620620
return resp
621621
}
622622

623+
// TestDecidedRebroadcastLimits verifies that once consensus has decided, post-decision
624+
// ROUND-CHANGE messages trigger at most one MsgDecided rebroadcast per source per
625+
// (strictly increasing) round, capped at maxDecidedResends per source. This bounds
626+
// amplification while still serving lagging peers that advance to new rounds.
627+
func TestDecidedRebroadcastLimits(t *testing.T) {
628+
const (
629+
n = 4
630+
process = 0
631+
value = 42
632+
)
633+
634+
// Build a justified MsgDecided: quorum (3) commits for round 1, value 42.
635+
commits := []msg{
636+
{msgType: MsgCommit, peerIdx: 1, round: 1, value: value},
637+
{msgType: MsgCommit, peerIdx: 2, round: 1, value: value},
638+
{msgType: MsgCommit, peerIdx: 3, round: 1, value: value},
639+
}
640+
decided := msg{msgType: MsgDecided, peerIdx: 1, round: 1, value: value, justify: commits}
641+
642+
rc := func(source, round int64) msg {
643+
return msg{msgType: MsgRoundChange, peerIdx: source, round: round}
644+
}
645+
646+
// runDecidedInstance starts a qbft instance, sends it the decided message and
647+
// returns a synchronous send function plus the channel collecting MsgDecided
648+
// broadcasts. The receive channel is unbuffered, so the instance only accepts
649+
// a send once it has fully processed all earlier messages (the just-sent
650+
// message may still be in flight, hence tests end with an inert flush send).
651+
// This makes broadcast-count assertions deterministic.
652+
runDecidedInstance := func(t *testing.T) (func(msg), chan MsgType) {
653+
t.Helper()
654+
655+
ctx, cancel := context.WithCancel(context.Background())
656+
t.Cleanup(cancel)
657+
658+
recv := make(chan Msg[int64, int64, int64])
659+
decidedBroadcasts := make(chan MsgType, 100)
660+
661+
def := noopDef
662+
def.Nodes = n
663+
def.FIFOLimit = 100
664+
def.Decide = func(context.Context, int64, int64, []Msg[int64, int64, int64]) {}
665+
666+
trans := Transport[int64, int64, int64]{
667+
Broadcast: func(_ context.Context, typ MsgType, _ int64, _ int64, _ int64, _ int64,
668+
_ int64, _ int64, _ []Msg[int64, int64, int64],
669+
) error {
670+
if typ == MsgDecided {
671+
decidedBroadcasts <- typ
672+
}
673+
674+
return nil
675+
},
676+
Receive: recv,
677+
}
678+
679+
// Never-delivering input channels (this process is not a leader and proposes nothing).
680+
go func() {
681+
_ = Run(ctx, def, trans, 0, process, make(chan int64), make(chan int64))
682+
}()
683+
684+
send := func(m msg) {
685+
select {
686+
case recv <- m:
687+
case <-time.After(5 * time.Second):
688+
require.Fail(t, "timeout sending message to qbft instance")
689+
}
690+
}
691+
692+
send(decided)
693+
694+
return send, decidedBroadcasts
695+
}
696+
697+
t.Run("dedup duplicates and stale rounds", func(t *testing.T) {
698+
send, broadcasts := runDecidedInstance(t)
699+
700+
for _, m := range []msg{
701+
rc(2, 2), // Rebroadcast #1.
702+
rc(2, 2), // Duplicate, no rebroadcast.
703+
rc(2, 2), // Duplicate, no rebroadcast.
704+
rc(3, 2), // Rebroadcast #2 (other source).
705+
rc(3, 2), // Duplicate, no rebroadcast.
706+
rc(2, 1), // Stale round (already rebroadcast for round 2), no rebroadcast.
707+
rc(2, 3), // Rebroadcast #3 (source advanced to a new round).
708+
} {
709+
send(m)
710+
}
711+
712+
// Flush with an inert message: once this send returns, all messages above
713+
// have been fully processed, so the broadcast count is final.
714+
send(rc(2, 1))
715+
716+
require.Len(t, broadcasts, 3)
717+
})
718+
719+
t.Run("resend cap per source", func(t *testing.T) {
720+
send, broadcasts := runDecidedInstance(t)
721+
722+
// One peer keeps advancing rounds: only the first maxDecidedResends
723+
// ROUND-CHANGE messages may trigger a rebroadcast.
724+
for round := int64(2); round < 2+maxDecidedResends+5; round++ {
725+
send(rc(2, round))
726+
}
727+
728+
// Flush with an inert message (stale round, never rebroadcast).
729+
send(rc(2, 1))
730+
731+
require.Len(t, broadcasts, maxDecidedResends)
732+
})
733+
}
734+
623735
func TestIsJustifiedPrePrepare(t *testing.T) {
624736
const (
625737
n = 4

0 commit comments

Comments
 (0)