Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 27 additions & 15 deletions message/validation/consensus_validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,28 +47,23 @@ func (mv *messageValidator) validateConsensusMessage(
return consensusMessage, err
}

state := mv.validatorState(ssvMessage.GetID(), committeeInfo)

if err := mv.validateQBFTLogic(signedSSVMessage, consensusMessage, committeeInfo, receivedFrom, receivedAt, state); err != nil {
if err := mv.verifyConsensusMessageSignatures(signedSSVMessage); err != nil {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you think about per-peer CPU amplification here? I fear a single peer could send multiple structurally-different messages with the same MsgID (varying Round, FullData, etc.) that all pass semantic validation and enter verifyConsensusMessageSignatures in parallel before the per-peer SeenMsgTypes limits kick in inside the lock. Pre-PR the outer lock capped simultaneous RSA work per MsgID at 1; now it's bounded only by libp2p's validation-worker pool. Maybe worth a follow-up: a cheap pre-verify per-peer counter keyed on (MsgID, peerID), or rely on topic scoring — could be bundled with the F-ssv-173 (2)/(3) follow-ups. Same applies to partial_validation.go verify path.

return consensusMessage, err
}

if err := mv.validateQBFTMessageByDutyLogic(signedSSVMessage, consensusMessage, committeeInfo, receivedAt, state); err != nil {
return consensusMessage, err
}
if err := mv.withValidationLock(ssvMessage.GetID(), func() error {
state := mv.validatorState(ssvMessage.GetID(), committeeInfo)

for i := range signedSSVMessage.Signatures {
operatorID := signedSSVMessage.OperatorIDs[i]
signature := signedSSVMessage.Signatures[i]
if err := mv.validateQBFTLogic(signedSSVMessage, consensusMessage, committeeInfo, receivedFrom, receivedAt, state); err != nil {
return err
}

if err := mv.signatureVerifier.VerifySignature(operatorID, ssvMessage, signature); err != nil {
e := ErrSignatureVerification
e.innerErr = fmt.Errorf("verify opid: %v signature: %w", operatorID, err)
return consensusMessage, e
if err := mv.validateQBFTMessageByDutyLogic(signedSSVMessage, consensusMessage, committeeInfo, receivedAt, state); err != nil {
return err
}
}

if err := mv.updateConsensusState(signedSSVMessage, consensusMessage, committeeInfo, receivedFrom, state); err != nil {
return mv.updateConsensusState(signedSSVMessage, consensusMessage, committeeInfo, receivedFrom, state)
}); err != nil {
Comment on lines +50 to +66

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Stateful cheap rejections now happen after expensive crypto

The ordering inversion means that legitimately-signed but stale/duplicate messages now incur full RSA signature verification before being rejected by the cheap stateful checks inside the lock (e.g. ErrRoundAlreadyAdvanced, ErrDuplicatedMessage, ErrDecidedMessageWithTooFewSigners).

In the old code a replayed, round-old, or duplicate message would be rejected immediately inside the lock without ever touching the crypto path. Under the new ordering, any message that carries a valid RSA signature can force O(signers) RSA verifications before the cheap stateful rejection fires. Because an attacker only needs to replay previously-seen, legitimately-signed messages (no forgery required), this creates a more efficient CPU-exhaustion path than before.

The PR description acknowledges this ("Cheap stateful rejections may still happen after signature verification"), but it may be worth considering whether a subset of the cheapest stateful checks (e.g. ErrRoundAlreadyAdvanced) could be moved back before signature verification to provide an inexpensive early-exit for replay traffic, without reintroducing lock contention for the common case.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

but it may be worth considering whether a subset of the cheapest stateful checks (e.g. ErrRoundAlreadyAdvanced) could be moved back before signature verification to provide an inexpensive early-exit for replay traffic, without reintroducing lock contention for the common case.

TBH I don't think this is worth the added code complexity.

return consensusMessage, err
}

Expand Down Expand Up @@ -171,6 +166,22 @@ func (mv *messageValidator) validateConsensusMessageSemantics(
return nil
}

func (mv *messageValidator) verifyConsensusMessageSignatures(signedSSVMessage *spectypes.SignedSSVMessage) error {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The board finding (F-ssv-173) recommended three optimizations:

  1. Move sig verification outside the lock — this PR
  2. Verify multiple signatures in parallel for decided messages (up to 13 sigs × ~1.5ms = ~20ms sequential) — not addressed
  3. Cache recently verified (operatorID, messageHash, signature) tuples to skip re-verification on retransmitted messages — not addressed

This PR reduces lock hold time but doesn't reduce the total CPU cost of verification itself. A decided message with 4 signatures still takes ~6ms of sequential RSA verification before acquiring the lock. Consider whether 2 (parallel verification in verifyConsensusMessageSignatures via a goroutine pool) should be part of this PR or tracked as a follow-up.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(2) and (3) depend on (1), so I want to decide on (1) first before continuing. It moves the expensive signature verification before the light checks, which might be worse than keeping the lock

Once we decide on it, (2) and (3) will be ready for implementation

@iurii-ssv iurii-ssv Mar 25, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3. Cache recently verified (operatorID, messageHash, signature) tuples to skip re-verification on retransmitted messages — not addressed

libp2p probably does some basic de-duplication for us already (not sure if by messageHash or somehow else) ? Probably want to check if our own caching will even catch any such duplicates before proceeding to it

ssvMessage := signedSSVMessage.SSVMessage
for i := range signedSSVMessage.Signatures {
operatorID := signedSSVMessage.OperatorIDs[i]
signature := signedSSVMessage.Signatures[i]

if err := mv.signatureVerifier.VerifySignature(operatorID, ssvMessage, signature); err != nil {
e := ErrSignatureVerification
e.innerErr = fmt.Errorf("verify opid: %v signature: %w", operatorID, err)
return e
}
}

return nil
}

func (mv *messageValidator) validateQBFTLogic(
signedSSVMessage *spectypes.SignedSSVMessage,
consensusMessage *specqbft.Message,
Expand Down Expand Up @@ -281,6 +292,7 @@ func (mv *messageValidator) validateQBFTMessageByDutyLogic(
}

msgSlot := phase0.Slot(consensusMessage.Height)

randaoMsg := false
if err := mv.validateBeaconDuty(signedSSVMessage.SSVMessage.GetID().GetRoleType(), msgSlot, committeeInfo.validatorIndices, randaoMsg); err != nil {
return err
Expand Down
29 changes: 20 additions & 9 deletions message/validation/partial_validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,26 +43,37 @@ func (mv *messageValidator) validatePartialSignatureMessage(
return partialSignatureMessages, err
}

state := mv.validatorState(ssvMessage.GetID(), committeeInfo)
if err := mv.validatePartialSigMessagesByDutyLogic(signedSSVMessage, partialSignatureMessages, committeeInfo, receivedFrom, receivedAt, state); err != nil {
if err := mv.verifyPartialSignatureMessageSignature(signedSSVMessage); err != nil {
return partialSignatureMessages, err
}

signature := signedSSVMessage.Signatures[0]
signer := signedSSVMessage.OperatorIDs[0]
if err := mv.signatureVerifier.VerifySignature(signer, ssvMessage, signature); err != nil {
e := ErrSignatureVerification
e.innerErr = fmt.Errorf("verify opid: %v signature: %w", signer, err)
return partialSignatureMessages, e
}
if err := mv.withValidationLock(ssvMessage.GetID(), func() error {
state := mv.validatorState(ssvMessage.GetID(), committeeInfo)
if err := mv.validatePartialSigMessagesByDutyLogic(signedSSVMessage, partialSignatureMessages, committeeInfo, receivedFrom, receivedAt, state); err != nil {
return err
}

if err := mv.updatePartialSignatureState(partialSignatureMessages, receivedFrom, state, signer, committeeInfo); err != nil {
return mv.updatePartialSignatureState(partialSignatureMessages, receivedFrom, state, signer, committeeInfo)
}); err != nil {
return partialSignatureMessages, err
}

return partialSignatureMessages, nil
}

func (mv *messageValidator) verifyPartialSignatureMessageSignature(signedSSVMessage *spectypes.SignedSSVMessage) error {
signature := signedSSVMessage.Signatures[0]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might be worth a defensive len guard or a short comment here? The [0] indexing is safe today because validateSignedSSVMessage (len≥1, equal arities) and validatePartialSignatureMessageSemantics (len==1) run before verify. A reorder in a future refactor would silently break it — a one-line // semantics above guarantees len==1 makes the invariant explicit.

signer := signedSSVMessage.OperatorIDs[0]
if err := mv.signatureVerifier.VerifySignature(signer, signedSSVMessage.SSVMessage, signature); err != nil {
e := ErrSignatureVerification
e.innerErr = fmt.Errorf("verify opid: %v signature: %w", signer, err)
return e
}

return nil
}

func (mv *messageValidator) validatePartialSignatureMessageSemantics(
signedSSVMessage *spectypes.SignedSSVMessage,
partialSignatureMessages *spectypes.PartialSignatureMessages,
Expand Down
12 changes: 8 additions & 4 deletions message/validation/validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -189,10 +189,6 @@ func (mv *messageValidator) handleSignedSSVMessage(
return decodedMessage, err
}

validationMu := mv.getValidationLock(signedSSVMessage.SSVMessage.GetID())
validationMu.Lock()
defer validationMu.Unlock()

switch signedSSVMessage.SSVMessage.MsgType {
case spectypes.SSVConsensusMsgType:
consensusMessage, err := mv.validateConsensusMessage(signedSSVMessage, committeeInfo, receivedFrom, receivedAt)
Expand Down Expand Up @@ -257,6 +253,14 @@ func (mv *messageValidator) getValidationLock(key spectypes.MessageID) *sync.Mut
return lock
}

func (mv *messageValidator) withValidationLock(key spectypes.MessageID, fn func() error) error {
validationMu := mv.getValidationLock(key)
validationMu.Lock()
defer validationMu.Unlock()

return fn()
}

func (mv *messageValidator) getCommitteeAndValidatorIndices(msgID spectypes.MessageID) (CommitteeInfo, error) {
if mv.committeeRole(msgID.GetRoleType()) {
// TODO: add metrics and logs for committee role
Expand Down
224 changes: 224 additions & 0 deletions message/validation/validation_lock_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
package validation

import (
"bytes"
"maps"
"slices"
"testing"
"time"

"github.com/attestantio/go-eth2-client/spec"
"github.com/attestantio/go-eth2-client/spec/phase0"
libp2ptest "github.com/libp2p/go-libp2p/core/test"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
"go.uber.org/zap/zaptest"

spectypes "github.com/ssvlabs/ssv-spec/types"
spectestingutils "github.com/ssvlabs/ssv-spec/types/testingutils"

"github.com/ssvlabs/ssv/network/commons"
"github.com/ssvlabs/ssv/networkconfig"
"github.com/ssvlabs/ssv/operator/duties/dutystore"
"github.com/ssvlabs/ssv/operator/storage"
ssvtypes "github.com/ssvlabs/ssv/protocol/v2/types"
registrystorage "github.com/ssvlabs/ssv/registry/storage"
"github.com/ssvlabs/ssv/registry/storage/mocks"
kv "github.com/ssvlabs/ssv/storage/badger"
"github.com/ssvlabs/ssv/storage/basedb"
)

type observingSignatureVerifier struct {
called chan struct{}
}

type validationLockTestEnv struct {
validator *messageValidator
committeeID spectypes.CommitteeID
committeeIdentifier spectypes.MessageID
netCfg *networkconfig.Network
ks *spectestingutils.TestKeySet
}

func (v *observingSignatureVerifier) VerifySignature(spectypes.OperatorID, *spectypes.SSVMessage, []byte) error {
select {
case v.called <- struct{}{}:
default:
}

return nil
}
Comment thread
nkryuchkov marked this conversation as resolved.

func newValidationLockTestEnv(t *testing.T) validationLockTestEnv {
ctrl := gomock.NewController(t)

logger := zaptest.NewLogger(t)
db, err := kv.NewInMemory(logger, basedb.Options{})
require.NoError(t, err)

ns, err := storage.NewNodeStorage(networkconfig.TestNetwork.Beacon, logger, db)
require.NoError(t, err)

netCfg := networkconfig.TestNetwork
ks := spectestingutils.Testing4SharesSet()
shares := generateShares(t, ks, ns, netCfg)

dutyStore := dutystore.New()
validatorStore := mocks.NewMockValidatorStore(ctrl)
operators := mocks.NewMockOperators(ctrl)

committee := slices.Collect(maps.Keys(ks.Shares))
slices.Sort(committee)

committeeID := shares.active.CommitteeID()
validatorStore.EXPECT().Committee(gomock.Any()).DoAndReturn(func(id spectypes.CommitteeID) (*registrystorage.Committee, bool) {
if id != committeeID {
return nil, false
}

share1 := cloneSSVShare(t, shares.active)
share2 := cloneSSVShare(t, share1)
share2.ValidatorIndex = share1.ValidatorIndex + 1
share3 := cloneSSVShare(t, share2)
share3.ValidatorIndex = share2.ValidatorIndex + 1

return &registrystorage.Committee{
ID: id,
Operators: committee,
Shares: []*ssvtypes.SSVShare{
share1,
share2,
share3,
},
Indices: []phase0.ValidatorIndex{
share1.ValidatorIndex,
share2.ValidatorIndex,
share3.ValidatorIndex,
},
}, true
}).AnyTimes()

for _, id := range []spectypes.OperatorID{1, 2, 3, 4, 5} {
operators.EXPECT().
OperatorsExist(gomock.Any(), []spectypes.OperatorID{id}).
Return(true, nil).
AnyTimes()
}

verifier := &observingSignatureVerifier{called: make(chan struct{}, 1)}

validator := New(
netCfg,
validatorStore,
operators,
dutyStore,
verifier,
).(*messageValidator)

encodedCommitteeID := append(bytes.Repeat([]byte{0}, 16), committeeID[:]...)
committeeIdentifier := spectypes.NewMsgID(netCfg.DomainType, encodedCommitteeID, spectypes.RoleCommittee)

return validationLockTestEnv{
validator: validator,
committeeID: committeeID,
committeeIdentifier: committeeIdentifier,
netCfg: netCfg,
ks: ks,
}
}

func TestConsensusSignatureVerificationOutsideValidationLock(t *testing.T) {
env := newValidationLockTestEnv(t)

slot := env.netCfg.FirstSlotAtEpoch(1)
signedSSVMessage := generateSignedMessage(env.ks, env.committeeIdentifier, slot)
topicID := commons.CommitteeTopicID(env.committeeID)[0]
peerID, err := libp2ptest.RandPeerID()
require.NoError(t, err)

validationMu := env.validator.getValidationLock(signedSSVMessage.SSVMessage.GetID())
validationMu.Lock()
locked := true
defer func() {
if locked {
validationMu.Unlock()
}
}()

done := make(chan error, 1)
go func() {
_, err := env.validator.handleSignedSSVMessage(signedSSVMessage, topicID, peerID, env.netCfg.SlotStartTime(slot))
done <- err
}()

select {
case <-env.validator.signatureVerifier.(*observingSignatureVerifier).called:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth considering whether this test actually proves the parallel-verify claim? Seems that it asserts verify runs while the test goroutine externally holds the lock, but the operational value of the PR is that two handler goroutines for the same MsgID can both be in verify simultaneously. A test that spawns two handleSignedSSVMessage goroutines (no external lock) and asserts both reach VerifySignature before either records state would be a stronger regression guard. Could be a follow-up alongside the F-ssv-173 items.

case <-time.After(time.Second):
t.Fatal("signature verification did not start while the validation lock was held")
}

select {
case err := <-done:
t.Fatalf("validation completed before the lock was released: %v", err)
default:
}

validationMu.Unlock()
locked = false

select {
case err := <-done:
require.NoError(t, err)
case <-time.After(time.Second):
t.Fatal("validation did not complete after the lock was released")
}
Comment on lines +130 to +174

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 ErrSignerNotLeader can silently break the consensus test

generateSignedMessage always creates a ProposalMsgType message signed by operator 1. Once the lock is released, validateQBFTLogic checks whether operator 1 is actually the round-robin leader at that height/round:

leader := mv.roundRobinProposer(consensusMessage.Height, consensusMessage.Round, committeeInfo.committee)
if signedSSVMessage.OperatorIDs[0] != leader {
    return ErrSignerNotLeader
}

roundRobinProposer computes:

firstRoundIndex = height % len(committee)   // height = FirstSlotAtEpoch(1) = SlotsPerEpoch
index           = (firstRoundIndex + round - 1) % len(committee)

For the test to succeed, index must be 0 (so that committee[0] = 1 is returned). With Testing4SharesSet (len = 4) and TestNetwork.SlotsPerEpoch = 32, the arithmetic works out (32 % 4 == 0), but this relies on an implicit relationship between the slot chosen in the test and the committee size.

If the test slot or committee size changes the modular arithmetic fails, ErrSignerNotLeader is returned from inside the lock, and require.NoError(t, err) at line 171 fails — but the test already proved its structural correctness (the called channel was signalled and the goroutine was blocked before the lock). The assertion at line 171 is testing something unrelated to the lock behaviour and couples the test to unrelated scheduling logic.

Consider either using a CommitMsgType message (no leader check), or computing the expected leader explicitly and signing with that operator's key.

}

func TestPartialSignatureVerificationOutsideValidationLock(t *testing.T) {
env := newValidationLockTestEnv(t)

slot := env.netCfg.FirstSlotAtEpoch(1)
ssvMessage := spectestingutils.SSVMsgAggregator(nil, spectestingutils.PostConsensusAggregatorMsg(env.ks.Shares[1], 1, spec.DataVersionPhase0))
ssvMessage.MsgID = env.committeeIdentifier
signedSSVMessage := spectestingutils.SignPartialSigSSVMessage(env.ks, ssvMessage)
Comment thread
nkryuchkov marked this conversation as resolved.
topicID := commons.CommitteeTopicID(env.committeeID)[0]
peerID, err := libp2ptest.RandPeerID()
require.NoError(t, err)

validationMu := env.validator.getValidationLock(signedSSVMessage.SSVMessage.GetID())
validationMu.Lock()
locked := true
defer func() {
if locked {
validationMu.Unlock()
}
}()

done := make(chan error, 1)
go func() {
_, err := env.validator.handleSignedSSVMessage(signedSSVMessage, topicID, peerID, env.netCfg.SlotStartTime(slot))
done <- err
}()

select {
case <-env.validator.signatureVerifier.(*observingSignatureVerifier).called:
case <-time.After(time.Second):
t.Fatal("partial signature verification did not start while the validation lock was held")
}

select {
case err := <-done:
t.Fatalf("partial validation completed before the lock was released: %v", err)
default:
}

validationMu.Unlock()
locked = false

select {
case err := <-done:
require.NoError(t, err)
case <-time.After(time.Second):
t.Fatal("partial validation did not complete after the lock was released")
}
}
Loading