-
Notifications
You must be signed in to change notification settings - Fork 2k
Stricter validation of ID in ocr signed responses #23003
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: develop
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 |
|---|---|---|
| @@ -0,0 +1,49 @@ | ||
| package vaultutils | ||
|
|
||
| import ( | ||
| "encoding/json" | ||
| "errors" | ||
| "fmt" | ||
|
|
||
| "google.golang.org/protobuf/encoding/protojson" | ||
|
|
||
| vaultcommon "github.com/smartcontractkit/chainlink-common/pkg/capabilities/actions/vault" | ||
| "github.com/smartcontractkit/chainlink/v2/core/capabilities/vault/vaulttypes" | ||
| ) | ||
|
|
||
| // SignedPayloadRequestID extracts the requestId field from a vault OCR signed response payload. | ||
| func SignedPayloadRequestID(method string, payload json.RawMessage) (string, error) { | ||
| if len(payload) == 0 { | ||
| return "", errors.New("signed payload is empty") | ||
| } | ||
|
|
||
| unmarshal := protojson.UnmarshalOptions{DiscardUnknown: true} | ||
| switch method { | ||
| case vaulttypes.MethodSecretsCreate: | ||
| resp := &vaultcommon.CreateSecretsResponse{} | ||
| if err := unmarshal.Unmarshal(payload, resp); err != nil { | ||
| return "", fmt.Errorf("failed to unmarshal signed create secrets payload: %w", err) | ||
| } | ||
| return resp.RequestId, nil | ||
| case vaulttypes.MethodSecretsUpdate: | ||
| resp := &vaultcommon.UpdateSecretsResponse{} | ||
| if err := unmarshal.Unmarshal(payload, resp); err != nil { | ||
| return "", fmt.Errorf("failed to unmarshal signed update secrets payload: %w", err) | ||
| } | ||
| return resp.RequestId, nil | ||
| case vaulttypes.MethodSecretsDelete: | ||
| resp := &vaultcommon.DeleteSecretsResponse{} | ||
| if err := unmarshal.Unmarshal(payload, resp); err != nil { | ||
| return "", fmt.Errorf("failed to unmarshal signed delete secrets payload: %w", err) | ||
| } | ||
| return resp.RequestId, nil | ||
| case vaulttypes.MethodSecretsList: | ||
| resp := &vaultcommon.ListSecretIdentifiersResponse{} | ||
| if err := unmarshal.Unmarshal(payload, resp); err != nil { | ||
| return "", fmt.Errorf("failed to unmarshal signed list secret identifiers payload: %w", err) | ||
| } | ||
| return resp.RequestId, nil | ||
| default: | ||
| return "", fmt.Errorf("unsupported method for signed payload request id validation: %s", method) | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| package vaultutils | ||
|
|
||
| import ( | ||
| "encoding/json" | ||
| "testing" | ||
|
|
||
| "github.com/stretchr/testify/require" | ||
|
|
||
| vaultcommon "github.com/smartcontractkit/chainlink-common/pkg/capabilities/actions/vault" | ||
| "github.com/smartcontractkit/chainlink/v2/core/capabilities/vault/vaulttypes" | ||
| ) | ||
|
|
||
| func TestSignedPayloadRequestID(t *testing.T) { | ||
| t.Parallel() | ||
|
|
||
| payload, err := ToCanonicalJSON(&vaultcommon.CreateSecretsResponse{ | ||
| RequestId: "owner::req-1", | ||
| Responses: []*vaultcommon.CreateSecretResponse{}, | ||
| }, false) | ||
| require.NoError(t, err) | ||
|
|
||
| got, err := SignedPayloadRequestID(vaulttypes.MethodSecretsCreate, json.RawMessage(payload)) | ||
| require.NoError(t, err) | ||
| require.Equal(t, "owner::req-1", got) | ||
|
|
||
| got, err = SignedPayloadRequestID(vaulttypes.MethodSecretsCreate, json.RawMessage(`{"responses":[]}`)) | ||
| require.NoError(t, err) | ||
| require.Empty(t, got) | ||
| } |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -13,16 +13,25 @@ import ( | |
| "strings" | ||
|
|
||
| "github.com/ethereum/go-ethereum/common" | ||
| "go.opentelemetry.io/otel/attribute" | ||
| "go.opentelemetry.io/otel/metric" | ||
|
|
||
| "github.com/smartcontractkit/chainlink-common/pkg/capabilities" | ||
| vaultcommon "github.com/smartcontractkit/chainlink-common/pkg/capabilities/actions/vault" | ||
| jsonrpc "github.com/smartcontractkit/chainlink-common/pkg/jsonrpc2" | ||
| "github.com/smartcontractkit/chainlink-common/pkg/logger" | ||
| "github.com/smartcontractkit/chainlink-common/pkg/settings/limits" | ||
| "github.com/smartcontractkit/chainlink/v2/core/capabilities/vault/vaulttypes" | ||
| "github.com/smartcontractkit/chainlink/v2/core/capabilities/vault/vaultutils" | ||
| ) | ||
|
|
||
| var errSignedPayloadRequestIDMismatch = errors.New("signed payload request id mismatch") | ||
|
|
||
| type baseAggregator struct { | ||
| capabilitiesRegistry capabilitiesRegistry | ||
| capabilitiesRegistry capabilitiesRegistry | ||
| metrics *metrics | ||
| donID string | ||
| signedResponseRequestIDGate limits.GateLimiter | ||
| // vaultHandlerDonID scopes registry lookup when several vault DONs exist. | ||
| // | ||
| // Source: gateway job TOML [[gatewayConfig.ShardedDONs]] DonName (see deployment/cre/jobs/pkg/gateway_job.go), | ||
|
|
@@ -33,18 +42,51 @@ type baseAggregator struct { | |
| vaultHandlerDonID string | ||
| } | ||
|
|
||
| func (a *baseAggregator) Aggregate(ctx context.Context, l logger.Logger, resps map[string]jsonrpc.Response[json.RawMessage], currResp *jsonrpc.Response[json.RawMessage]) (*jsonrpc.Response[json.RawMessage], error) { | ||
| func methodSupportsSignedOCRValidation(method string) bool { | ||
| switch method { | ||
| case vaulttypes.MethodSecretsCreate, | ||
| vaulttypes.MethodSecretsUpdate, | ||
| vaulttypes.MethodSecretsDelete, | ||
| vaulttypes.MethodSecretsList: | ||
| return true | ||
| default: | ||
| return false | ||
| } | ||
| } | ||
|
|
||
| func (a *baseAggregator) signedResponseRequestIDEnabled(ctx context.Context, l logger.Logger) bool { | ||
| allowed, err := a.signedResponseRequestIDGate.Limit(ctx) | ||
| if err != nil { | ||
| l.Errorw("unexpected error evaluating CRE gate", "gate", "VaultSignedResponseRequestIDEnabled", "error", err) | ||
| return false | ||
| } | ||
| return allowed | ||
| } | ||
|
|
||
| func (a *baseAggregator) Aggregate(ctx context.Context, l logger.Logger, requestID string, resps map[string]jsonrpc.Response[json.RawMessage], currResp *jsonrpc.Response[json.RawMessage]) (*jsonrpc.Response[json.RawMessage], error) { | ||
| don, err := a.donForVaultCapability(ctx) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("failed to get DON for vault capability: %w", err) | ||
| } | ||
|
|
||
| currResp, err = a.validateUsingSignatures(don.DON, don.Nodes, currResp) | ||
| if err == nil { | ||
| return currResp, nil | ||
| if a.signedResponseRequestIDEnabled(ctx, l) { | ||
| if methodSupportsSignedOCRValidation(currResp.Method) { | ||
| currResp, err = a.validateUsingSignatures(ctx, l, don.DON, don.Nodes, requestID, currResp, true) | ||
| if err == nil { | ||
| return currResp, nil | ||
| } | ||
|
|
||
| l.Debugw("failed to validate signatures, falling back to quorum aggregation", "error", err) | ||
| } | ||
| } else { | ||
| currResp, err = a.validateUsingSignatures(ctx, l, don.DON, don.Nodes, requestID, currResp, false) | ||
| if err == nil { | ||
| return currResp, nil | ||
| } | ||
|
|
||
| l.Debugw("failed to validate signatures, falling back to quorum aggregation", "error", err) | ||
|
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. For CRUD operations, we may need a more sophisticated strategy.
For 1st category, we should only trust a valid ocr response. If we see a ocr response but invalid, then throw it away and wait for more responses.
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. Lets break this out into it's own PR to keep this one smaller |
||
| } | ||
|
|
||
| l.Debugw("failed to validate signatures, falling back to quorum aggregation", "error", err) | ||
| currResp, err = a.validateUsingQuorum(don.DON, resps, l) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("failed to validate using quorum: %w", err) | ||
|
|
@@ -211,7 +253,14 @@ func (a *baseAggregator) sha(resp *jsonrpc.Response[json.RawMessage]) (string, e | |
| return copied.Digest() | ||
| } | ||
|
|
||
| func (a *baseAggregator) validateUsingSignatures(don capabilities.DON, nodes []capabilities.Node, resp *jsonrpc.Response[json.RawMessage]) (*jsonrpc.Response[json.RawMessage], error) { | ||
| func (a *baseAggregator) recordSignedPayloadRequestIDMismatch(ctx context.Context) { | ||
| a.metrics.requestInternalError.Add(ctx, 1, metric.WithAttributes( | ||
| attribute.String("don_id", a.donID), | ||
| attribute.String("error", "signed_payload_request_id_mismatch"), | ||
| )) | ||
| } | ||
|
|
||
| func (a *baseAggregator) validateUsingSignatures(ctx context.Context, l logger.Logger, don capabilities.DON, nodes []capabilities.Node, requestID string, resp *jsonrpc.Response[json.RawMessage], validateSignedPayloadRequestID bool) (*jsonrpc.Response[json.RawMessage], error) { | ||
| if resp.Result == nil { | ||
| if resp.Error != nil { | ||
| return nil, errors.New("response has an error, cannot validate signatures. Error: " + resp.Error.Error()) | ||
|
|
@@ -235,5 +284,25 @@ func (a *baseAggregator) validateUsingSignatures(don capabilities.DON, nodes []c | |
| return nil, fmt.Errorf("failed to validate signatures: %w", err) | ||
| } | ||
|
|
||
| if !validateSignedPayloadRequestID { | ||
| return resp, nil | ||
| } | ||
|
|
||
| payloadRequestID, err := vaultutils.SignedPayloadRequestID(resp.Method, r.Payload) | ||
| if err != nil { | ||
| l.Errorw("failed to read signed payload request id, discarding response", "requestID", requestID, "method", resp.Method, "error", err) | ||
| a.recordSignedPayloadRequestIDMismatch(ctx) | ||
| return nil, fmt.Errorf("%w: %w", errSignedPayloadRequestIDMismatch, err) | ||
| } | ||
| // Temporarily tolerate signed OCR reports from vault nodes that have not upgraded to | ||
| // include requestId in the signed payload. Once all vault nodes are upgraded, the | ||
| // gateway should start rejecting responses with a missing requestId. | ||
| // https://smartcontract-it.atlassian.net/browse/CRE-4875 | ||
| if payloadRequestID != "" && payloadRequestID != requestID { | ||
| logger.Sugared(l).Criticalw("signed payload request id mismatch, discarding response", "requestID", requestID, "signedPayloadRequestID", payloadRequestID, "method", resp.Method) | ||
| a.recordSignedPayloadRequestIDMismatch(ctx) | ||
| return nil, errSignedPayloadRequestIDMismatch | ||
| } | ||
|
|
||
| return resp, nil | ||
| } | ||
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.
I think this condition should be at top level.
For supported methods, only allow signature validation.
For unsupported methods, only allow quorum based validation.
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.
Especially for unsupported methods like GetPublicKey, we should ONLY rely on quorum, not a signed ocr response.
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.
This would be a change in logic from what we currently have so keeping with our new tenant of putting everything behind a feature flag I'd rather leave this here behind the feature flag