Skip to content
Draft
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
101 changes: 101 additions & 0 deletions pkg/capabilities/v2/actions/confidentialrelay/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"fmt"
"hash"
"sort"
"strings"

"github.com/smartcontractkit/chainlink-common/pkg/teeattestation"
"github.com/smartcontractkit/libocr/ragep2p/peeridhelper"
Expand All @@ -24,6 +25,11 @@ const (
// RelayResponseSignaturePrefix domain-separates signatures over relay
// response hashes from other ed25519 payloads in the system.
RelayResponseSignaturePrefix = "CONFIDENTIAL_RELAY_PAYLOAD_"

// WorkflowAuthzSignaturePrefix domain-separates Workflow DON signatures over a
// WorkflowAuthz hash from relay-response signatures and every other ed25519
// payload, so an authorization signature can never be replayed as one.
WorkflowAuthzSignaturePrefix = "WORKFLOW_DON_AUTHZ_BLOB_"
)

// EnclaveConfig mirrors the confidential-compute EnclaveConfig fields the
Expand Down Expand Up @@ -61,6 +67,101 @@ type SecretsRequestParams struct {
// the EnclaveConfig type doc-comment for the threat model.
EnclaveConfig EnclaveConfig `json:"enclave_config"`
Attestation string `json:"attestation,omitempty"`

// AuthzSignatures carry the Workflow->Relay authorization. The enclave
// forwards the F+1 signatures it received from the Workflow DON (via the
// per-node-data seam); the relay reconstructs WorkflowAuthz() and verifies the
// signatures against EnclaveConfig.Signers.
AuthzSignatures []WorkflowAuthzSignature `json:"authz_signatures,omitempty"`
}

// WorkflowAuthz is the identity the Workflow DON attests for a confidential execution.
// Each Workflow DON node signs WorkflowAuthz.Hash(); the relay DON verifies F+1 of
// those signatures (against EnclaveConfig.Signers) before honoring a GetSecrets
// request, so a compromised enclave cannot self-assert a different Owner than the
// one the Workflow DON authorized. Owner is the ownership gate (the Vault DON
// keys secrets on Owner::Namespace::Key). OrgID is bound but not gating (org_id
// is deprecated for ownership). ExecutionID binds the blob to a single execution.
type WorkflowAuthz struct {
Owner string `json:"owner"` // Ethereum address (hex, 0x-prefixed)
OrgID string `json:"org_id"` // bound, not gating
WorkflowID string `json:"workflow_id"`
ExecutionID string `json:"execution_id"` // 32 bytes, hex-encoded
}

// WorkflowAuthzSignature is a single Workflow DON node signature over a
// WorkflowAuthz hash.
type WorkflowAuthzSignature struct {
Signer []byte `json:"signer"`
Signature []byte `json:"signature"`
}

// Validate rejects a WorkflowAuthz missing or malforming a field the canonical
// hash binds to. OrgID is intentionally not required: it is deprecated for
// ownership and may be empty, but it is still bound so it cannot be spoofed for
// downstream metadata.
func (w WorkflowAuthz) Validate() error {
if w.Owner == "" {
return errors.New("owner is required")
}
if err := validateOwnerAddress(w.Owner); err != nil {
return err
}
if w.WorkflowID == "" {
return errors.New("workflow_id is required")
}
if w.ExecutionID == "" {
return errors.New("execution_id is required")
}
if err := validateExecutionID(w.ExecutionID); err != nil {
return err
}
return nil
}

// Hash computes the canonical hash a Workflow DON node signs and the relay DON
// reconstructs from the request. Returns an error if the WorkflowAuthz fails
// Validate so a caller cannot accidentally sign over an unbinding payload.
func (w WorkflowAuthz) Hash() ([32]byte, error) {
if err := w.Validate(); err != nil {
return [32]byte{}, fmt.Errorf("invalid workflow authz: %w", err)
}

h := sha256.New()
h.Write([]byte(teeattestation.DomainSeparator))
h.Write([]byte("\nWorkflowAuthz\n"))

// Owner and ExecutionID are hex whose validators accept any case, so lowercase
// them before hashing to keep the hash canonical w.r.t. hex case. OrgID and
// WorkflowID are opaque identifiers, hashed as-is.
writeString(h, strings.ToLower(w.Owner))
writeString(h, w.OrgID)
writeString(h, w.WorkflowID)
writeString(h, strings.ToLower(w.ExecutionID))

var result [32]byte
h.Sum(result[:0])
return result, nil
}

// WorkflowAuthz reconstructs the authorization blob from the request the enclave
// forwards. The relay DON computes WorkflowAuthz().Hash() and verifies it against
// the carried AuthzSignatures; if the enclave lied about Owner/OrgID/etc. the
// hash will not match what the Workflow DON signed.
func (p SecretsRequestParams) WorkflowAuthz() WorkflowAuthz {
return WorkflowAuthz{
Owner: p.Owner,
OrgID: p.OrgID,
WorkflowID: p.WorkflowID,
ExecutionID: p.ExecutionID,
}
}

// WorkflowAuthzSignaturePayload prepares a WorkflowAuthz hash for signing with
// the standard peerid domain-separated payload format, using a prefix distinct
// from relay-response signatures so it can never be replayed as one.
func WorkflowAuthzSignaturePayload(authzHash [32]byte) []byte {
return peeridhelper.MakePeerIDSignatureDomainSeparatedPayload(WorkflowAuthzSignaturePrefix, authzHash[:])
}

