Generate, import, list, activate, deactivate, and remove validator keys through the Chamber API
The Chamber Key Management API is the operator-facing lifecycle surface over loaded validator keys and DynamoDB-backed key records. Seven operations live under /api/v1/chamber/keys/*; write routes are independently gated:
| Method | Path | Operation | Scope | Enable flag |
|---|---|---|---|---|
GET |
/api/v1/chamber/keys |
List | chamber_keys_list |
Always mounted |
POST |
/api/v1/chamber/keys/generate |
Generate | chamber_keys_generate |
chamber.keys.generate.enabled |
POST |
/api/v1/chamber/keys/generate-keys |
Generate keys only | chamber_keys_generate_bls |
chamber.keys.generate_keys.enabled |
POST |
/api/v1/chamber/keys/deposit-data |
Deposit data | chamber_keys_deposit_data |
chamber.keys.deposit_data.enabled |
POST |
/api/v1/chamber/keys |
Import | chamber_keys_import |
chamber.keys.import.enabled |
PATCH |
/api/v1/chamber/keys |
Activate / deactivate | chamber_keys_patch |
chamber.keys.lifecycle.enabled |
DELETE |
/api/v1/chamber/keys |
Deactivate or remove | chamber_keys_delete |
chamber.keys.lifecycle.enabled |
All routes require an authenticated client token with the listed scope. All can return 503 Service Unavailable when the signer is not fully unsealed because the handler calls require_unsealed_signer before storage or key mutation starts.
Configuration
Section titled “Configuration”chamber: keys: list: max_concurrent_requests: 16 generate: enabled: true max_items_per_request: 100 max_concurrent_requests: 16 request_timeout_seconds: 600 generate_keys: enabled: true max_items_per_request: 100 deposit_data: enabled: true max_items_per_request: 100 backup: recipients: [] # age public keys; non-empty list enables age-encrypted mnemonic backup import: enabled: true max_items_per_request: 100 max_concurrent_requests: 16 request_timeout_seconds: 600 lifecycle: enabled: true max_items_per_request: 500 max_concurrent_requests: 16 request_timeout_seconds: 600Per-verb fields
Section titled “Per-verb fields”The generated Configuration Reference is the source of truth for defaults.
| Field | Type | Default | Notes |
|---|---|---|---|
list.max_concurrent_requests |
NonZero usize | 16 |
Tower concurrency cap. List is always mounted |
generate.enabled |
bool | false |
Boot validation refuses true without key_sources.dynamodb |
generate.max_items_per_request |
NonZero usize | 100 |
Maximum count per request |
generate.max_concurrent_requests |
NonZero usize | 16 |
Tower request-level cap. Per-request batch parallelism is std::thread::available_parallelism() and not operator-configurable |
generate.request_timeout_seconds |
NonZero u64 | 600 |
Per-request timeout |
generate_keys.enabled |
bool | false |
Keys only, no deposit data. Boot validation refuses true without key_sources.dynamodb |
generate_keys.max_items_per_request |
NonZero usize | 100 |
Maximum count per request |
deposit_data.enabled |
bool | false |
Deferred deposit-data computation. Boot validation refuses true without key_sources.dynamodb |
deposit_data.max_items_per_request |
NonZero usize | 100 |
Maximum pubkeys per request |
backup.recipients |
Vec<String> |
[] |
age public keys (age1...) for the BIP-39 mnemonic backup. A non-empty list enables backup; an empty/absent list disables it. On Nitro, lockable into the EIF — see Pinning configuration |
import.enabled |
bool | false |
Memory-only imports work without a mutable backend; persistent imports require key_sources.dynamodb |
import.max_items_per_request |
NonZero usize | 100 |
Hard cap on combined keystores + raw_keys length |
import.max_concurrent_requests |
NonZero usize | 16 |
Tower request-level cap |
import.request_timeout_seconds |
NonZero u64 | 600 |
Per-request timeout (long because scrypt + BLS per key) |
lifecycle.enabled |
bool | false |
Memory-key deletes work without a mutable backend; PATCH on memory keys returns a per-key error |
lifecycle.max_items_per_request |
NonZero usize | 500 |
Higher than import because PATCH/DELETE is one UpdateItem per key with no crypto |
lifecycle.max_concurrent_requests |
NonZero usize | 16 |
Tower request-level cap |
lifecycle.request_timeout_seconds |
NonZero u64 | 600 |
Per-request timeout |
Operations
Section titled “Operations”The key-management routes are documented interactively in the API Reference. This page focuses on behavior that matters operationally.
Generate
Section titled “Generate”Derives BLS validator keys via BIP-39 mnemonic → EIP-2333 → bls::Keypair, builds and self-verifies the DepositData, optionally encrypts the mnemonic with age to one or more recipients, and persists each key as KeyStatus::Inactive. The deposit-data root is recorded as a tamper-protected marker on the key’s DynamoDB row in the same conditional write, so the deferred Deposit Data route can recognize that the artifact already exists.
Generated keys start inactive so that:
- Operators submit deposit data to the Ethereum deposit contract.
- The validator appears on the beacon chain after the activation queue.
- Only then does the operator activate signing.
Signing against an inactive key fails at the GVR/key-resolution boundary — the key is in DynamoDB but is not loaded into the live signer cache.
Mnemonic Backup
Section titled “Mnemonic Backup”When chamber.keys.backup.recipients is non-empty (shared by Generate and Generate Keys), the BIP-39 mnemonic for each generated key is encrypted with each recipient’s age public key and stored alongside the key in DynamoDB. To recover: decrypt with the corresponding age private key (kept offline), re-derive via EIP-2333, re-import. This is independent of KMS — your last-resort recovery path if the master key becomes unreconstructable.
Generation is not a transactional batch. Each key is generated and inserted independently. If any derivation, deposit-data, backup, or storage step fails, the HTTP handler returns an error response instead of a partial success body; already completed inserts are not rolled back.
Generate Keys
Section titled “Generate Keys”POST /api/v1/chamber/keys/generate-keys derives and persists BLS validator keys exactly like Generate — BIP-39 → EIP-2333 → bls::Keypair, mnemonic optionally age-encrypted, each key stored KeyStatus::Inactive — but produces no deposit data: the response is the list of generated public keys only, and no deposit-data marker is written.
Use this when withdrawal credentials or the deposit amount are not yet known at generation time. Compute deposit data later, per pubkey, via the Deposit Data route. The shared chamber.keys.backup block applies here identically to Generate.
Deposit Data
Section titled “Deposit Data”POST /api/v1/chamber/keys/deposit-data computes DepositData for already-persisted validator keys — the deferred counterpart to Generate. It takes an array of pubkeys plus shared withdrawal_credentials (0x02 Pectra prefix) and amount, fetches each stored secret, signs, and records a tamper-protected marker on the key’s DynamoDB row.
The six whole-request gates — scope, seal, batch-count cap, the force_recompute_on_conflict kill switch (rejected unless chamber.keys.deposit_data.allow_recompute is true), withdrawal-credentials/amount validation, and duplicate-pubkey rejection — return a non-200. Every other result is reported per pubkey with HTTP 200 and one of these statuses:
| Status | Meaning |
|---|---|
computed |
Marker written for the first time; the response carries the deposit-data body |
already_computed |
An identical marker — same (withdrawal_credentials, amount, fork_version) — already exists; no write. The body is re-derived deterministically |
conflict |
A marker exists with different parameters; no write. The on-file marker is returned under existing. This is the default — it protects against silent divergence |
recomputed |
force_recompute_on_conflict: true overwrote a differing marker; the replaced marker is returned under replaced and the overwrite is logged at WARN |
not_found |
No DynamoDB row exists for the pubkey |
integrity_failure |
The stored marker’s root does not match the re-derived deposit-data root — the row was tampered with. Logged at ERROR |
concurrent_modification |
A racing writer changed the row mid-compute; the conditional write lost after bounded retries. Safe to retry |
The marker is HMAC-bound into the key row, so a marker injected or altered out-of-band fails verification on read (integrity_failure) instead of being trusted. Writes use DynamoDB conditional expressions (optimistic concurrency) on both the marker precondition and the row HMAC, so a generate and a concurrent deposit-data write cannot silently clobber one another.
Import
Section titled “Import”Accepts EIP-2335 keystores and raw hex BLS keys. Keystores are decrypted in parallel, then the handler either writes to DynamoDB or inserts only into the in-memory DashMap.
Import supports two storage modes:
storage.persist |
KeySource | Persists across restart? | Requires key_sources.dynamodb |
|---|---|---|---|
true (default) |
DynamoDB |
yes | yes |
false |
Memory |
no | no (works even on a filesystem-only signer) |
storage.status defaults to active. For persistent imports, active writes DynamoDB and immediately loads the key into the signer cache; inactive writes DynamoDB only. storage.persist=false only supports active memory keys, because there is no persistence layer to remember inactive status.
Memory mode is also the path the Key Manager API takes — different surface, identical outcome for KeySource::Memory keys.
Lists validator-key metadata. The route is always mounted.
By default, GET /api/v1/chamber/keys uses status=active and returns every key currently loaded into the signer cache, regardless of source (filesystem, dynamodb, or memory).
Use ?status=inactive to list inactive DynamoDB rows. Use ?status=all to list active and inactive DynamoDB rows when a mutable backend exists; without DynamoDB, all falls back to the loaded-key view and inactive returns an empty list. Inactive keys are never loaded into the signer cache, so operators can audit generated-but-not-yet-activated keys without making them signable.
Patch applies per-key status transitions:
active→inactive— removes the key from the in-memory signing cache. The DynamoDB row stays; the encrypted blob is untouched. Subsequent re-activation round-trips through the same decryption pathinactive→active— decrypts the DynamoDB row with the master key and inserts the keypair into the signing cache- PATCH on a
KeySource::Memorykey returns a per-key error (memory keys have no DynamoDB row to flip) - PATCH on a missing pubkey returns
not_foundper-key, not 404 overall
Delete
Section titled “Delete”For each pubkey:
KeySource::DynamoDb→ marks the DynamoDB rowinactiveand removes the key from the in-memory signing cache. The encrypted row remains for later reactivation or audit.KeySource::Memory→ removes from the in-memoryDashMaponly (no DynamoDB row exists)KeySource::Filesystem→ returns a per-keyerror(filesystem keys are readonly). Filesystem keys are immutable from HTTP; remove the file and restart
The response includes an EIP-3076 slashing-protection interchange for keys that were successfully deleted. If the export fails, deletion still succeeds and slashing_protection is omitted.
Auth and route layering
Section titled “Auth and route layering”Every chamber.keys.* route is gated by:
- The configured auth scope (see Auth Policies & Tokens)
require_unsealed_signer— returns503if the signer is in any state other thanUnsealed- Tower load-shedding and concurrency middleware
List uses LoadShedLayer plus ConcurrencyLimitLayer(list.max_concurrent_requests). Generate, import, and lifecycle routes add TimeoutLayer(request_timeout_seconds) because they can perform heavier storage or crypto work.
The 503-sealed response is documented on every chamber.keys endpoint in the OpenAPI spec.
Next Steps
Section titled “Next Steps”- DynamoDB key source — the storage layer this API operates on
- Seal State Machine — the master-key reconstruction the routes depend on
- Auth Policies & Tokens — policies, scopes, tokens
- API Reference — interactive OpenAPI spec

