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
+1 -1
View File
@@ -143,7 +143,7 @@ each project's **code-verified current state**, and the **gaps** between. See
| Config + validation (options / startup validation) | Adopted (lib `0.1.0`; all 3 apps, local) | Shared `ZB.MOM.WW.Configuration` lib | [`components/configuration/`](components/configuration/) | [`ZB.MOM.WW.Configuration/`](ZB.MOM.WW.Configuration/) | | Config + validation (options / startup validation) | Adopted (lib `0.1.0`; all 3 apps, local) | Shared `ZB.MOM.WW.Configuration` lib | [`components/configuration/`](components/configuration/) | [`ZB.MOM.WW.Configuration/`](ZB.MOM.WW.Configuration/) |
| Audit (event model + writer seam) | Adopted (lib `0.1.0`; all 3 apps, merged to **local default** main/master + **pushed to origin** (gitea)) | Shared `ZB.MOM.WW.Audit` lib | [`components/audit/`](components/audit/) | [`ZB.MOM.WW.Audit/`](ZB.MOM.WW.Audit/) | | Audit (event model + writer seam) | Adopted (lib `0.1.0`; all 3 apps, merged to **local default** main/master + **pushed to origin** (gitea)) | Shared `ZB.MOM.WW.Audit` lib | [`components/audit/`](components/audit/) | [`ZB.MOM.WW.Audit/`](ZB.MOM.WW.Audit/) |
| Galaxy Repository (object-hierarchy SQL browse + gRPC service) | Built (lib `0.1.0`, **published to the Gitea feed**; consumed by HistorianGateway as a feed `PackageReference`) | Shared `ZB.MOM.WW.GalaxyRepository` lib | _(design in histsdk + design doc 2026-06-23)_ | [`ZB.MOM.WW.GalaxyRepository/`](ZB.MOM.WW.GalaxyRepository/) | | Galaxy Repository (object-hierarchy SQL browse + gRPC service) | Built (lib `0.1.0`, **published to the Gitea feed**; consumed by HistorianGateway as a feed `PackageReference`) | Shared `ZB.MOM.WW.GalaxyRepository` lib | _(design in histsdk + design doc 2026-06-23)_ | [`ZB.MOM.WW.GalaxyRepository/`](ZB.MOM.WW.GalaxyRepository/) |
| Secrets (encrypted store + `${secret:}` resolution) | Built (libs `0.1.2`, **published to the Gitea feed**; **HistorianGateway adopted + live-proven** 2026-07-16; **mxaccessgw adopted G-4/G-5/G-6 + merged to `origin/main` @ `e088dfa`** 2026-07-16, box-verified fail-closed + encrypt-at-rest; **ScadaBridge adopted G-3/G-4/G-5/G-6 + merged to `origin/main` @ `128f1596`** 2026-07-16, **G-3 live-proven vs the real production MxGateway gateway** `wonder-app-vd03:5120` (secret-ref ApiKey → Connected + browsed real Galaxy); **OtOpcUa adopted G-2/G-4/G-5/G-6 + merged to `origin/master` @ `872cf7e3`** (lmxopcua) 2026-07-16 — the last app, so **all four now adopted**; Layer-B driver secrets (Galaxy API key + OpcUaClient `Password`/`UserCertificatePassword`) resolve fail-closed at driver session-open, `AddZbSecrets` registered unconditionally so driver-only nodes work; **G-2 live-proven vs the real production MxGateway** `wonder-app-vd03:5120` — a `secret:`-ref Galaxy ApiKey resolved through OtOpcUa's real `GalaxyDriverBrowser` → dummy key `MxGatewayAuthenticationException`, real key → browsed the real Galaxy root (10 nodes); temp key minted+revoked+deleted) | Shared `ZB.MOM.WW.Secrets` lib (3 packages + CLI) | [`components/secrets/`](components/secrets/) | [`ZB.MOM.WW.Secrets/`](ZB.MOM.WW.Secrets/) | | Secrets (encrypted store + `${secret:}` resolution) | Built (libs `0.1.2`, **published to the Gitea feed**; **HistorianGateway adopted + live-proven** 2026-07-16; **mxaccessgw adopted G-4/G-5/G-6 + merged to `origin/main` @ `e088dfa`** 2026-07-16, box-verified fail-closed + encrypt-at-rest; **ScadaBridge adopted G-3/G-4/G-5/G-6 + merged to `origin/main` @ `128f1596`** 2026-07-16, **G-3 live-proven vs the real production MxGateway gateway** `wonder-app-vd03:5120` (secret-ref ApiKey → Connected + browsed real Galaxy); **OtOpcUa adopted G-2/G-4/G-5/G-6 + merged to `origin/master` @ `872cf7e3`** (lmxopcua) 2026-07-16 — the last app, so **all four now adopted**; Layer-B driver secrets (Galaxy API key + OpcUaClient `Password`/`UserCertificatePassword`) resolve fail-closed at driver session-open, `AddZbSecrets` registered unconditionally so driver-only nodes work; **G-2 live-proven vs the real production MxGateway** `wonder-app-vd03:5120` — a `secret:`-ref Galaxy ApiKey resolved through OtOpcUa's real `GalaxyDriverBrowser` → dummy key `MxGatewayAuthenticationException`, real key → browsed the real Galaxy root (10 nodes); temp key minted+revoked+deleted); **G-8 KEK-rotation BUILT (lib bumped `0.1.2`→`0.1.3`, 2026-07-17):** `Rewrap` DEK primitive + CAS-guarded `ApplyRewrapAsync` + `KekRotationService.RewrapAllAsync` + `secret rewrap-all` CLI + operator runbook (`ZB.MOM.WW.Secrets/docs/operations/kek-rotation.md`), full suite green + CLI smoke + adversarial crypto review PASS (TOCTOU closed via compare-and-swap); republish to feed is opt-in. **G-7 clustered replication DESIGNED + PLANNED** — fork resolved to **build the shared SQL-Server `ISecretStore`** (Akka replicator deferred phase-2), executable plan `docs/plans/2026-07-17-secrets-g7-*`. Both not yet committed/pushed (awaiting go-ahead) | Shared `ZB.MOM.WW.Secrets` lib (3 packages + CLI) | [`components/secrets/`](components/secrets/) | [`ZB.MOM.WW.Secrets/`](ZB.MOM.WW.Secrets/) |
The auth component is fully populated: a normalized [`spec`](components/auth/spec/SPEC.md), a The auth component is fully populated: a normalized [`spec`](components/auth/spec/SPEC.md), a
proposed [`shared-contract`](components/auth/shared-contract/ZB.MOM.WW.Auth.md), three proposed [`shared-contract`](components/auth/shared-contract/ZB.MOM.WW.Auth.md), three
+1 -1
View File
@@ -5,7 +5,7 @@
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion> <LangVersion>latest</LangVersion>
<Version>0.1.2</Version> <Version>0.1.3</Version>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally> <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<PackageReadmeFile>README.md</PackageReadmeFile> <PackageReadmeFile>README.md</PackageReadmeFile>
</PropertyGroup> </PropertyGroup>
+19 -1
View File
@@ -12,7 +12,7 @@ plaintext back on demand — from application code, from configuration, or from
| `ZB.MOM.WW.Secrets` | Implementation: AES-256-GCM envelope cipher, env/file/DPAPI master-key providers, SQLite store, TTL resolver (audited), `${secret:}` config expander, `AddZbSecrets`. | | `ZB.MOM.WW.Secrets` | Implementation: AES-256-GCM envelope cipher, env/file/DPAPI master-key providers, SQLite store, TTL resolver (audited), `${secret:}` config expander, `AddZbSecrets`. |
| `ZB.MOM.WW.Secrets.Ui` | Blazor RCL on `ZB.MOM.WW.Theme`: list / add / rotate / delete + policy-gated, audited reveal. | | `ZB.MOM.WW.Secrets.Ui` | Blazor RCL on `ZB.MOM.WW.Theme`: list / add / rotate / delete + policy-gated, audited reveal. |
A `secret` CLI (`set` / `get` / `list` / `rm` / `rotate`) ships in the repo (not packed). A `secret` CLI (`set` / `get` / `list` / `rm` / `rotate` / `rewrap-all`) ships in the repo (not packed).
## How it protects secrets ## How it protects secrets
@@ -50,6 +50,24 @@ await expander.ExpandConfigurationAsync((IConfigurationRoot)builder.Configuratio
The master key is 32 bytes, provided base64 in `ZB_SECRETS_MASTER_KEY` (Environment source). The master key is 32 bytes, provided base64 in `ZB_SECRETS_MASTER_KEY` (Environment source).
A missing/invalid key fails closed at startup (`MasterKeyUnavailableException`). A missing/invalid key fails closed at startup (`MasterKeyUnavailableException`).
## Rotating the master KEK
Because each row is a body sealed under a per-secret DEK that is *wrapped* by the KEK, rotating
the master key only re-wraps DEKs — bodies are never re-encrypted and no value history changes.
The `secret rewrap-all` CLI verb (backed by `KekRotationService`) migrates every row from the
old KEK to the new one; it is idempotent and safe to re-run:
```bash
# New KEK defaults to the configured Secrets:MasterKey; supply the OLD key by env-var name or path.
ZB_SECRETS_MASTER_KEY=<new-base64> ZB_SECRETS_OLD_KEY=<old-base64> \
secret rewrap-all --old-key-env ZB_SECRETS_OLD_KEY
# → {"action":"rewrap-all","total":N,"rewrapped":N,"alreadyCurrent":0}
```
Run it with resolve traffic quiesced and once per independent store (once for a shared
SQL-Server store; once per node for per-node SQLite on a shared KEK). See the operator runbook:
[`docs/operations/kek-rotation.md`](docs/operations/kek-rotation.md).
## Runtime + human access ## Runtime + human access
- **App code:** inject `ISecretResolver` and call `GetAsync(name, ct)`. Every resolve is - **App code:** inject `ISecretResolver` and call `GetAsync(name, ct)`. Every resolve is
@@ -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. |
@@ -32,4 +32,34 @@ public interface ISecretCipher
/// The authentication tag fails to verify, or the row references an unknown or unavailable KEK. /// The authentication tag fails to verify, or the row references an unknown or unavailable KEK.
/// </exception> /// </exception>
string Decrypt(StoredSecret secret); string Decrypt(StoredSecret secret);
/// <summary>
/// Re-wraps the data-encryption key (DEK) of an existing row from <paramref name="oldKek"/> to
/// <paramref name="newKek"/> — the KEK-rotation primitive. The DEK is unwrapped under the old
/// master key and re-wrapped under the new one; the sealed body
/// (<see cref="StoredSecret.Ciphertext"/>/<see cref="StoredSecret.Nonce"/>/<see cref="StoredSecret.Tag"/>)
/// is <b>never</b> re-encrypted, and <see cref="StoredSecret.Revision"/>, the timestamps, the
/// tombstone flag, and every other field are preserved verbatim. Only
/// <see cref="StoredSecret.WrappedDek"/>/<see cref="StoredSecret.WrapNonce"/>/<see cref="StoredSecret.WrapTag"/>
/// and <see cref="StoredSecret.KekId"/> change. Plaintext is never exposed.
/// </summary>
/// <remarks>
/// This operation is <i>provider-explicit</i>: it uses only the two supplied providers and does
/// not consult whatever KEK the cipher instance was constructed with, because a rotation spans
/// two keys. The caller must have already established that <paramref name="secret"/> is wrapped
/// by <paramref name="oldKek"/> (the row's <see cref="StoredSecret.KekId"/> equals
/// <see cref="IMasterKeyProvider.KekId"/> of <paramref name="oldKek"/>); a mismatch fails closed.
/// </remarks>
/// <param name="secret">The stored row whose DEK should be re-wrapped.</param>
/// <param name="oldKek">The provider for the KEK that currently wraps the row's DEK.</param>
/// <param name="newKek">The provider for the KEK the DEK should be re-wrapped under.</param>
/// <returns>
/// A copy of <paramref name="secret"/> with the DEK re-wrapped under <paramref name="newKek"/>
/// and <see cref="StoredSecret.KekId"/> set to the new KEK id; all other fields unchanged.
/// </returns>
/// <exception cref="SecretDecryptionException">
/// The row is not wrapped by <paramref name="oldKek"/>, or the DEK fails to unwrap (wrong old
/// key material or a tampered wrap).
/// </exception>
StoredSecret Rewrap(StoredSecret secret, IMasterKeyProvider oldKek, IMasterKeyProvider newKek);
} }
@@ -63,4 +63,42 @@ public interface ISecretStore
/// <param name="ct">A token to cancel the operation.</param> /// <param name="ct">A token to cancel the operation.</param>
/// <returns>A task that completes when the row has been applied or ignored.</returns> /// <returns>A task that completes when the row has been applied or ignored.</returns>
Task ApplyReplicatedAsync(StoredSecret row, CancellationToken ct); Task ApplyReplicatedAsync(StoredSecret row, CancellationToken ct);
/// <summary>
/// Applies a KEK re-wrap in place: overwrites <b>only</b> the KEK-wrap envelope columns
/// (<see cref="StoredSecret.WrappedDek"/>/<see cref="StoredSecret.WrapNonce"/>/<see cref="StoredSecret.WrapTag"/>)
/// and <see cref="StoredSecret.KekId"/> for the row named by <paramref name="rewrappedRow"/>,
/// leaving the sealed body, revision, timestamps, tombstone flag, and audit stamps untouched.
/// </summary>
/// <remarks>
/// <para>
/// This is the persistence half of KEK rotation. It deliberately does <b>not</b> bump the
/// revision or refresh <c>updated_utc</c>: a re-wrap does not change the secret's plaintext, so
/// it must stay invisible to the (updated_utc, revision) last-writer-wins ordering used by
/// cluster anti-entropy — otherwise every node's independent re-wrap would churn replication.
/// </para>
/// <para>
/// The update is a <b>compare-and-swap</b> on <paramref name="expectedCurrentWrappedDek"/>: the
/// row is updated only if its current <see cref="StoredSecret.WrappedDek"/> still equals the DEK
/// wrap that was unwrapped to produce <paramref name="rewrappedRow"/>. A concurrent write to the
/// same secret (a <c>set</c>/<c>rotate</c> that generated a new DEK) changes that wrap, so the
/// CAS matches 0 rows and the row is left untouched — rather than corrupting it by pairing a
/// stale re-wrap with a newer body. The caller re-processes a 0-row result (fetch → re-wrap).
/// </para>
/// </remarks>
/// <param name="rewrappedRow">
/// The row carrying the new wrap envelope + <see cref="StoredSecret.KekId"/>; matched by
/// <see cref="StoredSecret.Name"/>. Only the four wrap-related columns are read from it.
/// </param>
/// <param name="expectedCurrentWrappedDek">
/// The <see cref="StoredSecret.WrappedDek"/> the row must still carry for the swap to apply — the
/// wrap that was unwrapped to produce <paramref name="rewrappedRow"/>.
/// </param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>
/// <c>true</c> if the row was updated; <c>false</c> if no row matched (absent, or its wrapped DEK
/// changed under a concurrent write).
/// </returns>
Task<bool> ApplyRewrapAsync(
StoredSecret rewrappedRow, byte[] expectedCurrentWrappedDek, CancellationToken ct);
} }
@@ -1,9 +1,11 @@
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.Secrets.Abstractions; using ZB.MOM.WW.Secrets.Abstractions;
using ZB.MOM.WW.Secrets.Cli; using ZB.MOM.WW.Secrets.Cli;
using ZB.MOM.WW.Secrets.DependencyInjection; using ZB.MOM.WW.Secrets.DependencyInjection;
using ZB.MOM.WW.Secrets.MasterKey;
using ZB.MOM.WW.Secrets.Sqlite; using ZB.MOM.WW.Secrets.Sqlite;
// Headless `secret` CLI: set / get / list / rm / rotate over the ZB.MOM.WW secrets store. // Headless `secret` CLI: set / get / list / rm / rotate over the ZB.MOM.WW secrets store.
@@ -77,6 +79,41 @@ try
return await commands.RemoveAsync(args[1], actor, ct); return await commands.RemoveAsync(args[1], actor, ct);
} }
case "rewrap-all":
{
// Rotate the master KEK by re-wrapping every row's DEK. Key material is supplied ONLY by
// env-var NAME or file PATH — never as a literal argument (which would be echoed/logged).
string? oldKind = null, oldValue = null, oldKekId = null;
string? newKind = null, newValue = null, newKekId = null;
for (int i = 1; i < args.Length; i++)
{
switch (args[i])
{
case "--old-key-env": (oldKind, oldValue) = ("env", RequireValue(args, ref i)); break;
case "--old-key-file": (oldKind, oldValue) = ("file", RequireValue(args, ref i)); break;
case "--old-key-id": oldKekId = RequireValue(args, ref i); break;
case "--new-key-env": (newKind, newValue) = ("env", RequireValue(args, ref i)); break;
case "--new-key-file": (newKind, newValue) = ("file", RequireValue(args, ref i)); break;
case "--new-key-id": newKekId = RequireValue(args, ref i); break;
default: return Usage($"Unknown option '{args[i]}'.");
}
}
if (oldKind is null)
return Usage("rewrap-all requires --old-key-env <VAR> or --old-key-file <path>.");
if (newKind is null && newKekId is not null)
return Usage("--new-key-id requires --new-key-env <VAR> or --new-key-file <path>.");
IMasterKeyProvider oldKek = BuildProvider(oldKind, oldValue!, oldKekId);
// Default the new KEK to the app's configured provider (Secrets:MasterKey); override with flags.
IMasterKeyProvider newKek = newKind is null
? host.Services.GetRequiredService<IMasterKeyProvider>()
: BuildProvider(newKind, newValue!, newKekId);
return await commands.RewrapAllAsync(oldKek, newKek, ct);
}
default: default:
return Usage($"Unknown command '{args[0]}'."); return Usage($"Unknown command '{args[0]}'.");
} }
@@ -132,6 +169,26 @@ static SecretContentType ParseContentType(string value) => value switch
$"Unknown --content-type '{value}'; expected text|connection-string|json|binary-base64."), $"Unknown --content-type '{value}'; expected text|connection-string|json|binary-base64."),
}; };
// Reads the value that must follow an option flag at args[i], advancing i past it.
static string RequireValue(string[] args, ref int i)
{
if (i + 1 >= args.Length)
throw new ArgumentException($"{args[i]} requires a value.");
return args[++i];
}
// Builds a master-key provider from a source kind ("env" | "file") + its env-var name or file path,
// with an optional explicit KEK id (needed when the app configured Secrets:MasterKey:KekId rather
// than the derived sha256: id). The key VALUE itself is only ever read from the env var / file.
static IMasterKeyProvider BuildProvider(string kind, string value, string? kekId) => kind switch
{
"env" => MasterKeyProviderFactory.Create(
new MasterKeyOptions { Source = MasterKeySource.Environment, EnvVarName = value, KekId = kekId }),
"file" => MasterKeyProviderFactory.Create(
new MasterKeyOptions { Source = MasterKeySource.File, FilePath = value, KekId = kekId }),
_ => throw new ArgumentException($"Unknown key source '{kind}'."),
};
// Prints usage (optionally preceded by an error line) and returns the standard usage exit code. // Prints usage (optionally preceded by an error line) and returns the standard usage exit code.
int Usage(string? error = null) int Usage(string? error = null)
{ {
@@ -147,6 +204,14 @@ int Usage(string? error = null)
get <name> get <name>
list [--include-deleted] list [--include-deleted]
rm <name> rm <name>
rewrap-all --old-key-env <VAR>|--old-key-file <path> [--old-key-id <id>]
[--new-key-env <VAR>|--new-key-file <path>] [--new-key-id <id>]
rewrap-all rotates the master KEK: it re-wraps every stored secret's data key from the old
KEK to the new one (bodies are never re-encrypted, no plaintext is exposed). The new KEK
defaults to the configured Secrets:MasterKey provider. Supply key material only by env-var
NAME or file PATH never as a literal. Run it with resolve traffic quiesced; it is
idempotent and safe to re-run.
"""); """);
return 2; return 2;
} }
@@ -1,5 +1,6 @@
using System.Text.Json; using System.Text.Json;
using ZB.MOM.WW.Secrets.Abstractions; using ZB.MOM.WW.Secrets.Abstractions;
using ZB.MOM.WW.Secrets.Rotation;
namespace ZB.MOM.WW.Secrets.Cli; namespace ZB.MOM.WW.Secrets.Cli;
@@ -156,6 +157,47 @@ public sealed class SecretCommands
return removed ? 0 : 1; return removed ? 0 : 1;
} }
/// <summary>
/// rewrap-all: rotates the master KEK by re-wrapping every row's DEK from <paramref name="oldKek"/>
/// onto <paramref name="newKek"/> — bodies are never re-encrypted and no plaintext is exposed.
/// Prints a JSON report of the counts (total / rewrapped / already-current) and NEVER any key
/// material. Idempotent + resumable; fails closed (non-zero) on an identical-KEK mistake or a row
/// wrapped by an unrecognized KEK.
/// </summary>
/// <param name="oldKek">The provider for the KEK rows are currently wrapped under.</param>
/// <param name="newKek">The provider for the KEK rows should be re-wrapped onto.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>0 on success; non-zero if the rotation was rejected or aborted.</returns>
public async Task<int> RewrapAllAsync(
IMasterKeyProvider oldKek, IMasterKeyProvider newKek, CancellationToken ct)
{
var service = new KekRotationService(_store, _cipher);
try
{
RewrapReport report = await service.RewrapAllAsync(oldKek, newKek, ct).ConfigureAwait(false);
WriteJson(new
{
action = "rewrap-all",
total = report.Total,
rewrapped = report.Rewrapped,
alreadyCurrent = report.AlreadyCurrent,
});
return 0;
}
catch (ArgumentException ex)
{
// Identical old/new KEK ids — nothing to rotate.
WriteJson(new { action = "rewrap-all", error = ex.Message });
return 1;
}
catch (SecretDecryptionException ex)
{
// A row on an unrecognized KEK aborted the pass, or a DEK failed to unwrap.
WriteJson(new { action = "rewrap-all", error = ex.Message });
return 1;
}
}
/// <summary>Seals a value under the cipher and upserts it, carrying description + actor stamps.</summary> /// <summary>Seals a value under the cipher and upserts it, carrying description + actor stamps.</summary>
private async Task UpsertSealedAsync( private async Task UpsertSealedAsync(
SecretName name, string value, SecretContentType contentType, string? description, string? actor, CancellationToken ct) SecretName name, string value, SecretContentType contentType, string? description, string? actor, CancellationToken ct)
@@ -153,4 +153,75 @@ public sealed class AesGcmEnvelopeCipher : ISecretCipher
CryptographicOperations.ZeroMemory(dek); CryptographicOperations.ZeroMemory(dek);
} }
} }
/// <inheritdoc />
public StoredSecret Rewrap(StoredSecret secret, IMasterKeyProvider oldKek, IMasterKeyProvider newKek)
{
ArgumentNullException.ThrowIfNull(secret);
ArgumentNullException.ThrowIfNull(oldKek);
ArgumentNullException.ThrowIfNull(newKek);
// Fail closed if the row is not actually wrapped by the supplied old KEK — a caller must
// not silently "rewrap" a row that is already on a different key.
if (!string.Equals(secret.KekId, oldKek.KekId, StringComparison.Ordinal))
{
throw new SecretDecryptionException(
$"Cannot rewrap secret '{secret.Name.Value}': row is wrapped by KEK '{secret.KekId}', " +
$"not the supplied old KEK '{oldKek.KekId}'.");
}
ReadOnlySpan<byte> oldMasterKey = oldKek.GetMasterKey().Span;
ReadOnlySpan<byte> newMasterKey = newKek.GetMasterKey().Span;
string newKekId = newKek.KekId;
byte[] dek = new byte[KeySizeBytes];
try
{
// Unwrap the DEK under the OLD master key, verifying the old KEK-id AAD binding. A wrong
// old key or tampered wrap surfaces as SecretDecryptionException (never a raw crypto leak).
byte[] oldKekAad = Encoding.UTF8.GetBytes(secret.KekId);
try
{
using var unwrap = new AesGcm(oldMasterKey, TagSizeBytes);
unwrap.Decrypt(secret.WrapNonce, secret.WrappedDek, secret.WrapTag, dek, oldKekAad);
}
// CryptographicException = wrong key / tampered wrap; ArgumentException = a malformed
// (wrong-length) wrap nonce/tag on a corrupt row. Both fail closed as a decryption error
// rather than leaking a raw crypto/arg exception (which the caller would misclassify).
catch (Exception ex) when (ex is CryptographicException or ArgumentException)
{
throw new SecretDecryptionException(
$"Failed to rewrap secret '{secret.Name.Value}': the DEK could not be unwrapped " +
"under the supplied old KEK.", ex);
}
// Re-wrap the SAME DEK under the NEW master key with a fresh nonce, binding the new KEK id
// as AAD. The body ciphertext is left untouched — rotation re-wraps DEKs only.
byte[] wrapNonce = new byte[NonceSizeBytes];
byte[] wrapTag = new byte[TagSizeBytes];
byte[] wrappedDek = new byte[KeySizeBytes];
byte[] newKekAad = Encoding.UTF8.GetBytes(newKekId);
RandomNumberGenerator.Fill(wrapNonce);
using (var wrapGcm = new AesGcm(newMasterKey, TagSizeBytes))
{
wrapGcm.Encrypt(wrapNonce, dek, wrappedDek, wrapTag, newKekAad);
}
// Only the KEK-wrap envelope + kek_id change; body, revision, timestamps, tombstone,
// content-type, and every other field are preserved so a rewrap is invisible to the
// (updated_utc, revision) last-writer-wins ordering used by cluster anti-entropy.
return secret with
{
WrappedDek = wrappedDek,
WrapNonce = wrapNonce,
WrapTag = wrapTag,
KekId = newKekId,
};
}
finally
{
CryptographicOperations.ZeroMemory(dek);
}
}
} }
@@ -0,0 +1,155 @@
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.Secrets.Rotation;
/// <summary>
/// Rotates the master key-encryption key (KEK) across every stored secret by re-wrapping each
/// row's data-encryption key (DEK) from the old KEK to the new one. Because the envelope model
/// wraps a per-secret DEK under the KEK, rotation never re-encrypts secret bodies — only the DEK
/// wrap changes — so plaintext is never exposed and no value history is touched.
/// </summary>
/// <remarks>
/// <para>
/// The pass is <b>idempotent and resumable</b>: a row already wrapped by the new KEK is skipped
/// (counted in <see cref="RewrapReport.AlreadyCurrent"/>), so a run interrupted part-way can simply
/// be re-run to finish. It is <b>fail-closed</b>: a row wrapped by neither the old nor the new KEK
/// aborts the pass (rather than silently leaving an un-migratable row that would become
/// undecryptable once the old KEK is retired); rows already re-wrapped in the aborted run stay
/// persisted, so a re-run resumes cleanly after the anomaly is investigated.
/// </para>
/// <para>
/// Rotation is a per-store operation and must be run once per independent store (once for a shared
/// SQL-Server store; once per node for a per-node SQLite store on a shared KEK). It should be run
/// with resolve traffic quiesced: the persistence path deliberately does not bump revision or
/// <c>updated_utc</c>, so a re-wrap is invisible to cluster last-writer-wins reconciliation.
/// </para>
/// </remarks>
public sealed class KekRotationService
{
private readonly ISecretStore _store;
private readonly ISecretCipher _cipher;
/// <summary>Creates the rotation service over the encrypted store and envelope cipher.</summary>
/// <param name="store">The store to enumerate and re-wrap in place.</param>
/// <param name="cipher">The envelope cipher providing the DEK re-wrap primitive.</param>
public KekRotationService(ISecretStore store, ISecretCipher cipher)
{
_store = store ?? throw new ArgumentNullException(nameof(store));
_cipher = cipher ?? throw new ArgumentNullException(nameof(cipher));
}
/// <summary>
/// Re-wraps every row from <paramref name="oldKek"/> onto <paramref name="newKek"/>.
/// </summary>
/// <param name="oldKek">The provider for the KEK rows are currently wrapped under.</param>
/// <param name="newKek">The provider for the KEK rows should be re-wrapped onto.</param>
/// <param name="ct">A token to cancel the pass (already-persisted re-wraps are retained).</param>
/// <returns>A <see cref="RewrapReport"/> summarizing the pass (no secret material).</returns>
/// <exception cref="ArgumentException">
/// The old and new KEK identifiers are the same — there is nothing to rotate.
/// </exception>
/// <exception cref="SecretDecryptionException">
/// A row is wrapped by a KEK that is neither the old nor the new one (the pass aborts), or a
/// row's DEK fails to unwrap under the supplied old KEK.
/// </exception>
public async Task<RewrapReport> RewrapAllAsync(
IMasterKeyProvider oldKek, IMasterKeyProvider newKek, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(oldKek);
ArgumentNullException.ThrowIfNull(newKek);
if (string.Equals(oldKek.KekId, newKek.KekId, StringComparison.Ordinal))
{
throw new ArgumentException(
$"The old and new KEK identifiers are identical ('{oldKek.KekId}'); there is nothing to rotate.",
nameof(newKek));
}
// Enumerate metadata (never ciphertext) for ALL rows including tombstones — a tombstoned row
// still carries a KEK-wrapped DEK and would become un-migratable if left on a retired KEK.
IReadOnlyList<SecretMetadata> all =
await _store.ListAsync(includeDeleted: true, ct).ConfigureAwait(false);
int rewrapped = 0;
int alreadyCurrent = 0;
foreach (SecretMetadata meta in all)
{
ct.ThrowIfCancellationRequested();
// Fast-path skip on the metadata kek_id (no ciphertext fetch): a row already on the new
// KEK is a completed migration — this is what makes the pass idempotent / resumable.
if (string.Equals(meta.KekId, newKek.KekId, StringComparison.Ordinal))
{
alreadyCurrent++;
continue;
}
switch (await RewrapOneAsync(meta.Name, oldKek, newKek, rewrapped, ct).ConfigureAwait(false))
{
case RowOutcome.Rewrapped:
rewrapped++;
break;
case RowOutcome.AlreadyCurrent:
alreadyCurrent++;
break;
case RowOutcome.Vanished:
break;
}
}
return new RewrapReport { Total = all.Count, Rewrapped = rewrapped, AlreadyCurrent = alreadyCurrent };
}
private enum RowOutcome { Rewrapped, AlreadyCurrent, Vanished }
// Re-wraps a single row with a bounded fetch→rewrap→compare-and-swap retry so a rare concurrent
// write (the pass should run with writes quiesced) is retried rather than corrupting the row.
private async Task<RowOutcome> RewrapOneAsync(
SecretName name, IMasterKeyProvider oldKek, IMasterKeyProvider newKek, int rewrappedSoFar, CancellationToken ct)
{
const int maxAttempts = 5;
for (int attempt = 1; ; attempt++)
{
ct.ThrowIfCancellationRequested();
StoredSecret? row = await _store.GetAsync(name, ct).ConfigureAwait(false);
if (row is null)
{
// Vanished between the list and the fetch (a concurrent hard-absence).
return RowOutcome.Vanished;
}
// Re-evaluate against the FRESH row: a concurrent write may have already moved it.
if (string.Equals(row.KekId, newKek.KekId, StringComparison.Ordinal))
{
return RowOutcome.AlreadyCurrent;
}
if (!string.Equals(row.KekId, oldKek.KekId, StringComparison.Ordinal))
{
throw new SecretDecryptionException(
$"Secret '{name.Value}' is wrapped by KEK '{row.KekId}', which is neither the old " +
$"('{oldKek.KekId}') nor the new ('{newKek.KekId}') KEK. Rotation aborted after re-wrapping " +
$"{rewrappedSoFar} row(s); investigate the anomalous row and re-run to resume.");
}
// Rewrap re-verifies row.KekId == oldKek.KekId and fails closed on a wrong old key.
StoredSecret rewrappedRow = _cipher.Rewrap(row, oldKek, newKek);
// Compare-and-swap on the wrap we just unwrapped: a concurrent set/rotate changed it →
// 0 rows → retry (fetch the newer row and re-evaluate).
if (await _store.ApplyRewrapAsync(rewrappedRow, row.WrappedDek, ct).ConfigureAwait(false))
{
return RowOutcome.Rewrapped;
}
if (attempt >= maxAttempts)
{
throw new SecretDecryptionException(
$"Secret '{name.Value}' is being modified concurrently; rotation could not converge after " +
$"{maxAttempts} attempts. Re-run with secret writes quiesced.");
}
}
}
}
@@ -0,0 +1,21 @@
namespace ZB.MOM.WW.Secrets.Rotation;
/// <summary>
/// The outcome of a <see cref="KekRotationService.RewrapAllAsync"/> pass: how many rows were
/// re-wrapped onto the new KEK, how many were already on it (skipped), and the total scanned.
/// Carries no secret material.
/// </summary>
public sealed record RewrapReport
{
/// <summary>Total number of rows scanned (includes tombstoned rows).</summary>
public required int Total { get; init; }
/// <summary>Number of rows re-wrapped from the old KEK onto the new KEK in this pass.</summary>
public required int Rewrapped { get; init; }
/// <summary>
/// Number of rows already wrapped by the new KEK and therefore skipped — the count that makes a
/// re-run idempotent (a completed rotation reports <see cref="Rewrapped"/> 0 and all rows here).
/// </summary>
public required int AlreadyCurrent { get; init; }
}
@@ -260,6 +260,43 @@ public sealed class SqliteSecretStore(SecretsSqliteConnectionFactory connectionF
await transaction.CommitAsync(ct).ConfigureAwait(false); await transaction.CommitAsync(ct).ConfigureAwait(false);
} }
/// <inheritdoc />
public async Task<bool> ApplyRewrapAsync(
StoredSecret rewrappedRow, byte[] expectedCurrentWrappedDek, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(rewrappedRow);
ArgumentNullException.ThrowIfNull(expectedCurrentWrappedDek);
await using SqliteConnection connection =
await connectionFactory.OpenConnectionAsync(ct).ConfigureAwait(false);
// Overwrite ONLY the KEK-wrap envelope + kek_id. Revision, updated_utc/by, the sealed body,
// and the tombstone state are deliberately untouched — a re-wrap is not a logical change, so
// it must not bump the revision or updated timestamp (see ISecretStore.ApplyRewrapAsync).
//
// Compare-and-swap on wrapped_dek: a concurrent set/rotate generates a fresh (random) DEK
// wrap, so this matches 0 rows instead of pairing this stale re-wrap with a newer body —
// which would leave the row permanently undecryptable. The caller re-processes a 0-row miss.
await using SqliteCommand command = connection.CreateCommand();
command.CommandText = """
UPDATE secret SET
wrapped_dek = $wrapped_dek,
wrap_nonce = $wrap_nonce,
wrap_tag = $wrap_tag,
kek_id = $kek_id
WHERE name = $name AND wrapped_dek = $expected_wrapped_dek;
""";
command.Parameters.AddWithValue("$wrapped_dek", rewrappedRow.WrappedDek);
command.Parameters.AddWithValue("$wrap_nonce", rewrappedRow.WrapNonce);
command.Parameters.AddWithValue("$wrap_tag", rewrappedRow.WrapTag);
command.Parameters.AddWithValue("$kek_id", rewrappedRow.KekId);
command.Parameters.AddWithValue("$name", rewrappedRow.Name.Value);
command.Parameters.AddWithValue("$expected_wrapped_dek", expectedCurrentWrappedDek);
int rowsAffected = await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
return rowsAffected > 0;
}
// Binds the identity, description, content-type, KEK id, and all six crypto BLOB columns // Binds the identity, description, content-type, KEK id, and all six crypto BLOB columns
// shared by every insert path. // shared by every insert path.
private static void BindCryptoColumns(SqliteCommand command, StoredSecret row) private static void BindCryptoColumns(SqliteCommand command, StoredSecret row)
@@ -19,6 +19,7 @@ public sealed class SecretCommandsTests : IAsyncLifetime, IDisposable
private readonly SecretsSqliteConnectionFactory _factory; private readonly SecretsSqliteConnectionFactory _factory;
private readonly SqliteSecretStore _store; private readonly SqliteSecretStore _store;
private readonly FakeMasterKeyProvider _oldKek = new("kek-cli");
private readonly AesGcmEnvelopeCipher _cipher; private readonly AesGcmEnvelopeCipher _cipher;
private readonly DefaultSecretResolver _resolver; private readonly DefaultSecretResolver _resolver;
@@ -26,7 +27,7 @@ public sealed class SecretCommandsTests : IAsyncLifetime, IDisposable
{ {
_factory = new SecretsSqliteConnectionFactory(_dbPath); _factory = new SecretsSqliteConnectionFactory(_dbPath);
_store = new SqliteSecretStore(_factory); _store = new SqliteSecretStore(_factory);
_cipher = new AesGcmEnvelopeCipher(new FakeMasterKeyProvider("kek-cli")); _cipher = new AesGcmEnvelopeCipher(_oldKek);
// A near-zero TTL keeps the resolver from serving a stale plaintext across a rotate/remove. // A near-zero TTL keeps the resolver from serving a stale plaintext across a rotate/remove.
_resolver = new DefaultSecretResolver( _resolver = new DefaultSecretResolver(
_store, _cipher, NoOpAuditWriter.Instance, TimeSpan.Zero); _store, _cipher, NoOpAuditWriter.Instance, TimeSpan.Zero);
@@ -53,6 +54,45 @@ public sealed class SecretCommandsTests : IAsyncLifetime, IDisposable
private static CancellationToken Ct => CancellationToken.None; private static CancellationToken Ct => CancellationToken.None;
[Fact]
public async Task RewrapAll_MigratesRowsToNewKek_ReportsCountsWithoutLeakingValues()
{
var (seed, _) = NewSut();
await seed.SetAsync("sql/a", "value-a", SecretContentType.Text, null, "alice", Ct);
await seed.SetAsync("sql/b", "value-b", SecretContentType.Text, null, "alice", Ct);
var newKek = new FakeMasterKeyProvider("kek-new");
var (cmd, output) = NewSut();
int rc = await cmd.RewrapAllAsync(_oldKek, newKek, Ct);
Assert.Equal(0, rc);
string outStr = output.ToString();
Assert.Contains("\"action\":\"rewrap-all\"", outStr);
Assert.Contains("\"total\":2", outStr);
Assert.Contains("\"rewrapped\":2", outStr);
Assert.Contains("\"alreadyCurrent\":0", outStr);
// The report carries counts only — never a secret value.
Assert.DoesNotContain("value-a", outStr);
Assert.DoesNotContain("value-b", outStr);
// The rows now decrypt under the NEW KEK (proof the rewrap actually re-keyed them).
var newCipher = new AesGcmEnvelopeCipher(newKek);
Assert.Equal("value-a", newCipher.Decrypt((await _store.GetAsync(new SecretName("sql/a"), Ct))!));
}
[Fact]
public async Task RewrapAll_IdenticalKek_ReturnsErrorExitCode()
{
var (seed, _) = NewSut();
await seed.SetAsync("sql/a", "value-a", SecretContentType.Text, null, "alice", Ct);
var (cmd, output) = NewSut();
int rc = await cmd.RewrapAllAsync(_oldKek, new FakeMasterKeyProvider("kek-cli"), Ct);
Assert.Equal(1, rc);
Assert.Contains("\"error\"", output.ToString());
}
[Fact] [Fact]
public async Task Set_Then_Get_RoundTrips() public async Task Set_Then_Get_RoundTrips()
{ {
@@ -95,6 +95,97 @@ public class AesGcmEnvelopeCipherTests
Assert.Contains("unknown KEK", ex.Message, StringComparison.Ordinal); Assert.Contains("unknown KEK", ex.Message, StringComparison.Ordinal);
} }
[Fact]
public void Rewrap_RewrapsDek_BodyStillDecryptsUnderNewKek()
{
var oldKek = new FakeMasterKeyProvider("kek-old");
var newKek = new FakeMasterKeyProvider("kek-new");
var oldCipher = new AesGcmEnvelopeCipher(oldKek);
var name = new SecretName("sql/foo");
StoredSecret row = oldCipher.Encrypt(name, "hunter2", SecretContentType.Text);
Assert.Equal("kek-old", row.KekId);
// Rewrap is provider-explicit — it can be driven through any cipher instance.
StoredSecret rewrapped = oldCipher.Rewrap(row, oldKek, newKek);
Assert.Equal("kek-new", rewrapped.KekId);
// The DEK-sealed body is untouched; only the KEK-wrap envelope changed.
Assert.True(rewrapped.Ciphertext.AsSpan().SequenceEqual(row.Ciphertext));
Assert.True(rewrapped.Nonce.AsSpan().SequenceEqual(row.Nonce));
Assert.True(rewrapped.Tag.AsSpan().SequenceEqual(row.Tag));
Assert.False(rewrapped.WrappedDek.AsSpan().SequenceEqual(row.WrappedDek));
// The rewrapped row decrypts under the NEW KEK...
var newCipher = new AesGcmEnvelopeCipher(newKek);
Assert.Equal("hunter2", newCipher.Decrypt(rewrapped));
// ...and no longer under the OLD KEK (kek_id now names the new KEK; old cipher fails closed).
Assert.Throws<SecretDecryptionException>(() => oldCipher.Decrypt(rewrapped));
}
[Fact]
public void Rewrap_PreservesRevisionTimestampsTombstoneAndMetadata()
{
var oldKek = new FakeMasterKeyProvider("kek-old");
var newKek = new FakeMasterKeyProvider("kek-new");
var cipher = new AesGcmEnvelopeCipher(oldKek);
StoredSecret row = cipher.Encrypt(new SecretName("sql/foo"), "hunter2", SecretContentType.Text) with
{
Revision = 7,
IsDeleted = true,
DeletedUtc = DateTimeOffset.UnixEpoch,
CreatedUtc = DateTimeOffset.UnixEpoch,
UpdatedUtc = DateTimeOffset.UnixEpoch.AddMinutes(5),
CreatedBy = "alice",
UpdatedBy = "bob",
Description = "the foo password",
};
StoredSecret rewrapped = cipher.Rewrap(row, oldKek, newKek);
// Only the wrap envelope + kek_id change — a rewrap must be invisible to (updated_utc, revision) LWW.
Assert.Equal(7, rewrapped.Revision);
Assert.True(rewrapped.IsDeleted);
Assert.Equal(row.DeletedUtc, rewrapped.DeletedUtc);
Assert.Equal(row.CreatedUtc, rewrapped.CreatedUtc);
Assert.Equal(row.UpdatedUtc, rewrapped.UpdatedUtc);
Assert.Equal("alice", rewrapped.CreatedBy);
Assert.Equal("bob", rewrapped.UpdatedBy);
Assert.Equal("the foo password", rewrapped.Description);
Assert.Equal(SecretContentType.Text, rewrapped.ContentType);
}
[Fact]
public void Rewrap_WrongOldKekId_FailsClosed()
{
var actualKek = new FakeMasterKeyProvider("kek-old");
var wrongOld = new FakeMasterKeyProvider("kek-somethingelse");
var newKek = new FakeMasterKeyProvider("kek-new");
var cipher = new AesGcmEnvelopeCipher(actualKek);
StoredSecret row = cipher.Encrypt(new SecretName("sql/foo"), "hunter2", SecretContentType.Text);
// Row is wrapped by "kek-old" but the caller claims the old KEK is "kek-somethingelse".
var ex = Assert.Throws<SecretDecryptionException>(() => cipher.Rewrap(row, wrongOld, newKek));
Assert.Contains("kek-old", ex.Message, StringComparison.Ordinal);
}
[Fact]
public void Rewrap_CorrectKekIdButWrongKeyMaterial_FailsClosed()
{
// Same KEK-id string, different key bytes → the unwrap must fail closed (tag mismatch),
// never emitting a garbage DEK.
var realOld = new FakeMasterKeyProvider("kek-old");
var imposterOld = new FakeMasterKeyProvider("kek-old"); // same id, fresh random bytes
var newKek = new FakeMasterKeyProvider("kek-new");
var cipher = new AesGcmEnvelopeCipher(realOld);
StoredSecret row = cipher.Encrypt(new SecretName("sql/foo"), "hunter2", SecretContentType.Text);
Assert.Throws<SecretDecryptionException>(() => cipher.Rewrap(row, imposterOld, newKek));
}
private static bool Contains(byte[] haystack, byte[] needle) private static bool Contains(byte[] haystack, byte[] needle)
{ {
if (needle.Length == 0 || haystack.Length < needle.Length) if (needle.Length == 0 || haystack.Length < needle.Length)
@@ -48,4 +48,9 @@ public sealed class CountingSecretStore : ISecretStore
/// <inheritdoc /> /// <inheritdoc />
public Task ApplyReplicatedAsync(StoredSecret row, CancellationToken ct) => public Task ApplyReplicatedAsync(StoredSecret row, CancellationToken ct) =>
throw new NotSupportedException(); throw new NotSupportedException();
/// <inheritdoc />
public Task<bool> ApplyRewrapAsync(
StoredSecret rewrappedRow, byte[] expectedCurrentWrappedDek, CancellationToken ct) =>
throw new NotSupportedException();
} }
@@ -0,0 +1,163 @@
using Microsoft.Data.Sqlite;
using ZB.MOM.WW.Secrets.Abstractions;
using ZB.MOM.WW.Secrets.Crypto;
using ZB.MOM.WW.Secrets.Rotation;
using ZB.MOM.WW.Secrets.Sqlite;
using ZB.MOM.WW.Secrets.Tests.Fakes;
namespace ZB.MOM.WW.Secrets.Tests.Rotation;
/// <summary>
/// End-to-end KEK-rotation tests against the REAL SQLite store and REAL AES-256-GCM cipher — the
/// combination that actually proves a re-wrapped row decrypts under the new KEK (and no longer
/// under the old one).
/// </summary>
public sealed class KekRotationServiceTests : IAsyncLifetime, IDisposable
{
private readonly string _dbPath =
Path.Combine(Path.GetTempPath(), $"zb-secrets-rotate-{Guid.NewGuid():N}.db");
private readonly SecretsSqliteConnectionFactory _factory;
private readonly SqliteSecretStore _store;
private readonly FakeMasterKeyProvider _oldKek = new("kek-old");
private readonly FakeMasterKeyProvider _newKek = new("kek-new");
public KekRotationServiceTests()
{
_factory = new SecretsSqliteConnectionFactory(_dbPath);
_store = new SqliteSecretStore(_factory);
}
public async Task InitializeAsync() =>
await new SqliteSecretsStoreMigrator(_factory).MigrateAsync(CancellationToken.None);
public Task DisposeAsync() => Task.CompletedTask;
// Seeds a secret sealed under a given KEK provider (defaults to the old KEK).
private async Task SeedAsync(string name, string value, IMasterKeyProvider? kek = null)
{
var cipher = new AesGcmEnvelopeCipher(kek ?? _oldKek);
StoredSecret row = cipher.Encrypt(new SecretName(name), value, SecretContentType.Text);
await _store.UpsertAsync(row, CancellationToken.None);
}
private KekRotationService Service() => new(_store, new AesGcmEnvelopeCipher(_oldKek));
[Fact]
public async Task RewrapAll_MigratesEveryRow_DecryptsUnderNewKekOnly()
{
await SeedAsync("sql/a", "value-a");
await SeedAsync("sql/b", "value-b");
await SeedAsync("ldap/c", "value-c");
RewrapReport report = await Service().RewrapAllAsync(_oldKek, _newKek, CancellationToken.None);
Assert.Equal(3, report.Total);
Assert.Equal(3, report.Rewrapped);
Assert.Equal(0, report.AlreadyCurrent);
var newCipher = new AesGcmEnvelopeCipher(_newKek);
var oldCipher = new AesGcmEnvelopeCipher(_oldKek);
foreach ((string name, string value) in new[] { ("sql/a", "value-a"), ("sql/b", "value-b"), ("ldap/c", "value-c") })
{
StoredSecret row = (await _store.GetAsync(new SecretName(name), CancellationToken.None))!;
Assert.Equal("kek-new", row.KekId);
// Decrypts cleanly under the NEW KEK...
Assert.Equal(value, newCipher.Decrypt(row));
// ...and fails closed under the OLD KEK (kek_id no longer matches).
Assert.Throws<SecretDecryptionException>(() => oldCipher.Decrypt(row));
}
}
[Fact]
public async Task RewrapAll_IsIdempotent_SecondRunSkipsEverything()
{
await SeedAsync("sql/a", "value-a");
await SeedAsync("sql/b", "value-b");
RewrapReport first = await Service().RewrapAllAsync(_oldKek, _newKek, CancellationToken.None);
Assert.Equal(2, first.Rewrapped);
RewrapReport second = await Service().RewrapAllAsync(_oldKek, _newKek, CancellationToken.None);
Assert.Equal(2, second.Total);
Assert.Equal(0, second.Rewrapped);
Assert.Equal(2, second.AlreadyCurrent);
}
[Fact]
public async Task RewrapAll_RewrapsTombstonedRows()
{
await SeedAsync("sql/live", "live");
await SeedAsync("sql/dead", "dead");
await _store.DeleteAsync(new SecretName("sql/dead"), "carol", CancellationToken.None);
RewrapReport report = await Service().RewrapAllAsync(_oldKek, _newKek, CancellationToken.None);
Assert.Equal(2, report.Rewrapped);
StoredSecret dead = (await _store.GetAsync(new SecretName("sql/dead"), CancellationToken.None))!;
Assert.Equal("kek-new", dead.KekId);
Assert.True(dead.IsDeleted);
}
[Fact]
public async Task RewrapAll_DoesNotBumpRevisionOrUpdatedUtc()
{
await SeedAsync("sql/a", "value-a");
StoredSecret before = (await _store.GetAsync(new SecretName("sql/a"), CancellationToken.None))!;
await Service().RewrapAllAsync(_oldKek, _newKek, CancellationToken.None);
StoredSecret after = (await _store.GetAsync(new SecretName("sql/a"), CancellationToken.None))!;
Assert.Equal(before.Revision, after.Revision);
Assert.Equal(before.UpdatedUtc, after.UpdatedUtc);
}
[Fact]
public async Task RewrapAll_IdenticalKekIds_Throws()
{
await SeedAsync("sql/a", "value-a");
var same = new FakeMasterKeyProvider("kek-old");
await Assert.ThrowsAsync<ArgumentException>(
() => Service().RewrapAllAsync(_oldKek, same, CancellationToken.None));
}
[Fact]
public async Task RewrapAll_RowOnUnknownKek_Aborts_ButKeepsPriorProgress()
{
// Names are scanned in ORDER BY name: "a-old" and "b-old" migrate, then "z-third" (a KEK that
// is neither old nor new) aborts the pass.
await SeedAsync("a-old", "va");
await SeedAsync("b-old", "vb");
await SeedAsync("z-third", "vz", new FakeMasterKeyProvider("kek-third"));
await Assert.ThrowsAsync<SecretDecryptionException>(
() => Service().RewrapAllAsync(_oldKek, _newKek, CancellationToken.None));
// The two old-KEK rows scanned before the anomaly are already persisted on the new KEK,
// so a re-run (after removing the anomaly) resumes cleanly.
Assert.Equal("kek-new", (await _store.GetAsync(new SecretName("a-old"), CancellationToken.None))!.KekId);
Assert.Equal("kek-new", (await _store.GetAsync(new SecretName("b-old"), CancellationToken.None))!.KekId);
Assert.Equal("kek-third", (await _store.GetAsync(new SecretName("z-third"), CancellationToken.None))!.KekId);
}
public void Dispose()
{
SqliteConnection.ClearAllPools();
foreach (string path in new[] { _dbPath, _dbPath + "-wal", _dbPath + "-shm" })
{
try
{
if (File.Exists(path))
{
File.Delete(path);
}
}
catch (IOException)
{
// Best-effort temp cleanup.
}
}
}
}
@@ -204,6 +204,101 @@ public sealed class SqliteSecretStoreTests : IAsyncLifetime, IDisposable
Assert.Equal(new byte[] { 6, 6, 6 }, afterStale.Ciphertext); Assert.Equal(new byte[] { 6, 6, 6 }, afterStale.Ciphertext);
} }
[Fact]
public async Task ApplyRewrap_ChangesOnlyWrapEnvelopeAndKekId_PreservingRevisionBodyAndTimestamps()
{
DateTimeOffset created = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
StoredSecret seed = MakeSecret("app/rewrap", ciphertext: [1, 2, 3], createdUtc: created);
await _store.UpsertAsync(seed, CancellationToken.None);
// Bump the revision once so we can prove ApplyRewrap does NOT change it.
await _store.UpsertAsync(MakeSecret("app/rewrap", ciphertext: [1, 2, 3]), CancellationToken.None);
StoredSecret before = (await _store.GetAsync(new SecretName("app/rewrap"), CancellationToken.None))!;
Assert.Equal(1, before.Revision);
// Re-wrap: only the four wrap-related fields differ; everything else is carried from `before`.
StoredSecret rewrapped = before with
{
WrappedDek = [90, 91, 92],
WrapNonce = [93, 94, 95],
WrapTag = [96, 97, 98],
KekId = "kek-2",
};
bool applied = await _store.ApplyRewrapAsync(rewrapped, before.WrappedDek, CancellationToken.None);
Assert.True(applied);
StoredSecret after = (await _store.GetAsync(new SecretName("app/rewrap"), CancellationToken.None))!;
// Wrap envelope + kek_id changed...
Assert.Equal(new byte[] { 90, 91, 92 }, after.WrappedDek);
Assert.Equal(new byte[] { 93, 94, 95 }, after.WrapNonce);
Assert.Equal(new byte[] { 96, 97, 98 }, after.WrapTag);
Assert.Equal("kek-2", after.KekId);
// ...but revision, updated timestamp, the sealed body, and audit stamps are untouched.
Assert.Equal(before.Revision, after.Revision);
Assert.Equal(before.UpdatedUtc, after.UpdatedUtc);
Assert.Equal(before.CreatedUtc, after.CreatedUtc);
Assert.Equal(before.UpdatedBy, after.UpdatedBy);
Assert.Equal(new byte[] { 1, 2, 3 }, after.Ciphertext);
Assert.Equal(before.Nonce, after.Nonce);
Assert.Equal(before.Tag, after.Tag);
}
[Fact]
public async Task ApplyRewrap_RewrapsTombstonedRows()
{
await _store.UpsertAsync(MakeSecret("app/dead"), CancellationToken.None);
await _store.DeleteAsync(new SecretName("app/dead"), "carol", CancellationToken.None);
StoredSecret tomb = (await _store.GetAsync(new SecretName("app/dead"), CancellationToken.None))!;
Assert.True(tomb.IsDeleted);
bool applied = await _store.ApplyRewrapAsync(
tomb with { WrappedDek = [1], WrapNonce = [2], WrapTag = [3], KekId = "kek-2" },
tomb.WrappedDek,
CancellationToken.None);
Assert.True(applied);
StoredSecret after = (await _store.GetAsync(new SecretName("app/dead"), CancellationToken.None))!;
Assert.Equal("kek-2", after.KekId);
// The tombstone survives the rewrap.
Assert.True(after.IsDeleted);
Assert.Equal(tomb.Revision, after.Revision);
}
[Fact]
public async Task ApplyRewrap_ReturnsFalse_WhenRowAbsent()
{
StoredSecret ghost = MakeSecret("never-existed") with { KekId = "kek-2" };
bool applied = await _store.ApplyRewrapAsync(ghost, ghost.WrappedDek, CancellationToken.None);
Assert.False(applied);
}
[Fact]
public async Task ApplyRewrap_ReturnsFalse_WhenCurrentWrapChanged_UnderConcurrentWrite()
{
StoredSecret original = MakeSecret("app/cas", ciphertext: [1, 2, 3]);
await _store.UpsertAsync(original, CancellationToken.None);
StoredSecret before = (await _store.GetAsync(new SecretName("app/cas"), CancellationToken.None))!;
// Simulate a concurrent set/rotate: a fresh write changes the wrapped DEK out from under us.
await _store.UpsertAsync(
MakeSecret("app/cas", ciphertext: [9, 9, 9]) with { WrappedDek = [77, 78, 79] },
CancellationToken.None);
// A rewrap that still expects the ORIGINAL wrap must NOT apply (0 rows) — it would otherwise
// pair a stale re-wrap with the newer body and corrupt the row.
bool applied = await _store.ApplyRewrapAsync(
before with { WrappedDek = [1], WrapNonce = [2], WrapTag = [3], KekId = "kek-2" },
before.WrappedDek,
CancellationToken.None);
Assert.False(applied);
StoredSecret after = (await _store.GetAsync(new SecretName("app/cas"), CancellationToken.None))!;
// The concurrent write's wrap survived untouched; no stale re-wrap was applied.
Assert.Equal(new byte[] { 77, 78, 79 }, after.WrappedDek);
Assert.Equal("kek-1", after.KekId);
}
public void Dispose() public void Dispose()
{ {
// Drop pooled connections so the WAL/-shm/-wal sidecars release before we delete. // Drop pooled connections so the WAL/-shm/-wal sidecars release before we delete.
@@ -42,4 +42,8 @@ public sealed class FakeCipher : ISecretCipher
return Encoding.UTF8.GetString(secret.Ciphertext); return Encoding.UTF8.GetString(secret.Ciphertext);
} }
/// <inheritdoc />
public StoredSecret Rewrap(StoredSecret secret, IMasterKeyProvider oldKek, IMasterKeyProvider newKek)
=> secret with { KekId = newKek.KekId };
} }
@@ -88,4 +88,9 @@ public sealed class RecordingSecretStore : ISecretStore
/// <inheritdoc /> /// <inheritdoc />
public Task ApplyReplicatedAsync(StoredSecret row, CancellationToken ct) public Task ApplyReplicatedAsync(StoredSecret row, CancellationToken ct)
=> throw new NotSupportedException(); => throw new NotSupportedException();
/// <inheritdoc />
public Task<bool> ApplyRewrapAsync(
StoredSecret rewrappedRow, byte[] expectedCurrentWrappedDek, CancellationToken ct)
=> throw new NotSupportedException();
} }
@@ -124,5 +124,9 @@ public class SecretsListTests : TestContext
public Task ApplyReplicatedAsync(StoredSecret row, CancellationToken ct) public Task ApplyReplicatedAsync(StoredSecret row, CancellationToken ct)
=> throw new NotSupportedException(); => throw new NotSupportedException();
public Task<bool> ApplyRewrapAsync(
StoredSecret rewrappedRow, byte[] expectedCurrentWrappedDek, CancellationToken ct)
=> throw new NotSupportedException();
} }
} }
+37 -8
View File
@@ -39,6 +39,14 @@ construction).
parallelization for Host.Tests). parallelization for Host.Tests).
- **OtOpcUa — G-2 + G-4 + G-5 + G-6 DONE (the LAST app — all three now adopted).** Executed 2026-07-16 via the plan below (10 tasks, 2 slices, subagent-driven, classification-driven reviews), **FF-merged + pushed to `origin/master` (lmxopcua @ `872cf7e3`, `ec6598ce..872cf7e3`)**. Clean build (0 errors). The full offline suite caught **one real regression**`AddOtOpcUaDriverFactories`' registration now resolves `ISecretResolver` (Galaxy/OpcUaClient secret: refs), so two `ResilienceInvokerFactoryRegistrationTests` that built a minimal container threw `No service for ISecretResolver`; fixed test-only by registering a stub resolver (production always has it via the unconditional `AddZbSecrets`, and `GetRequiredService` correctly fails-fast for a genuinely misconfigured host). Touched-area suites green (Galaxy 338, OpcUaClient 142, AdminUI 662); remaining non-passes are unrelated (flaky Akka/Roslyn timing tests pass in isolation; the environmental `AbCip_Green_AgainstSim` sim-fixture test — zero AbCip code touched). **Three deliberate deviations from the plan's literal text** (verified corrections, same class as the CPM miss the plan itself caught): (1) **G-4 does NOT commit a `${secret:}` token into the default appsettings** — the expander is fail-closed AND section-agnostic and the only committed secret-shaped value is `ServerHistorian:ApiKey=""` (empty, disabled-by-default), so a committed token would break every dev/CI/`TwoNodeClusterHarness` boot for zero at-rest benefit; instead documented the token-delivery convention (`docs/security.md`) + updated the ServerHistorian comments, and proved fail-closed live (unseeded → `SecretNotFoundException` at pre-host expansion; seeded → resolves). (2) **G-5 shipped docs-only** (`docs/operations/2026-07-16-secrets-clustered-master-key.md`) — hardcoding `Source=File`+shared paths into the committed role-overlay appsettings (also consumed by dev + integration) would break those boots; base stays `Source=Environment`. Mirrors the ScadaBridge G-5 resolution. (3) **Task 8 resolves in the factory/driver only, NOT the probe** — the v3 `OpcUaClientDriverProbe` is unauthenticated `GetEndpoints`-only and never reads `Password`/`UserCertificatePassword`, so probe resolution would be dead code. Layer-B resolution mirrors Galaxy: the sync driver-registry factory means resolution happens lazily at the driver's async session-open (`GalaxyDriver.BuildClientOptionsAsync` / `OpcUaClientDriver.InitializeAsync`), threaded via ctor + `DriverFactoryBootstrap` (real resolver from DI; `NullSecretResolver` null-object backs test/parse paths only, fail-closed on `secret:`). `OpcUaClientDriverOptions` was converted `sealed class``sealed record` for the `with`-expression (no positional params → identical JSON; verified no reference-equality/dict-key/ToString-log usages → no leak/behavior change). AdminUI G-2c is structurally leak-proof: `DriverConfig` persists as an opaque JSON string via `RawTreeService`, which has NO resolver dependency. **Live wonder gate PROVEN 2026-07-16 (VPN up):** a throwaway harness drove OtOpcUa's real `GalaxyDriverBrowser` (resolves the `secret:` `ApiKeySecretRef` via `ISecretResolver``GalaxySecretRef.ResolveApiKeyAsync` → connects) against the REAL production MxGateway `wonder-app-vd03:5120`. **Dummy `secret:` ref → `MxGatewayAuthenticationException` (proves the resolved value is transmitted, not fail-closed-first); real `metadata:read` key → CONNECTED + browsed the real Galaxy root (10 nodes: ControlSystem/EUR/KPI_CNC/NAM/_TestArea/MESReceiver…).** Temp key minted via the gateway dashboard, then **revoked (functionally proven — the same key that browsed 10 nodes then returned Unauthenticated) + deleted** (row gone; no prod key touched; the dashboard key-list is stale-cached so revoke/delete were verified FUNCTIONALLY, not by the list UI). Harness + scratch stores destroyed. **All of G-2/G-4/G-5/G-6 now DONE + live-proven across all three apps + mxaccessgw.** - **OtOpcUa — G-2 + G-4 + G-5 + G-6 DONE (the LAST app — all three now adopted).** Executed 2026-07-16 via the plan below (10 tasks, 2 slices, subagent-driven, classification-driven reviews), **FF-merged + pushed to `origin/master` (lmxopcua @ `872cf7e3`, `ec6598ce..872cf7e3`)**. Clean build (0 errors). The full offline suite caught **one real regression**`AddOtOpcUaDriverFactories`' registration now resolves `ISecretResolver` (Galaxy/OpcUaClient secret: refs), so two `ResilienceInvokerFactoryRegistrationTests` that built a minimal container threw `No service for ISecretResolver`; fixed test-only by registering a stub resolver (production always has it via the unconditional `AddZbSecrets`, and `GetRequiredService` correctly fails-fast for a genuinely misconfigured host). Touched-area suites green (Galaxy 338, OpcUaClient 142, AdminUI 662); remaining non-passes are unrelated (flaky Akka/Roslyn timing tests pass in isolation; the environmental `AbCip_Green_AgainstSim` sim-fixture test — zero AbCip code touched). **Three deliberate deviations from the plan's literal text** (verified corrections, same class as the CPM miss the plan itself caught): (1) **G-4 does NOT commit a `${secret:}` token into the default appsettings** — the expander is fail-closed AND section-agnostic and the only committed secret-shaped value is `ServerHistorian:ApiKey=""` (empty, disabled-by-default), so a committed token would break every dev/CI/`TwoNodeClusterHarness` boot for zero at-rest benefit; instead documented the token-delivery convention (`docs/security.md`) + updated the ServerHistorian comments, and proved fail-closed live (unseeded → `SecretNotFoundException` at pre-host expansion; seeded → resolves). (2) **G-5 shipped docs-only** (`docs/operations/2026-07-16-secrets-clustered-master-key.md`) — hardcoding `Source=File`+shared paths into the committed role-overlay appsettings (also consumed by dev + integration) would break those boots; base stays `Source=Environment`. Mirrors the ScadaBridge G-5 resolution. (3) **Task 8 resolves in the factory/driver only, NOT the probe** — the v3 `OpcUaClientDriverProbe` is unauthenticated `GetEndpoints`-only and never reads `Password`/`UserCertificatePassword`, so probe resolution would be dead code. Layer-B resolution mirrors Galaxy: the sync driver-registry factory means resolution happens lazily at the driver's async session-open (`GalaxyDriver.BuildClientOptionsAsync` / `OpcUaClientDriver.InitializeAsync`), threaded via ctor + `DriverFactoryBootstrap` (real resolver from DI; `NullSecretResolver` null-object backs test/parse paths only, fail-closed on `secret:`). `OpcUaClientDriverOptions` was converted `sealed class``sealed record` for the `with`-expression (no positional params → identical JSON; verified no reference-equality/dict-key/ToString-log usages → no leak/behavior change). AdminUI G-2c is structurally leak-proof: `DriverConfig` persists as an opaque JSON string via `RawTreeService`, which has NO resolver dependency. **Live wonder gate PROVEN 2026-07-16 (VPN up):** a throwaway harness drove OtOpcUa's real `GalaxyDriverBrowser` (resolves the `secret:` `ApiKeySecretRef` via `ISecretResolver``GalaxySecretRef.ResolveApiKeyAsync` → connects) against the REAL production MxGateway `wonder-app-vd03:5120`. **Dummy `secret:` ref → `MxGatewayAuthenticationException` (proves the resolved value is transmitted, not fail-closed-first); real `metadata:read` key → CONNECTED + browsed the real Galaxy root (10 nodes: ControlSystem/EUR/KPI_CNC/NAM/_TestArea/MESReceiver…).** Temp key minted via the gateway dashboard, then **revoked (functionally proven — the same key that browsed 10 nodes then returned Unauthenticated) + deleted** (row gone; no prod key touched; the dashboard key-list is stale-cached so revoke/delete were verified FUNCTIONALLY, not by the list UI). Harness + scratch stores destroyed. **All of G-2/G-4/G-5/G-6 now DONE + live-proven across all three apps + mxaccessgw.**
**Plan re-verified 2026-07-16 against `origin/master` @ `ec6598ce`** (the merged **v3.0 dual-namespace** rewrite, PR #472). The v3.0 rewrite did NOT touch the driver-secret flow, so the approach held; the plan was corrected for: OtOpcUa **is** on Central Package Management (was mis-stated as inline-versioned), `OpcUaClientDriverOptions` lives in `…Driver.OpcUaClient.Contracts` + `GalaxyDriverBrowser` in `…Driver.Galaxy.Browser`, and `DriverHostActor.cs` line shifts (1704/1756/1790 → 1766/1818/1852). Program.cs / Security / AdminUI anchors survived essentially unchanged. **Plan re-verified 2026-07-16 against `origin/master` @ `ec6598ce`** (the merged **v3.0 dual-namespace** rewrite, PR #472). The v3.0 rewrite did NOT touch the driver-secret flow, so the approach held; the plan was corrected for: OtOpcUa **is** on Central Package Management (was mis-stated as inline-versioned), `OpcUaClientDriverOptions` lives in `…Driver.OpcUaClient.Contracts` + `GalaxyDriverBrowser` in `…Driver.Galaxy.Browser`, and `DriverHostActor.cs` line shifts (1704/1756/1790 → 1766/1818/1852). Program.cs / Security / AdminUI anchors survived essentially unchanged.
- **G-8 (KEK rotation) — BUILT; G-7 (clustered replication) — DESIGNED + PLANNED (2026-07-17).**
G-8 shipped into `ZB.MOM.WW.Secrets` (lib `0.1.3`): the `Rewrap` DEK primitive, CAS-guarded
`ApplyRewrapAsync`, `KekRotationService.RewrapAllAsync`, the `secret rewrap-all` CLI verb, and an
operator runbook — full suite green (82+15, 0 regressions) + end-to-end CLI smoke, adversarial
crypto review PASS with a review-caught TOCTOU closed via compare-and-swap. G-7's SQL-store-vs-Akka
fork is resolved (build the shared SQL-Server `ISecretStore`; Akka replicator deferred phase-2)
with a design doc + executable plan + `.tasks.json`, **no code built**. See the G-7/G-8 backlog
entries below. Not yet committed/pushed (awaiting go-ahead).
## Design + implementation plans (2026-07-16) ## Design + implementation plans (2026-07-16)
@@ -55,7 +63,7 @@ code-verified anchors, co-located `.tasks.json`):
- [`docs/plans/2026-07-16-secrets-adoption-mxaccessgw.md`](../../docs/plans/2026-07-16-secrets-adoption-mxaccessgw.md) - [`docs/plans/2026-07-16-secrets-adoption-mxaccessgw.md`](../../docs/plans/2026-07-16-secrets-adoption-mxaccessgw.md)
**G-4 + G-5 + G-6** (8 tasks; single-box, no Layer B). **G-4 + G-5 + G-6** (8 tasks; single-box, no Layer B).
G-7/G-8 remain design-only/deferred (below). **G-8 (KEK rotation) is now BUILT; G-7 (clustered replication) is now DESIGNED + PLANNED** (2026-07-17, below).
## Cross-cutting observations ## Cross-cutting observations
@@ -109,14 +117,35 @@ mounted key)** for clustered pairs (hard requirement — same KEK on every node)
Add the RCL page to each dashboard (OtOpcUa AdminUI, MxGateway Server, ScadaBridge CentralUI) Add the RCL page to each dashboard (OtOpcUa AdminUI, MxGateway Server, ScadaBridge CentralUI)
and map each app's admin role onto `secrets:manage` / `secrets:reveal`. and map each app's admin role onto `secrets:manage` / `secrets:reveal`.
### G-7 — Clustered replication (ScadaBridge, OtOpcUa) — build `ZB.MOM.WW.Secrets.Akka` ### G-7 — Clustered replication (ScadaBridge, OtOpcUa) — DESIGNED + PLANNED (2026-07-17)
Deferred until G-2…G-6 land for a clustered app. Options per SPEC: shared SQL-Server The SPEC's "shared SQL-Server store **vs** Akka replicator" fork is **resolved: build Option A —
`ISecretStore` (simplest — mirrors the shared Data-Protection key ring) **or** the Akka a shared SQL-Server `ISecretStore`** (both clustered apps already run shared SQL + a shared
replicator (LWW, anti-entropy resync, tombstones). Requires a shared KEK. Data-Protection key ring, so it delivers cluster-wide secrets with zero distributed-systems failure
modes); the Akka replicator (`ZB.MOM.WW.Secrets.Akka`, LWW/anti-entropy/tombstones) is a **deferred
phase-2** to build only on a stated partition-tolerance requirement. Both require a shared KEK; the
`ISecretReplicator` seam stays in place so Option B remains a drop-in later. Design + executable
plan (+ `.tasks.json`): [`docs/plans/2026-07-17-secrets-g7-clustered-replication-design.md`](../../docs/plans/2026-07-17-secrets-g7-clustered-replication-design.md)
+ [`docs/plans/2026-07-17-secrets-g7-sqlserver-store.md`](../../docs/plans/2026-07-17-secrets-g7-sqlserver-store.md). **No code built yet — plan is ready to execute.**
### G-8 — KEK-rotation runbook + `RewrapAll` ### G-8 — KEK-rotation `RewrapAll` + runbook ✅ BUILT (2026-07-17, lib `0.1.3`)
The `RewrapAll(oldKek, newKek)` admin primitive + an operator runbook. Deferred (design-only The `RewrapAll(oldKek, newKek)` admin primitive + operator runbook are **built + fully tested** in
in this cut). `ZB.MOM.WW.Secrets`. Because the envelope wraps a per-secret DEK under the KEK, rotation **re-wraps
DEKs only** — bodies are never re-encrypted, revision/timestamps are preserved (invisible to cluster
LWW). Shipped: `ISecretCipher.Rewrap(row, oldKek, newKek)` (fail-closed on wrong old KEK; DEK zeroed
on all paths; also catches malformed-length wraps → `SecretDecryptionException`),
`ISecretStore.ApplyRewrapAsync(rewrappedRow, expectedCurrentWrappedDek)` (updates only the 4 wrap
columns + `kek_id`, **compare-and-swap on the current wrapped DEK** so a concurrent write can't
corrupt a row — review-caught TOCTOU), `KekRotationService.RewrapAllAsync` + `RewrapReport`
(enumerates all rows incl. tombstones, idempotent/resumable skip-already-current, bounded CAS-retry,
fail-closed on unknown/identical KEK), the `secret rewrap-all` CLI verb (key material only via
env-var-name/file-path — never a literal), README section, and the operator runbook
[`ZB.MOM.WW.Secrets/docs/operations/kek-rotation.md`](../../ZB.MOM.WW.Secrets/docs/operations/kek-rotation.md).
**Verified:** full offline suite green (82 core + 15 UI, 0 regressions) + an end-to-end CLI smoke
(migrate → decrypts under new KEK, fail-closed under old → idempotent re-run → identical-KEK guard).
Version bumped `0.1.2``0.1.3` (additive interface members); **republish to the Gitea feed is a
separate opt-in step (the apps don't need rewrap — it's a library/CLI admin primitive).** Reviewed
by an adversarial crypto/correctness pass (all 7 categories PASS); the one real finding (concurrent-
write TOCTOU) is fixed by the CAS above, plus two low/nit fixes folded in.
## Out of scope (this component) ## Out of scope (this component)
@@ -0,0 +1,102 @@
# G-7 — Clustered secret replication: design & fork resolution
> **Status:** design-only (G-7 in [`components/secrets/GAPS.md`](../../components/secrets/GAPS.md)).
> Companion executable plan: [`2026-07-17-secrets-g7-sqlserver-store.md`](2026-07-17-secrets-g7-sqlserver-store.md).
> Prerequisite G-2…G-6 have landed for both clustered apps (ScadaBridge, OtOpcUa); G-8 (KEK
> rotation) is built. This resolves the SPEC's "shared SQL store **vs** Akka replicator" fork and
> specifies the recommended build.
## Problem
`ZB.MOM.WW.Secrets` stores secrets in a **local SQLite** file by default. The two Akka-clustered
apps run **multiple nodes** (ScadaBridge: central pair + N site pairs; OtOpcUa: admin/driver/dev
roles). A secret written on one node must be resolvable on every node that needs it, **without**
exposing plaintext or the KEK over the wire, and **without** each node holding a divergent copy.
The library was built anticipating this: the schema already carries `revision` / `updated_utc` /
`is_deleted`; `ISecretStore` exposes `GetManifestAsync` + `ApplyReplicatedAsync` (last-writer-wins);
`ISecretReplicator` is a no-op seam; `SecretManifestEntry` supports anti-entropy. Nothing needs a
migration to turn replication on.
## The fork (from SPEC §"Clustered pairs")
Two ways to make one logical secret set visible cluster-wide. **Both require a shared KEK** — every
node must resolve the *same* master key (shared mounted key file or shared env), because a row whose
`kek_id` doesn't match the local provider fails closed on resolve. Ciphertext can cross the wire /
sit in a shared DB safely because the KEK never does.
### Option A — Shared SQL-Server `ISecretStore` (one source of truth)
Point every node's `ISecretStore` at **one shared SQL-Server database** instead of a per-node
SQLite file. There is exactly one copy of each row; no replication, no reconciliation. The store
still holds **ciphertext only** (the KEK stays per-node/out-of-DB). Build a
`SqlServerSecretStore : ISecretStore` behind the existing seam (the SPEC already scoped "SQL-Server
`ISecretStore` provider construction — build with G-7").
- **Pro:** zero distributed-systems code or failure modes (no split-brain, no anti-entropy lag, no
tombstone GC, no LWW clock-skew). ~1 new file mirroring `SqliteSecretStore`'s SQL + a migrator.
- **Pro:** both apps **already run shared SQL Server** (ScadaBridge ConfigDb + central `dbo.*`;
OtOpcUa central config DB) and already share their Data-Protection key ring through SQL — a shared
secret store is operationally identical to what ops already run and back up.
- **Pro:** rotation (G-8 `rewrap-all`) runs **once** against the one store.
- **Con:** availability is coupled to the shared DB. A brief outage is bridged by the resolver's
in-memory TTL cache; but a node that must keep resolving secrets while *partitioned from* the
shared DB is not served by this option (see Option B).
### Option B — Akka replicator `ZB.MOM.WW.Secrets.Akka` (each node keeps a local store)
Each node keeps its **local SQLite** store; a real `ISecretReplicator` broadcasts each newly-written
encrypted row to peers, and a cluster anti-entropy actor periodically exchanges manifests
(`GetManifestAsync`) and pulls missing/newer rows (`ApplyReplicatedAsync`, LWW). Tombstones
propagate deletes.
- **Pro:** each node resolves from local state, so a node **survives partition** from its peers /
from any central DB — the right answer for air-gapped or intermittently-connected sites.
- **Con:** a whole new package + actor lifecycle (a cluster-singleton reconciler or per-node gossip),
message serialization for `StoredSecret`, LWW edge cases (clock skew, tombstone retention), and —
critically — it can only be *proven* against a live multi-node cluster, a G-2-class live-validation
effort per app.
## Decision
**Build Option A (shared SQL-Server `ISecretStore`) as G-7.** Specify Option B as a **deferred
phase-2**, to be built only when an app declares a concrete requirement to resolve secrets while
partitioned from the shared store (an availability SLA neither app states today).
Rationale: Option A delivers cluster-wide secrets for both apps with the least new code and **no new
failure modes**, reusing the shared-SQL operational posture both apps already depend on for config,
audit, and the DP key ring. The clustered-secrets requirement today is "every node sees the same
secrets," which a shared store satisfies exactly. Partition-tolerance (Option B's only unique
benefit) is a stronger, unstated requirement; paying its distributed-systems complexity now would be
speculative (YAGNI). The `ISecretReplicator` seam stays in place, so Option B remains a drop-in later
with **no change** to consumers or the wire/store contract.
## Option A — build shape (detail in the executable plan)
1. **`SqlServerSecretStore : ISecretStore`** — mirrors `SqliteSecretStore` in T-SQL: `GetAsync`,
`UpsertAsync` (revision bump via `MERGE`/`UPDATE`), `DeleteAsync` (tombstone), `ListAsync`
(metadata projection — never ciphertext), `GetManifestAsync`, `ApplyReplicatedAsync` (LWW under a
serializable transaction), and **`ApplyRewrapAsync`** (G-8; UPDATE the 4 wrap columns only).
`Microsoft.Data.SqlClient`, fully parameterized.
2. **`SqlServerSecretsStoreMigrator`** — schema-versioned, idempotent (`IF NOT EXISTS`), same column
set/semantics as SQLite; `varbinary(max)` for the crypto BLOBs, `datetimeoffset` (or ISO-8601
text, to match the SQLite round-trip exactly) for timestamps.
3. **DI selection** — add `SecretsOptions.Store` (`Sqlite` default | `SqlServer`) +
`SqlServerConnectionString`; `AddZbSecrets` binds the store + migrator from it. SQLite stays the
default so single-process consumers (HistorianGateway, mxaccessgw) are unaffected.
4. **Adoption (ScadaBridge, OtOpcUa)** — set `Secrets:Store = SqlServer` + a connection string
(delivered via `${secret:}` / a `secret:` ref like every other connstr), and ensure **the same
KEK** on every node (shared key file or shared env). Run G-8 `rewrap-all` once against the shared
store when the KEK rotates.
## Hard constraints (both options)
- **Same KEK on every node.** Non-negotiable — a mismatched `kek_id` fails closed on resolve.
- **Ciphertext only crosses trust boundaries.** The store/wire never carries plaintext or the KEK.
- **Rotation is per independent store.** G-8 `rewrap-all`: once for the shared SQL store (Option A);
once per node for per-node SQLite (Option B).
## Out of scope
- Akka remoting auth/TLS hardening (a separate concern from secret storage).
- Option B's actual construction — captured as the deferred phase-2 in the executable plan.
@@ -0,0 +1,122 @@
# G-7 (Option A) — Shared SQL-Server `ISecretStore` Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans (or
> subagent-driven-development) to implement this plan task-by-task.
**Goal:** Add a shared SQL-Server `ISecretStore` provider to `ZB.MOM.WW.Secrets` so the Akka-clustered
apps (ScadaBridge, OtOpcUa) can back their secrets with one shared, ciphertext-only database — every
node sees the same secrets, no replication code.
**Architecture:** Mirror the existing `SqliteSecretStore` + `SqliteSecretsStoreMigrator` in T-SQL
behind the unchanged `ISecretStore` seam. Select the store in `AddZbSecrets` from a new
`SecretsOptions.Store` enum (SQLite stays the default). The KEK stays per-node/out-of-DB, so the DB
holds only ciphertext. See the design + fork rationale in
[`2026-07-17-secrets-g7-clustered-replication-design.md`](2026-07-17-secrets-g7-clustered-replication-design.md).
**Tech Stack:** .NET 10, `Microsoft.Data.SqlClient`, xUnit + Shouldly, env-gated live SQL tests
(LocalDB or a `mcr.microsoft.com/mssql/server` container), matching the app live-test idiom.
**Precondition:** G-8 is merged — `ISecretStore.ApplyRewrapAsync` exists and the SQL-Server store
must implement it. `rewrap-all` then runs once against the shared store.
---
### Task 1: SQL-Server schema + migrator
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none (Task 2 depends on it)
**Files:**
- Create: `src/ZB.MOM.WW.Secrets/SqlServer/SqlServerSecretsSchema.cs`
- Create: `src/ZB.MOM.WW.Secrets/SqlServer/SqlServerSecretsStoreMigrator.cs`
- Create: `src/ZB.MOM.WW.Secrets/SqlServer/SecretsSqlServerConnectionFactory.cs`
- Test: `tests/ZB.MOM.WW.Secrets.Tests/SqlServer/SqlServerSecretsStoreMigratorTests.cs` (env-gated)
**Notes:**
- Mirror `SqliteSecretsSchema` / `SqliteSecretsStoreMigrator` semantics: `schema_version` single-row
table + `secret` table, `CurrentVersion = 1`, idempotent `IF NOT EXISTS` DDL, refuse a newer
on-disk version. Column set identical to SQLite; use `varbinary(max)` for the 6 crypto BLOBs,
`nvarchar` for text, and store timestamps as **ISO-8601 (`"O"`) text** so `StoredSecret`
round-trips byte-identically to the SQLite path (avoids `datetimeoffset` precision drift in tests).
- Add `Microsoft.Data.SqlClient` to `Directory.Packages.props` + the core csproj.
- Gate live tests on an env var (e.g. `SECRETS_SQLSERVER_CONNSTR`); skip cleanly when unset.
### Task 2: `SqlServerSecretStore : ISecretStore`
**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** none (depends on Task 1)
**Files:**
- Create: `src/ZB.MOM.WW.Secrets/SqlServer/SqlServerSecretStore.cs`
- Test: `tests/ZB.MOM.WW.Secrets.Tests/SqlServer/SqlServerSecretStoreTests.cs` (env-gated; port the
`SqliteSecretStoreTests` cases 1:1, incl. the G-8 `ApplyRewrap*` cases)
**Notes:**
- Implement all 7 members: `GetAsync`, `UpsertAsync` (revision bump — `MERGE` or
insert/`ON CONFLICT`-equivalent via `UPDATE`+`INSERT` under a transaction), `DeleteAsync`
(tombstone + revision bump), `ListAsync` (metadata projection — **never** the crypto columns),
`GetManifestAsync`, `ApplyReplicatedAsync` (LWW under `IsolationLevel.Serializable`, applied
verbatim, no revision bump), `ApplyRewrapAsync` (UPDATE the 4 wrap columns + `kek_id` only; no
revision/updated_utc change).
- Fully parameterized commands; same "created_utc/created_by preserved on overwrite" semantics as
SQLite. This is high-risk because the LWW + rewrap semantics MUST match SQLite exactly (a cluster
reconciles across both if a node ever migrates).
### Task 3: DI store-selection wiring
**Classification:** standard
**Estimated implement time:** ~4 min
**Parallelizable with:** none (depends on Task 2)
**Files:**
- Modify: `src/ZB.MOM.WW.Secrets/SecretsOptions.cs` (add `SecretsStoreKind Store` = `Sqlite` default;
add `string? SqlServerConnectionString`)
- Create: `src/ZB.MOM.WW.Secrets/SecretsStoreKind.cs` (enum `Sqlite | SqlServer`)
- Modify: `src/ZB.MOM.WW.Secrets/DependencyInjection/SecretsServiceCollectionExtensions.cs`
(branch the `ISecretStore` + migrator registration on `Store`; keep SQLite the default so
HistorianGateway/mxaccessgw are unaffected)
- Modify: `src/ZB.MOM.WW.Secrets/DependencyInjection/SecretsMigrationHostedService.cs` if it hard-refs
the SQLite migrator type (abstract behind a shared migrator seam or a switch)
- Test: `tests/ZB.MOM.WW.Secrets.Tests/DependencyInjection/AddZbSecretsTests.cs` (assert the correct
store type is resolved for each `Store` value)
**Notes:**
- Introduce a tiny `ISecretsStoreMigrator` seam (both migrators implement it) so the hosted service +
CLI stay store-agnostic, OR switch on `Store` at registration. Prefer the seam — cleaner.
- The connection string should itself be deliverable via `${secret:}` / a `secret:` ref (consumers
already do this for other connstrings).
### Task 4: Docs + adoption notes
**Classification:** small
**Estimated implement time:** ~3 min
**Parallelizable with:** Task 3
**Files:**
- Modify: `README.md` (Clustered deployments: document `Secrets:Store = SqlServer` + shared-KEK
requirement + "run `rewrap-all` once against the shared store")
- Create: `docs/operations/shared-sql-store.md` (operator setup: provision the DB, grant, set the
connstr via a secret ref, confirm the same KEK on every node)
- Modify: `components/secrets/GAPS.md` (G-7 → "Option A built"; note Option B deferred)
**Notes:** No app changes here — adoption in ScadaBridge/OtOpcUa is a follow-on (flip `Secrets:Store`,
supply the connstr + shared KEK). Capture that as a checklist, not code.
---
## Deferred phase-2 (NOT built here): Option B — `ZB.MOM.WW.Secrets.Akka`
Build only when an app declares it must resolve secrets while **partitioned** from the shared store.
Shape (for when it's warranted):
- New package `ZB.MOM.WW.Secrets.Akka` (net10; refs Akka.Cluster + Abstractions).
- `AkkaSecretReplicator : ISecretReplicator``PublishAsync` broadcasts the encrypted `StoredSecret`
to peers (a cluster-aware router / distributed pub-sub topic).
- An anti-entropy actor (cluster singleton or per-node) that periodically exchanges
`GetManifestAsync` digests and pulls newer/missing rows via `ApplyReplicatedAsync` (LWW);
tombstones propagate deletes; a retention window bounds tombstone GC.
- `StoredSecret` serialization (ciphertext only — never the KEK) + an `ISecretActorAccessor` wiring.
- **Validation:** a live 2-node ScadaBridge/OtOpcUa rig proving write-on-A → resolve-on-B, delete
propagation, and partition-heal resync — a G-2-class live gate.
- KEK constraint identical to Option A: same master KEK on every node; `rewrap-all` per node.
@@ -0,0 +1,11 @@
{
"planPath": "docs/plans/2026-07-17-secrets-g7-sqlserver-store.md",
"tasks": [
{"id": 0, "subject": "Task 1: SQL-Server schema + migrator", "status": "pending"},
{"id": 1, "subject": "Task 2: SqlServerSecretStore : ISecretStore", "status": "pending", "blockedBy": [0]},
{"id": 2, "subject": "Task 3: DI store-selection wiring", "status": "pending", "blockedBy": [1]},
{"id": 3, "subject": "Task 4: Docs + adoption notes", "status": "pending", "blockedBy": [1]}
],
"deferredPhase2": "Option B — ZB.MOM.WW.Secrets.Akka replicator (build only on a stated partition-tolerance requirement); see plan doc.",
"lastUpdated": "2026-07-17"
}