Skip to content

Commit e9eb648

Browse files
committed
[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
1 parent 8f535f8 commit e9eb648

18 files changed

Lines changed: 976 additions & 54 deletions

File tree

core/capabilities/confidentialrelay/handler.go

Lines changed: 495 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 342 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,342 @@
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, _ string) error { return nil }
45+
46+
type mockGatewayConnector struct {
47+
lastResp *jsonrpc.Response[json.RawMessage]
48+
addedMethods []string
49+
removed bool
50+
}
51+
52+
func (m *mockGatewayConnector) SendToGateway(_ context.Context, _ string, resp *jsonrpc.Response[json.RawMessage]) error {
53+
m.lastResp = resp
54+
return nil
55+
}
56+
func (m *mockGatewayConnector) AddHandler(_ context.Context, methods []string, _ core.GatewayConnectorHandler) error {
57+
m.addedMethods = methods
58+
return nil
59+
}
60+
func (m *mockGatewayConnector) RemoveHandler(_ context.Context, _ []string) error {
61+
m.removed = true
62+
return nil
63+
}
64+
65+
type mockExecutable struct {
66+
infoResult capabilities.CapabilityInfo
67+
infoErr error
68+
execResult capabilities.CapabilityResponse
69+
execErr error
70+
lastRequest *capabilities.CapabilityRequest
71+
}
72+
73+
func (m *mockExecutable) Info(_ context.Context) (capabilities.CapabilityInfo, error) {
74+
return m.infoResult, m.infoErr
75+
}
76+
func (m *mockExecutable) Execute(_ context.Context, req capabilities.CapabilityRequest) (capabilities.CapabilityResponse, error) {
77+
m.lastRequest = &req
78+
return m.execResult, m.execErr
79+
}
80+
func (m *mockExecutable) RegisterToWorkflow(_ context.Context, _ capabilities.RegisterToWorkflowRequest) error {
81+
return nil
82+
}
83+
func (m *mockExecutable) UnregisterFromWorkflow(_ context.Context, _ capabilities.UnregisterFromWorkflowRequest) error {
84+
return nil
85+
}
86+
87+
type mockCapRegistry struct {
88+
core.UnimplementedCapabilitiesRegistry
89+
executables map[string]*mockExecutable
90+
configs map[string]capabilities.CapabilityConfiguration
91+
localNode capabilities.Node
92+
}
93+
94+
func (m *mockCapRegistry) GetExecutable(_ context.Context, id string) (capabilities.ExecutableCapability, error) {
95+
if exec, ok := m.executables[id]; ok {
96+
return exec, nil
97+
}
98+
return nil, fmt.Errorf("capability not found: %s", id)
99+
}
100+
func (m *mockCapRegistry) ConfigForCapability(_ context.Context, capID string, _ uint32) (capabilities.CapabilityConfiguration, error) {
101+
if cfg, ok := m.configs[capID]; ok {
102+
return cfg, nil
103+
}
104+
return capabilities.CapabilityConfiguration{}, fmt.Errorf("config not found: %s", capID)
105+
}
106+
func (m *mockCapRegistry) LocalNode(_ context.Context) (capabilities.Node, error) {
107+
return m.localNode, nil
108+
}
109+
110+
func newTestHandler(t *testing.T, registry core.CapabilitiesRegistry, gwConn gatewayConnector) *Handler {
111+
t.Helper()
112+
lggr, err := logger.New()
113+
require.NoError(t, err)
114+
h, err := NewHandler(registry, gwConn, []byte(`{}`), lggr)
115+
require.NoError(t, err)
116+
h.validateAttestation = noopValidator
117+
return h
118+
}
119+
120+
func makeRequest(t *testing.T, method string, params any) *jsonrpc.Request[json.RawMessage] {
121+
t.Helper()
122+
b, err := json.Marshal(params)
123+
require.NoError(t, err)
124+
raw := json.RawMessage(b)
125+
return &jsonrpc.Request[json.RawMessage]{
126+
Method: method,
127+
ID: "req-1",
128+
Params: &raw,
129+
}
130+
}
131+
132+
func TestHandler_HandleGatewayMessage(t *testing.T) {
133+
tests := []struct {
134+
name string
135+
registry func(t *testing.T) *mockCapRegistry
136+
req func(t *testing.T) *jsonrpc.Request[json.RawMessage]
137+
checkResp func(t *testing.T, resp *jsonrpc.Response[json.RawMessage])
138+
checkExecutable func(t *testing.T, reg *mockCapRegistry)
139+
}{
140+
{
141+
name: "capability execute success",
142+
registry: func(_ *testing.T) *mockCapRegistry {
143+
return &mockCapRegistry{
144+
executables: map[string]*mockExecutable{
145+
"my-cap@1.0.0": {
146+
execResult: capabilities.CapabilityResponse{
147+
Payload: &anypb.Any{Value: []byte("result-proto-bytes")},
148+
},
149+
},
150+
},
151+
}
152+
},
153+
req: func(t *testing.T) *jsonrpc.Request[json.RawMessage] {
154+
return makeRequest(t, confidentialrelaytypes.MethodCapabilityExec, confidentialrelaytypes.CapabilityRequestParams{
155+
WorkflowID: "wf-1",
156+
CapabilityID: "my-cap@1.0.0",
157+
Payload: makeCapabilityPayload(t, map[string]any{"key": "val"}),
158+
Attestation: testAttestationB64,
159+
})
160+
},
161+
checkResp: func(t *testing.T, resp *jsonrpc.Response[json.RawMessage]) {
162+
require.Nil(t, resp.Error)
163+
var result confidentialrelaytypes.CapabilityResponseResult
164+
require.NoError(t, json.Unmarshal(*resp.Result, &result))
165+
decoded, err := base64.StdEncoding.DecodeString(result.Payload)
166+
require.NoError(t, err)
167+
var capResp sdkpb.CapabilityResponse
168+
require.NoError(t, proto.Unmarshal(decoded, &capResp))
169+
require.NotNil(t, capResp.GetPayload())
170+
assert.Equal(t, "result-proto-bytes", string(capResp.GetPayload().GetValue()))
171+
assert.Empty(t, result.Error)
172+
},
173+
},
174+
{
175+
name: "capability execute sets Inputs from Payload for backward compat",
176+
registry: func(_ *testing.T) *mockCapRegistry {
177+
return &mockCapRegistry{
178+
executables: map[string]*mockExecutable{
179+
"my-cap@1.0.0": {
180+
execResult: capabilities.CapabilityResponse{},
181+
},
182+
},
183+
}
184+
},
185+
req: func(t *testing.T) *jsonrpc.Request[json.RawMessage] {
186+
return makeRequest(t, confidentialrelaytypes.MethodCapabilityExec, confidentialrelaytypes.CapabilityRequestParams{
187+
WorkflowID: "wf-1",
188+
CapabilityID: "my-cap@1.0.0",
189+
Payload: makeCapabilityPayload(t, map[string]any{"echo": "hello"}),
190+
Attestation: testAttestationB64,
191+
})
192+
},
193+
checkResp: func(t *testing.T, resp *jsonrpc.Response[json.RawMessage]) {
194+
require.Nil(t, resp.Error)
195+
},
196+
checkExecutable: func(t *testing.T, reg *mockCapRegistry) {
197+
exec := reg.executables["my-cap@1.0.0"]
198+
require.NotNil(t, exec.lastRequest, "Execute should have been called")
199+
require.NotNil(t, exec.lastRequest.Payload)
200+
var valPB valuespb.Value
201+
require.NoError(t, exec.lastRequest.Payload.UnmarshalTo(&valPB))
202+
require.NotNil(t, exec.lastRequest.Inputs)
203+
unwrapped, err := exec.lastRequest.Inputs.Unwrap()
204+
require.NoError(t, err)
205+
m, ok := unwrapped.(map[string]any)
206+
require.True(t, ok)
207+
assert.Equal(t, "hello", m["echo"])
208+
},
209+
},
210+
{
211+
name: "capability execute attestation failure",
212+
registry: func(_ *testing.T) *mockCapRegistry {
213+
return &mockCapRegistry{}
214+
},
215+
req: func(t *testing.T) *jsonrpc.Request[json.RawMessage] {
216+
return makeRequest(t, confidentialrelaytypes.MethodCapabilityExec, confidentialrelaytypes.CapabilityRequestParams{
217+
WorkflowID: "wf-1",
218+
CapabilityID: "my-cap@1.0.0",
219+
Payload: base64.StdEncoding.EncodeToString([]byte("payload")),
220+
})
221+
},
222+
checkResp: func(t *testing.T, resp *jsonrpc.Response[json.RawMessage]) {
223+
require.NotNil(t, resp.Error)
224+
assert.Equal(t, jsonrpc.ErrInternal, resp.Error.Code)
225+
},
226+
},
227+
{
228+
name: "capability execute not found",
229+
registry: func(_ *testing.T) *mockCapRegistry {
230+
return &mockCapRegistry{executables: map[string]*mockExecutable{}}
231+
},
232+
req: func(t *testing.T) *jsonrpc.Request[json.RawMessage] {
233+
return makeRequest(t, confidentialrelaytypes.MethodCapabilityExec, confidentialrelaytypes.CapabilityRequestParams{
234+
WorkflowID: "wf-1",
235+
CapabilityID: "missing-cap@1.0.0",
236+
Payload: base64.StdEncoding.EncodeToString([]byte("payload")),
237+
Attestation: testAttestationB64,
238+
})
239+
},
240+
checkResp: func(t *testing.T, resp *jsonrpc.Response[json.RawMessage]) {
241+
require.NotNil(t, resp.Error)
242+
assert.Equal(t, jsonrpc.ErrInternal, resp.Error.Code)
243+
assert.Contains(t, resp.Error.Message, "capability not found")
244+
},
245+
},
246+
{
247+
name: "capability execute error returned in result",
248+
registry: func(_ *testing.T) *mockCapRegistry {
249+
return &mockCapRegistry{
250+
executables: map[string]*mockExecutable{
251+
"fail-cap@1.0.0": {execErr: errors.New("execution failed")},
252+
},
253+
}
254+
},
255+
req: func(t *testing.T) *jsonrpc.Request[json.RawMessage] {
256+
sdkReq := &sdkpb.CapabilityRequest{Id: "fail-cap@1.0.0", Method: "Execute"}
257+
b, err := proto.Marshal(sdkReq)
258+
require.NoError(t, err)
259+
return makeRequest(t, confidentialrelaytypes.MethodCapabilityExec, confidentialrelaytypes.CapabilityRequestParams{
260+
WorkflowID: "wf-1",
261+
CapabilityID: "fail-cap@1.0.0",
262+
Payload: base64.StdEncoding.EncodeToString(b),
263+
Attestation: testAttestationB64,
264+
})
265+
},
266+
checkResp: func(t *testing.T, resp *jsonrpc.Response[json.RawMessage]) {
267+
require.Nil(t, resp.Error)
268+
var result confidentialrelaytypes.CapabilityResponseResult
269+
require.NoError(t, json.Unmarshal(*resp.Result, &result))
270+
assert.Equal(t, "execution failed", result.Error)
271+
assert.Empty(t, result.Payload)
272+
},
273+
},
274+
{
275+
name: "unsupported method",
276+
registry: func(_ *testing.T) *mockCapRegistry {
277+
return &mockCapRegistry{}
278+
},
279+
req: func(t *testing.T) *jsonrpc.Request[json.RawMessage] {
280+
return makeRequest(t, "unknown.method", nil)
281+
},
282+
checkResp: func(t *testing.T, resp *jsonrpc.Response[json.RawMessage]) {
283+
require.NotNil(t, resp.Error)
284+
assert.Equal(t, jsonrpc.ErrMethodNotFound, resp.Error.Code)
285+
},
286+
},
287+
{
288+
name: "invalid params JSON",
289+
registry: func(_ *testing.T) *mockCapRegistry {
290+
return &mockCapRegistry{}
291+
},
292+
req: func(_ *testing.T) *jsonrpc.Request[json.RawMessage] {
293+
raw := json.RawMessage([]byte(`{invalid json`))
294+
return &jsonrpc.Request[json.RawMessage]{
295+
Method: confidentialrelaytypes.MethodCapabilityExec,
296+
ID: "req-1",
297+
Params: &raw,
298+
}
299+
},
300+
checkResp: func(t *testing.T, resp *jsonrpc.Response[json.RawMessage]) {
301+
require.NotNil(t, resp.Error)
302+
assert.Equal(t, jsonrpc.ErrInvalidParams, resp.Error.Code)
303+
},
304+
},
305+
}
306+
307+
for _, tt := range tests {
308+
t.Run(tt.name, func(t *testing.T) {
309+
gwConn := &mockGatewayConnector{}
310+
reg := tt.registry(t)
311+
h := newTestHandler(t, reg, gwConn)
312+
err := h.HandleGatewayMessage(t.Context(), "gw-1", tt.req(t))
313+
require.NoError(t, err)
314+
require.NotNil(t, gwConn.lastResp)
315+
tt.checkResp(t, gwConn.lastResp)
316+
if tt.checkExecutable != nil {
317+
tt.checkExecutable(t, reg)
318+
}
319+
})
320+
}
321+
}
322+
323+
func TestHandler_Lifecycle(t *testing.T) {
324+
gwConn := &mockGatewayConnector{}
325+
h := newTestHandler(t, &mockCapRegistry{}, gwConn)
326+
327+
t.Run("start registers handler", func(t *testing.T) {
328+
require.NoError(t, h.Start(t.Context()))
329+
assert.Equal(t, h.Methods(), gwConn.addedMethods)
330+
})
331+
332+
t.Run("close removes handler", func(t *testing.T) {
333+
require.NoError(t, h.Close())
334+
assert.True(t, gwConn.removed)
335+
})
336+
337+
t.Run("ID returns handler name", func(t *testing.T) {
338+
id, err := h.ID(t.Context())
339+
require.NoError(t, err)
340+
assert.Equal(t, HandlerName, id)
341+
})
342+
}

0 commit comments

Comments
 (0)