Skip to content

Seal & Unseal Operations Guide

This runbook covers every ceremony you’ll perform as an operator: first boot, unsealing after restart, emergency seal, and all rotation procedures. For the concepts behind each state and mode, see Seal & Unseal Ceremony.

The seal/unseal system only applies when you’re using the DynamoDB key source. Filesystem keystores load directly at startup and aren’t affected.

Before running any ceremony, confirm:

  • DynamoDB table — provisioned via the terraform/dynamodb-keystore module
  • KMS keys — created via the same Terraform module (one per AWS region or account for cross-region redundancy)
  • IAM permissions — the signer’s role has kms:Encrypt, kms:Decrypt, and kms:GenerateDataKey on each KMS key, plus DynamoDB read/write on the table
  • Binary installedcontainment-chamber is on your $PATH
  • Config file readykey_sources.dynamodb.table is set; see Configuration Reference
  • Auth policies — configure auth_policies in your config file with scopes for seal operations. Without this, operator seal, operator status, and operator rotate commands return 401.
auth_policies:
signing-client:
token: "env:SIGNING_TOKEN"
allowed_scopes:
- sign
- public_keys
ops-team:
token: "env:CC_AUTH_TOKEN"
allowed_scopes:
- chamber_seal
- chamber_rotate
- chamber_status

See Access Control for all available scopes.


Use this mode when you trust your AWS IAM posture and want fully automated restarts. No operator passphrases are required.

1. Start the signer.

Terminal window
containment-chamber --config config.yaml

On first boot, the signer logs a one-time setup token:

WARN Setup token generated (save securely, one-time use) setup_token=cc-setup-Xk9mP2...

Save this token from the logs. It’s generated once and never stored.

2. Verify the signer is ready for initialization.

Terminal window
containment-chamber operator status \
--auth-token "$AUTH_TOKEN" \
--signer-url http://localhost:9000
{"seal": {"status": "uninitialized"}, "keys": null}

3. Initialize with your KMS keys.

Terminal window
containment-chamber operator init \
--setup-token "$SETUP_TOKEN" \
--signer-url http://localhost:9000 \
--kms-key-arns "arn:aws:kms:us-east-1:111111111111:key/aaa...,arn:aws:kms:eu-west-1:222222222222:key/bbb...,arn:aws:kms:ap-southeast-1:333333333333:key/ccc..." \
--kms-threshold 2

The --kms-threshold sets how many KMS keys must be available to reconstruct the master key. A 2-of-3 setup tolerates one unavailable region.

4. Confirm the signer is unsealed.

{
"status": "unsealed",
"validators_loaded": 42
}

The signer is now operational. On every subsequent restart, KMS unseal happens automatically in the background — no human action needed.


Use this mode when you want human-in-the-loop protection. Even a full KMS compromise can’t unseal the signer without operator passphrases.

1. Start the signer.

Terminal window
containment-chamber --config config.yaml

Save the setup token from the logs.

2. Verify the signer is in uninitialized state (same as KMS-only above).

3. Initialize with operators.

Terminal window
containment-chamber operator init \
--setup-token "$SETUP_TOKEN" \
--signer-url http://localhost:9000 \
--kms-key-arns "arn:aws:kms:us-east-1:111111111111:key/aaa...,arn:aws:kms:eu-west-1:222222222222:key/bbb..." \
--kms-threshold 2 \
--unseal-threshold 2 \
--operators "alice,bob,carol"

The --unseal-threshold sets how many operators must submit passphrases to unseal. A 2-of-3 setup tolerates one unavailable operator.

The response includes a registration token for each operator:

{
"status": "awaiting_registration",
"operator_tokens": {
"alice": "cc-reg-A1b2C3d4...",
"bob": "cc-reg-E5f6G7h8...",
"carol": "cc-reg-I9j0K1l2..."
}
}

4. Distribute registration tokens to each operator.

The person running operator init is the ceremony coordinator. They receive all registration tokens in the response and must distribute each token to its corresponding operator through a secure, out-of-band channel.

For each operator, send them:

  • Their operator name (e.g., alice)
  • Their registration token (starts with cc-reg-)
  • The signer URL

Use a secure channel: Signal, encrypted email, or your organization’s secrets manager. Never send tokens via plaintext email, Slack, or other unencrypted channels.

