Skip to content
Open
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
56 changes: 56 additions & 0 deletions core/capabilities/confidentialrelay/handler.go
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
215 changes: 215 additions & 0 deletions core/capabilities/confidentialrelay/handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}

Expand All @@ -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 {
Expand Down Expand Up @@ -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"},
},
Expand Down Expand Up @@ -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,
})
},
Expand All @@ -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))
Expand Down Expand Up @@ -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,
})
},
Expand Down Expand Up @@ -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,
})
},
Expand Down Expand Up @@ -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,
})
},
Expand All @@ -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))
Expand Down Expand Up @@ -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)
})
}
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions go.sum

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading