G-8 (KEK rotation) — built in ZB.MOM.WW.Secrets, lib 0.1.2->0.1.3: - ISecretCipher.Rewrap(row, oldKek, newKek): re-wraps the per-secret DEK only (bodies never re-encrypted; revision/timestamps preserved -> invisible to cluster LWW). Fail-closed on wrong old-KEK id, wrong key bytes, and malformed wraps; DEK zeroed on all paths. - ISecretStore.ApplyRewrapAsync(rewrappedRow, expectedCurrentWrappedDek): updates only the 4 wrap columns + kek_id, compare-and-swap on the current wrapped DEK so a concurrent set/rotate cannot corrupt a row (closes a review-caught TOCTOU). - KekRotationService.RewrapAllAsync + RewrapReport: enumerate all rows incl. tombstones, idempotent/resumable skip-already-current, bounded CAS-retry, fail-closed on unknown/identical KEK. - `secret rewrap-all` CLI verb: key material only via env-var name / file path, JSON counts report; README section + operator runbook. Verified: full offline suite green (82 core + 15 UI, 0 regressions) + end-to-end CLI smoke + adversarial crypto review (all 7 categories PASS; TOCTOU fixed). G-7 (clustered replication) — designed + planned, no code: - Fork resolved to build Option A (shared SQL-Server ISecretStore); Akka replicator ZB.MOM.WW.Secrets.Akka is a deferred phase-2. Design doc + executable plan + .tasks.json under docs/plans/2026-07-17-secrets-g7-*. Tracking: components/secrets/GAPS.md + CLAUDE.md secrets row updated.
6.8 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 deferred G-7 clustered
replication (ZB.MOM.WW.Secrets.Akka).
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. |