If a token is compromised before the operator registers, run operator init-reset to cancel the entire initialization and start over with fresh tokens.

5. Each operator registers their passphrase.

Terminal window
containment-chamber operator register \
--operator alice \
--registration-token "$ALICE_TOKEN" \
--credential-id password-1 \
--signer-url http://localhost:9000
# Prompts for passphrase interactively (minimum 16 characters)

The --credential-id names this credential. You can add more credentials later (see Registering Multiple Credentials). The passphrase is never stored — only the Argon2id-derived key is persisted in DynamoDB. If an operator loses all their credentials, their share is permanently inaccessible. Plan your threshold accordingly.

While operators are still registering:

{"registered": 1, "remaining": 2}

6. After all operators register, the signer transitions to sealed.

{"registered": 3, "remaining": 0, "status": "sealed"}

The setup token is now invalidated. Proceed to the unseal ceremony below.


After initial registration, each operator can add more credentials to their share. This lets them unseal with a password, a YubiKey, or a secrets manager entry — whichever is available at the time.

Adding a second password credential:

Terminal window
containment-chamber operator credentials add \
--operator alice \
--existing-credential-id password-1 \
--new-credential-id password-2 \
--signer-url http://localhost:9000
# Prompts for existing passphrase, then new passphrase

Adding a YubiKey credential (see YubiKey Setup for device configuration):

Terminal window
containment-chamber operator credentials add \
--operator alice \
--existing-credential-id password-1 \
--new-credential-id yubikey-1 \
--yubikey \
--signer-url http://localhost:9000
# Prompts for existing passphrase; YubiKey performs challenge-response automatically

After a restart in unseal mode, KMS unseal happens automatically in the background. The signer transitions to kms_unsealed — signing is still unavailable at this point. Operators then submit their passphrases to complete the ceremony.

1. Check the current state.

Terminal window
containment-chamber operator status \
--auth-token "$AUTH_TOKEN" \
--signer-url http://localhost:9000

You should see kms_unsealed. If you see sealed, KMS unseal is still in progress — wait a moment and check again.

2. Each operator submits their passphrase.

Terminal window
containment-chamber operator unseal \
--operator alice \
--signer-url http://localhost:9000
# Prompts for passphrase

No --credential-id is needed. The server tries all registered credentials for that operator and accepts the first one that decrypts the share. Operators can use whichever credential is convenient — password, YubiKey, or secrets manager.

Operators can submit in any order. While below threshold:

{"shares_received": 1, "shares_required": 2}

3. Once the threshold is reached, the signer unseals.

{"status": "unsealed", "validators_loaded": 42}

Signing is now available.

Partial unseal timeout. By default, accumulated shares expire after 30 minutes if the ceremony isn’t completed. Configure this in your YAML:

key_sources:
dynamodb:
unseal_timeout_minutes: 30 # 0 = disabled

Operators can add, remove, and list their credentials at any time. All credential management operations require proving an existing credential — there’s no admin override.

Add a credential:

Terminal window
containment-chamber operator credentials add \
--operator alice \
--existing-credential-id password-1 \
--new-credential-id yubikey-1 \
--yubikey \
--signer-url http://localhost:9000

Remove a credential:

Terminal window
containment-chamber operator credentials remove \
--operator alice \
--existing-credential-id password-1 \
--remove-credential-id old-password \
--signer-url http://localhost:9000

The server rejects removal of the last credential. An operator must always have at least one credential registered.

List credentials:

Terminal window
containment-chamber operator credentials list \
--operator alice \
--signer-url http://localhost:9000
{
"operator": "alice",
"credential_ids": ["password-1", "yubikey-1"]
}

Seal the signer immediately to stop all signing. Use this for suspected compromise, before maintenance, or before rotating credentials.

Terminal window
containment-chamber operator seal \
--auth-token "$AUTH_TOKEN" \
--signer-url http://localhost:9000
{"status": "sealed"}

The master key is zeroized from memory. All subsequent signing requests return 503. In-flight requests that already loaded the signer reference complete normally — the seal takes effect for new requests only.

To resume signing, restart the signer (KMS unseal happens automatically) and, in unseal mode, run the operator ceremony.


Rotate to a new set of KMS keys without interrupting signing. Use this to add or remove keys, change the threshold, or move to keys in different AWS accounts.

