Skip to content

Chamber Key Management

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.

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: 600

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

The key-management routes are documented interactively in the API Reference. This page focuses on behavior that matters operationally.

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:

  1. Operators submit deposit data to the Ethereum deposit contract.
  2. The validator appears on the beacon chain after the activation queue.
  3. 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.

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.

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.

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.

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:

  • activeinactive — 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 path
  • inactiveactive — decrypts the DynamoDB row with the master key and inserts the keypair into the signing cache
  • PATCH on a KeySource::Memory key returns a per-key error (memory keys have no DynamoDB row to flip)
  • PATCH on a missing pubkey returns not_found per-key, not 404 overall

For each pubkey:

  • KeySource::DynamoDb → marks the DynamoDB row inactive and removes the key from the in-memory signing cache. The encrypted row remains for later reactivation or audit.
  • KeySource::Memory → removes from the in-memory DashMap only (no DynamoDB row exists)
  • KeySource::Filesystem → returns a per-key error (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.

Every chamber.keys.* route is gated by:

  1. The configured auth scope (see Auth Policies & Tokens)
  2. require_unsealed_signer — returns 503 if the signer is in any state other than Unsealed
  3. 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.