Files
Joseph Doherty d82d3451e7 feat(secrets): build G-8 KEK rotation (RewrapAll); design+plan G-7 clustered replication
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.
2026-07-17 02:55:43 -04:00

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) because kek_id is 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_KEY or the old key to a terminal, log, or ticket.
  • The new KEK defaults to the app's configured Secrets:MasterKey provider, 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:MasterKey resolve), 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.