// SecretEntry is a single secret in the relay DON's response.
Expand Down
119 changes: 119 additions & 0 deletions pkg/capabilities/v2/actions/confidentialrelay/types_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package confidentialrelay

import (
"strings"
"testing"

"github.com/stretchr/testify/require"
Expand Down Expand Up @@ -436,3 +437,121 @@ func TestSecretsResponseHash_StableUnderSignerReordering(t *testing.T) {
}
require.Equal(t, base, mustSecretsHash(t, result, reversed))
}

func validWorkflowAuthz() WorkflowAuthz {
return WorkflowAuthz{
Owner: validOwnerA,
OrgID: "org-1",
WorkflowID: "wf-1",
ExecutionID: validExecutionID,
}
}

func mustWorkflowAuthzHash(t *testing.T, w WorkflowAuthz) [32]byte {
t.Helper()
h, err := w.Hash()
require.NoError(t, err)
return h
}

func TestWorkflowAuthz_Hash_Deterministic(t *testing.T) {
w := validWorkflowAuthz()
require.Equal(t, mustWorkflowAuthzHash(t, w), mustWorkflowAuthzHash(t, w))
}

// Every field WorkflowAuthz claims to bind must actually change the hash, or a
// compromised enclave could mutate that field without invalidating the F+1
// signatures the relay verifies.
func TestWorkflowAuthz_Hash_BindsEveryField(t *testing.T) {
base := mustWorkflowAuthzHash(t, validWorkflowAuthz())

mutations := map[string]func(*WorkflowAuthz){
"owner": func(w *WorkflowAuthz) { w.Owner = validOwnerB },
"org_id": func(w *WorkflowAuthz) { w.OrgID = "org-2" },
"workflow_id": func(w *WorkflowAuthz) { w.WorkflowID = "wf-2" },
"execution_id": func(w *WorkflowAuthz) {
w.ExecutionID = "2222222222222222222222222222222222222222222222222222222222222222"
},
}
for name, mutate := range mutations {
t.Run(name, func(t *testing.T) {
w := validWorkflowAuthz()
mutate(&w)
require.NotEqual(t, base, mustWorkflowAuthzHash(t, w), "hash must change when %s changes", name)
})
}
}

// Owner and ExecutionID are hex with case-insensitive validators, so the hash must
// be invariant to hex case or a signer and verifier could disagree.
func TestWorkflowAuthz_Hash_CanonicalHexCase(t *testing.T) {
lower := validWorkflowAuthz()
lower.Owner = "0x" + strings.Repeat("a", 40)
lower.ExecutionID = strings.Repeat("a", 64)

upper := lower
upper.Owner = "0x" + strings.Repeat("A", 40)
upper.ExecutionID = strings.Repeat("A", 64)

require.Equal(t, mustWorkflowAuthzHash(t, lower), mustWorkflowAuthzHash(t, upper))
}

// The relay reconstructs WorkflowAuthz from the request the enclave forwards. Its
// hash must equal the one the Workflow DON signed, or a faithful request would
// fail verification. This is the core round-trip the whole scheme rests on.
func TestWorkflowAuthz_ReconstructionMatchesSignedHash(t *testing.T) {
w := validWorkflowAuthz()
signed := mustWorkflowAuthzHash(t, w)

p := SecretsRequestParams{
WorkflowID: w.WorkflowID,
Owner: w.Owner,
ExecutionID: w.ExecutionID,
OrgID: w.OrgID,
}
require.Equal(t, w, p.WorkflowAuthz())
require.Equal(t, signed, mustWorkflowAuthzHash(t, p.WorkflowAuthz()))
}

func TestWorkflowAuthz_Validate(t *testing.T) {
t.Run("valid", func(t *testing.T) {
require.NoError(t, validWorkflowAuthz().Validate())
})
t.Run("empty org_id is allowed", func(t *testing.T) {
w := validWorkflowAuthz()
w.OrgID = ""
require.NoError(t, w.Validate())
})

cases := map[string]func(*WorkflowAuthz){
"empty owner": func(w *WorkflowAuthz) { w.Owner = "" },
"malformed owner": func(w *WorkflowAuthz) { w.Owner = "0xnothex" },
"empty workflow_id": func(w *WorkflowAuthz) { w.WorkflowID = "" },
"empty execution_id": func(w *WorkflowAuthz) { w.ExecutionID = "" },
"malformed execution_id": func(w *WorkflowAuthz) { w.ExecutionID = "abc" },
}
for name, mutate := range cases {
t.Run(name, func(t *testing.T) {
w := validWorkflowAuthz()
mutate(&w)
require.Error(t, w.Validate())
})
}
}

func TestWorkflowAuthz_Hash_RejectsInvalid(t *testing.T) {
w := validWorkflowAuthz()
w.Owner = ""
_, err := w.Hash()
require.Error(t, err)
}

// The signing payload must be domain-separated from relay-response signatures so
// a WorkflowAuthz signature can never be replayed as one (and vice versa).
func TestWorkflowAuthzSignaturePayload_DomainSeparated(t *testing.T) {
h := mustWorkflowAuthzHash(t, validWorkflowAuthz())

got := WorkflowAuthzSignaturePayload(h)
require.Equal(t, peeridhelper.MakePeerIDSignatureDomainSeparatedPayload(WorkflowAuthzSignaturePrefix, h[:]), got)
require.NotEqual(t, RelayResponseSignaturePayload(h), got)
}
Loading