From 3045553b1113fadfa2fd4b503549f7680beb8b77 Mon Sep 17 00:00:00 2001 From: Tejaswi Nadahalli Date: Mon, 18 May 2026 14:41:50 +0200 Subject: [PATCH] Verify enclave config against onchain DON in confidentialrelay handler (PRIV-458) The handler now compares the attested EnclaveConfig in every incoming SecretsRequestParams and CapabilityRequestParams against the local node's WorkflowDON membership and fault tolerance, after Nitro attestation validation succeeds. Closes Sigma Prime CL112-01 on the relay-DON path. The relay DON runs on the same nodes as the workflow DON, so localNode.WorkflowDON.Members is the right comparison target. LocalNode is an O(1) in-memory map lookup populated by the registry syncer on a ~12s tick, so the check stays off the RPC hot path. Up to ~12s staleness applies during DON membership rotations and is acceptable given how rare those events are. Tests cover match-accepts, F mismatch, signers-count mismatch, signer value mismatch, order-independent comparison, and the secrets-get path. Existing tests updated to fill EnclaveConfig in fixtures with a matching WorkflowDON.Members in the mock registry. Bumps chainlink-common to v0.11.2-0.20260518112011-40a8e4cedaa8 to pick up the EnclaveConfig field on confidentialrelay request params (smartcontractkit/chainlink-common#2063). Companion PRs: - smartcontractkit/confidential-compute#329 (pool.go-side check). - smartcontractkit/chainlink-common#2063 (field on params). - smartcontractkit/confidential-compute#330 (enclave fills field). --- .../capabilities/confidentialrelay/handler.go | 56 +++++ .../confidentialrelay/handler_test.go | 215 ++++++++++++++++++ go.mod | 2 +- go.sum | 4 +- 4 files changed, 274 insertions(+), 3 deletions(-) diff --git a/core/capabilities/confidentialrelay/handler.go b/core/capabilities/confidentialrelay/handler.go index 4956436eec7..b38b1b93c69 100644 --- a/core/capabilities/confidentialrelay/handler.go +++ b/core/capabilities/confidentialrelay/handler.go @@ -1,12 +1,14 @@ package confidentialrelay import ( + "bytes" "context" "encoding/base64" "encoding/hex" "encoding/json" "errors" "fmt" + "sort" "time" "github.com/ethereum/go-ethereum/common" @@ -228,6 +230,14 @@ func (h *Handler) handleSecretsGet(ctx context.Context, gatewayID string, req *j if err := h.verifyAttestationHash(ctx, att, params, confidentialrelaytypes.DomainSecretsGet); err != nil { return h.errorResponse(ctx, gatewayID, req, jsonrpc.ErrInternal, err) } + // Verify the enclave's reported config matches the onchain DON state + // before treating the attested request as trusted. Sigma Prime CL112-01: + // the Nitro attestation binds the request hash, but a malicious host + // can produce a genuinely-attested request over a forged enclave config + // unless we compare the config value against an onchain reference. + if err := h.verifyEnclaveConfigMatchesDON(ctx, params.EnclaveConfig); err != nil { + return h.errorResponse(ctx, gatewayID, req, jsonrpc.ErrInternal, err) + } vaultCap, err := h.capRegistry.GetExecutable(ctx, vault.CapabilityID) if err != nil { @@ -401,6 +411,9 @@ func (h *Handler) handleCapabilityExecute(ctx context.Context, gatewayID string, if err := h.verifyAttestationHash(ctx, att, params, confidentialrelaytypes.DomainCapabilityExec); err != nil { return h.errorResponse(ctx, gatewayID, req, jsonrpc.ErrInternal, err) } + if err := h.verifyEnclaveConfigMatchesDON(ctx, params.EnclaveConfig); err != nil { + return h.errorResponse(ctx, gatewayID, req, jsonrpc.ErrInternal, err) + } capability, err := h.capRegistry.GetExecutable(ctx, params.CapabilityID) if err != nil { @@ -472,6 +485,49 @@ func (h *Handler) handleCapabilityExecute(ctx context.Context, gatewayID string, return h.jsonResponse(req, signedResult) } +// verifyEnclaveConfigMatchesDON compares the enclave's reported EnclaveConfig +// against the local node's WorkflowDON membership and fault tolerance. The +// relay DON runs on the same nodes as the workflow DON, so +// localNode.WorkflowDON.Members is the right comparison target. +// +// Sigma Prime CL112-01 / PRIV-458: pool.go validates the Nitro attestation +// cryptographically but a malicious host can still produce a +// genuinely-attested request over a forged config. The fix is to compare +// the attested config value against onchain DON state. +// +// LocalNode is an O(1) in-memory map lookup populated by the registry +// syncer (background goroutine, default 12s tick); not an RPC on the hot +// path. Up to a ~12s staleness window applies during DON membership +// rotations and is acceptable: rotations are rare planned events. +func (h *Handler) verifyEnclaveConfigMatchesDON(ctx context.Context, cfg confidentialrelaytypes.EnclaveConfig) error { + localNode, err := h.capRegistry.LocalNode(ctx) + if err != nil { + return fmt.Errorf("fetch LocalNode for enclave config check: %w", err) + } + expectedF := uint32(localNode.WorkflowDON.F) + if cfg.F != expectedF { + return fmt.Errorf("enclave config F mismatch: enclave reports %d, expected %d", cfg.F, expectedF) + } + if len(cfg.Signers) != len(localNode.WorkflowDON.Members) { + return fmt.Errorf("enclave config signers count mismatch: enclave reports %d, expected %d", + len(cfg.Signers), len(localNode.WorkflowDON.Members)) + } + expected := make([][]byte, len(localNode.WorkflowDON.Members)) + for i := range localNode.WorkflowDON.Members { + expected[i] = localNode.WorkflowDON.Members[i][:] + } + actual := append([][]byte(nil), cfg.Signers...) + sort.Slice(actual, func(i, j int) bool { return bytes.Compare(actual[i], actual[j]) < 0 }) + sort.Slice(expected, func(i, j int) bool { return bytes.Compare(expected[i], expected[j]) < 0 }) + for i := range actual { + if !bytes.Equal(actual[i], expected[i]) { + return fmt.Errorf("enclave config signer mismatch at sorted index %d: enclave reports %x, expected %x", + i, actual[i], expected[i]) + } + } + return nil +} + // getEnclaveAttestationConfig reads the enclave pool configuration from the // capabilities registry and returns trusted measurement sets and CA roots // for attestation validation. Called per-request so the config stays fresh diff --git a/core/capabilities/confidentialrelay/handler_test.go b/core/capabilities/confidentialrelay/handler_test.go index 4d9e3fae737..576dd536988 100644 --- a/core/capabilities/confidentialrelay/handler_test.go +++ b/core/capabilities/confidentialrelay/handler_test.go @@ -28,6 +28,7 @@ import ( valuespb "github.com/smartcontractkit/chainlink-protos/cre/go/values/pb" vaulttypes "github.com/smartcontractkit/chainlink/v2/core/capabilities/vault/vaulttypes" + p2ptypes "github.com/smartcontractkit/chainlink/v2/core/services/p2p/types" ) func makeCapabilityPayload(t *testing.T, inputs map[string]any) string { @@ -153,6 +154,11 @@ func withEnclaveConfig(reg *mockCapRegistry) *mockCapRegistry { reg.dons[confidentialWorkflowsCapID] = []capabilities.DONWithNodes{ {DON: capabilities.DON{ID: 1}}, } + // Wire WorkflowDON membership to match testEnclaveConfig so the relay-side + // verifyEnclaveConfigMatchesDON check passes for fixtures that build + // request params with testEnclaveConfig. + reg.localNode.WorkflowDON.Members = testWorkflowDONMembers() + reg.localNode.WorkflowDON.F = uint8(testEnclaveConfig().F) return reg } @@ -168,6 +174,48 @@ func makeRequest(t *testing.T, method string, params any) *jsonrpc.Request[json. } } +// make32Byte builds a 32-byte slice filled with the given byte. Used so +// EnclaveConfig.Signers byte-for-byte equals the PeerIDs in +// WorkflowDON.Members produced by testWorkflowDONMembers. +func make32Byte(b byte) []byte { + s := make([]byte, 32) + for i := range s { + s[i] = b + } + return s +} + +// testEnclaveConfig is the canonical EnclaveConfig that handler tests put on +// outgoing request params. withEnclaveConfig wires the matching WorkflowDON +// membership into the mock CapabilitiesRegistry so +// verifyEnclaveConfigMatchesDON accepts requests built with this config. +func testEnclaveConfig() confidentialrelaytypes.EnclaveConfig { + return confidentialrelaytypes.EnclaveConfig{ + Signers: [][]byte{ + make32Byte(0xa1), + make32Byte(0xb1), + make32Byte(0xc1), + make32Byte(0xd1), + }, + MasterPublicKey: []byte("test-master-public-key"), + T: 3, + F: 1, + } +} + +// testWorkflowDONMembers returns []p2ptypes.PeerID whose [:] slices match +// testEnclaveConfig().Signers byte-for-byte. +func testWorkflowDONMembers() []p2ptypes.PeerID { + cfg := testEnclaveConfig() + members := make([]p2ptypes.PeerID, len(cfg.Signers)) + for i, s := range cfg.Signers { + var pid p2ptypes.PeerID + copy(pid[:], s) + members[i] = pid + } + return members +} + // secretsGetTestRegistry builds a mock registry with a vault executable that // returns a valid GetSecretsResponse for the "API_KEY" secret. func secretsGetTestRegistry(t *testing.T) *mockCapRegistry { @@ -238,6 +286,7 @@ func secretsGetTestParams() confidentialrelaytypes.SecretsRequestParams { ExecutionID: "0000000000000000000000000000000000000000000000000000000000000001", OrgID: "org-123", EnclavePublicKey: "aabbcc", + EnclaveConfig: testEnclaveConfig(), Secrets: []confidentialrelaytypes.SecretIdentifier{ {Key: "API_KEY", Namespace: "main"}, }, @@ -275,6 +324,7 @@ func TestHandler_HandleGatewayMessage(t *testing.T) { ReferenceID: "17", CapabilityID: "my-cap@1.0.0", Payload: makeCapabilityPayload(t, map[string]any{"key": "val"}), + EnclaveConfig: testEnclaveConfig(), Attestation: testAttestationB64, }) }, @@ -287,6 +337,7 @@ func TestHandler_HandleGatewayMessage(t *testing.T) { ReferenceID: "17", CapabilityID: "my-cap@1.0.0", Payload: makeCapabilityPayload(t, map[string]any{"key": "val"}), + EnclaveConfig: testEnclaveConfig(), } var result confidentialrelaytypes.SignedCapabilityResponseResult require.NoError(t, json.Unmarshal(*resp.Result, &result)) @@ -329,6 +380,7 @@ func TestHandler_HandleGatewayMessage(t *testing.T) { ReferenceID: "17", CapabilityID: "my-cap@1.0.0", Payload: makeCapabilityPayload(t, map[string]any{"echo": "hello"}), + EnclaveConfig: testEnclaveConfig(), Attestation: testAttestationB64, }) }, @@ -376,6 +428,7 @@ func TestHandler_HandleGatewayMessage(t *testing.T) { WorkflowID: "wf-1", CapabilityID: "missing-cap@1.0.0", Payload: base64.StdEncoding.EncodeToString([]byte("payload")), + EnclaveConfig: testEnclaveConfig(), Attestation: testAttestationB64, }) }, @@ -405,6 +458,7 @@ func TestHandler_HandleGatewayMessage(t *testing.T) { ReferenceID: "17", CapabilityID: "fail-cap@1.0.0", Payload: base64.StdEncoding.EncodeToString(b), + EnclaveConfig: testEnclaveConfig(), Attestation: testAttestationB64, }) }, @@ -417,6 +471,7 @@ func TestHandler_HandleGatewayMessage(t *testing.T) { ReferenceID: "17", CapabilityID: "fail-cap@1.0.0", Payload: base64.StdEncoding.EncodeToString(mustMarshalProto(t, &sdkpb.CapabilityRequest{Id: "fail-cap@1.0.0", Method: "Execute"})), + EnclaveConfig: testEnclaveConfig(), } var result confidentialrelaytypes.SignedCapabilityResponseResult require.NoError(t, json.Unmarshal(*resp.Result, &result)) @@ -594,3 +649,163 @@ func TestHandler_Lifecycle(t *testing.T) { assert.Equal(t, HandlerName, id) }) } + +// TestHandler_VerifyEnclaveConfig covers the PRIV-458 / CL112-01 relay-side +// hardening: after the Nitro attestation cryptographically verifies the +// request hash, the handler must also compare the attested EnclaveConfig +// value against the local node's WorkflowDON state. Without this check, a +// malicious host can produce a genuinely-attested request over a forged +// EnclaveConfig and have it accepted. +func TestHandler_VerifyEnclaveConfig(t *testing.T) { + t.Run("matching config accepted on capability execute", func(t *testing.T) { + reg := withEnclaveConfig(&mockCapRegistry{ + executables: map[string]*mockExecutable{ + "my-cap@1.0.0": {execResult: capabilities.CapabilityResponse{Payload: &anypb.Any{}}}, + }, + }) + gwConn := &mockGatewayConnector{} + h := newTestHandler(t, reg, gwConn) + req := makeRequest(t, confidentialrelaytypes.MethodCapabilityExec, confidentialrelaytypes.CapabilityRequestParams{ + WorkflowID: "wf-1", + Owner: testOwner, + ExecutionID: "32c631d295ef5e32deb99a10ee6804bc4af13855687559d7ff6552ac6dbb2ce1", + ReferenceID: "1", + CapabilityID: "my-cap@1.0.0", + Payload: makeCapabilityPayload(t, map[string]any{"key": "val"}), + EnclaveConfig: testEnclaveConfig(), + Attestation: testAttestationB64, + }) + err := h.HandleGatewayMessage(context.Background(), "gw-1", req) + require.NoError(t, err) + resp := gwConn.lastResp + require.Nil(t, resp.Error) + }) + + t.Run("F mismatch rejected on capability execute", func(t *testing.T) { + reg := withEnclaveConfig(&mockCapRegistry{ + executables: map[string]*mockExecutable{ + "my-cap@1.0.0": {execResult: capabilities.CapabilityResponse{Payload: &anypb.Any{}}}, + }, + }) + gwConn := &mockGatewayConnector{} + h := newTestHandler(t, reg, gwConn) + badCfg := testEnclaveConfig() + badCfg.F = badCfg.F + 5 // any non-matching value + req := makeRequest(t, confidentialrelaytypes.MethodCapabilityExec, confidentialrelaytypes.CapabilityRequestParams{ + WorkflowID: "wf-1", + Owner: testOwner, + ExecutionID: "32c631d295ef5e32deb99a10ee6804bc4af13855687559d7ff6552ac6dbb2ce1", + ReferenceID: "1", + CapabilityID: "my-cap@1.0.0", + Payload: makeCapabilityPayload(t, map[string]any{"key": "val"}), + EnclaveConfig: badCfg, + Attestation: testAttestationB64, + }) + err := h.HandleGatewayMessage(context.Background(), "gw-1", req) + require.NoError(t, err) + resp := gwConn.lastResp + require.NotNil(t, resp.Error) + }) + + t.Run("signers count mismatch rejected on capability execute", func(t *testing.T) { + reg := withEnclaveConfig(&mockCapRegistry{ + executables: map[string]*mockExecutable{ + "my-cap@1.0.0": {execResult: capabilities.CapabilityResponse{Payload: &anypb.Any{}}}, + }, + }) + gwConn := &mockGatewayConnector{} + h := newTestHandler(t, reg, gwConn) + badCfg := testEnclaveConfig() + badCfg.Signers = badCfg.Signers[:2] + req := makeRequest(t, confidentialrelaytypes.MethodCapabilityExec, confidentialrelaytypes.CapabilityRequestParams{ + WorkflowID: "wf-1", + Owner: testOwner, + ExecutionID: "32c631d295ef5e32deb99a10ee6804bc4af13855687559d7ff6552ac6dbb2ce1", + ReferenceID: "1", + CapabilityID: "my-cap@1.0.0", + Payload: makeCapabilityPayload(t, map[string]any{"key": "val"}), + EnclaveConfig: badCfg, + Attestation: testAttestationB64, + }) + err := h.HandleGatewayMessage(context.Background(), "gw-1", req) + require.NoError(t, err) + resp := gwConn.lastResp + require.NotNil(t, resp.Error) + }) + + t.Run("signer value mismatch rejected on capability execute", func(t *testing.T) { + reg := withEnclaveConfig(&mockCapRegistry{ + executables: map[string]*mockExecutable{ + "my-cap@1.0.0": {execResult: capabilities.CapabilityResponse{Payload: &anypb.Any{}}}, + }, + }) + gwConn := &mockGatewayConnector{} + h := newTestHandler(t, reg, gwConn) + badCfg := testEnclaveConfig() + badCfg.Signers = [][]byte{ + make32Byte(0xa1), + make32Byte(0xb1), + make32Byte(0xc1), + make32Byte(0xff), // last signer differs + } + req := makeRequest(t, confidentialrelaytypes.MethodCapabilityExec, confidentialrelaytypes.CapabilityRequestParams{ + WorkflowID: "wf-1", + Owner: testOwner, + ExecutionID: "32c631d295ef5e32deb99a10ee6804bc4af13855687559d7ff6552ac6dbb2ce1", + ReferenceID: "1", + CapabilityID: "my-cap@1.0.0", + Payload: makeCapabilityPayload(t, map[string]any{"key": "val"}), + EnclaveConfig: badCfg, + Attestation: testAttestationB64, + }) + err := h.HandleGatewayMessage(context.Background(), "gw-1", req) + require.NoError(t, err) + resp := gwConn.lastResp + require.NotNil(t, resp.Error) + }) + + t.Run("matching is order-independent on capability execute", func(t *testing.T) { + reg := withEnclaveConfig(&mockCapRegistry{ + executables: map[string]*mockExecutable{ + "my-cap@1.0.0": {execResult: capabilities.CapabilityResponse{Payload: &anypb.Any{}}}, + }, + }) + gwConn := &mockGatewayConnector{} + h := newTestHandler(t, reg, gwConn) + shuffled := testEnclaveConfig() + // Reverse Signers; the comparison must still pass. + n := len(shuffled.Signers) + rev := make([][]byte, n) + for i, s := range shuffled.Signers { + rev[n-1-i] = s + } + shuffled.Signers = rev + req := makeRequest(t, confidentialrelaytypes.MethodCapabilityExec, confidentialrelaytypes.CapabilityRequestParams{ + WorkflowID: "wf-1", + Owner: testOwner, + ExecutionID: "32c631d295ef5e32deb99a10ee6804bc4af13855687559d7ff6552ac6dbb2ce1", + ReferenceID: "1", + CapabilityID: "my-cap@1.0.0", + Payload: makeCapabilityPayload(t, map[string]any{"key": "val"}), + EnclaveConfig: shuffled, + Attestation: testAttestationB64, + }) + err := h.HandleGatewayMessage(context.Background(), "gw-1", req) + require.NoError(t, err) + resp := gwConn.lastResp + require.Nil(t, resp.Error) + }) + + t.Run("F mismatch rejected on secrets get", func(t *testing.T) { + reg := secretsGetTestRegistry(t) + gwConn := &mockGatewayConnector{} + h := newTestHandler(t, reg, gwConn) + params := secretsGetTestParams() + params.EnclaveConfig.F = params.EnclaveConfig.F + 5 + req := makeRequest(t, confidentialrelaytypes.MethodSecretsGet, params) + err := h.HandleGatewayMessage(context.Background(), "gw-1", req) + require.NoError(t, err) + resp := gwConn.lastResp + require.NotNil(t, resp.Error) + }) +} diff --git a/go.mod b/go.mod index 936cc496f96..a8074e25299 100644 --- a/go.mod +++ b/go.mod @@ -85,7 +85,7 @@ require ( github.com/smartcontractkit/chainlink-ccip/chains/solana v0.0.0-20260415165642-49f23e4d76cc github.com/smartcontractkit/chainlink-ccip/chains/solana/gobindings v0.0.0-20260415165642-49f23e4d76cc github.com/smartcontractkit/chainlink-ccv v0.0.2-0.20260428133800-3b1484e8b1fd - github.com/smartcontractkit/chainlink-common v0.11.2-0.20260518100439-9564f35fd264 + github.com/smartcontractkit/chainlink-common v0.11.2-0.20260518112011-40a8e4cedaa8 github.com/smartcontractkit/chainlink-common/keystore v1.1.0 github.com/smartcontractkit/chainlink-common/pkg/chipingress v0.0.10 github.com/smartcontractkit/chainlink-data-streams v0.1.14-0.20260512145107-b41c0e2855ec diff --git a/go.sum b/go.sum index 0214e01dd3b..655e053216c 100644 --- a/go.sum +++ b/go.sum @@ -1184,8 +1184,8 @@ github.com/smartcontractkit/chainlink-ccip/chains/solana/gobindings v0.0.0-20260 github.com/smartcontractkit/chainlink-ccip/chains/solana/gobindings v0.0.0-20260415165642-49f23e4d76cc/go.mod h1:67YbnoglYD61Pz/jTVCgav9wFq7S35OU8UyQSvPllRw= github.com/smartcontractkit/chainlink-ccv v0.0.2-0.20260428133800-3b1484e8b1fd h1:IMopuENFVS63AerRELdfWo6o60UNUidcldJOxJLmk24= github.com/smartcontractkit/chainlink-ccv v0.0.2-0.20260428133800-3b1484e8b1fd/go.mod h1:SBN8Urnh5sQvrQRbSo1Nr8coWatHg8LZoPw3R/42sho= -github.com/smartcontractkit/chainlink-common v0.11.2-0.20260518100439-9564f35fd264 h1:r4sH0uvOoSXegOQPVaMEsu27q1dfWplIwO4WvdcEtKM= -github.com/smartcontractkit/chainlink-common v0.11.2-0.20260518100439-9564f35fd264/go.mod h1:B+eYJSQmOc28kzs7OwJjwo0DEV2f01HnUk89r9R1d/Y= +github.com/smartcontractkit/chainlink-common v0.11.2-0.20260518112011-40a8e4cedaa8 h1:DrriTre0jvJo1QQ4CINxe6Idbp/kzTe1hvT4Id2Ld+o= +github.com/smartcontractkit/chainlink-common v0.11.2-0.20260518112011-40a8e4cedaa8/go.mod h1:B+eYJSQmOc28kzs7OwJjwo0DEV2f01HnUk89r9R1d/Y= github.com/smartcontractkit/chainlink-common/keystore v1.1.0 h1:2wzySccgk2fpWusPKO0bpeAZzfSU9eq6CS5U+JwYaVo= github.com/smartcontractkit/chainlink-common/keystore v1.1.0/go.mod h1:6JexOOhPhknQ0QMuppFIlOpm6wCp54yZMxai+tWugwY= github.com/smartcontractkit/chainlink-common/pkg/chipingress v0.0.10 h1:FJAFgXS9oqASnkS03RE1HQwYQQxrO4l46O5JSzxqLgg=