From 291b6516b1041f221c6203d377eef57fe8da0b3d Mon Sep 17 00:00:00 2001 From: Tejaswi Nadahalli Date: Fri, 15 May 2026 18:01:44 +0200 Subject: [PATCH 1/3] Add EnclaveConfig to confidentialrelay request params (PRIV-458) SecretsRequestParams and CapabilityRequestParams now carry an EnclaveConfig field describing the enclave's current Signers, MasterPublicKey, T, and F. The relay-DON handler in chainlink/core uses this to compare against onchain DON state before treating an attested request as trusted. Without this field, the request's Nitro attestation cryptographically binds the request hash but does not let the relay see the enclave's config. A malicious host can produce a genuinely-attested request over a forged enclave config and have it accepted. This closes the gap Sigma Prime CL112-01 identified, mirroring the pool.go-side hardening in confidential-compute #329. EnclaveConfig is defined as a parallel struct in this package rather than imported from confidential-compute to keep the dependency direction one-way. The fields match types.EnclaveConfig there and the enclave fills this struct from its local instance before sending. Validate enforces non-empty Signers, F > 0, and non-empty MasterPublicKey. The canonical hash for relay-response signing now binds every field with Signers sorted so that two logically-equivalent configs hash the same regardless of Signer ordering. --- .../v2/actions/confidentialrelay/types.go | 82 +++++++++- .../actions/confidentialrelay/types_test.go | 149 +++++++++++++++++- 2 files changed, 222 insertions(+), 9 deletions(-) diff --git a/pkg/capabilities/v2/actions/confidentialrelay/types.go b/pkg/capabilities/v2/actions/confidentialrelay/types.go index 6bfdaf12c5..f9187b5976 100644 --- a/pkg/capabilities/v2/actions/confidentialrelay/types.go +++ b/pkg/capabilities/v2/actions/confidentialrelay/types.go @@ -1,6 +1,7 @@ package confidentialrelay import ( + "bytes" "crypto/sha256" "encoding/binary" "encoding/hex" @@ -25,6 +26,22 @@ const ( RelayResponseSignaturePrefix = "CONFIDENTIAL_RELAY_PAYLOAD_" ) +// EnclaveConfig mirrors the confidential-compute EnclaveConfig fields the +// relay needs to verify against onchain DON state. The enclave fills this +// from its local types.EnclaveConfig before sending each relay request. +// +// PRIV-458 / Sigma Prime CL112-01: without this field the request's Nitro +// attestation cryptographically binds the request hash but does not let the +// relay compare the enclave's config against any reference. A malicious host +// can produce genuinely-attested requests over a forged enclave config and +// have them accepted unless the relay can see and verify the config value. +type EnclaveConfig struct { + Signers [][]byte `json:"signers"` + MasterPublicKey []byte `json:"master_public_key"` + T uint32 `json:"t"` + F uint32 `json:"f"` +} + // SecretIdentifier identifies a secret by key and namespace. type SecretIdentifier struct { Key string `json:"key"` @@ -39,7 +56,11 @@ type SecretsRequestParams struct { OrgID string `json:"org_id,omitempty"` // Organization identifier for org-based secret ownership Secrets []SecretIdentifier `json:"secrets"` EnclavePublicKey string `json:"enclave_public_key"` - Attestation string `json:"attestation,omitempty"` + // EnclaveConfig is the enclave's current config, included so the relay can + // verify it against onchain DON state after attestation validation. See + // the EnclaveConfig type doc-comment for the threat model. + EnclaveConfig EnclaveConfig `json:"enclave_config"` + Attestation string `json:"attestation,omitempty"` } // SecretEntry is a single secret in the relay DON's response. @@ -89,6 +110,9 @@ func (p SecretsRequestParams) Validate() error { if err := validateSecretIdentifiers(p.Secrets); err != nil { return err } + if err := validateEnclaveConfig(p.EnclaveConfig); err != nil { + return err + } return nil } @@ -141,7 +165,11 @@ type CapabilityRequestParams struct { ReferenceID string `json:"reference_id"` CapabilityID string `json:"capability_id"` Payload string `json:"payload"` - Attestation string `json:"attestation,omitempty"` + // EnclaveConfig is the enclave's current config, included so the relay can + // verify it against onchain DON state after attestation validation. See + // the EnclaveConfig type doc-comment for the threat model. + EnclaveConfig EnclaveConfig `json:"enclave_config"` + Attestation string `json:"attestation,omitempty"` } // CapabilityResponseResult is the JSON-RPC result for "confidential.capability.execute". @@ -180,6 +208,9 @@ func (p CapabilityRequestParams) Validate() error { if p.Payload == "" { return errors.New("payload is required") } + if err := validateEnclaveConfig(p.EnclaveConfig); err != nil { + return err + } return nil } @@ -217,6 +248,30 @@ func validateEnclavePublicKey(s string) error { return nil } +// validateEnclaveConfig rejects configs missing fields the canonical hash binds to. +// Signers must be non-empty (the relay needs to compare against the onchain DON +// membership). F must be > 0 (a DON with no fault tolerance is not a configuration +// the relay will trust). MasterPublicKey is checked for presence only; encoding is +// the enclave's contract. T is allowed to be zero in case some future enclave +// configurations carry it implicitly, but in practice the enclave will set it. +func validateEnclaveConfig(c EnclaveConfig) error { + if len(c.Signers) == 0 { + return errors.New("enclave_config.signers must be non-empty") + } + for i, s := range c.Signers { + if len(s) == 0 { + return fmt.Errorf("enclave_config.signers[%d] is empty", i) + } + } + if c.F == 0 { + return errors.New("enclave_config.f must be > 0") + } + if len(c.MasterPublicKey) == 0 { + return errors.New("enclave_config.master_public_key must be non-empty") + } + return nil +} + // validateSecretIdentifiers rejects any entry with an empty Key or Namespace because the // canonical hash binds to them and an empty value would produce a signature over an // ambiguous selector. @@ -296,6 +351,7 @@ func writeSecretsRequestParams(h hash.Hash, params SecretsRequestParams) { } writeString(h, params.EnclavePublicKey) + writeEnclaveConfig(h, params.EnclaveConfig) } func writeCapabilityRequestParams(h hash.Hash, params CapabilityRequestParams) { @@ -305,6 +361,7 @@ func writeCapabilityRequestParams(h hash.Hash, params CapabilityRequestParams) { writeString(h, params.ReferenceID) writeString(h, params.CapabilityID) writeString(h, params.Payload) + writeEnclaveConfig(h, params.EnclaveConfig) } func writeSecretIdentifier(h hash.Hash, id SecretIdentifier) { @@ -342,6 +399,27 @@ func writeBytes(h hash.Hash, b []byte) { h.Write(b) } +// writeEnclaveConfig binds every field of EnclaveConfig into the hash with +// canonical length prefixes. Signers are sorted so two logically-equivalent +// configs that differ only in Signer ordering produce the same hash; the +// relay-side comparison against onchain state is order-independent so the +// hashing must be too. +func writeEnclaveConfig(h hash.Hash, c EnclaveConfig) { + signers := append([][]byte(nil), c.Signers...) + sort.Slice(signers, func(i, j int) bool { return bytes.Compare(signers[i], signers[j]) < 0 }) + writeLengthPrefix(h, len(signers)) + for _, s := range signers { + writeBytes(h, s) + } + writeBytes(h, c.MasterPublicKey) + + var buf [4]byte + binary.BigEndian.PutUint32(buf[:], c.T) + h.Write(buf[:]) + binary.BigEndian.PutUint32(buf[:], c.F) + h.Write(buf[:]) +} + func writeLengthPrefix(h hash.Hash, length int) { var buf [8]byte binary.BigEndian.PutUint64(buf[:], uint64(length)) diff --git a/pkg/capabilities/v2/actions/confidentialrelay/types_test.go b/pkg/capabilities/v2/actions/confidentialrelay/types_test.go index f675f5e85a..78be8b78db 100644 --- a/pkg/capabilities/v2/actions/confidentialrelay/types_test.go +++ b/pkg/capabilities/v2/actions/confidentialrelay/types_test.go @@ -29,6 +29,20 @@ func mustCapabilityHash(t *testing.T, r CapabilityResponseResult, p CapabilityRe return h } +func validEnclaveConfig() EnclaveConfig { + return EnclaveConfig{ + Signers: [][]byte{ + {0x01, 0x02, 0x03}, + {0x04, 0x05, 0x06}, + {0x07, 0x08, 0x09}, + {0x0a, 0x0b, 0x0c}, + }, + MasterPublicKey: []byte("master-public-key"), + T: 2, + F: 1, + } +} + func validSecretsParams() SecretsRequestParams { return SecretsRequestParams{ WorkflowID: "wf-1", @@ -36,6 +50,7 @@ func validSecretsParams() SecretsRequestParams { ExecutionID: validExecutionID, OrgID: "org-1", EnclavePublicKey: validEnclavePubKey, + EnclaveConfig: validEnclaveConfig(), Attestation: "att-a", Secrets: []SecretIdentifier{ {Key: "alpha", Namespace: "ns-a"}, @@ -45,13 +60,14 @@ func validSecretsParams() SecretsRequestParams { func validCapabilityParams() CapabilityRequestParams { return CapabilityRequestParams{ - WorkflowID: "wf-1", - Owner: validOwnerA, - ExecutionID: validExecutionID, - ReferenceID: "42", - CapabilityID: "write_ethereum-testnet-sepolia@1.0.0", - Payload: "request-payload", - Attestation: "att-a", + WorkflowID: "wf-1", + Owner: validOwnerA, + ExecutionID: validExecutionID, + ReferenceID: "42", + CapabilityID: "write_ethereum-testnet-sepolia@1.0.0", + Payload: "request-payload", + EnclaveConfig: validEnclaveConfig(), + Attestation: "att-a", } } @@ -297,3 +313,122 @@ func TestRelayResponseSignaturePayload_UsesExpectedPrefix(t *testing.T) { require.Equal(t, expected, RelayResponseSignaturePayload(hash)) require.NotEqual(t, hash[:], RelayResponseSignaturePayload(hash)) } + +// TestValidateEnclaveConfig covers the EnclaveConfig validation added for +// PRIV-458 / CL112-01. The relay needs each request to carry a non-empty +// signers list, non-zero F, and a non-empty MasterPublicKey so it can +// meaningfully compare against onchain DON state. +func TestValidateEnclaveConfig(t *testing.T) { + t.Run("valid config accepted", func(t *testing.T) { + require.NoError(t, validateEnclaveConfig(validEnclaveConfig())) + }) + t.Run("missing signers rejected", func(t *testing.T) { + c := validEnclaveConfig() + c.Signers = nil + require.Error(t, validateEnclaveConfig(c)) + }) + t.Run("empty signer rejected", func(t *testing.T) { + c := validEnclaveConfig() + c.Signers = append(c.Signers, []byte{}) + err := validateEnclaveConfig(c) + require.Error(t, err) + require.Contains(t, err.Error(), "signers[") + }) + t.Run("F=0 rejected", func(t *testing.T) { + c := validEnclaveConfig() + c.F = 0 + require.Error(t, validateEnclaveConfig(c)) + }) + t.Run("empty master_public_key rejected", func(t *testing.T) { + c := validEnclaveConfig() + c.MasterPublicKey = nil + require.Error(t, validateEnclaveConfig(c)) + }) +} + +// TestSecretsRequestParams_Validate_RequiresEnclaveConfig covers that the +// Validate gate rejects requests missing the EnclaveConfig. +func TestSecretsRequestParams_Validate_RequiresEnclaveConfig(t *testing.T) { + p := validSecretsParams() + p.EnclaveConfig = EnclaveConfig{} + require.Error(t, p.Validate()) +} + +// TestCapabilityRequestParams_Validate_RequiresEnclaveConfig same as above +// for the capability execute path. +func TestCapabilityRequestParams_Validate_RequiresEnclaveConfig(t *testing.T) { + p := validCapabilityParams() + p.EnclaveConfig = EnclaveConfig{} + require.Error(t, p.Validate()) +} + +// TestSecretsResponseHash_BindsEnclaveConfig proves the response signature +// hash differs when EnclaveConfig differs. If the hash did not bind +// EnclaveConfig, two responses signed over the same secrets but with +// different enclave configs would have indistinguishable signatures. +func TestSecretsResponseHash_BindsEnclaveConfig(t *testing.T) { + params := validSecretsParams() + result := SecretsResponseResult{ + Secrets: []SecretEntry{ + { + ID: SecretIdentifier{Key: "alpha", Namespace: "ns-a"}, + Ciphertext: "cipher-a", + EncryptedShares: []string{"share-a1"}, + }, + }, + } + base := mustSecretsHash(t, result, params) + + changed := params + changed.EnclaveConfig.F = params.EnclaveConfig.F + 1 + require.NotEqual(t, base, mustSecretsHash(t, result, changed)) + + changed2 := params + changed2.EnclaveConfig.T = params.EnclaveConfig.T + 1 + require.NotEqual(t, base, mustSecretsHash(t, result, changed2)) + + changed3 := params + changed3.EnclaveConfig.MasterPublicKey = append([]byte(nil), params.EnclaveConfig.MasterPublicKey...) + changed3.EnclaveConfig.MasterPublicKey[0] ^= 0xff + require.NotEqual(t, base, mustSecretsHash(t, result, changed3)) + + changed4 := params + changed4.EnclaveConfig.Signers = append([][]byte(nil), params.EnclaveConfig.Signers...) + changed4.EnclaveConfig.Signers = append(changed4.EnclaveConfig.Signers, []byte{0xff}) + require.NotEqual(t, base, mustSecretsHash(t, result, changed4)) +} + +// TestCapabilityResponseHash_BindsEnclaveConfig same as above for the +// capability execute path. +func TestCapabilityResponseHash_BindsEnclaveConfig(t *testing.T) { + params := validCapabilityParams() + result := CapabilityResponseResult{Payload: "out"} + base := mustCapabilityHash(t, result, params) + + changed := params + changed.EnclaveConfig.F = params.EnclaveConfig.F + 1 + require.NotEqual(t, base, mustCapabilityHash(t, result, changed)) +} + +// TestSecretsResponseHash_StableUnderSignerReordering proves that the +// EnclaveConfig.Signers ordering does not affect the hash. The relay-side +// comparison against onchain state is order-independent so the hash must be +// too, otherwise an enclave permuting Signers (or a different ordering +// emitted across reboots) would invalidate signatures over identical +// logical state. +func TestSecretsResponseHash_StableUnderSignerReordering(t *testing.T) { + params := validSecretsParams() + result := SecretsResponseResult{ + Secrets: []SecretEntry{ + {ID: SecretIdentifier{Key: "alpha", Namespace: "ns-a"}, Ciphertext: "c", EncryptedShares: []string{"s"}}, + }, + } + base := mustSecretsHash(t, result, params) + + reversed := params + reversed.EnclaveConfig.Signers = make([][]byte, len(params.EnclaveConfig.Signers)) + for i, s := range params.EnclaveConfig.Signers { + reversed.EnclaveConfig.Signers[len(params.EnclaveConfig.Signers)-1-i] = s + } + require.Equal(t, base, mustSecretsHash(t, result, reversed)) +} From ae6454525413c5801c4698963c058693ddf1b73b Mon Sep 17 00:00:00 2001 From: Tejaswi Nadahalli Date: Mon, 18 May 2026 12:46:17 +0200 Subject: [PATCH 2/3] Mark EnclavePublicKey on SecretsRequestParams as Deprecated The relay will source the enclave's ephemeral encryption key from a different mechanism in a future change, not from this request payload. Flag the field so new callers know not to depend on it. No functional change. Validate still enforces the field is set for now, because removing it is a separate rollout. --- .../v2/actions/confidentialrelay/types.go | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/pkg/capabilities/v2/actions/confidentialrelay/types.go b/pkg/capabilities/v2/actions/confidentialrelay/types.go index f9187b5976..abbaabe68c 100644 --- a/pkg/capabilities/v2/actions/confidentialrelay/types.go +++ b/pkg/capabilities/v2/actions/confidentialrelay/types.go @@ -54,8 +54,15 @@ type SecretsRequestParams struct { Owner string `json:"owner"` // Ethereum address (hex, 0x-prefixed) ExecutionID string `json:"execution_id"` // 32 bytes, hex-encoded OrgID string `json:"org_id,omitempty"` // Organization identifier for org-based secret ownership - Secrets []SecretIdentifier `json:"secrets"` - EnclavePublicKey string `json:"enclave_public_key"` + Secrets []SecretIdentifier `json:"secrets"` + // EnclavePublicKey is the ephemeral encryption key the relay encrypts + // returned shares to. + // + // Deprecated: this field will be removed in a future change. The relay + // will source the enclave public key from a different mechanism (not + // from this request payload). New callers should not depend on the + // field being present. + EnclavePublicKey string `json:"enclave_public_key"` // EnclaveConfig is the enclave's current config, included so the relay can // verify it against onchain DON state after attestation validation. See // the EnclaveConfig type doc-comment for the threat model. From 40a8e4cedaa8f58c4759eac9efe361812b9cd30a Mon Sep 17 00:00:00 2001 From: Tejaswi Nadahalli Date: Mon, 18 May 2026 13:20:11 +0200 Subject: [PATCH 3/3] Revert EnclavePublicKey Deprecated annotation EnclavePublicKey is the enclave's ephemeral encryption key the relay encrypts returned shares to. EnclaveConfig.MasterPublicKey is the DKG master public key, which is a different key. The field is not being superseded by EnclaveConfig and there is no plan to remove it. --- .../v2/actions/confidentialrelay/types.go | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/pkg/capabilities/v2/actions/confidentialrelay/types.go b/pkg/capabilities/v2/actions/confidentialrelay/types.go index abbaabe68c..f9187b5976 100644 --- a/pkg/capabilities/v2/actions/confidentialrelay/types.go +++ b/pkg/capabilities/v2/actions/confidentialrelay/types.go @@ -54,15 +54,8 @@ type SecretsRequestParams struct { Owner string `json:"owner"` // Ethereum address (hex, 0x-prefixed) ExecutionID string `json:"execution_id"` // 32 bytes, hex-encoded OrgID string `json:"org_id,omitempty"` // Organization identifier for org-based secret ownership - Secrets []SecretIdentifier `json:"secrets"` - // EnclavePublicKey is the ephemeral encryption key the relay encrypts - // returned shares to. - // - // Deprecated: this field will be removed in a future change. The relay - // will source the enclave public key from a different mechanism (not - // from this request payload). New callers should not depend on the - // field being present. - EnclavePublicKey string `json:"enclave_public_key"` + Secrets []SecretIdentifier `json:"secrets"` + EnclavePublicKey string `json:"enclave_public_key"` // EnclaveConfig is the enclave's current config, included so the relay can // verify it against onchain DON state after attestation validation. See // the EnclaveConfig type doc-comment for the threat model.