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 a8eaf2e8fe7..0e2b3c14791 100644 --- a/go.mod +++ b/go.mod @@ -84,7 +84,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.20260520194751-11a4f360f4e2 + github.com/smartcontractkit/chainlink-common v0.11.2-0.20260521100905-4f5ce0d45e11 github.com/smartcontractkit/chainlink-common/keystore v1.1.0 github.com/smartcontractkit/chainlink-common/pkg/chipingress v0.0.11-0.20260520194751-11a4f360f4e2 github.com/smartcontractkit/chainlink-data-streams v0.1.15-0.20260520173048-0333bc7b082f diff --git a/go.sum b/go.sum index 81c1543505a..2a2b011aa29 100644 --- a/go.sum +++ b/go.sum @@ -1177,8 +1177,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.20260520194751-11a4f360f4e2 h1:Ne11+eg/uuVJ5duEfr4ec+1EoeZt/dHS9IFIGDdTr00= -github.com/smartcontractkit/chainlink-common v0.11.2-0.20260520194751-11a4f360f4e2/go.mod h1:Pu4czYGiGRAJo+a1M3ZXY+wEyItMe9wtJCVp0pBgAzg= +github.com/smartcontractkit/chainlink-common v0.11.2-0.20260521100905-4f5ce0d45e11 h1:Tc63UsF3WaLG7hYLKzsfoPzEnykg21c3iXB0blduxWI= +github.com/smartcontractkit/chainlink-common v0.11.2-0.20260521100905-4f5ce0d45e11/go.mod h1:Pu4czYGiGRAJo+a1M3ZXY+wEyItMe9wtJCVp0pBgAzg= 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.11-0.20260520194751-11a4f360f4e2 h1:22H/CQy2L1unVJ2KEViEqvM8evJLSIqJxEdfDeXB4o8=