Skip to content

Commit c6f7067

Browse files
authored
Allow capability DONs to include OCR attestation of the responses (#1907)
* Allow capability DONs to include OCR attestation of the responses * Added tests for ResponseToReportData * Move OCR attestation to response * Set OCRAttestation to the response * Hash based OCR feature flag
1 parent eeae8b2 commit c6f7067

28 files changed

Lines changed: 686 additions & 248 deletions

File tree

keystore/corekeys/ocr2key/evm_keyring.go

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -87,13 +87,7 @@ func (ekr *evmKeyring) Verify3(publicKey ocrtypes.OnchainPublicKey, cd ocrtypes.
8787
}
8888

8989
func (ekr *evmKeyring) VerifyBlob(pubkey types.OnchainPublicKey, b, sig []byte) bool {
90-
authorPubkey, err := crypto.SigToPub(b, sig)
91-
if err != nil {
92-
return false
93-
}
94-
authorAddress := crypto.PubkeyToAddress(*authorPubkey)
95-
// no need for constant time compare since neither arg is sensitive
96-
return bytes.Equal(pubkey[:], authorAddress[:])
90+
return EvmVerifyBlob(pubkey, b, sig)
9791
}
9892

9993
func (ekr *evmKeyring) MaxSignatureLength() int {
@@ -116,3 +110,13 @@ func (ekr *evmKeyring) Unmarshal(in []byte) error {
116110
ekr.privateKey = func() *ecdsa.PrivateKey { return privateKey }
117111
return nil
118112
}
113+
114+
func EvmVerifyBlob(pubkey types.OnchainPublicKey, b, sig []byte) bool {
115+
authorPubkey, err := crypto.SigToPub(b, sig)
116+
if err != nil {
117+
return false
118+
}
119+
authorAddress := crypto.PubkeyToAddress(*authorPubkey)
120+
// no need for constant time compare since neither arg is sensitive
121+
return bytes.Equal(pubkey[:], authorAddress[:])
122+
}

pkg/capabilities/capabilities.go

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package capabilities
22

33
import (
44
"context"
5+
"encoding/binary"
56
"errors"
67
"fmt"
78
"iter"
@@ -10,6 +11,7 @@ import (
1011
"strings"
1112
"time"
1213

14+
"golang.org/x/crypto/sha3"
1315
"google.golang.org/protobuf/proto"
1416
"google.golang.org/protobuf/types/known/anypb"
1517

@@ -39,6 +41,21 @@ func (e errStopExecution) Is(err error) bool {
3941
return strings.Contains(err.Error(), errStopExecutionMsg)
4042
}
4143

44+
// ErrResponsePayloadNotAvailable is returned when a capability's Execute method cannot provide a response payload and engine should wait for another response instead of treating it as an error.
45+
var ErrResponsePayloadNotAvailable = &responsePayloadNotAvailableError{}
46+
47+
type responsePayloadNotAvailableError struct{}
48+
49+
const errResponsePayloadNotAvailableMsg = "__response_payload_not_available"
50+
51+
func (e responsePayloadNotAvailableError) Error() string {
52+
return errResponsePayloadNotAvailableMsg
53+
}
54+
55+
func (e responsePayloadNotAvailableError) Is(err error) bool {
56+
return strings.Contains(err.Error(), errResponsePayloadNotAvailableMsg)
57+
}
58+
4259
// CapabilityType enum values.
4360
const (
4461
CapabilityTypeUnknown CapabilityType = "unknown"
@@ -76,14 +93,63 @@ type CapabilityResponse struct {
7693
Metadata ResponseMetadata
7794

7895
// Payload is used for no DAG workflows
79-
Payload *anypb.Any
96+
Payload *anypb.Any
97+
OCRAttestation *OCRAttestation
8098
}
8199

82100
type ResponseMetadata struct {
83101
Metering []MeteringNodeDetail
84102
CapDON_N uint32
85103
}
86104

105+
type OCRAttestation struct {
106+
ConfigDigest ocrtypes.ConfigDigest
107+
SequenceNumber uint64
108+
Sigs []AttributedSignature
109+
}
110+
111+
type AttributedSignature struct {
112+
Signature []byte
113+
Signer uint32
114+
}
115+
116+
func ExtractMeteringFromMetadata(sender p2ptypes.PeerID, metadata ResponseMetadata) (MeteringNodeDetail, error) {
117+
if len(metadata.Metering) != 1 {
118+
return MeteringNodeDetail{}, fmt.Errorf("unexpected number of metering records received from peer %s: got %d, want 1", sender, len(metadata.Metering))
119+
}
120+
121+
rpt := metadata.Metering[0]
122+
rpt.Peer2PeerID = sender.String()
123+
return rpt, nil
124+
}
125+
126+
func ResponseToReportData(workflowExecutionID, referenceID string, responsePayload []byte, metadata ResponseMetadata) ([32]byte, error) {
127+
// use empty PeerID since the sender must not be included in the hash
128+
metering, err := ExtractMeteringFromMetadata(p2ptypes.PeerID{}, metadata)
129+
if err != nil {
130+
return [32]byte{}, fmt.Errorf("failed to extract metering from metadata: %w", err)
131+
}
132+
133+
hash := sha3.New256()
134+
const domainSeparator = "CapabilityResponseReportData:v1"
135+
hash.Write([]byte(domainSeparator))
136+
// Helper to write a length-prefixed byte slice.
137+
writeField := func(b []byte) {
138+
// Use a fixed-width length prefix to make encoding unambiguous.
139+
_ = binary.Write(hash, binary.BigEndian, uint64(len(b)))
140+
_, _ = hash.Write(b)
141+
}
142+
writeField([]byte(workflowExecutionID))
143+
writeField([]byte(referenceID))
144+
writeField(responsePayload)
145+
writeField([]byte(metering.SpendUnit))
146+
writeField([]byte(metering.SpendValue))
147+
148+
var result [32]byte
149+
copy(result[:], hash.Sum(nil))
150+
return result, nil
151+
}
152+
87153
type MeteringNodeDetail struct {
88154
Peer2PeerID string
89155
SpendUnit string
@@ -94,6 +160,7 @@ type MeteringNodeDetail struct {
94160
type ResponseAndMetadata[T proto.Message] struct {
95161
Response T
96162
ResponseMetadata ResponseMetadata
163+
OCRAttestation *OCRAttestation
97164
}
98165

99166
type SpendLimit struct {

pkg/capabilities/capabilities_test.go

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package capabilities
33
import (
44
"bytes"
55
"context"
6+
"encoding/hex"
67
"errors"
78
"maps"
89
"strings"
@@ -360,3 +361,113 @@ func TestRegistrationMetadata_ContextWithCRE(t *testing.T) {
360361
ctx = md.ContextWithCRE(ctx)
361362
require.Equal(t, "org-id", contexts.CREValue(ctx).Org)
362363
}
364+
365+
func testResponseMetadata(spendUnit, spendValue string) ResponseMetadata {
366+
return ResponseMetadata{
367+
Metering: []MeteringNodeDetail{{SpendUnit: spendUnit, SpendValue: spendValue}},
368+
}
369+
}
370+
371+
func TestResponseToReportData(t *testing.T) {
372+
t.Run("deterministic golden digest", func(t *testing.T) {
373+
// SHA3-256 over domain "CapabilityResponseReportData:v1" plus length-prefixed fields.
374+
want, err := hex.DecodeString("5d39c100ecf57f83b095cc9e129e0be24809e208af97a40e6447932749272a50")
375+
require.NoError(t, err)
376+
require.Len(t, want, 32)
377+
378+
got, err := ResponseToReportData(
379+
"wf-exec",
380+
"ref-1",
381+
[]byte("payload"),
382+
testResponseMetadata("unit", "42"),
383+
)
384+
require.NoError(t, err)
385+
assert.Equal(t, want, got[:])
386+
})
387+
388+
t.Run("same inputs yield same hash", func(t *testing.T) {
389+
md := testResponseMetadata("u", "1")
390+
a, err := ResponseToReportData("wf", "ref", []byte("p"), md)
391+
require.NoError(t, err)
392+
b, err := ResponseToReportData("wf", "ref", []byte("p"), md)
393+
require.NoError(t, err)
394+
assert.Equal(t, a, b)
395+
})
396+
397+
t.Run("nil payload matches empty payload", func(t *testing.T) {
398+
md := testResponseMetadata("u", "v")
399+
a, err := ResponseToReportData("w", "r", nil, md)
400+
require.NoError(t, err)
401+
b, err := ResponseToReportData("w", "r", []byte{}, md)
402+
require.NoError(t, err)
403+
assert.Equal(t, a, b)
404+
})
405+
406+
t.Run("each field affects the digest", func(t *testing.T) {
407+
base, err := ResponseToReportData(
408+
"workflow-exec-id",
409+
"reference-id",
410+
[]byte{0x01, 0x02},
411+
testResponseMetadata("spend-unit", "spend-value"),
412+
)
413+
require.NoError(t, err)
414+
415+
other, err := ResponseToReportData(
416+
"other-workflow",
417+
"reference-id",
418+
[]byte{0x01, 0x02},
419+
testResponseMetadata("spend-unit", "spend-value"),
420+
)
421+
require.NoError(t, err)
422+
assert.NotEqual(t, base, other)
423+
424+
other, err = ResponseToReportData(
425+
"workflow-exec-id",
426+
"other-ref",
427+
[]byte{0x01, 0x02},
428+
testResponseMetadata("spend-unit", "spend-value"),
429+
)
430+
require.NoError(t, err)
431+
assert.NotEqual(t, base, other)
432+
433+
other, err = ResponseToReportData(
434+
"workflow-exec-id",
435+
"reference-id",
436+
[]byte{0x01},
437+
testResponseMetadata("spend-unit", "spend-value"),
438+
)
439+
require.NoError(t, err)
440+
assert.NotEqual(t, base, other)
441+
442+
other, err = ResponseToReportData(
443+
"workflow-exec-id",
444+
"reference-id",
445+
[]byte{0x01, 0x02},
446+
testResponseMetadata("other-unit", "spend-value"),
447+
)
448+
require.NoError(t, err)
449+
assert.NotEqual(t, base, other)
450+
451+
other, err = ResponseToReportData(
452+
"workflow-exec-id",
453+
"reference-id",
454+
[]byte{0x01, 0x02},
455+
testResponseMetadata("spend-unit", "other-value"),
456+
)
457+
require.NoError(t, err)
458+
assert.NotEqual(t, base, other)
459+
})
460+
461+
t.Run("metering must contain exactly one entry", func(t *testing.T) {
462+
_, err := ResponseToReportData("w", "r", nil, ResponseMetadata{})
463+
require.ErrorContains(t, err, "failed to extract metering from metadata: unexpected number of metering records received from peer 12D3KooW9pNAk8aiBuGVQtWRdbkLmo5qVL3e2h5UxbN2Nz9ttwiw: got 0, want 1")
464+
465+
_, err = ResponseToReportData("w", "r", nil, ResponseMetadata{
466+
Metering: []MeteringNodeDetail{
467+
{SpendUnit: "a", SpendValue: "1"},
468+
{SpendUnit: "b", SpendValue: "2"},
469+
},
470+
})
471+
require.ErrorContains(t, err, "failed to extract metering from metadata: unexpected number of metering records received from peer 12D3KooW9pNAk8aiBuGVQtWRdbkLmo5qVL3e2h5UxbN2Nz9ttwiw: got 2, want 1")
472+
})
473+
}

0 commit comments

Comments
 (0)