Skip to content

feat(kmsClient): ECDSA-attested decrypt, environment presets, and creator-bound ECDSA /secrets#109

Open
seanmcgary wants to merge 14 commits into
masterfrom
feat/kmsclient-ecdsa-attestation
Open

feat(kmsClient): ECDSA-attested decrypt, environment presets, and creator-bound ECDSA /secrets#109
seanmcgary wants to merge 14 commits into
masterfrom
feat/kmsclient-ecdsa-attestation

Conversation

@seanmcgary

Copy link
Copy Markdown
Member

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.

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: sepoliaavs-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.
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/kmsClientTestLoadECDSAKey (8 cases: file/flag precedence, 0x prefix, whitespace, errors) and TestResolveConnection / TestSupportedEnvironmentsString (preset fill, explicit override, unknown env, missing-avs error).
  • pkg/contractCallerTestTestableStubGetAppCreator 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

@claude

claude Bot commented Jun 23, 2026

Copy link
Copy Markdown

Claude finished @seanmcgary's task in 3m 31s —— View job


Code Review

  • Gather context and understand the PR
  • Review pkg/node/handlers.go (security fix — ECDSA owner binding)
  • Review cmd/kmsClient/main.go (ECDSA decrypt wiring + environment flag)
  • Review cmd/kmsClient/environments.go (environment presets)
  • Review pkg/node/secrets_test.go (new ECDSA test coverage)
  • Review pkg/contractCaller/testhelpers.go (stub changes)
  • Review internal/tests/integration/attestation_methods_test.go
  • Post final review

Summary

This is a well-structured PR that genuinely closes a real security gap (ECDSA attestation was effectively un-gated before this). The implementation is clean and the spec → plan → TDD approach shows in the test coverage. A few items need attention before merge:


Security

1. Nil context in verifyECDSAOwnership — confirmed, new inline comment posted. GetAppCreator(…, nil) doesn't propagate the HTTP request context. GetLatestReleaseAsRelease on the same request correctly uses r.Context(); these two calls should be consistent. The fix is adding ctx context.Context to verifyECDSAOwnership's signature and threading it through as &bind.CallOpts{Context: ctx}. [Previously raised by reviewer; confirming.]

2. --ecdsa-private-key flag exposes key in process listings — the Usage string should include a visible warning that the value appears in ps aux / shell history / audit logs and that --ecdsa-private-key-file is the safer production path. [Previously raised; confirming.]


Correctness

3. os.Stat silent fallthrough on non-ENOENT errors in parseEncryptedInput (main.go:313): a permission-denied stat error silently falls through to hex decode, producing a confusing "failed to decode hex data" error. Prefer errors.Is(statErr, os.ErrNotExist). [Previously raised; confirming.]

4. fmt.Printf("Using encrypted input %s\n", input) on line 325 — new inline comment posted. When --encrypted-data is a large hex string (the normal case when encrypt --output - is not used), this dumps the entire blob to stdout. It should be removed or made opt-in.


Testing

5. Missing 502 test for GetAppCreator errorverifyECDSAOwnership has a 502 branch when GetAppCreator itself errors, but TestableContractCallerStub.GetAppCreator never returns an error — unknown apps return (zero_addr, nil). A signer ≠ zero address comparison then fires a 403 instead. The 502 path has no test. [Previously raised; confirming.] To cover it you'd need either a dedicated error-returning stub or a new SetAppCreatorError(app, err) helper on TestableContractCallerStub.

6. postECDSASecrets helper omits Challenge — new inline comment posted. Not a bug (the stub bypasses real ECDSA verification), but the omission is unexplained and a future reader may assume challenge coverage exists at the unit level when it actually lives in the integration suite.


Nits

  • environments.go:19 — source of the sepolia AVS address not captured in code [previously raised]: a short inline comment ("sourced from operator deployment charts") saves future grep sessions.
  • Preset AVS address not validated — new inline comment posted on resolveConnection. Low-risk since the value is hardcoded, but a malformed entry would surface with a confusing RPC error rather than a clear config error.

