Skip to content

Commit 37b2706

Browse files
authored
[CRE] [2/5] Relay DON node handler for confidential relay (#21639)
* [CRE] [2/5] Relay DON node handler for confidential relay Add GatewayConnectorHandler that validates Nitro attestation and proxies enclave requests to VaultDON (secrets_get) and capability DONs (capability_exec). Supports multi-PCR validation for enclave pools. Part of #21635 * Add ConfidentialRelay config for relay DON handler * Remove Nitro-specific wording from attestation comments * Look up trusted measurements from cap registry instead of TOML config Replace the static trustedPCRs TOML parameter with a per-request lookup from the capabilities registry. The handler reads the confidential-workflows capability config to extract enclave TrustedValues, same pattern as CC's EnsureFreshEnclaves. Remove TrustedPCRs from CREConfidentialRelay config interface, ConfidentialRelayConfig TOML struct, and config_cre.go. * Also read CARootsPEM from cap registry, remove from TOML config CARootsPEM is stored per-enclave in the same cap registry config as TrustedValues. Read both in getEnclaveAttestationConfig, eliminating the last TOML field besides Enabled from CRE.ConfidentialRelay. * Remove CARootsPEM from relay handler ValidateAttestation in chainlink-common now always uses the hardcoded AWS Nitro root cert (c7b8c137). Drop the caRootsPEM parameter from the handler's attestation validator func, getEnclaveAttestationConfig, and the enclaveEntry struct. * Relay handler: use CARootsPEM from registry for fake enclave attestation * forward relay metadata for remote capability execution (cherry picked from commit 16e4fc9) * add relay request metrics and use exported connector type * sanitize internal relay error messages * bump chainlink-common and sync relay config snapshots * Fix confidential relay CI fallout * regenerate module graph after relay rebase * format cre config types for goimports * sync relay config docs and validation snapshots
1 parent dcbb4bf commit 37b2706

26 files changed

Lines changed: 1149 additions & 1 deletion

core/capabilities/confidentialrelay/handler.go

Lines changed: 590 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 383 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,383 @@
1+
package confidentialrelay
2+
3+
import (
4+
"context"
5+
"encoding/base64"
6+
"encoding/json"
7+
"errors"
8+
"fmt"
9+
"testing"
10+
11+
"github.com/stretchr/testify/assert"
12+
"github.com/stretchr/testify/require"
13+
"google.golang.org/protobuf/proto"
14+
"google.golang.org/protobuf/types/known/anypb"
15+
16+
"github.com/smartcontractkit/chainlink-common/pkg/capabilities"
17+
confidentialrelaytypes "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/actions/confidentialrelay"
18+
jsonrpc "github.com/smartcontractkit/chainlink-common/pkg/jsonrpc2"
19+
"github.com/smartcontractkit/chainlink-common/pkg/logger"
20+
"github.com/smartcontractkit/chainlink-common/pkg/types/core"
21+
sdkpb "github.com/smartcontractkit/chainlink-protos/cre/go/sdk"
22+
"github.com/smartcontractkit/chainlink-protos/cre/go/values"
23+
valuespb "github.com/smartcontractkit/chainlink-protos/cre/go/values/pb"
24+
)
25+
26+
func makeCapabilityPayload(t *testing.T, inputs map[string]any) string {
27+
t.Helper()
28+
wrapped, err := values.Wrap(inputs)
29+
require.NoError(t, err)
30+
payload, err := anypb.New(values.Proto(wrapped))
31+
require.NoError(t, err)
32+
sdkReq := &sdkpb.CapabilityRequest{
33+
Id: "my-cap@1.0.0",
34+
Payload: payload,
35+
Method: "Execute",
36+
}
37+
b, err := proto.Marshal(sdkReq)
38+
require.NoError(t, err)
39+
return base64.StdEncoding.EncodeToString(b)
40+
}
41+
42+
const testAttestationB64 = "ZHVtbXktYXR0ZXN0YXRpb24=" // base64("dummy-attestation")
43+
44+
func noopValidator(_ []byte, _, _ []byte) error { return nil }
45+
46+
type mockGatewayConnector struct {
47+
core.UnimplementedGatewayConnector
48+
lastResp *jsonrpc.Response[json.RawMessage]
49+
addedMethods []string
50+
removed bool
51+
}
52+
53+
func (m *mockGatewayConnector) SendToGateway(_ context.Context, _ string, resp *jsonrpc.Response[json.RawMessage]) error {
54+
m.lastResp = resp
55+
return nil
56+
}
57+
func (m *mockGatewayConnector) AddHandler(_ context.Context, methods []string, _ core.GatewayConnectorHandler) error {
58+
m.addedMethods = methods
59+
return nil
60+
}
61+
func (m *mockGatewayConnector) RemoveHandler(_ context.Context, _ []string) error {
62+
m.removed = true
63+
return nil
64+
}
65+
66+
type mockExecutable struct {
67+
infoResult capabilities.CapabilityInfo
68+
infoErr error
69+
execResult capabilities.CapabilityResponse
70+
execErr error
71+
lastRequest *capabilities.CapabilityRequest
72+
}
73+
74+
func (m *mockExecutable) Info(_ context.Context) (capabilities.CapabilityInfo, error) {
75+
return m.infoResult, m.infoErr
76+
}
77+
func (m *mockExecutable) Execute(_ context.Context, req capabilities.CapabilityRequest) (capabilities.CapabilityResponse, error) {
78+
m.lastRequest = &req
79+
return m.execResult, m.execErr
80+
}
81+
func (m *mockExecutable) RegisterToWorkflow(_ context.Context, _ capabilities.RegisterToWorkflowRequest) error {
82+
return nil
83+
}
84+
func (m *mockExecutable) UnregisterFromWorkflow(_ context.Context, _ capabilities.UnregisterFromWorkflowRequest) error {
85+
return nil
86+
}
87+
88+
type mockCapRegistry struct {
89+
core.UnimplementedCapabilitiesRegistry
90+
executables map[string]*mockExecutable
91+
configs map[string]capabilities.CapabilityConfiguration
92+
dons map[string][]capabilities.DONWithNodes
93+
localNode capabilities.Node
94+
}
95+
96+
func (m *mockCapRegistry) GetExecutable(_ context.Context, id string) (capabilities.ExecutableCapability, error) {
97+
if exec, ok := m.executables[id]; ok {
98+
return exec, nil
99+
}
100+
return nil, fmt.Errorf("capability not found: %s", id)
101+
}
102+
func (m *mockCapRegistry) ConfigForCapability(_ context.Context, capID string, _ uint32) (capabilities.CapabilityConfiguration, error) {
103+
if cfg, ok := m.configs[capID]; ok {
104+
return cfg, nil
105+
}
106+
return capabilities.CapabilityConfiguration{}, fmt.Errorf("config not found: %s", capID)
107+
}
108+
func (m *mockCapRegistry) DONsForCapability(_ context.Context, capID string) ([]capabilities.DONWithNodes, error) {
109+
if dons, ok := m.dons[capID]; ok {
110+
return dons, nil
111+
}
112+
return nil, fmt.Errorf("no DONs found for: %s", capID)
113+
}
114+
func (m *mockCapRegistry) LocalNode(_ context.Context) (capabilities.Node, error) {
115+
return m.localNode, nil
116+
}
117+
118+
func newTestHandler(t *testing.T, registry core.CapabilitiesRegistry, gwConn core.GatewayConnector) *Handler {
119+
t.Helper()
120+
lggr, err := logger.New()
121+
require.NoError(t, err)
122+
h, err := NewHandler(registry, gwConn, lggr)
123+
require.NoError(t, err)
124+
h.validateAttestation = noopValidator
125+
return h
126+
}
127+
128+
// withEnclaveConfig adds the default confidential-workflows enclave config
129+
// to a mock registry so getEnclaveAttestationConfig succeeds during tests.
130+
func withEnclaveConfig(reg *mockCapRegistry) *mockCapRegistry {
131+
enclaveConfig := enclavesList{
132+
Enclaves: []enclaveEntry{{TrustedValues: []json.RawMessage{json.RawMessage(`{}`)}}},
133+
}
134+
wrapped, _ := values.WrapMap(enclaveConfig)
135+
if reg.configs == nil {
136+
reg.configs = map[string]capabilities.CapabilityConfiguration{}
137+
}
138+
reg.configs[confidentialWorkflowsCapID] = capabilities.CapabilityConfiguration{
139+
DefaultConfig: wrapped,
140+
}
141+
if reg.dons == nil {
142+
reg.dons = map[string][]capabilities.DONWithNodes{}
143+
}
144+
reg.dons[confidentialWorkflowsCapID] = []capabilities.DONWithNodes{
145+
{DON: capabilities.DON{ID: 1}},
146+
}
147+
return reg
148+
}
149+
150+
func makeRequest(t *testing.T, method string, params any) *jsonrpc.Request[json.RawMessage] {
151+
t.Helper()
152+
b, err := json.Marshal(params)
153+
require.NoError(t, err)
154+
raw := json.RawMessage(b)
155+
return &jsonrpc.Request[json.RawMessage]{
156+
Method: method,
157+
ID: "req-1",
158+
Params: &raw,
159+
}
160+
}
161+
162+
func TestHandler_HandleGatewayMessage(t *testing.T) {
163+
tests := []struct {
164+
name string
165+
registry func(t *testing.T) *mockCapRegistry
166+
req func(t *testing.T) *jsonrpc.Request[json.RawMessage]
167+
checkResp func(t *testing.T, resp *jsonrpc.Response[json.RawMessage])
168+
checkExecutable func(t *testing.T, reg *mockCapRegistry)
169+
}{
170+
{
171+
name: "capability execute success",
172+
registry: func(_ *testing.T) *mockCapRegistry {
173+
return withEnclaveConfig(&mockCapRegistry{
174+
executables: map[string]*mockExecutable{
175+
"my-cap@1.0.0": {
176+
execResult: capabilities.CapabilityResponse{
177+
Payload: &anypb.Any{Value: []byte("result-proto-bytes")},
178+
},
179+
},
180+
},
181+
})
182+
},
183+
req: func(t *testing.T) *jsonrpc.Request[json.RawMessage] {
184+
return makeRequest(t, confidentialrelaytypes.MethodCapabilityExec, confidentialrelaytypes.CapabilityRequestParams{
185+
WorkflowID: "wf-1",
186+
Owner: "0xowner",
187+
ExecutionID: "32c631d295ef5e32deb99a10ee6804bc4af13855687559d7ff6552ac6dbb2ce1",
188+
ReferenceID: "17",
189+
CapabilityID: "my-cap@1.0.0",
190+
Payload: makeCapabilityPayload(t, map[string]any{"key": "val"}),
191+
Attestation: testAttestationB64,
192+
})
193+
},
194+
checkResp: func(t *testing.T, resp *jsonrpc.Response[json.RawMessage]) {
195+
require.Nil(t, resp.Error)
196+
var result confidentialrelaytypes.CapabilityResponseResult
197+
require.NoError(t, json.Unmarshal(*resp.Result, &result))
198+
decoded, err := base64.StdEncoding.DecodeString(result.Payload)
199+
require.NoError(t, err)
200+
var capResp sdkpb.CapabilityResponse
201+
require.NoError(t, proto.Unmarshal(decoded, &capResp))
202+
require.NotNil(t, capResp.GetPayload())
203+
assert.Equal(t, "result-proto-bytes", string(capResp.GetPayload().GetValue()))
204+
assert.Empty(t, result.Error)
205+
},
206+
checkExecutable: func(t *testing.T, reg *mockCapRegistry) {
207+
exec := reg.executables["my-cap@1.0.0"]
208+
require.NotNil(t, exec.lastRequest, "Execute should have been called")
209+
assert.Equal(t, "wf-1", exec.lastRequest.Metadata.WorkflowID)
210+
assert.Equal(t, "0xowner", exec.lastRequest.Metadata.WorkflowOwner)
211+
assert.Equal(t, "32c631d295ef5e32deb99a10ee6804bc4af13855687559d7ff6552ac6dbb2ce1", exec.lastRequest.Metadata.WorkflowExecutionID)
212+
assert.Equal(t, "17", exec.lastRequest.Metadata.ReferenceID)
213+
},
214+
},
215+
{
216+
name: "capability execute sets Inputs from Payload for backward compat",
217+
registry: func(_ *testing.T) *mockCapRegistry {
218+
return withEnclaveConfig(&mockCapRegistry{
219+
executables: map[string]*mockExecutable{
220+
"my-cap@1.0.0": {
221+
execResult: capabilities.CapabilityResponse{},
222+
},
223+
},
224+
})
225+
},
226+
req: func(t *testing.T) *jsonrpc.Request[json.RawMessage] {
227+
return makeRequest(t, confidentialrelaytypes.MethodCapabilityExec, confidentialrelaytypes.CapabilityRequestParams{
228+
WorkflowID: "wf-1",
229+
CapabilityID: "my-cap@1.0.0",
230+
Payload: makeCapabilityPayload(t, map[string]any{"echo": "hello"}),
231+
Attestation: testAttestationB64,
232+
})
233+
},
234+
checkResp: func(t *testing.T, resp *jsonrpc.Response[json.RawMessage]) {
235+
require.Nil(t, resp.Error)
236+
},
237+
checkExecutable: func(t *testing.T, reg *mockCapRegistry) {
238+
exec := reg.executables["my-cap@1.0.0"]
239+
require.NotNil(t, exec.lastRequest, "Execute should have been called")
240+
require.NotNil(t, exec.lastRequest.Payload)
241+
var valPB valuespb.Value
242+
require.NoError(t, exec.lastRequest.Payload.UnmarshalTo(&valPB))
243+
require.NotNil(t, exec.lastRequest.Inputs)
244+
unwrapped, err := exec.lastRequest.Inputs.Unwrap()
245+
require.NoError(t, err)
246+
m, ok := unwrapped.(map[string]any)
247+
require.True(t, ok)
248+
assert.Equal(t, "hello", m["echo"])
249+
},
250+
},
251+
{
252+
name: "capability execute attestation failure",
253+
registry: func(_ *testing.T) *mockCapRegistry {
254+
return withEnclaveConfig(&mockCapRegistry{})
255+
},
256+
req: func(t *testing.T) *jsonrpc.Request[json.RawMessage] {
257+
return makeRequest(t, confidentialrelaytypes.MethodCapabilityExec, confidentialrelaytypes.CapabilityRequestParams{
258+
WorkflowID: "wf-1",
259+
CapabilityID: "my-cap@1.0.0",
260+
Payload: base64.StdEncoding.EncodeToString([]byte("payload")),
261+
})
262+
},
263+
checkResp: func(t *testing.T, resp *jsonrpc.Response[json.RawMessage]) {
264+
require.NotNil(t, resp.Error)
265+
assert.Equal(t, jsonrpc.ErrInternal, resp.Error.Code)
266+
},
267+
},
268+
{
269+
name: "capability execute not found",
270+
registry: func(_ *testing.T) *mockCapRegistry {
271+
return withEnclaveConfig(&mockCapRegistry{executables: map[string]*mockExecutable{}})
272+
},
273+
req: func(t *testing.T) *jsonrpc.Request[json.RawMessage] {
274+
return makeRequest(t, confidentialrelaytypes.MethodCapabilityExec, confidentialrelaytypes.CapabilityRequestParams{
275+
WorkflowID: "wf-1",
276+
CapabilityID: "missing-cap@1.0.0",
277+
Payload: base64.StdEncoding.EncodeToString([]byte("payload")),
278+
Attestation: testAttestationB64,
279+
})
280+
},
281+
checkResp: func(t *testing.T, resp *jsonrpc.Response[json.RawMessage]) {
282+
require.NotNil(t, resp.Error)
283+
assert.Equal(t, jsonrpc.ErrInternal, resp.Error.Code)
284+
assert.Equal(t, internalErrorMessage, resp.Error.Message)
285+
},
286+
},
287+
{
288+
name: "capability execute error returned in result",
289+
registry: func(_ *testing.T) *mockCapRegistry {
290+
return withEnclaveConfig(&mockCapRegistry{
291+
executables: map[string]*mockExecutable{
292+
"fail-cap@1.0.0": {execErr: errors.New("execution failed")},
293+
},
294+
})
295+
},
296+
req: func(t *testing.T) *jsonrpc.Request[json.RawMessage] {
297+
sdkReq := &sdkpb.CapabilityRequest{Id: "fail-cap@1.0.0", Method: "Execute"}
298+
b, err := proto.Marshal(sdkReq)
299+
require.NoError(t, err)
300+
return makeRequest(t, confidentialrelaytypes.MethodCapabilityExec, confidentialrelaytypes.CapabilityRequestParams{
301+
WorkflowID: "wf-1",
302+
CapabilityID: "fail-cap@1.0.0",
303+
Payload: base64.StdEncoding.EncodeToString(b),
304+
Attestation: testAttestationB64,
305+
})
306+
},
307+
checkResp: func(t *testing.T, resp *jsonrpc.Response[json.RawMessage]) {
308+
require.Nil(t, resp.Error)
309+
var result confidentialrelaytypes.CapabilityResponseResult
310+
require.NoError(t, json.Unmarshal(*resp.Result, &result))
311+
assert.Equal(t, "execution failed", result.Error)
312+
assert.Empty(t, result.Payload)
313+
},
314+
},
315+
{
316+
name: "unsupported method",
317+
registry: func(_ *testing.T) *mockCapRegistry {
318+
return withEnclaveConfig(&mockCapRegistry{})
319+
},
320+
req: func(t *testing.T) *jsonrpc.Request[json.RawMessage] {
321+
return makeRequest(t, "unknown.method", nil)
322+
},
323+
checkResp: func(t *testing.T, resp *jsonrpc.Response[json.RawMessage]) {
324+
require.NotNil(t, resp.Error)
325+
assert.Equal(t, jsonrpc.ErrMethodNotFound, resp.Error.Code)
326+
},
327+
},
328+
{
329+
name: "invalid params JSON",
330+
registry: func(_ *testing.T) *mockCapRegistry {
331+
return withEnclaveConfig(&mockCapRegistry{})
332+
},
333+
req: func(_ *testing.T) *jsonrpc.Request[json.RawMessage] {
334+
raw := json.RawMessage([]byte(`{invalid json`))
335+
return &jsonrpc.Request[json.RawMessage]{
336+
Method: confidentialrelaytypes.MethodCapabilityExec,
337+
ID: "req-1",
338+
Params: &raw,
339+
}
340+
},
341+
checkResp: func(t *testing.T, resp *jsonrpc.Response[json.RawMessage]) {
342+
require.NotNil(t, resp.Error)
343+
assert.Equal(t, jsonrpc.ErrInvalidParams, resp.Error.Code)
344+
},
345+
},
346+
}
347+
348+
for _, tt := range tests {
349+
t.Run(tt.name, func(t *testing.T) {
350+
gwConn := &mockGatewayConnector{}
351+
reg := tt.registry(t)
352+
h := newTestHandler(t, reg, gwConn)
353+
err := h.HandleGatewayMessage(t.Context(), "gw-1", tt.req(t))
354+
require.NoError(t, err)
355+
require.NotNil(t, gwConn.lastResp)
356+
tt.checkResp(t, gwConn.lastResp)
357+
if tt.checkExecutable != nil {
358+
tt.checkExecutable(t, reg)
359+
}
360+
})
361+
}
362+
}
363+
364+
func TestHandler_Lifecycle(t *testing.T) {
365+
gwConn := &mockGatewayConnector{}
366+
h := newTestHandler(t, &mockCapRegistry{}, gwConn)
367+
368+
t.Run("start registers handler", func(t *testing.T) {
369+
require.NoError(t, h.Start(t.Context()))
370+
assert.Equal(t, h.Methods(), gwConn.addedMethods)
371+
})
372+
373+
t.Run("close removes handler", func(t *testing.T) {
374+
require.NoError(t, h.Close())
375+
assert.True(t, gwConn.removed)
376+
})
377+
378+
t.Run("ID returns handler name", func(t *testing.T) {
379+
id, err := h.ID(t.Context())
380+
require.NoError(t, err)
381+
assert.Equal(t, HandlerName, id)
382+
})
383+
}

0 commit comments

Comments
 (0)