Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
7.0 KiB
Operator runbook — rotating the master KEK (ZB.MOM.WW.Secrets)
Audience: operators rotating the key-encryption key (KEK) that protects a secrets store.
Primitive: secret rewrap-all (CLI) → KekRotationService.RewrapAllAsync → per-row
ISecretCipher.Rewrap + ISecretStore.ApplyRewrapAsync.
Status: G-8 (KEK-rotation RewrapAll + runbook). Companion to the G-7 clustered replication,
which was BUILT 2026-07-18 (lib 0.2.0, both options: ZB.MOM.WW.Secrets.Replicator.SqlServer
and ZB.MOM.WW.Secrets.Replicator.AkkaDotNet; see clustered-secrets.md) — the earlier "deferred"
wording here predated that build.
What rotation does (and does not) touch
Each secret is stored as a body sealed under a fresh per-secret data-encryption key (DEK); the DEK is then wrapped by the master KEK. Rotation re-wraps the DEK only:
- Changed:
wrapped_dek,wrap_nonce,wrap_tag,kek_id— the KEK-wrap envelope. - Untouched: the sealed body (
ciphertext/nonce/tag),revision,updated_utc,created_*/updated_by, the tombstone flag. Plaintext is never decrypted to disk or logged.
Because revision/updated_utc are preserved, a re-wrap is invisible to cluster
last-writer-wins anti-entropy — so it never churns replication, and it must be run per
independent store (see step 5).
Properties you can rely on
- Idempotent / resumable. A row already on the new KEK is skipped (
alreadyCurrent). If a run is interrupted, just run it again — it resumes from where it stopped. - Fail-closed. A row wrapped by a KEK that is neither the old nor the new one aborts
the pass (rows already re-wrapped stay persisted). A wrong old-key or tampered wrap aborts with
SecretDecryptionException. An identical old/new KEK id is rejected up front. - No plaintext exposure. The report is counts only:
{total, rewrapped, alreadyCurrent}.
KEK identity — the one thing to get right
The old KEK you supply must resolve to the same kek_id that is stored on the rows. The
provider derives kek_id as sha256:<12 hex> of the key bytes unless an explicit
Secrets:MasterKey:KekId was configured.
- Derived id (default): supplying the same old key bytes reproduces the same id — nothing extra to pass.
- Explicit id: the app set
Secrets:MasterKey:KekId. Pass the matching id with--old-key-id <id>(and--new-key-id <id>if the new KEK also uses an explicit id).
A mismatch fails closed (Cannot rewrap … not the supplied old KEK) — it never corrupts a row.
The new KEK id MUST differ from the old one. With derived ids this is automatic (new key bytes → new
sha256:id). With explicit ids you must assign a new--new-key-id; reusing the old id for new key material is rejected up front (old and new KEK identifiers are identical; there is nothing to rotate) becausekek_idis the only per-row rotation discriminator.
Key material handling
- Key values are never passed as command-line arguments (they would be echoed/logged). Supply
the old (and optionally new) key by env-var NAME (
--old-key-env VAR) or file PATH (--old-key-file /path). The value lives only in that env var / file. - Never print
ZB_SECRETS_MASTER_KEYor the old key to a terminal, log, or ticket. - The new KEK defaults to the app's configured
Secrets:MasterKeyprovider, so you usually only supply the old key.
Procedure
Rotation is an offline, store-local operation. Do it once per store.
1. Back up the store. Copy the SQLite file (or snapshot the SQL-Server DB). This is the undo — if anything is wrong, restore and retry.
2. Quiesce resolve traffic. Stop (or drain) the app(s) reading this store. Rotation preserves
updated_utc/revision, so a running node on the old KEK would keep working until you flip its
key — but running rotation against a store that other writers are mutating risks racing a fresh
write onto the old KEK. Simplest correct posture: app down.
3. Stage the new KEK. Generate a fresh 32-byte key, base64-encode it, and place it where the
CLI (and, after cutover, the app) will read it — e.g. ZB_SECRETS_MASTER_KEY for the Environment
provider, or the mounted key file for the File provider.
4. Run rewrap-all. Point the CLI at the store's appsettings.json (so Secrets:SqlitePath
- the new
Secrets:MasterKeyresolve), and supply the old key:
# Old key delivered via a throwaway env var (name only on the command line):
ZB_SECRETS_OLD_KEY='<old-base64>' \
secret rewrap-all --old-key-env ZB_SECRETS_OLD_KEY
# Explicit-id deployment:
# secret rewrap-all --old-key-env ZB_SECRETS_OLD_KEY --old-key-id sha256:abc… --new-key-id sha256:def…
# Override the new key explicitly instead of the configured provider:
# secret rewrap-all --old-key-env ZB_SECRETS_OLD_KEY --new-key-file /run/secrets/new_kek
Expected: {"action":"rewrap-all","total":N,"rewrapped":N,"alreadyCurrent":0} and exit 0. On a
re-run everything reports alreadyCurrent and rewrapped:0.
5. For clustered / multi-store deployments, repeat per store.
- Shared SQL-Server store (the G-7 primary path): one store → run rotation once.
- Per-node SQLite on a shared KEK: each node has its own store file → run rotation on each node (all with the same old→new keys). Re-wrap does not replicate (revision preserved), so every store must be migrated independently.
6. Cut the app over to the new KEK. Ensure Secrets:MasterKey (and every node's KEK source)
now resolves the new key, then start the app(s). Confirm a resolve succeeds
(e.g. secret get <known-name> or an app readiness probe that exercises a ${secret:} value).
7. Destroy the old key. Remove the old key material from env/files/secret managers once every store is confirmed on the new KEK and resolves are healthy.
Rollback
If step 6/7 surfaces a problem, restore the pre-rotation store backup (step 1) and revert the app to the old KEK. Because bodies were never re-encrypted, the backup is a complete, consistent undo.
Troubleshooting
| Symptom | Cause | Action |
|---|---|---|
error: … old and new KEK identifiers are identical |
New KEK resolves to the same id as the old | You supplied the wrong new key, or forgot to stage it. Verify Secrets:MasterKey. |
error: Secret '…' is wrapped by KEK 'X', which is neither the old nor the new KEK |
A row is on a third KEK (a prior partial/aborted rotation, or a mis-set id) | Investigate that row (secret list shows each row's kekId); re-point old/new keys or fix the row, then re-run (completed rows are skipped). |
error: … DEK could not be unwrapped under the supplied old KEK |
Right kek_id but wrong key bytes, or a tampered row |
Confirm you staged the correct old key bytes. If the row is genuinely tampered, restore from backup. |
Post-cutover resolves fail closed (Row wrapped by unknown KEK) |
App still on the old KEK, or a store/node was missed in step 5 | Ensure every node's Secrets:MasterKey resolves the new key and every store was rewrapped. |