Skip to content
Open
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
82 changes: 80 additions & 2 deletions pkg/capabilities/v2/actions/confidentialrelay/types.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package confidentialrelay

import (
"bytes"
"crypto/sha256"
"encoding/binary"
"encoding/hex"
Expand All @@ -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"`
Expand All @@ -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.
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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".
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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) {
Expand All @@ -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) {
Expand Down Expand Up @@ -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))
Expand Down
149 changes: 142 additions & 7 deletions pkg/capabilities/v2/actions/confidentialrelay/types_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,28 @@ 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",
Owner: validOwnerA,
ExecutionID: validExecutionID,
OrgID: "org-1",
EnclavePublicKey: validEnclavePubKey,
EnclaveConfig: validEnclaveConfig(),
Attestation: "att-a",
Secrets: []SecretIdentifier{
{Key: "alpha", Namespace: "ns-a"},
Expand All @@ -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",
}
}

Expand Down Expand Up @@ -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))
}
Loading