What's solid

  • The security model is correct: ECDSA binds to the on-chain creator rather than an arbitrary key, and the creator comparison uses the already-verified claims.PublicKey (not the raw request field).
  • The StubECDSAMethod correctly echoes request.PublicKeyclaims.PublicKey, so unit tests for verifyECDSAOwnership work exactly as described.
  • Regression guard testSecretsNonECDSAStillRequiresRelease cleanly prevents silent erosion of non-ECDSA enforcement.
  • writeSecretFile explicitly Chmod(0600) before writing to handle pre-existing files — the comment explains exactly why os.WriteFile alone is insufficient.
  • resolveConnection is a pure function with good unit coverage; the explicit-flag-over-preset precedence logic is clear and correct.
  • decryptWithECDSAAttestation correctly generates an ephemeral RSA keypair per invocation rather than reusing a static key.

The blocking items are #1 (nil context) and #3 (stat error handling); the others are low-severity improvements.

Comment thread pkg/node/handlers.go
}
signer := ethcrypto.PubkeyToAddress(*pub)

creator, err := s.node.baseContractCaller.GetAppCreator(common.HexToAddress(appID), nil)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Context propagation] GetLatestReleaseAsRelease (called a few lines below) correctly uses r.Context(), but GetAppCreator here passes nil opts. If the HTTP request is cancelled the on-chain call won't abort. You'd need to thread the context through verifyECDSAOwnership, e.g.:

func (s *Server) verifyECDSAOwnership(ctx context.Context, appID string, publicKey []byte) (int, error) {
    ...
    creator, err := s.node.baseContractCaller.GetAppCreator(
        common.HexToAddress(appID),
        &bind.CallOpts{Context: ctx},
    )

Low severity for now (the chain call is fast), but consistent with how the rest of the handler handles context.

Comment thread cmd/kmsClient/main.go
// 0x-prefixed output that `encrypt --output` writes; TrimSpace handles trailing
// newlines from editors or `echo`.
func parseEncryptedInput(input string) ([]byte, error) {
if _, statErr := os.Stat(input); statErr == nil {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Bug - subtle] os.Stat can fail for reasons other than "file does not exist" (e.g. permission denied). In those cases the code silently falls through and tries to interpret input as a hex string — which will produce a confusing "failed to decode hex data" error instead of "permission denied reading file".

Prefer checking errors.Is(statErr, os.ErrNotExist) explicitly:

if _, statErr := os.Stat(input); statErr == nil {
    // file path
} else if !errors.Is(statErr, os.ErrNotExist) {
    return nil, fmt.Errorf("cannot stat encrypted data path: %w", statErr)
} else {
    // hex string path
}

Comment thread cmd/kmsClient/main.go
Usage: "Output file for decrypted data",
Value: "",
},
&cli.StringFlag{

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Security - Medium] Passing a raw ECDSA private key as a CLI flag (--ecdsa-private-key) exposes the key in:

  • ps aux / /proc/<pid>/cmdline on Linux
  • shell history files (bash/zsh)
  • system audit logs

--ecdsa-private-key-file is the safer default. Consider adding a Usage warning here, or making the file path the recommended path and the inline hex an explicit "not for production" shortcut:

Usage: "Hex-encoded secp256k1 private key for ECDSA attestation. WARNING: visible in process listings and shell history — prefer --ecdsa-private-key-file for production use.",

Comment thread pkg/node/secrets_test.go
creatorKey, _ := ethcrypto.GenerateKey()
f.contractCallerStub.SetAppCreator(appAddr, ethcrypto.PubkeyToAddress(creatorKey.PublicKey))

wrongKey, err := ethcrypto.GenerateKey()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Missing test coverage] The verifyECDSAOwnership path returns HTTP 502 when GetAppCreator itself errors. There's no test for this branch. Worth adding a subtest that configures the stub to return an error (via a mock or a short-circuit in TestableContractCallerStub) and asserts the 502. Without it, a regression that swallows the error or maps it to the wrong status would go undetected.

// GetAppCreator returns the configured creator for an app, or the zero address
// if none was set. It never errors — release lookups and creator lookups are
// independent in tests.
func (m *TestableContractCallerStub) GetAppCreator(app common.Address, opts *bind.CallOpts) (common.Address, error) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Testing note] GetAppCreator here returns (common.Address{}, nil) — the zero address — when no creator has been set. The zero address is a valid common.Address, so calling verifyECDSAOwnership without first calling SetAppCreator will produce a 403 (signer ≠ zero address) rather than a 502. This is acceptable test behaviour, but it's worth documenting that tests that want to exercise the "app not found" → 502 path need a stub that returns an error, not this one.