Terminal window
containment-chamber operator rotate kms \
--auth-token "$AUTH_TOKEN" \
--signer-url http://localhost:9000 \
--kms-key-arns "arn:aws:kms:us-east-1:111111111111:key/new-aaa...,arn:aws:kms:eu-west-1:222222222222:key/new-bbb..." \
--kms-threshold 2
{"status": "ok", "shares": 2, "threshold": 2}

The rotation validates KMS access (encrypt + decrypt roundtrip) for each new key before committing. If any key fails validation, the rotation is rejected and the existing shares remain unchanged. Signing continues throughout.


Replace the operator set with new operators and/or a new threshold. Signing continues throughout — the signer stays in awaiting_rotation, which still serves signing requests.

1. Start the rotation.

Terminal window
containment-chamber operator rotate unseal \
--auth-token "$AUTH_TOKEN" \
--signer-url http://localhost:9000 \
--unseal-threshold 2 \
--operators "dave,eve,frank"
{
"status": "awaiting_rotation",
"operator_tokens": {
"dave": "cc-reg-M3n4O5p6...",
"eve": "cc-reg-Q7r8S9t0...",
"frank": "cc-reg-U1v2W3x4..."
}
}

2. Distribute tokens to each new operator via the same secure channels used during initial setup (see First Boot: Unseal Mode for distribution guidance). If a token is compromised, cancel the rotation with operator rotate unseal-cancel.

3. Each new operator registers their passphrase.

Terminal window
containment-chamber operator register \
--operator dave \
--registration-token "$DAVE_TOKEN" \
--signer-url http://localhost:9000
# Prompts for passphrase

4. Once all new operators register, the rotation completes.

The signer returns to unsealed. The old operator set is replaced.

To cancel a rotation in progress and restore the previous operator set:

Terminal window
containment-chamber operator rotate unseal-cancel \
--auth-token "$AUTH_TOKEN" \
--signer-url http://localhost:9000

New operators can register while the signer is in awaiting_rotation — signing continues throughout. The same --credential-id flag applies, and operators can add multiple credentials immediately after their initial registration:

Terminal window
# Initial registration during rotation
containment-chamber operator register \
--operator dave \
--registration-token "$DAVE_TOKEN" \
--credential-id password-1 \
--signer-url http://localhost:9000
# Add a YubiKey credential right after
containment-chamber operator credentials add \
--operator dave \
--existing-credential-id password-1 \
--new-credential-id yubikey-1 \
--yubikey \
--signer-url http://localhost:9000

Switch between kms_only and unseal modes without restarting. Signing continues during the switch.

Terminal window
containment-chamber operator rotate mode \
--auth-token "$AUTH_TOKEN" \
--signer-url http://localhost:9000 \
--mode unseal \
--unseal-threshold 2 \
--operators "alice,bob,carol"

The signer enters awaiting_rotation. New operators register using operator register with their tokens. Once all register, the signer returns to unsealed in unseal mode.


When you deploy a new version of containment-chamber:

KMS-only mode: No action needed. The signer auto-unseals on restart using KMS.

Unseal mode without TEE: No action needed. KMS auto-unseal happens, then operators run the unseal ceremony as usual.

Unseal mode with TEE (Nitro Enclave): A binary update changes the enclave measurements (PCR0, PCR2). The TEE sealed blob from the previous binary will fail to decrypt. The signer falls back to the standard KMS + operator ceremony automatically. After operators unseal, a new TEE sealed blob is created for the updated binary.


For automated workflows, pass passphrases via stdin instead of the interactive prompt:

Terminal window
echo "my-secure-passphrase-here" | containment-chamber operator unseal \
--operator alice \
--signer-url http://localhost:9000 \
--passphrase-stdin

The same --passphrase-stdin flag works for operator register.


Token typeLifetimeHow to get it
Setup tokenOne-time, logged on first bootRestart the signer process — a new token is generated on every startup while in uninitialized state
Registration tokenSingle-use per operatorPrinted in operator init or operator rotate unseal response
Auth tokenLong-lived, configured by youSet in auth_policies in your config file

Auth tokens in config — a typical production setup separates signing clients from operators:

