Skip to content

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.

The signer moves through six states. Signing is available in unsealed and awaiting_rotation. All other states return 503 to signing requests.

Diagram
StateSigningDescriptionTriggered by
uninitializedNo (503)No unseal config existsFresh start, or after reset
awaiting_registrationNo (503)Operators registering passphrasesoperator init with unseal config
sealedNo (503)Config stored, awaiting KMS unsealAll operators registered, or operator seal
kms_unsealedNo (503)KMS shares decrypted, awaiting operator passphrasesAutomatic on restart (unseal mode)
unsealedYesMaster key in memory, signer operationalKMS-only init, or threshold of operator shares received
awaiting_rotationYesRotating operator set, signing continuesoperator rotate unseal or operator rotate 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.

Diagram

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 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).

Diagram

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.

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.

Terminal window
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 2

Response — signer is immediately unsealed:

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

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.

Terminal window
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.

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.

When an operator registers, they assign a credential_id to identify that credential:

Terminal window
containment-chamber operator register \
--operator alice \
--registration-token "$ALICE_TOKEN" \
--credential-id password-1 \
--signer-url http://localhost:9000

Later, they can add a second credential by proving ownership of an existing one:

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

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

Terminal window
containment-chamber operator unseal \
--operator alice \
--signer-url http://localhost:9000
# Prompts for passphrase — works with any registered credential

This 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.

Add a credential by proving an existing one:

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

Remove a credential by proving an existing one:

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

Terminal window
containment-chamber operator credentials list \
--operator alice \
--signer-url http://localhost:9000

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.

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.

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:

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

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

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

Partial 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 = disabled

Seal the signer immediately to stop all signing:

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.

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.

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.

Re-split the master key with a new set of KMS keys and threshold:

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

Replace the operator set with new operators and/or a new threshold:

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..."
}
}

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:

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

Switch between kms-only and unseal without restarting:

Terminal window
# KMS-only → Unseal
containment-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-only
containment-chamber operator rotate mode \
--auth-token "$AUTH_TOKEN" \
--signer-url http://localhost:9000 \
--mode kms-only

Switching 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.

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:

PCRBinds to
PCR0Enclave image hash
PCR1Linux kernel and bootstrap
PCR2Application
PCR3IAM role attached to the parent instance
PCR8Enclave 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:

Terminal window
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
}
}
PropertyMechanism
Setup tokenOne-time use, HMAC-SHA256 hashed, never stored in plaintext
Registration tokensPer-operator, zeroized after use, constant-time comparison
PassphrasesNever stored; only Argon2id-derived AES-256-GCM keys are persisted
Master keyZeroizing<[u8; 32]> — overwritten on drop, zeroized on seal
Unseal keyZeroizing<[u8; 32]> — never persisted, reconstructed transiently
Operator sharesZeroizing<Vec<u8>> — zeroized after Shamir combine
Token comparisonsubtle::ConstantTimeEq — timing-safe, prevents prefix inference
Master key integrityHMAC-SHA256 verified after reconstruction — detects wrong KMS shares
Audit loggingAll state transitions logged to the audit target
Source IPX-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.

MethodPathAuthPreconditionDescription
POST/api/v1/chamber/initSetup token (Bearer)uninitializedInitialize KMS config and optional unseal operators
DELETE/api/v1/chamber/initSetup token (Bearer)awaiting_registrationReset to uninitialized, print new setup token
GET/api/v1/chamber/statuschamber_status scopeAnyCurrent seal state, key counts, enclave info
GET/api/v1/chamber/attestationchamber_status scopeNitro Enclave onlyRaw COSE-signed attestation document
POST/api/v1/chamber/unseal/registerRegistration token (Bearer)awaiting_registration or awaiting_rotationRegister operator passphrase, encrypt share
POST/api/v1/chamber/unsealNone (passphrase is auth)kms_unsealedSubmit operator passphrase, accumulate share
DELETE/api/v1/chamber/unsealchamber_rotate scopekms_unsealedDiscard partial shares, restart ceremony
POST/api/v1/chamber/unseal/credentialsNone (existing passphrase is auth)unsealed or kms_unsealedAdd a new credential for an operator
DELETE/api/v1/chamber/unseal/credentialsNone (existing passphrase is auth)AnyRemove a credential (cannot remove last)
GET/api/v1/chamber/unseal/credentialsNoneAnyList credential IDs for an operator
POST/api/v1/chamber/sealchamber_seal scopeunsealedEmergency seal, zeroize master key
POST/api/v1/chamber/rotate/kmschamber_rotate scopeunsealedRe-split master key with new KMS keys
POST/api/v1/chamber/rotate/unsealchamber_rotate scopeunsealedStart operator rotation, enter awaiting_rotation
DELETE/api/v1/chamber/rotate/unsealchamber_rotate scopeawaiting_rotationCancel rotation, restore unsealed
POST/api/v1/chamber/rotate/modechamber_rotate scopeunsealedSwitch between kms_only and unseal modes

For full request and response schemas, see the API Reference.

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
FieldDefaultDescription
dynamodb.unseal_timeout_minutes30Minutes 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.seccompfalseEnable seccomp syscall filtering on Linux.