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.
This commit is contained in:
Joseph Doherty
2026-07-17 02:55:43 -04:00
parent b009507e10
commit d82d3451e7
24 changed files with 1280 additions and 12 deletions
@@ -0,0 +1,120 @@
# 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:
```bash
# 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. |