auth_policies:
signing-client:
token: "env:SIGNING_TOKEN"
allowed_scopes:
- sign
- public_keys
ops-team:
token: "env:CC_AUTH_TOKEN"
allowed_scopes:
- chamber_seal
- chamber_rotate
- chamber_status
key-manager:
token: "env:KEY_MANAGER_TOKEN"
allowed_scopes:
- chamber_keys_generate
- chamber_keys_write
- chamber_keys_list
- chamber_keys_delete

Using env:VAR_NAME to avoid plaintext tokens in commands:

Terminal window
export CC_AUTH_TOKEN="my-operator-token-here"
containment-chamber operator status \
--auth-token "env:CC_AUTH_TOKEN" \
--signer-url http://localhost:9000

This works for all --auth-token, --setup-token, and --registration-token flags. The value is resolved from the environment at runtime and never appears in process listings.


503 on signing requests — “signer is sealed”

The signer is in sealed or kms_unsealed state. Check status and run the unseal ceremony:

Terminal window
containment-chamber operator status --auth-token "$AUTH_TOKEN" --signer-url http://localhost:9000

409 — “already initialized”

The signer has already been initialized. Use operator rotate kms, operator rotate unseal, or operator rotate mode instead of operator init.

Unseal timeout expired

Accumulated shares were cleared because the ceremony wasn’t completed within unseal_timeout_minutes. Start over — each operator submits their passphrase again from the beginning.

“invalid KMS ARN”

Check the ARN format: arn:aws:kms:<region>:<account-id>:key/<key-id>. The region is extracted from the ARN, so it must be correct. Verify the key exists and your IAM role has access.

“passphrase too short”

Passphrases must be at least 16 characters. There’s no maximum length.

“wrong passphrase” / share decryption fails

The passphrase doesn’t match what was registered. If you’re certain the passphrase is correct, check for encoding issues (trailing newlines, different character encoding). Run operator unseal-reset to clear partial shares and try again.

KMS unseal fails on restart

Check that the KMS keys are still accessible from the signer’s IAM role. If you rotated KMS keys, confirm the rotation completed successfully before the restart. Check CloudTrail for kms:Decrypt denials.

For issues not covered here, see the Troubleshooting guide or run with debug logging:

Terminal window
RUST_LOG=containment_chamber=debug containment-chamber --config config.yaml

The credential management endpoints are available whenever the DynamoDB key source is configured. Authentication uses the existing passphrase as proof of identity — no separate auth token is required.

POST /api/v1/chamber/unseal/credentials

Request:

{
"operator": "alice",
"existing_passphrase": "my-current-passphrase",
"existing_credential_id": "password-1",
"new_passphrase": "my-new-passphrase",
"new_credential_id": "password-2"
}

For a YubiKey credential, the new_passphrase field contains the HMAC-SHA1 challenge-response output (handled automatically by the CLI).

Response:

{
"operator": "alice",
"credential_id": "password-2"
}
DELETE /api/v1/chamber/unseal/credentials

Request:

{
"operator": "alice",
"existing_passphrase": "my-current-passphrase",
"existing_credential_id": "password-1",
"remove_credential_id": "old-password"
}

Response:

{
"operator": "alice",
"removed_credential_id": "old-password"
}

Returns 409 Conflict if remove_credential_id is the last credential for that operator.

GET /api/v1/chamber/unseal/credentials?operator=alice

Response:

{
"operator": "alice",
"credential_ids": ["password-1", "yubikey-1"]
}

This endpoint requires no authentication — credential IDs are not secret.


If you need to start over during the initial setup (e.g., wrong KMS keys, wrong operator list), use init-reset while the signer is still in awaiting_registration:

Terminal window
containment-chamber operator init-reset \
--setup-token "$SETUP_TOKEN" \
--signer-url http://localhost:9000

This deletes the MASTER_KEY row from DynamoDB and returns the signer to uninitialized. A new setup token is generated and logged.

If you lose access to both KMS keys and operator passphrases (e.g., all KMS keys deleted and all operators unavailable), the master key is permanently unrecoverable. The BLS validator keys encrypted by that master key cannot be decrypted.

Your recovery option is the age-encrypted mnemonic backup — if backup.enabled: true was configured during key generation. This backup contains the BIP-39 mnemonic from which all BLS keys were derived. With it, you can:

  1. Set up a fresh Containment Chamber instance
  2. Run a new init ceremony with new KMS keys and operators
  3. Re-import the validator keys from the mnemonic backup

See age-encrypted mnemonic backup for configuration and recovery procedures.