diff --git a/pkg/capabilities/v2/actions/confidentialrelay/types.go b/pkg/capabilities/v2/actions/confidentialrelay/types.go index 6d12fe651..88b04ca3f 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 } @@ -142,7 +166,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". @@ -181,6 +209,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 } @@ -218,6 +249,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. @@ -297,6 +352,7 @@ func writeSecretsRequestParams(h hash.Hash, params SecretsRequestParams) { } writeString(h, params.EnclavePublicKey) + writeEnclaveConfig(h, params.EnclaveConfig) } func writeCapabilityRequestParams(h hash.Hash, params CapabilityRequestParams) { @@ -307,6 +363,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) { @@ -344,6 +401,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 4c2301f15..0691627c9 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", } } @@ -301,3 +317,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)) +}