diff --git a/pkg/capabilities/v2/actions/confidentialrelay/computerequest.go b/pkg/capabilities/v2/actions/confidentialrelay/computerequest.go new file mode 100644 index 0000000000..cea4c2ae95 --- /dev/null +++ b/pkg/capabilities/v2/actions/confidentialrelay/computerequest.go @@ -0,0 +1,97 @@ +package confidentialrelay + +import ( + "crypto/sha256" + + "github.com/smartcontractkit/libocr/ragep2p/peeridhelper" +) + +// computeRequestDomainSeparator is vendored verbatim from confidential-compute +// types.DomainSeparator. It MUST stay byte-identical to the source, or +// ComputeRequest.Hash will not match the digest the Workflow DON nodes signed and +// F+1 verification at the relay DON will fail. chainlink-common cannot import +// confidential-compute, so the byte-for-byte conformance check lives in that repo +// (which can import this package). +const computeRequestDomainSeparator = "CONFIDENTIAL_COMPUTE_PAYLOAD" + +// signedComputeRequestSignaturePrefix is vendored verbatim from confidential-compute +// util.GetConfidentialComputePayloadPrefix(). Each Workflow DON node signs the peerid +// domain-separated payload over ComputeRequest.Hash() using this prefix; the relay DON +// reconstructs the same payload (via SignedComputeRequestSignaturePayload) to verify the +// F+1 signatures against the Workflow DON signer set. Note the trailing underscore: this +// is the signature prefix, distinct from computeRequestDomainSeparator (the hash prefix). +const signedComputeRequestSignaturePrefix = "CONFIDENTIAL_COMPUTE_PAYLOAD_" + +// SignedComputeRequestSignaturePayload reconstructs the exact payload a Workflow DON node +// signed over a ComputeRequest hash, so the relay DON can verify the signature with the +// node's public key. +func SignedComputeRequestSignaturePayload(computeRequestHash [32]byte) []byte { + return peeridhelper.MakePeerIDSignatureDomainSeparatedPayload(signedComputeRequestSignaturePrefix, computeRequestHash[:]) +} + +// ComputeRequest is vendored from confidential-compute types.ComputeRequest. The +// relay DON cannot import confidential-compute (the dependency runs the other way), +// so the type and its canonical Hash are copied here. The enclave forwards the +// Workflow-DON-signed compute request to the relay, which reconstructs the hash and +// verifies the F+1 signatures over it. +// +// PublicData carries the marshaled WorkflowExecution (owner, orgid, workflowID, +// executionID); the relay unmarshals it via chainlink-protos to recover the +// authorized identity. +type ComputeRequest struct { + RequestID [32]byte `json:"requestID"` + PublicData []byte `json:"publicData"` + Ciphertexts [][]byte `json:"ciphertexts"` + CiphertextNames []string `json:"CiphertextNames"` + EncryptedDecryptionKeyShares [][][]byte `json:"encryptedDecryptionKeyShares"` + EnclaveEphemeralPublicKey []byte `json:"enclaveEphemeralPublicKey"` + MasterPublicKey []byte `json:"masterPublicKey"` + AppID string `json:"appID"` + Version string `json:"version"` +} + +// Hash mirrors confidential-compute types.ComputeRequest.Hash byte-for-byte. It +// reuses this package's length-prefix helpers (writeBytes/writeString/ +// writeLengthPrefix), which are identical to the source's writeWithLength/ +// writeLengthPrefix. EncryptedDecryptionKeyShares is intentionally excluded, +// matching the source. +func (cr ComputeRequest) Hash() [32]byte { + h := sha256.New() + + h.Write([]byte(computeRequestDomainSeparator)) + h.Write([]byte("\nComputeRequest\n")) + + h.Write(cr.RequestID[:]) + + writeBytes(h, cr.PublicData) + + writeLengthPrefix(h, len(cr.CiphertextNames)) + for _, name := range cr.CiphertextNames { + writeString(h, name) + } + + writeLengthPrefix(h, len(cr.Ciphertexts)) + for _, ciphertext := range cr.Ciphertexts { + writeBytes(h, ciphertext) + } + + writeBytes(h, cr.EnclaveEphemeralPublicKey) + writeBytes(h, cr.MasterPublicKey) + + writeString(h, cr.AppID) + writeString(h, cr.Version) + + var result [32]byte + h.Sum(result[:0]) + return result +} + +// SignedComputeRequest is vendored from confidential-compute +// types.SignedComputeRequest: a ComputeRequest plus one Workflow DON node's +// signature over ComputeRequest.Hash. The enclave forwards the F+1 signed requests +// to the relay DON as the authorization for a secrets request. +type SignedComputeRequest struct { + ComputeRequest + Signature []byte `json:"signature"` + PerNodeData map[string]string `json:"perNodeData,omitempty"` +} diff --git a/pkg/capabilities/v2/actions/confidentialrelay/computerequest_test.go b/pkg/capabilities/v2/actions/confidentialrelay/computerequest_test.go new file mode 100644 index 0000000000..6f33fc6388 --- /dev/null +++ b/pkg/capabilities/v2/actions/confidentialrelay/computerequest_test.go @@ -0,0 +1,61 @@ +package confidentialrelay + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func sampleComputeRequest() ComputeRequest { + var rid [32]byte + for i := range rid { + rid[i] = byte(i) + } + return ComputeRequest{ + RequestID: rid, + PublicData: []byte("public-data"), + Ciphertexts: [][]byte{[]byte("ct-a"), []byte("ct-b")}, + CiphertextNames: []string{"name-a", "name-b"}, + EnclaveEphemeralPublicKey: []byte("ephemeral-pub-key"), + MasterPublicKey: []byte("master-pub-key"), + AppID: "test-app", + Version: "v1.2.3", + } +} + +func TestComputeRequestHash_Deterministic(t *testing.T) { + require.Equal(t, sampleComputeRequest().Hash(), sampleComputeRequest().Hash()) +} + +// Every field the source binds must change the hash. (Conformance with +// confidential-compute's source Hash is enforced by a test in that repo, which can +// import this package; chainlink-common cannot import confidential-compute.) +func TestComputeRequestHash_BindsFields(t *testing.T) { + base := sampleComputeRequest().Hash() + + mutations := map[string]func(*ComputeRequest){ + "requestID": func(c *ComputeRequest) { c.RequestID = [32]byte{0xff} }, + "publicData": func(c *ComputeRequest) { c.PublicData = []byte("other") }, + "ciphertextNames": func(c *ComputeRequest) { c.CiphertextNames = []string{"x"} }, + "ciphertexts": func(c *ComputeRequest) { c.Ciphertexts = [][]byte{[]byte("x")} }, + "ephemeralKey": func(c *ComputeRequest) { c.EnclaveEphemeralPublicKey = []byte("x") }, + "masterKey": func(c *ComputeRequest) { c.MasterPublicKey = []byte("x") }, + "appID": func(c *ComputeRequest) { c.AppID = "other" }, + "version": func(c *ComputeRequest) { c.Version = "other" }, + } + for name, mutate := range mutations { + t.Run(name, func(t *testing.T) { + c := sampleComputeRequest() + mutate(&c) + require.NotEqual(t, base, c.Hash(), "hash must change when %s changes", name) + }) + } +} + +// EncryptedDecryptionKeyShares is intentionally excluded from the hash, matching the +// source; this pins that so a future copy edit can't silently start binding it. +func TestComputeRequestHash_IgnoresEncryptedShares(t *testing.T) { + withShares := sampleComputeRequest() + withShares.EncryptedDecryptionKeyShares = [][][]byte{{[]byte("share")}} + require.Equal(t, sampleComputeRequest().Hash(), withShares.Hash()) +} diff --git a/pkg/capabilities/v2/actions/confidentialrelay/types.go b/pkg/capabilities/v2/actions/confidentialrelay/types.go index 6658270c7e..c9b4d67fb4 100644 --- a/pkg/capabilities/v2/actions/confidentialrelay/types.go +++ b/pkg/capabilities/v2/actions/confidentialrelay/types.go @@ -66,6 +66,13 @@ type SecretsRequestParams struct { // that omit the field. When present, it is validated and hash-bound. EnclaveConfig *EnclaveConfig `json:"enclave_config,omitempty"` Attestation string `json:"attestation,omitempty"` + + // SignedComputeRequests carries the F+1 Workflow-DON-signed compute requests the + // enclave forwards verbatim. The relay DON verifies the signatures over + // ComputeRequest.Hash() against its Workflow DON signer set and reads the + // authorized identity from PublicData (the WorkflowExecution proto). Like + // Attestation, it is authorization input and is excluded from the response hash. + SignedComputeRequests []SignedComputeRequest `json:"signed_compute_requests,omitempty"` } // SecretEntry is a single secret in the relay DON's response.