-
Notifications
You must be signed in to change notification settings - Fork 150
message/validation: move signature verification outside validation lock #2728
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: stage
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 { | ||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
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. 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.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
TBH I don't think this is worth the added code complexity. |
||
| return consensusMessage, err | ||
| } | ||
|
|
||
|
|
@@ -171,6 +166,22 @@ func (mv *messageValidator) validateConsensusMessageSemantics( | |
| return nil | ||
| } | ||
|
|
||
| func (mv *messageValidator) verifyConsensusMessageSignatures(signedSSVMessage *spectypes.SignedSSVMessage) error { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The board finding (F-ssv-173) recommended three optimizations:
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
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
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, | ||
|
|
@@ -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 | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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] | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Might be worth a defensive |
||
| 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, | ||
|
|
||
| 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 | ||
| } | ||
|
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 ®istrystorage.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: | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
leader := mv.roundRobinProposer(consensusMessage.Height, consensusMessage.Round, committeeInfo.committee)
if signedSSVMessage.OperatorIDs[0] != leader {
return ErrSignerNotLeader
}
For the test to succeed, If the test slot or committee size changes the modular arithmetic fails, Consider either using a |
||
| } | ||
|
|
||
| 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) | ||
|
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") | ||
| } | ||
| } | ||
There was a problem hiding this comment.
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
verifyConsensusMessageSignaturesin parallel before the per-peerSeenMsgTypeslimits 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 topartial_validation.goverify path.