Skip to content

Commit fe29adc

Browse files
authored
feat(kmsClient): ECDSA-attested decrypt, environment presets, and creator-bound ECDSA /secrets (#109)
## Overview This PR makes the `kms-client` CLI usable with ECDSA attestation, adds a named-environment shortcut so connection flags don't have to be retyped, and — most importantly — fixes a security gap in how operators authorize ECDSA `/secrets` requests. It bundles three related changes (each developed spec → plan → TDD, docs under `docs/superpowers/`): 1. **Client: ECDSA-attested `decrypt`** (`cmd/kmsClient`) 2. **Client: `--environment` connection presets** (`cmd/kmsClient`) 3. **Server: bind ECDSA `/secrets` to the app creator + drop the release requirement** (`pkg/node`) > Note: the branch name (`feat/kmsclient-ecdsa-attestation`) predates changes 2 and 3 — the scope grew during review/testing. --- ## 1. ECDSA-attested decrypt (client) The CLI's `decrypt` previously only used the unauthenticated `/app/sign` endpoint. It now optionally authenticates with ECDSA challenge-response attestation against the `/secrets` endpoint. New flags on `decrypt`: - `--attestation` — `""` (default, legacy `/app/sign`) or `ecdsa`. Any other value is a usage error. - `--ecdsa-private-key` — hex-encoded secp256k1 key (optional `0x` prefix). Takes priority over the file flag. - `--ecdsa-private-key-file` — path to a file holding the hex key. When `--attestation ecdsa` is set, the CLI loads the key, generates an ephemeral RSA transit keypair, calls the library's existing `RetrieveSecretsWithOptions` to recover the app private key, then decrypts the supplied ciphertext via `crypto.DecryptForApp`. When unset, behavior is byte-for-byte unchanged. This is a thin wiring layer — no library (`pkg/clients/kmsClient`) changes. ```bash kms-client --avs-address 0x.. decrypt \ --app-id 0x<appAddr> --encrypted-data enc.hex \ --attestation ecdsa --ecdsa-private-key 0x<creatorKey> ``` ## 2. `--environment` connection presets (client) New global flag `--environment` / `-e` fills `--avs-address` and `--operator-set-id` from a named preset, so they don't need to be passed every invocation. - First registered environment: `sepolia` → `avs-address=0x47c9806e7DC4e6fE9a0a2399831F32d06DaE5730`, `operator-set-id=0` (sourced from the operator deployment charts). - **Precedence:** explicit flag > preset > built-in default. `--avs-address` lost its hard `Required: true` and is now required only when no preset supplies it. - **The RPC URL is deliberately NOT part of any preset** — production RPC URLs embed API-key credentials and must not be committed. Users still pass `--rpc-url`. - Resolution happens in a pure, unit-tested helper (`resolveConnection`) and runs at the very top of `createClient`, so a config error (e.g. missing avs-address) fails fast without needing a reachable RPC. ```bash kms-client --environment sepolia --rpc-url https://my-sepolia-rpc/... \ get-pubkey --app-id 0x<appAddr> ``` ## 3. Server: ECDSA `/secrets` binds to the app creator (security fix) **The gap:** ECDSA attestation authenticated *nothing app-specific*. The operator verified the request signature against the **client-supplied** public key, but never tied that key to the app. Because an `appID` is an app contract address with no private key, "prove you control a key" was satisfied by any freshly generated keypair — so anyone could request any app's key material over the ECDSA path. (In practice it failed for unrelated reasons: ECDSA sets `ImageDigest="ecdsa:unverified"`, which never matches a real release, so every ECDSA request against a real app 404'd or 403'd.) **The fix** (`pkg/node/handlers.go`, ECDSA path of `handleSecretsRequest`): - **Owner binding** — derive the signer address from the verified `claims.PublicKey` and require it to equal the app's on-chain creator (`GetAppCreator(appID)`, already on the contract-caller interface). The supplied ECDSA key must be the EOA that deployed/created the app. The `appID` must be a valid contract address. - **No release requirement for ECDSA** — ECDSA is a lightweight testing method. It no longer fetches-and-requires an on-chain release, and skips the image-digest / registry / container-policy checks (all meaningless for ECDSA). Env is **best-effort**: if a release exists, its `encrypted_env`/`public_env` are returned; otherwise empty env is returned alongside the recovered key. The partial signature is always returned on success. - **All other methods unchanged** — gcp / intel / tpm / eigenx-snp keep the full release + digest + registry + container-policy enforcement (guarded by a regression test). HTTP statuses on the ECDSA path: non-address appID → 400; unparseable pubkey → 400; `GetAppCreator` failure → 502; signer ≠ creator → 403; success → 200. `pkg/attestation/ecdsa.go` is intentionally untouched — it already surfaces the verified public key. --- ## Testing - **`cmd/kmsClient`** — `TestLoadECDSAKey` (8 cases: file/flag precedence, `0x` prefix, whitespace, errors) and `TestResolveConnection` / `TestSupportedEnvironmentsString` (preset fill, explicit override, unknown env, missing-avs error). - **`pkg/contractCaller`** — `TestTestableStubGetAppCreator` for the new configurable-creator test hook. - **`pkg/node`** — 6 new ECDSA `/secrets` subtests: owner+env, owner+no-release (→ 200 empty env, the no-404 fix), owner+empty-env-release, wrong-signer (403), non-address appID (400), bad pubkey (400) — plus a `NonECDSAStillRequiresRelease` regression guard proving non-ECDSA enforcement is intact. All suites green locally: `pkg/node`, `pkg/contractCaller`, `cmd/kmsClient` all `ok`. The full `pkg/node` suite (including the anvil-backed persistence tests) passes. ## Docs `cmd/kmsClient/README.md` documents the attestation flags, the `--environment` preset (incl. the RPC-secret rationale), and the ECDSA security model — notably that the key must be the app creator's, and that the attested path no longer needs a release. Design specs and implementation plans live under `docs/superpowers/`. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
2 parents f2c4c32 + fd99a16 commit fe29adc

20 files changed

Lines changed: 3037 additions & 134 deletions

.github/workflows/main.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ jobs:
6868
- name: Install Foundry
6969
uses: foundry-rs/foundry-toolchain@c7450ba673e133f5ee30098b3b54f444d3a2ca2d # v1.8
7070
with:
71-
version: v1.5.1
71+
version: v1.7.1
7272
- name: Run tests
7373
run: |
7474
make deps

cmd/kmsClient/README.md

Lines changed: 83 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -52,17 +52,69 @@ A CLI tool for interacting with EigenX KMS operators to encrypt and decrypt appl
5252
--threshold 2
5353
```
5454

55+
#### Decrypt Data with ECDSA Attestation
56+
57+
Some operator deployments require attestation before serving an application's
58+
key material. The `decrypt` command can authenticate with an ECDSA
59+
challenge-response attestation against the operators' `/secrets` endpoint:
60+
61+
```bash
62+
# ECDSA key passed directly (hex, 0x prefix optional)
63+
./bin/kms-client --avs-address "0x1234..." --operator-set-id 0 \
64+
decrypt --app-id "my-application" --encrypted-data encrypted-data.hex \
65+
--attestation ecdsa --ecdsa-private-key 0xabc123...
66+
67+
# ECDSA key read from a file
68+
./bin/kms-client --avs-address "0x1234..." --operator-set-id 0 \
69+
decrypt --app-id "my-application" --encrypted-data encrypted-data.hex \
70+
--attestation ecdsa --ecdsa-private-key-file ./app-key.hex
71+
```
72+
73+
Decrypt flags:
74+
75+
- `--attestation`: attestation method. Empty (default) uses the
76+
unauthenticated `/app/sign` endpoint; `ecdsa` uses ECDSA challenge-response
77+
attestation against `/secrets`.
78+
- `--ecdsa-private-key`: hex-encoded secp256k1 private key (an optional `0x`
79+
prefix is accepted). Takes priority over `--ecdsa-private-key-file`.
80+
- `--ecdsa-private-key-file`: path to a file containing the hex-encoded key.
81+
Used when `--ecdsa-private-key` is not set.
82+
83+
When `--attestation ecdsa` is set, at least one of `--ecdsa-private-key` or
84+
`--ecdsa-private-key-file` is required.
85+
86+
**Prerequisites for the attested path** (stricter than the default
87+
`/app/sign` flow):
88+
89+
- Operators must run with ECDSA attestation enabled
90+
(`--enable-ecdsa-attestation=true`).
91+
- The app must exist on-chain so the operator can look up its creator. For ECDSA
92+
specifically, a published release is **not** required (env is returned only if
93+
a release exists); the signing key must belong to the app's creator.
94+
95+
**Security caveat:** ECDSA attestation proves only ownership of the ECDSA
96+
private key and the freshness of the challenge. It does **not** prove a TEE
97+
execution environment. The operator binds the ECDSA signer to the app's on-chain
98+
**creator**: the `--ecdsa-private-key` / `--ecdsa-private-key-file` you supply
99+
MUST be the key of the EOA that deployed/created the app, or the request is
100+
rejected with `ecdsa signer is not the app creator`. The attested ECDSA path
101+
does not require an on-chain release; it returns the app's environment only if a
102+
release exists, and otherwise returns empty env alongside the recovered key.
103+
Use ECDSA attestation for development and for operators configured to require
104+
it — not as a production confidentiality guarantee. For production, use a TEE
105+
attestation method (GCP Confidential Space / Intel Trust Authority).
106+
55107
## How It Works
56108

57109
### CLI Tool (This Binary)
58110

59111
1. **Operator Discovery**: Queries the blockchain using AVS address and operator set ID to get operators
60112
2. **Master Public Key**: Queries `/pubkey` endpoint from all operators and computes master public key
61113
3. **Encryption**: Uses IBE where app public key = `H_1(app_id)`
62-
4. **Decryption**: Collects partial signatures from `/app/sign` endpoint (no attestation required)
114+
4. **Decryption**: Collects partial signatures from the `/app/sign` endpoint (no attestation) by default, or from the attested `/secrets` endpoint when `--attestation ecdsa` is set
63115
5. **Fault Tolerance**: Handles operator failures automatically
64116

65-
**Note**: The CLI decrypt command uses `/app/sign` which does NOT require attestation.
117+
**Note**: By default the CLI decrypt command uses `/app/sign`, which does NOT require attestation. Pass `--attestation ecdsa` to use the attested `/secrets` endpoint instead.
66118

67119
### Library (pkg/clients/kmsClient)
68120

@@ -110,8 +162,35 @@ See `examples/ecdsa_attestation.go` for complete implementation.
110162

111163
## Global Options
112164

165+
- `--environment`, `-e`: named connection preset that fills `--avs-address` and `--operator-set-id` (e.g. `sepolia`). Explicit flags override the preset. The RPC URL is never part of a preset.
113166
- `--rpc-url`: Ethereum RPC endpoint (default: http://localhost:8545)
114-
- `--avs-address`: AVS contract address (required)
167+
- `--avs-address`: AVS contract address (required unless provided by `--environment`)
115168
- `--operator-set-id`: Operator set ID to use (default: 0)
116169

117-
All commands automatically discover and interact with the current operator set from the blockchain.
170+
All commands automatically discover and interact with the current operator set from the blockchain.
171+
172+
### Environments
173+
174+
`--environment` (alias `-e`) selects a named connection preset so you don't have
175+
to pass `--avs-address`/`--operator-set-id` on every call:
176+
177+
| Environment | avs-address | operator-set-id |
178+
|-------------|-------------|-----------------|
179+
| `sepolia` | `0x47c9806e7DC4e6fE9a0a2399831F32d06DaE5730` | `0` |
180+
181+
The preset supplies only `--avs-address` and `--operator-set-id`. It does **not**
182+
set `--rpc-url` — production RPC URLs embed API-key credentials, so you must
183+
still pass your own `--rpc-url`. Any flag you pass explicitly overrides the
184+
preset value.
185+
186+
```bash
187+
# Use the sepolia preset; supply your own RPC URL
188+
./bin/kms-client --environment sepolia \
189+
--rpc-url "https://eth-sepolia.example/v2/<key>" \
190+
get-pubkey --app-id "my-application"
191+
192+
# Override the preset's operator-set-id
193+
./bin/kms-client -e sepolia --operator-set-id 1 \
194+
--rpc-url "https://eth-sepolia.example/v2/<key>" \
195+
get-pubkey --app-id "my-application"
196+
```

cmd/kmsClient/environments.go

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
package main
2+
3+
import (
4+
"fmt"
5+
"sort"
6+
"strings"
7+
)
8+
9+
// environment holds the non-secret connection defaults for a named network.
10+
// The RPC URL is intentionally excluded — production RPC URLs embed API-key
11+
// credentials and must not be committed. Users still supply --rpc-url.
12+
type environment struct {
13+
AVSAddress string
14+
OperatorSetID uint32
15+
}
16+
17+
// environments is the registry of known connection presets, selected via
18+
// --environment. Values are sourced from the operator deployment charts.
19+
var environments = map[string]environment{
20+
"sepolia": {
21+
AVSAddress: "0x47c9806e7DC4e6fE9a0a2399831F32d06DaE5730",
22+
OperatorSetID: 0,
23+
},
24+
}
25+
26+
// supportedEnvironmentsString returns the known environment names as a sorted,
27+
// comma-separated string for error and usage messages.
28+
func supportedEnvironmentsString() string {
29+
names := make([]string, 0, len(environments))
30+
for name := range environments {
31+
names = append(names, name)
32+
}
33+
sort.Strings(names)
34+
return strings.Join(names, ", ")
35+
}
36+
37+
// resolveConnection determines the effective AVS address and operator-set id
38+
// from the --environment preset and any explicitly-set flags. Explicitly-set
39+
// flags always win over the preset; the preset wins over the built-in default.
40+
// avsSet/setIDSet report whether the corresponding flag was passed on the
41+
// command line (cli.Context.IsSet).
42+
//
43+
// Resolution:
44+
// - avs-address: explicit flag → preset → else error.
45+
// - operator-set-id: explicit flag → preset → else 0.
46+
func resolveConnection(envName, avsFlag string, avsSet bool, setIDFlag uint32, setIDSet bool) (string, uint32, error) {
47+
var preset environment
48+
havePreset := false
49+
if envName != "" {
50+
p, ok := environments[envName]
51+
if !ok {
52+
return "", 0, fmt.Errorf("unknown environment %q: supported environments are: %s", envName, supportedEnvironmentsString())
53+
}
54+
preset = p
55+
havePreset = true
56+
}
57+
58+
// Resolve AVS address: explicit flag wins, then preset, else error.
59+
avsAddress := ""
60+
switch {
61+
case avsSet:
62+
avsAddress = avsFlag
63+
case havePreset:
64+
avsAddress = preset.AVSAddress
65+
default:
66+
return "", 0, fmt.Errorf("avs-address is required (provide --avs-address or --environment)")
67+
}
68+
69+
// Resolve operator-set id: explicit flag wins, then preset, else 0.
70+
var operatorSetID uint32
71+
switch {
72+
case setIDSet:
73+
operatorSetID = setIDFlag
74+
case havePreset:
75+
operatorSetID = preset.OperatorSetID
76+
default:
77+
operatorSetID = 0
78+
}
79+
80+
return avsAddress, operatorSetID, nil
81+
}

cmd/kmsClient/environments_test.go

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
package main
2+
3+
import (
4+
"strings"
5+
"testing"
6+
7+
"github.com/stretchr/testify/require"
8+
)
9+
10+
func TestResolveConnection(t *testing.T) {
11+
const sepoliaAVS = "0x47c9806e7DC4e6fE9a0a2399831F32d06DaE5730"
12+
13+
t.Run("environment preset fills avs and operator-set-id", func(t *testing.T) {
14+
avs, setID, err := resolveConnection("sepolia", "", false, 0, false)
15+
require.NoError(t, err)
16+
require.Equal(t, sepoliaAVS, avs)
17+
require.Equal(t, uint32(0), setID)
18+
})
19+
20+
t.Run("explicit avs-address overrides preset", func(t *testing.T) {
21+
avs, setID, err := resolveConnection("sepolia", "0xABC", true, 0, false)
22+
require.NoError(t, err)
23+
require.Equal(t, "0xABC", avs)
24+
require.Equal(t, uint32(0), setID)
25+
})
26+
27+
t.Run("explicit operator-set-id overrides preset", func(t *testing.T) {
28+
avs, setID, err := resolveConnection("sepolia", "", false, 2, true)
29+
require.NoError(t, err)
30+
require.Equal(t, sepoliaAVS, avs)
31+
require.Equal(t, uint32(2), setID)
32+
})
33+
34+
t.Run("no environment, explicit avs is used", func(t *testing.T) {
35+
avs, setID, err := resolveConnection("", "0xABC", true, 0, false)
36+
require.NoError(t, err)
37+
require.Equal(t, "0xABC", avs)
38+
require.Equal(t, uint32(0), setID)
39+
})
40+
41+
t.Run("no environment, explicit operator-set-id is used", func(t *testing.T) {
42+
avs, setID, err := resolveConnection("", "0xABC", true, 5, true)
43+
require.NoError(t, err)
44+
require.Equal(t, "0xABC", avs)
45+
require.Equal(t, uint32(5), setID)
46+
})
47+
48+
t.Run("no environment and no avs is an error", func(t *testing.T) {
49+
_, _, err := resolveConnection("", "", false, 0, false)
50+
require.Error(t, err)
51+
require.Contains(t, err.Error(), "avs-address is required")
52+
})
53+
54+
t.Run("unknown environment is an error listing supported names", func(t *testing.T) {
55+
_, _, err := resolveConnection("mainnet", "", false, 0, false)
56+
require.Error(t, err)
57+
require.Contains(t, err.Error(), "unknown environment")
58+
require.Contains(t, err.Error(), "sepolia")
59+
})
60+
}
61+
62+
func TestSupportedEnvironmentsString(t *testing.T) {
63+
got := supportedEnvironmentsString()
64+
require.True(t, strings.Contains(got, "sepolia"), "should list sepolia, got %q", got)
65+
}

0 commit comments

Comments
 (0)