Seal & Unseal Ceremony
The seal/unseal system protects the master key that encrypts all validator private keys in DynamoDB. When the signer starts, it’s sealed — signing requests return 503 until the master key is reconstructed in memory. Once unsealed, signing proceeds normally.
This is a vault-style design: the master key never persists in plaintext. It lives only in process memory, wrapped in Zeroizing<[u8; 32]> so it’s overwritten on drop. On restart, the key must be reconstructed from KMS-encrypted Shamir shares, with or without human involvement depending on which mode you choose.
The seal/unseal system only applies to the DynamoDB key source. Filesystem keystores load directly at startup and are not affected.
State Machine
Section titled “State Machine”The signer moves through six states. Signing is available in unsealed and awaiting_rotation. All other states return 503 to signing requests.
| State | Signing | Description | Triggered by |
|---|---|---|---|
uninitialized | No (503) | No unseal config exists | Fresh start, or after reset |
awaiting_registration | No (503) | Operators registering passphrases | operator init with unseal config |
sealed | No (503) | Config stored, awaiting KMS unseal | All operators registered, or operator seal |
kms_unsealed | No (503) | KMS shares decrypted, awaiting operator passphrases | Automatic on restart (unseal mode) |
unsealed | Yes | Master key in memory, signer operational | KMS-only init, or threshold of operator shares received |
awaiting_rotation | Yes | Rotating operator set, signing continues | operator rotate unseal or operator rotate mode |
KMS-Only Mode
Section titled “KMS-Only Mode”The master key is split into N Shamir shares. Each share is encrypted by a different AWS KMS key and stored in DynamoDB. On restart, the signer fetches and decrypts at least M shares (the threshold), combines them to reconstruct the master key, and transitions directly to unsealed. No human interaction required.
This mode is appropriate when your AWS IAM controls are your primary security boundary. A compromised IAM role with access to enough KMS keys can reconstruct the master key without any human involvement.
Unseal Mode
Section titled “Unseal Mode”Unseal mode adds a second encryption layer. The master key is encrypted with an unseal_key before the Shamir split. The unseal_key itself is split and each share is encrypted with an operator’s passphrase using Argon2id key derivation. Reconstructing the master key requires both KMS access (automated) and operator passphrases (human).
On restart, KMS unseal happens automatically in the background, transitioning the signer to kms_unsealed. Signing is still unavailable at this point. Operators then submit their passphrases one at a time. Once the threshold is reached, the unseal_key is reconstructed, the master_key is decrypted, and the signer transitions to unsealed.
Init Ceremony
Section titled “Init Ceremony”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...This token authenticates the init request. It’s invalidated immediately after a successful init. If you need to reset and start over, use operator init-reset with the same token.
containment-chamber operator init \ --setup-token "$SETUP_TOKEN" \ --signer-url http://localhost:9000 \ --kms-key-arns "arn:aws:kms:us-east-1:111:key/aaa,arn:aws:kms:eu-west-1:222:key/bbb,arn:aws:kms:ap-southeast-1:333:key/ccc" \ --kms-threshold 2Response — signer is immediately unsealed:
{ "status": "unsealed", "validators_loaded": 42}containment-chamber operator init \ --setup-token "$SETUP_TOKEN" \ --signer-url http://localhost:9000 \ --kms-key-arns "arn:aws:kms:us-east-1:111:key/aaa,arn:aws:kms:eu-west-1:222:key/bbb" \ --kms-threshold 2 \ --unseal-threshold 2 \ --operators "alice,bob,carol"Response — signer is in awaiting_registration, one token per operator:
{ "status": "awaiting_registration", "operator_tokens": { "alice": "cc-reg-A1b2C3d4...", "bob": "cc-reg-E5f6G7h8...", "carol": "cc-reg-I9j0K1l2..." }}Distribute each token to the corresponding operator out-of-band. Each operator uses their token exactly once to register their passphrase. See the operations guide for detailed token distribution guidance.
Operator Registration
Section titled “Operator Registration”After init in unseal mode, each operator registers their passphrase using their registration token. The passphrase is used to derive an AES-256-GCM key via Argon2id, which encrypts the operator’s Shamir share before it’s stored in DynamoDB. The passphrase itself is never stored.
containment-chamber operator register \ --operator alice \ --registration-token "$ALICE_TOKEN" \ --signer-url http://localhost:9000# Prompts for passphrase interactively (minimum 16 characters)Response while operators are still pending:
{ "registered": 1, "remaining": 2}Response when the last operator registers — signer transitions to sealed:
{ "registered": 3, "remaining": 0, "status": "sealed"}Once all operators have registered, the setup token is invalidated and the signer is ready for normal operation. On the next restart, KMS unseal happens automatically and operators submit their passphrases to complete the unseal.
Multi-Credential Support
Section titled “Multi-Credential Support”Each operator can register multiple credentials for their unseal share. A credential is anything that produces a passphrase: a memorized password, a YubiKey HMAC-SHA1 challenge-response, or a secrets manager entry. Each credential independently encrypts the same Shamir share using Argon2id key derivation and AES-256-GCM.
How it works
Section titled “How it works”When an operator registers, they assign a credential_id to identify that credential:
containment-chamber operator register \ --operator alice \ --registration-token "$ALICE_TOKEN" \ --credential-id password-1 \ --signer-url http://localhost:9000Later, they can add a second credential by proving ownership of an existing one:
containment-chamber operator credentials add \ --operator alice \ --existing-credential-id password-1 \ --new-credential-id yubikey-1 \ --yubikey \ --signer-url http://localhost:9000On unseal, the operator just provides their passphrase. No credential_id is needed — the server tries all registered credentials automatically and accepts the first one that decrypts the share:
containment-chamber operator unseal \ --operator alice \ --signer-url http://localhost:9000# Prompts for passphrase — works with any registered credentialThis means an operator can unseal with whichever credential is convenient at the time: their password on a laptop, their YubiKey in the office, or a secrets manager entry in an automated script.
Credential management
Section titled “Credential management”Add a credential by proving an existing one:
containment-chamber operator credentials add \ --operator alice \ --existing-credential-id password-1 \ --new-credential-id password-2 \ --signer-url http://localhost:9000Remove a credential by proving an existing one:
containment-chamber operator credentials remove \ --operator alice \ --existing-credential-id password-1 \ --remove-credential-id old-password \ --signer-url http://localhost:9000The server prevents removing the last credential. An operator must always have at least one credential registered — otherwise their share becomes permanently inaccessible.
List all credentials for an operator:
containment-chamber operator credentials list \ --operator alice \ --signer-url http://localhost:9000Hardware-backed credentials
Section titled “Hardware-backed credentials”For stronger security, operators can use a YubiKey as a credential. The YubiKey performs HMAC-SHA1 challenge-response in hardware — the secret never leaves the device. See the YubiKey Setup guide for configuration and enrollment instructions.
Security properties
Section titled “Security properties”Each credential produces an independent encryption of the same share. Adding a credential doesn’t change the share itself — it adds a new encrypted copy. Removing a credential deletes that encrypted copy. The underlying Shamir share is unchanged throughout.
The server never stores passphrases or credential secrets. Only the Argon2id-derived AES-256-GCM ciphertext is persisted in DynamoDB, one row per credential per operator.
Unseal Ceremony
Section titled “Unseal Ceremony”On restart in unseal mode, the signer automatically decrypts the KMS shares and transitions to kms_unsealed. Signing is still unavailable. Each operator then submits their passphrase:
containment-chamber operator unseal \ --operator alice \ --signer-url http://localhost:9000# Prompts for passphrase interactivelyResponse while below threshold (partial unseal):
{ "shares_received": 1, "shares_required": 2}Response when threshold is reached — signer transitions to unsealed:
{ "status": "unsealed", "validators_loaded": 42}Operators can submit in any order. Duplicate submissions from the same operator are ignored. To discard accumulated shares and start over:
containment-chamber operator unseal-reset \ --auth-token "$AUTH_TOKEN" \ --signer-url http://localhost:9000Partial unseal timeout. Configure unseal_timeout_minutes to automatically expire accumulated shares if the ceremony isn’t completed in time. When the timeout expires, accumulated shares are cleared and operators must start over. Set to 0 to disable the timeout.
key_sources: dynamodb: unseal_timeout_minutes: 30 # default; 0 = disabledEmergency Seal
Section titled “Emergency Seal”Seal the signer immediately to stop all signing:
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.
The TEE sealed blob (if present) is also deleted from DynamoDB, so an auto-restart won’t bypass the seal. Use this for suspected compromise, planned maintenance, or before rotating credentials.
To resume signing, restart the signer (KMS unseal happens automatically) and, in unseal mode, run the operator ceremony again.
Rotation
Section titled “Rotation”All rotation operations require the chamber_rotate auth scope. Signing continues during rotation — the signer stays in awaiting_rotation state, which still serves signing requests.
Rotate KMS Keys
Section titled “Rotate KMS Keys”Re-split the master key with a new set of KMS keys and threshold:
containment-chamber operator rotate kms \ --auth-token "$AUTH_TOKEN" \ --signer-url http://localhost:9000 \ --kms-key-arns "arn:aws:kms:us-east-1:111:key/new-aaa,arn:aws:kms:eu-west-1:222: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.
Rotate Unseal Operators
Section titled “Rotate Unseal Operators”Replace the operator set with new operators and/or a new threshold:
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..." }}New operators register using operator register with their tokens. Once all new operators have registered, the rotation completes and the signer returns to unsealed.
To cancel a rotation in progress:
containment-chamber operator rotate unseal-cancel \ --auth-token "$AUTH_TOKEN" \ --signer-url http://localhost:9000Switch Modes
Section titled “Switch Modes”Switch between kms-only and unseal without restarting:
# KMS-only → Unsealcontainment-chamber operator rotate mode \ --auth-token "$AUTH_TOKEN" \ --signer-url http://localhost:9000 \ --mode kms-only \ --unseal-threshold 2 \ --operators "alice,bob,carol"
# Unseal → KMS-onlycontainment-chamber operator rotate mode \ --auth-token "$AUTH_TOKEN" \ --signer-url http://localhost:9000 \ --mode kms-onlySwitching to unseal starts an awaiting_rotation phase where new operators register. Switching to kms-only takes effect immediately and deletes all operator share rows from DynamoDB.
TEE Sealed Storage
Section titled “TEE Sealed Storage”When running in an AWS Nitro Enclave, the signer can store a TEE-sealed copy of the master key in DynamoDB after a successful unseal. On the next restart, it attempts to unseal using this blob before falling back to the operator ceremony.
The sealed blob is encrypted using a key derived from the enclave’s Platform Configuration Registers (PCRs) via HKDF-SHA256:
| PCR | Binds to |
|---|---|
| PCR0 | Enclave image hash |
| PCR1 | Linux kernel and bootstrap |
| PCR2 | Application |
| PCR3 | IAM role attached to the parent instance |
| PCR8 | Enclave signing certificate |
If the enclave image is updated (changing PCR0 or PCR2), the TEE unseal will fail because the PCR values no longer match. The operator ceremony runs instead, and a new sealed blob is stored after successful unseal. This is expected behavior during deployments.
Check enclave status via:
containment-chamber operator status \ --auth-token "$AUTH_TOKEN" \ --signer-url http://localhost:9000{ "seal": {"status": "unsealed"}, "enclave": { "enabled": true, "platform": "nitro", "sealed_blob_exists": true }}Security Properties
Section titled “Security Properties”| Property | Mechanism |
|---|---|
| Setup token | One-time use, HMAC-SHA256 hashed, never stored in plaintext |
| Registration tokens | Per-operator, zeroized after use, constant-time comparison |
| Passphrases | Never stored; only Argon2id-derived AES-256-GCM keys are persisted |
| Master key | Zeroizing<[u8; 32]> — overwritten on drop, zeroized on seal |
| Unseal key | Zeroizing<[u8; 32]> — never persisted, reconstructed transiently |
| Operator shares | Zeroizing<Vec<u8>> — zeroized after Shamir combine |
| Token comparison | subtle::ConstantTimeEq — timing-safe, prevents prefix inference |
| Master key integrity | HMAC-SHA256 verified after reconstruction — detects wrong KMS shares |
| Audit logging | All state transitions logged to the audit target |
| Source IP | X-Forwarded-For header logged on signing requests for audit trail |
The POST /api/v1/chamber/seal endpoint requires the chamber_seal auth scope. Rotation endpoints require chamber_rotate. Status requires chamber_status. See Access Control for scope configuration.
API Reference
Section titled “API Reference”| Method | Path | Auth | Precondition | Description |
|---|---|---|---|---|
POST | /api/v1/chamber/init | Setup token (Bearer) | uninitialized | Initialize KMS config and optional unseal operators |
DELETE | /api/v1/chamber/init | Setup token (Bearer) | awaiting_registration | Reset to uninitialized, print new setup token |
GET | /api/v1/chamber/status | chamber_status scope | Any | Current seal state, key counts, enclave info |
GET | /api/v1/chamber/attestation | chamber_status scope | Nitro Enclave only | Raw COSE-signed attestation document |
POST | /api/v1/chamber/unseal/register | Registration token (Bearer) | awaiting_registration or awaiting_rotation | Register operator passphrase, encrypt share |
POST | /api/v1/chamber/unseal | None (passphrase is auth) | kms_unsealed | Submit operator passphrase, accumulate share |
DELETE | /api/v1/chamber/unseal | chamber_rotate scope | kms_unsealed | Discard partial shares, restart ceremony |
POST | /api/v1/chamber/unseal/credentials | None (existing passphrase is auth) | unsealed or kms_unsealed | Add a new credential for an operator |
DELETE | /api/v1/chamber/unseal/credentials | None (existing passphrase is auth) | Any | Remove a credential (cannot remove last) |
GET | /api/v1/chamber/unseal/credentials | None | Any | List credential IDs for an operator |
POST | /api/v1/chamber/seal | chamber_seal scope | unsealed | Emergency seal, zeroize master key |
POST | /api/v1/chamber/rotate/kms | chamber_rotate scope | unsealed | Re-split master key with new KMS keys |
POST | /api/v1/chamber/rotate/unseal | chamber_rotate scope | unsealed | Start operator rotation, enter awaiting_rotation |
DELETE | /api/v1/chamber/rotate/unseal | chamber_rotate scope | awaiting_rotation | Cancel rotation, restore unsealed |
POST | /api/v1/chamber/rotate/mode | chamber_rotate scope | unsealed | Switch between kms_only and unseal modes |
For full request and response schemas, see the API Reference.
Configuration
Section titled “Configuration”key_sources: dynamodb: table: containment-keys
# Unseal ceremony timeout. Accumulated shares are cleared if the ceremony # isn't completed within this window. 0 = disabled. unseal_timeout_minutes: 30
# Canary keys: validator public keys that trigger an alert when they sign.# Signing proceeds normally — the canary only logs a warning and increments a metric.canary_keys: - "0xabc123..."
server: # Enable seccomp syscall filtering (Linux only). # Restricts the process to a minimal syscall allowlist. seccomp: false| Field | Default | Description |
|---|---|---|
dynamodb.unseal_timeout_minutes | 30 | Minutes before partial unseal shares expire. 0 disables the timeout. |
canary_keys | [] | Validator public keys that trigger a warning and metric increment when they sign. |
server.seccomp | false | Enable seccomp syscall filtering on Linux. |