var environments = map[string]environment{
"sepolia": {
AVSAddress: "0x47c9806e7DC4e6fE9a0a2399831F32d06DaE5730",
OperatorSetID: 0,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: the source of this address is mentioned only in the PR description ("operator deployment charts"). A short inline comment here would save future maintainers a grep:

// sepolia: canonical AVS deployment, sourced from operator deployment charts.
"sepolia": {

Comment thread cmd/kmsClient/main.go
return data, nil
}

fmt.Printf("Using encrypted input %s\n", input)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Noise / potential output pollution] This Printf prints the raw encrypted-data hex string to stdout. When the user passes a large ciphertext inline (which is common — encrypt writes 0x + hex and decrypt consumes the same), this line dumps the entire hex blob to the terminal before the decryption result, which can be multiple kilobytes of noise.

Suggest removing it, or replacing it with a shorter confirmation:

Suggested change
fmt.Printf("Using encrypted input %s\n", input)
fmt.Println("Treating encrypted-data as hex string (not a file path)")

If you want to keep the full value, gate it on a --verbose flag so it's opt-in. As written it will fire for every non-file invocation.

Comment thread pkg/node/handlers.go
}
signer := ethcrypto.PubkeyToAddress(*pub)

creator, err := s.node.baseContractCaller.GetAppCreator(common.HexToAddress(appID), nil)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Confirmed: context propagation] Confirming the existing review comment: GetAppCreator is passed nil opts here while GetLatestReleaseAsRelease (called just below) correctly uses r.Context(). If the HTTP request is cancelled between the two calls, the chain call won't abort. Since verifyECDSAOwnership is a pure helper with no access to the request, the cleanest fix is adding a ctx context.Context parameter:

func (s *Server) verifyECDSAOwnership(ctx context.Context, appID string, publicKey []byte) (int, error) {
    ...
    creator, err := s.node.baseContractCaller.GetAppCreator(
        common.HexToAddress(appID),
        &bind.CallOpts{Context: ctx},
    )

Then the call site becomes s.verifyECDSAOwnership(r.Context(), req.AppID, claims.PublicKey). Fix this →

Comment thread pkg/node/secrets_test.go
req := kmsTypes.SecretsRequestV1{
AppID: appID,
AttestationMethod: "ecdsa",
Attestation: []byte("sig-placeholder"), // StubECDSAMethod ignores it

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Testing clarity] The postECDSASecrets helper omits the Challenge field. This is fine because newTestSecretsFixture wires in NewStubManager() (which uses StubECDSAMethod), and StubECDSAMethod.Verify() ignores the challenge entirely — it just echoes request.PublicKey back into claims.PublicKey. So the unit tests here correctly exercise the handler's ownership-binding logic in isolation.

The tradeoff is that no unit test in pkg/node exercises the challenge expiry / replay path for ECDSA; that coverage comes from the integration tests in internal/tests/integration which use the real ECDSAAttestationMethod. Consider adding a brief comment here so a future reader doesn't wonder why Challenge is missing:

// No Challenge field — StubECDSAMethod bypasses real ECDSA verification;
// challenge expiry and replay are covered by integration tests.

// Resolution:
// - avs-address: explicit flag → preset → else error.
// - operator-set-id: explicit flag → preset → else 0.
func resolveConnection(envName, avsFlag string, avsSet bool, setIDFlag uint32, setIDSet bool) (string, uint32, error) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Minor: preset AVS address not validated] resolveConnection propagates the preset's AVSAddress directly without checking whether it's a valid hex address. Since the value is hardcoded today this is low risk, but if someone adds a malformed entry to environments the error will surface later during the RPC call with a less helpful message than "invalid AVS address in preset".

Consider a cheap guard when a preset is used:

if havePreset && !common.IsHexAddress(avsAddress) {
    return "", 0, fmt.Errorf("preset %q has invalid avs-address %q", envName, avsAddress)
}

This doesn't affect the explicit-flag path (the user controls their own input) and is essentially free for the hardcoded preset path.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant