diff --git a/ZB.MOM.WW.Secrets/Directory.Build.props b/ZB.MOM.WW.Secrets/Directory.Build.props
index c915ed0..c610048 100644
--- a/ZB.MOM.WW.Secrets/Directory.Build.props
+++ b/ZB.MOM.WW.Secrets/Directory.Build.props
@@ -5,7 +5,7 @@
enable
enable
latest
- 0.2.3
+ 0.3.0
true
README.md
diff --git a/ZB.MOM.WW.Secrets/Directory.Packages.props b/ZB.MOM.WW.Secrets/Directory.Packages.props
index 1b9476f..dab26a3 100644
--- a/ZB.MOM.WW.Secrets/Directory.Packages.props
+++ b/ZB.MOM.WW.Secrets/Directory.Packages.props
@@ -57,6 +57,10 @@
+
+
+
+
diff --git a/ZB.MOM.WW.Secrets/README.md b/ZB.MOM.WW.Secrets/README.md
index 1e74986..ba3431c 100644
--- a/ZB.MOM.WW.Secrets/README.md
+++ b/ZB.MOM.WW.Secrets/README.md
@@ -16,6 +16,45 @@ plaintext back on demand — from application code, from configuration, or from
A `secret` CLI (`set` / `get` / `list` / `rm` / `rotate` / `rewrap-all`) ships in the repo (not packed).
+### Interactive console
+
+Run `secret` with **no arguments** in a real terminal and it opens an interactive console
+instead of printing usage — a menu-driven session for an operator sitting at a deployment,
+rather than a scripted one-shot verb. The launch condition is exact: no verb **and** neither
+stdin nor stdout is redirected. Any redirection (a pipe, `| cat`, CI, a script) falls straight
+through to the headless usage/exit-2 behavior above, so nothing scripted ever hangs on a
+prompt.
+
+The first screen picks a **store target** — recent targets (persisted to
+`~/.zb-secrets/recent-targets.json`, names + paths only, never key material), an app's
+`appsettings.json` (the console composes base + `appsettings..json` + environment
+variables and binds `Secrets` exactly like the app itself, so every operation hits precisely
+the store the app reads), or manual entry (a raw SQLite path + a master-key environment
+variable name). If the configured KEK cannot be resolved in the operator's shell, the session
+still opens — **degraded**, metadata-only — and any flow that needs the key prompts for one
+(masked paste or a key file) to upgrade it in place.
+
+Once a target is open, the menu offers:
+
+- **List secrets** — metadata table (name, content type, KEK id, revision, updated); no KEK needed.
+- **Set / rotate a secret** — masked value prompt, seals a fresh row under the session KEK.
+- **Get (reveal) a secret** — confirm-gated; prints the plaintext once, never cached or logged.
+- **Delete a secret** — confirm-gated tombstone; no KEK needed.
+- **Reference audit & seed** — scans the target's composed configuration for `${secret:NAME}`
+ tokens, classifies each Ok / Missing / Tombstoned / Undecryptable / InvalidName, and walks the
+ gaps through a seed prompt. This is *the* deployment-setup flow.
+- **KEK doctor (lockout recovery)** — per-row diagnosis (Ok / WrongKek / Corrupt, with the
+ foreign KEK id) plus a guided remedy: rewrap every row from an old KEK, or re-set individual
+ rows when the old KEK is unrecoverable.
+- **Export bundle (ciphertext-only)** / **Import bundle** — move rows between stores. Bundles
+ carry name + metadata + wrapped DEK + ciphertext, never plaintext; importing across KEKs
+ verifies the pasted source key against the bundle's KEK id before re-wrapping, conflicts
+ resolve last-writer-wins (with an optional per-row prompt), and the format is versioned.
+
+There is no login — the console runs locally with direct file/DB access, the same trust model
+as any other admin CLI. See the operator runbook for the full walkthrough:
+[`docs/operations/interactive-console.md`](docs/operations/interactive-console.md).
+
## How it protects secrets
Each value is encrypted with a fresh **data key (DEK)** using AES-256-GCM; the DEK is then
@@ -149,4 +188,6 @@ node can always reach a shared database, because you pay real distributed-system
stays invisible to last-writer-wins. Run `rewrap-all` against **each** independent store.
See [`docs/operations/clustered-secrets.md`](docs/operations/clustered-secrets.md) for operator
-setup, and [`docs/operations/kek-rotation.md`](docs/operations/kek-rotation.md) for rotation.
+setup, [`docs/operations/kek-rotation.md`](docs/operations/kek-rotation.md) for rotation, and
+[`docs/operations/interactive-console.md`](docs/operations/interactive-console.md) for the
+`secret` interactive console (deployment seeding, lockout recovery, bundles).
diff --git a/ZB.MOM.WW.Secrets/docs/operations/interactive-console.md b/ZB.MOM.WW.Secrets/docs/operations/interactive-console.md
new file mode 100644
index 0000000..40e28c9
--- /dev/null
+++ b/ZB.MOM.WW.Secrets/docs/operations/interactive-console.md
@@ -0,0 +1,256 @@
+# Operator runbook — the `secret` interactive console (`ZB.MOM.WW.Secrets`)
+
+**Audience:** operators running the `secret` CLI at a terminal against a deployment's store.
+**Primitive:** a bare `secret` invocation (no verb) on a real TTY → `InteractiveEntry` →
+`InteractiveShell` → pluggable `IInteractiveFlow`s over an opened `SecretsSession`.
+**Status:** ships alongside the headless verbs (`set` / `get` / `list` / `rm` / `rotate` /
+`rewrap-all`), which are unaffected. Companion to
+[`kek-rotation.md`](kek-rotation.md) (the `rewrap-all` primitive the KEK-doctor remedy uses
+under the hood) and [`clustered-secrets.md`](clustered-secrets.md) (replication topologies the
+console does not manage).
+
+---
+
+## What the console is (and is not)
+
+Every flow in the console is a thin UI wrapper over the same store/cipher primitives the
+headless CLI and the app itself use — `ISecretStore`, `ISecretCipher`, `KekRotationService`,
+`SecretReferenceExpander`'s token pattern. Nothing about the store, the wrap scheme, or the
+crypto changes. What the console adds is:
+
+- **Target discovery** — pick a deployment's store the same way its app would, instead of
+ hand-assembling a connection string.
+- **A guided menu** in place of memorized verb syntax, with confirm-gates and masked prompts
+ where a headless verb would take a positional argument.
+- **Two flows with no headless equivalent**: the reference audit & seed (deployment setup) and
+ the KEK doctor (lockout triage + guided remedy).
+
+It is **not** a login surface, a replication controller, or a replacement for `rewrap-all` —
+it drives that primitive for one convenience case (rewrap onto the *session's* KEK) and defers
+to the headless verb (and [`kek-rotation.md`](kek-rotation.md)) for a general old→new rotation.
+
+## Launching
+
+```bash
+secret
+```
+
+- **Real terminal, no args:** opens the console. The check is exact —
+ `args.Length == 0 && !Console.IsInputRedirected && !Console.IsOutputRedirected` — so any
+ redirection at all (a pipe, `secret | cat`, a CI runner, a script capturing output) falls
+ through to the ordinary headless path instead of hanging on a prompt.
+- **Any verb, or a redirected stream:** unchanged headless behavior — `set` / `get` / `list` /
+ `rm` / `rotate` / `rewrap-all` dispatch exactly as before; a bare `secret` with a redirected
+ stream (or an unrecognized verb) prints usage and exits **2**, same as pre-console builds.
+- **Exit codes:** the console itself returns **0** on a clean quit (menu "Quit", or Ctrl-C at
+ the target picker or the action menu) and never returns a non-zero code on its own — a
+ failed action is caught, rendered as an error panel, and returns you to the menu rather than
+ exiting the process. Ctrl-C mid-flow abandons just that action.
+- **Ctrl-C behavior:** cooperative cancellation is checked between actions and at the target
+ picker; Spectre's blocking prompts cannot observe the token mid-prompt, but the surrounding
+ loop always resolves to a clean exit rather than a stack trace.
+
+## Targeting a deployment
+
+The first screen is always the target picker:
+
+1. **Recent targets** — up to 10 entries from `~/.zb-secrets/recent-targets.json`, newest
+ first, labeled ` — `. Selecting one re-reads that path (config may
+ have changed since last time) and re-stamps it as most-recent.
+2. **Enter an appsettings.json path…** — point at any app's base `appsettings.json`.
+3. **Manual entry (SQLite db + KEK env var)…** — a raw SQLite file path plus a master-key
+ environment variable name (default `ZB_SECRETS_MASTER_KEY`), for a store with no
+ accompanying appsettings file. Manual targets are never added to recents (there is no
+ appsettings path to remember).
+
+**The honest-store guarantee.** Option 2 composes configuration *exactly the way the target
+app would at startup*: `appsettings.json` as the base, an optional `appsettings..json`
+overlay, then environment variables — layered in that order via the same
+`ConfigurationBuilder` pattern the app's own `Host.CreateApplicationBuilder` uses — and binds
+the `Secrets` section (and, if present, `Secrets:SqlServer`) out of the result. There is no
+separate "CLI view" of the config that could drift from what the app actually reads: if the app
+resolves its SQLite path or its SQL-Server hub connection string a particular way, the console
+resolves it the same way, against the same files, from the same working directory. This is
+also why `list`/`get`/`set` here can be trusted as evidence of what the running app sees.
+
+**Store selection mirrors the app's DI wiring**: a configured `Secrets:SqlServer` connection
+string wins over local SQLite — *unless* that connection string is itself an unresolved
+`${secret:...}` reference (the app resolves that from its own bootstrapped store at startup;
+the console has no such bootstrap step), in which case the console falls back to the local
+SQLite store and prints a warning. In hub-replication mode, editing the SQL-Server hub through
+the console is the correct target — the change converges to every node's local store on the
+next sync sweep, rather than racing one node's SQLite against the hub.
+
+**Recents contain no secret material** — only a display name and the appsettings path, ever.
+See the security notes below.
+
+## Degraded sessions and the KEK prompt
+
+Opening a session never fails outright on a bad or missing KEK. If the configured master key
+cannot be resolved — unset/empty env var, missing key file, a malformed `MasterKey:Source`,
+DPAPI off Windows — the session opens anyway, **degraded**: metadata-only operations (List,
+Delete — tombstoning touches no ciphertext) work immediately, and the header banner shows
+`KEK UNAVAILABLE (degraded)` in red instead of `KEK OK` in green.
+
+The moment the operator picks a flow that needs plaintext access (Set/rotate, Get, reference
+audit & seed, KEK doctor, bundle export/import — anything whose `RequiresKek` is true), the
+shell intercepts the dispatch and prompts:
+
+- **Paste base64 key** — a masked `TextPrompt` (nothing echoed) fed straight to an in-memory
+ provider; nothing is written to disk.
+- **Key file path** — points at a file the configured `File` provider can read.
+
+A successful upgrade re-opens the session with that key and continues into the flow the
+operator originally chose. A key that fails to resolve, or resolves but still doesn't unlock
+the session (wrong key), reports the failure and returns to the menu — the degraded session is
+never silently discarded.
+
+## Deployment seeding walk-through: Reference audit & seed
+
+This is the flow to run **the first time you point the console at a fresh deployment**, or
+whenever an app fails to start because a `${secret:NAME}` reference can't resolve.
+
+1. The auditor walks every value in the target's *composed* configuration (same honest-store
+ composition as targeting, above) with the identical `\$\{secret:([^}]+)\}` token pattern
+ `SecretReferenceExpander` uses at app startup — so what you see here is exactly what the app
+ would try to resolve, including config keys documented via a `"_comment": "...${secret:x}..."`
+ convention, which are skipped the same way the expander skips them.
+2. Each distinct referenced name is looked up against the store and classified:
+ - **Ok** — exists and decrypts under the session KEK. Nothing to do.
+ - **Missing** — no row at all. A fail-closed expansion would throw at app startup.
+ - **Tombstoned** — a soft-deleted row. Also unresolvable to the app.
+ - **Undecryptable** — the row exists but does not decrypt under the session KEK (wrong or
+ rotated key, or damage). *This is often the first sign a KEK doctor pass is needed.*
+ - **InvalidName** — the token text isn't a legal secret name (illegal characters, an
+ unfilled placeholder like `REPLACE_ME`). Can't be looked up or seeded — fix the token in
+ the config file itself.
+3. A color-coded table renders every finding with the config key paths that reference it (so
+ you know exactly which setting(s) are affected), then the flow walks each seedable gap
+ (Missing / Tombstoned / Undecryptable) in turn, asking to seed it now. Accepting prompts for
+ a masked value, a content type, and an optional description, then seals a fresh row under
+ the session KEK — **overwriting** an Undecryptable row and **reviving** (un-tombstoning) a
+ Tombstoned one.
+4. A closing re-audit prints a one-line count-by-status summary so you can confirm the gap
+ closed without re-reading the whole table.
+
+Run this again any time after adding a new `${secret:}` reference to a config file, or after
+deploying to a fresh environment — it is the fast path from "app won't start, references don't
+resolve" to "every reference is Ok."
+
+## Lockout playbook: KEK doctor
+
+Run this when an app (or the console itself) reports decrypt failures it didn't before —
+typically after a KEK rotation was only partially applied, a config value pointing at the
+wrong key source, or a store copied from another environment.
+
+**Diagnosis** probes *every* row (including tombstones, since they still carry a KEK-wrapped
+DEK and block a clean `rewrap-all`) against the session's KEK:
+
+- **Ok** — the row's `kek_id` matches the session KEK and it decrypts cleanly.
+- **WrongKek** — the row's `kek_id` differs from the session KEK. Not decrypted (no point —
+ the wrap alone tells you it won't work); reported with the row's *own* `kek_id` so you know
+ which key to go find. The summary line also lists the distinct **foreign KEK ids** seen
+ across all wrong-KEK rows — usually there is exactly one (the store's actual current key),
+ which tells you the session simply has the wrong key configured.
+- **Corrupt** — the `kek_id` matches (so the wrap envelope claims to be under the right key),
+ but the decrypt still fails closed. This means the wrap envelope or the sealed body itself is
+ damaged — a **different problem than WrongKek**, and *not* fixable by rewrapping: rewrapping
+ only re-wraps a DEK it can first unwrap, and a Corrupt row can't be unwrapped at all.
+
+If every row reports Ok, the doctor says so and there is nothing to remedy — the lockout is
+elsewhere (check the app's actual configured KEK source against what you used to open this
+session).
+
+**Two remedies**, offered only when something is unhealthy:
+
+- **Rewrap from old KEK** — the non-destructive path, and the *only* correct remedy for
+ WrongKek rows. You supply the old KEK (env var, key file, or a masked paste) the affected
+ rows are actually wrapped under; the flow shows exactly how many rows will move and from
+ which KEK id, then asks for an explicit confirmation (default **No** — nothing happens on
+ Enter) before calling the same `KekRotationService.RewrapAllAsync` primitive
+ [`kek-rotation.md`](kek-rotation.md) documents for the headless `rewrap-all` verb. Secret
+ bodies are never touched — only the DEK wrap moves onto the session KEK. A wrong old key
+ surfaces as a decryption failure and aborts (rows already rewrapped before the anomaly stay
+ persisted, so a retry after fixing the key resumes cleanly).
+- **Old KEK is lost — re-set affected secrets** — the destructive last resort, and the *only*
+ remedy for a Corrupt row (which cannot be rewrapped because it can't be unwrapped in the
+ first place). For every WrongKek or Corrupt row still affected, the flow offers a masked
+ re-set: type a fresh value, and it's sealed fresh under the session KEK, overwriting the
+ unrecoverable row. Each row is confirmed individually (default **No**) so you can skip rows
+ you'd rather chase down the old key for instead.
+
+A closing re-diagnosis prints a one-line count-by-status summary so you can see the remedy's
+effect immediately, in the same session, without re-running the flow.
+
+## Bundles: export / import
+
+Bundles move secret rows **between deployment stores** — cloning a deployment, staging a
+recovery, seeding a new environment from a known-good one — without ever touching plaintext.
+
+**Export bundle (ciphertext-only)** writes every live row (or, if you opt in, tombstones too)
+to a JSON file: for each row, the six crypto fields (`ciphertext`/`nonce`/`tag` for the body,
+`wrapped_dek`/`wrap_nonce`/`wrap_tag` for the DEK wrap) ride as base64, plus the row's
+`kek_id`, revision, and timestamps. The bundle also records the exporting session's KEK id
+(`SourceKekId`) and a `FormatVersion`. A default filename
+(`secrets-bundle--.json`) is offered but can be overridden. The bundle is
+useless without the source KEK — there is no plaintext anywhere in it — so it is safe to copy,
+attach to a ticket, or commit to a secured artifact store, but treat it with the same care as
+any other encrypted-at-rest artifact (it does reveal *which* names and content types exist).
+
+**Import bundle** reads and parses the file *before* touching the store or prompting for
+anything, so a malformed file or unsupported `FormatVersion` (this build supports **version
+1** only) is caught up front. Then, per row:
+
+- **Same KEK as the session** — imported directly.
+- **Different KEK (`SourceKekId` ≠ the session's)** — the flow tells you both KEK ids and asks
+ for the bundle's source key (paste / env var / key file) so it can re-wrap each row onto the
+ session KEK. The supplied key's own derived id is checked against the bundle's declared
+ `SourceKekId` *before* anything is imported — a valid-but-wrong key is rejected with both ids
+ shown, rather than silently landing every row in the "skipped foreign KEK" bucket. Declining
+ to supply a key aborts the whole import (no key means no row can be re-wrapped).
+- **Name collision (a row with that name already exists locally)** — resolved by **last-writer-
+ wins** (the shared `SecretLastWriterWins` tie-break used by every replication topology:
+ later `updated_utc`, then higher revision, wins) unless you opt into **per-conflict
+ prompting**, in which case each collision shows both rows' timestamp and revision and asks
+ whether to take the bundle's row (default **No** — keep local). A winning row lands via the
+ store's replicated-apply path so its own revision/timestamps are preserved verbatim (the same
+ path cluster anti-entropy uses); a row you force *against* LWW ordering is instead written as
+ a fresh local upsert so the override actually sticks rather than being reverted by the next
+ reconciliation.
+
+A closing tally table (Imported / Skipped-older / Skipped-foreign-KEK / Conflicts) reports the
+outcome; a non-zero "skipped foreign KEK" count is annotated with the bundle's source KEK id so
+a wrong-key paste is diagnosable from the report alone without re-running the import.
+
+## Security notes
+
+- **No login.** The console is a local admin tool: whoever can run it already has direct
+ filesystem/DB access to the target store (they could `sqlite3` the file open themselves), so
+ the console adds no new trust boundary — it does not check identity, does not audit its own
+ actions distinctly from the store-level audit the underlying `ISecretResolver`/store calls
+ already perform, and should be restricted the same way you'd restrict shell access to the
+ host or the deployment's config files.
+- **Values are never echoed.** Every prompt for a secret value or key material uses Spectre's
+ masked `TextPrompt(...).Secret()` — nothing typed is drawn to the terminal, and nothing typed
+ is written to a log.
+- **Values are never cached.** Get (reveal) decrypts and writes the plaintext exactly once, with
+ no resolver, no in-memory cache, and no re-use across the session — asking again re-decrypts
+ from the row.
+- **Recents contain no key material.** `~/.zb-secrets/recent-targets.json` stores only a
+ display name, an appsettings path, and a timestamp — never a KEK, a secret value, or a
+ connection string. It is safe to inspect or delete.
+- **Bundles are ciphertext-only and safe at rest.** A bundle file contains no plaintext under
+ any circumstance — export cannot produce one and import cannot be pointed at one that
+ "leaks" plaintext through the format; the only way to recover a value from a bundle is to
+ import it into a session holding the matching KEK and then explicitly reveal it.
+
+## Troubleshooting
+
+| Symptom | Cause | Action |
+|---|---|---|
+| Bare `secret` prints usage instead of opening the console | stdin or stdout is redirected (a pipe, a script, CI) | Expected — run it directly at an interactive terminal with no redirection. |
+| Header shows `KEK UNAVAILABLE (degraded)` | The target's configured KEK source didn't resolve in this shell (unset env var, missing file, DPAPI off Windows) | List/Delete still work. Pick a KEK-requiring flow to get the upgrade prompt, or fix the env var/file and re-select the target. |
+| Reference audit shows `Undecryptable` for a name that used to resolve | The row is wrapped under a KEK the session doesn't have — often a partially-applied rotation | Run the KEK doctor; if it reports `WrongKek`, rewrap from the old KEK. |
+| KEK doctor reports `Corrupt`, not `WrongKek` | `kek_id` matches but the wrap/body itself is damaged | Rewrap cannot fix this (nothing to unwrap first) — use "Old KEK is lost" to re-set the row, or restore the store from a backup. |
+| Bundle import reports a large `Skipped (foreign KEK)` count | Declined (or gave the wrong) source key when prompted | Re-run the import and supply the bundle's actual source key — the report shows the expected `SourceKekId`. |
+| `secret rewrap-all`-style behavior needed across a whole fleet, not just this session's KEK | The KEK doctor's rewrap remedy always targets the *session's* KEK, for lockout recovery on one store | For a general old→new rotation across every store, use the headless `secret rewrap-all` verb per [`kek-rotation.md`](kek-rotation.md). |
diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/BundleService.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/BundleService.cs
new file mode 100644
index 0000000..f243b29
--- /dev/null
+++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/BundleService.cs
@@ -0,0 +1,237 @@
+using ZB.MOM.WW.Secrets.Abstractions;
+
+namespace ZB.MOM.WW.Secrets.Cli.Interactive;
+
+///
+/// Tally of an run.
+///
+/// Rows written (new inserts plus conflict winners).
+/// Rows a last-writer-wins (or override) decision rejected as not newer.
+///
+/// Rows wrapped under a KEK other than the target's, with no matching source KEK supplied to re-wrap them.
+///
+/// Rows whose name already existed in the target store (won or lost).
+public sealed record BundleImportReport(int Imported, int SkippedOlder, int SkippedForeignKek, int Conflicts);
+
+///
+/// Exports and imports documents — ciphertext-only movement of secret rows
+/// between deployment stores. Import reconciles per row with the shared last-writer-wins ordering (an
+/// optional per-row override can force a decision) and, when a row is wrapped under a foreign KEK,
+/// re-wraps it under the target's KEK given the source KEK — otherwise it is skipped and reported.
+/// Both operations require a full (non-degraded) session: import needs the target cipher to
+/// re-wrap, and export is a data-movement operation that must not run half-configured.
+///
+public sealed class BundleService
+{
+ private readonly TimeProvider _timeProvider;
+
+ /// Creates the service.
+ /// Clock used to stamp ; defaults to .
+ public BundleService(TimeProvider? timeProvider = null) =>
+ _timeProvider = timeProvider ?? TimeProvider.System;
+
+ ///
+ /// Exports every secret in 's store to a ciphertext-only bundle at
+ /// , written atomically (temp file then move). Tombstones are excluded unless
+ /// is set.
+ ///
+ /// The full session to export from.
+ /// Destination bundle path.
+ /// When , tombstoned rows are exported too.
+ /// A token to cancel the operation.
+ /// The number of rows written to the bundle.
+ /// The session is degraded (no KEK).
+ public async Task ExportAsync(SecretsSession session, string path, bool includeDeleted, CancellationToken ct)
+ {
+ ArgumentNullException.ThrowIfNull(session);
+ ArgumentException.ThrowIfNullOrWhiteSpace(path);
+
+ IMasterKeyProvider masterKey = session.MasterKey
+ ?? throw new InvalidOperationException(
+ "Cannot export a bundle from a degraded session (no KEK available).");
+
+ IReadOnlyList metadata =
+ await session.Store.ListAsync(includeDeleted, ct).ConfigureAwait(false);
+
+ var entries = new List(metadata.Count);
+ foreach (SecretMetadata meta in metadata)
+ {
+ StoredSecret? row = await session.Store.GetAsync(meta.Name, ct).ConfigureAwait(false);
+ if (row is null)
+ {
+ continue;
+ }
+
+ entries.Add(SecretBundleCodec.FromRow(row));
+ }
+
+ var bundle = new SecretBundle
+ {
+ ExportedUtc = _timeProvider.GetUtcNow(),
+ SourceKekId = masterKey.KekId,
+ Entries = entries,
+ };
+
+ await WriteAtomicAsync(path, SecretBundleCodec.Serialize(bundle), ct).ConfigureAwait(false);
+ return entries.Count;
+ }
+
+ ///
+ /// Imports the bundle at into 's store.
+ ///
+ ///
+ ///
+ /// Per row: a row wrapped under a foreign KEK is re-wrapped under the target KEK when
+ /// matches its , otherwise it is
+ /// skipped (). A row whose name does not yet
+ /// exist is imported. A row whose name already exists is a conflict resolved by
+ /// when supplied, else by ;
+ /// the winner is written and the loser skipped ().
+ /// No plaintext is ever handled — the bundle is ciphertext only.
+ ///
+ ///
+ /// Writes go through the store's so the row lands
+ /// verbatim — its own revision, timestamps, and tombstone flag preserved — under a
+ /// serializable transaction with the shared last-writer-wins gate built in. This keeps LWW metadata
+ /// intact across further hops (a second import, or cluster anti-entropy) and means an imported
+ /// tombstone stays a tombstone. The one exception is a that
+ /// forces the incoming row against the LWW ordering (operator adopts an older row): a
+ /// verbatim replicate would be silently rejected by the LWW gate, so that single case is written
+ /// through instead — a local-write that restamps the row as
+ /// current, which is exactly what makes the operator's forced choice durable rather than flipped
+ /// back on the next reconciliation. Note the narrowed remnant of this: a
+ /// that forces an older, tombstoned bundle row over a
+ /// newer live local row resurrects it, because that forced
+ /// path clears the tombstone.
+ ///
+ ///
+ /// The full session to import into.
+ /// Source bundle path.
+ /// The KEK the bundle was exported under, to re-wrap foreign-KEK rows; may be .
+ ///
+ /// Optional per-row decision for an existing row: given (existing, incoming), return
+ /// to take the incoming row. When , last-writer-wins decides.
+ ///
+ /// A token to cancel the operation.
+ /// A tally of the import.
+ /// The session is degraded (no KEK/cipher).
+ public async Task ImportAsync(
+ SecretsSession session,
+ string path,
+ IMasterKeyProvider? sourceKek,
+ Func? conflictOverride,
+ CancellationToken ct)
+ {
+ ArgumentNullException.ThrowIfNull(session);
+ ArgumentException.ThrowIfNullOrWhiteSpace(path);
+
+ IMasterKeyProvider targetKek = session.MasterKey
+ ?? throw new InvalidOperationException(
+ "Cannot import a bundle into a degraded session (no KEK available).");
+ ISecretCipher cipher = session.Cipher
+ ?? throw new InvalidOperationException(
+ "Cannot import a bundle into a degraded session (no cipher available).");
+
+ string json = await File.ReadAllTextAsync(path, ct).ConfigureAwait(false);
+ SecretBundle bundle = SecretBundleCodec.Deserialize(json);
+
+ if (bundle.FormatVersion != SecretBundle.CurrentFormatVersion)
+ {
+ throw new InvalidOperationException(
+ $"Bundle '{path}' has unsupported format version {bundle.FormatVersion}; " +
+ $"this build supports version {SecretBundle.CurrentFormatVersion}.");
+ }
+
+ int imported = 0, skippedOlder = 0, skippedForeignKek = 0, conflicts = 0;
+
+ for (int i = 0; i < bundle.Entries.Count; i++)
+ {
+ BundleEntry entry = bundle.Entries[i];
+
+ // Rehydrate the row; a malformed entry (bad base64, unknown content type, illegal name)
+ // surfaces as an entry-aware failure rather than a bare FormatException/ArgumentException.
+ StoredSecret row;
+ try
+ {
+ row = SecretBundleCodec.ToRow(entry);
+ }
+ catch (Exception ex) when (ex is FormatException or ArgumentException)
+ {
+ throw new InvalidOperationException(
+ $"Bundle '{path}' entry #{i} ('{entry.Name}') is malformed: {ex.Message}", ex);
+ }
+
+ // Foreign KEK: re-wrap under the target KEK if the source KEK is available, else skip.
+ if (!string.Equals(row.KekId, targetKek.KekId, StringComparison.Ordinal))
+ {
+ if (sourceKek is not null && string.Equals(sourceKek.KekId, row.KekId, StringComparison.Ordinal))
+ {
+ row = cipher.Rewrap(row, sourceKek, targetKek);
+ }
+ else
+ {
+ skippedForeignKek++;
+ continue;
+ }
+ }
+
+ StoredSecret? existing = await session.Store.GetAsync(row.Name, ct).ConfigureAwait(false);
+ if (existing is null)
+ {
+ // No local row: ApplyReplicatedAsync inserts it verbatim (its LWW read finds nothing).
+ await session.Store.ApplyReplicatedAsync(row, ct).ConfigureAwait(false);
+ imported++;
+ continue;
+ }
+
+ conflicts++;
+ bool lwwWin = SecretLastWriterWins.IsNewer(
+ row.UpdatedUtc, row.Revision, existing.UpdatedUtc, existing.Revision);
+ bool takeIncoming = conflictOverride is not null ? conflictOverride(existing, row) : lwwWin;
+
+ if (!takeIncoming)
+ {
+ skippedOlder++;
+ continue;
+ }
+
+ if (lwwWin)
+ {
+ // Natural (or override-agreed) winner: apply verbatim, keeping LWW metadata intact.
+ await session.Store.ApplyReplicatedAsync(row, ct).ConfigureAwait(false);
+ }
+ else
+ {
+ // Override forced an older row against LWW. A verbatim replicate would be rejected by
+ // the LWW gate, so adopt it as a local write — restamped current so the choice sticks.
+ await session.Store.UpsertAsync(row, ct).ConfigureAwait(false);
+ }
+
+ imported++;
+ }
+
+ return new BundleImportReport(imported, skippedOlder, skippedForeignKek, conflicts);
+ }
+
+ // Writes content to a sibling temp file then moves it over the target, so a reader never observes
+ // a half-written bundle and a crash mid-write cannot corrupt an existing bundle.
+ private static async Task WriteAtomicAsync(string path, string content, CancellationToken ct)
+ {
+ string directory = Path.GetDirectoryName(Path.GetFullPath(path)) ?? ".";
+ Directory.CreateDirectory(directory);
+ string temp = Path.Combine(directory, $".{Path.GetFileName(path)}.{Guid.NewGuid():N}.tmp");
+
+ try
+ {
+ await File.WriteAllTextAsync(temp, content, ct).ConfigureAwait(false);
+ File.Move(temp, path, overwrite: true);
+ }
+ finally
+ {
+ if (File.Exists(temp))
+ {
+ try { File.Delete(temp); } catch (IOException) { /* best-effort cleanup of the temp file */ }
+ }
+ }
+ }
+}
diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/Flows/BundleExportFlow.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/Flows/BundleExportFlow.cs
new file mode 100644
index 0000000..a14c1ef
--- /dev/null
+++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/Flows/BundleExportFlow.cs
@@ -0,0 +1,79 @@
+using System.Globalization;
+using Spectre.Console;
+
+namespace ZB.MOM.WW.Secrets.Cli.Interactive.Flows;
+
+///
+/// Exports the session's store to a ciphertext-only on disk. The operator is
+/// offered a default filename (secrets-bundle-<target>-<yyyyMMdd>.json) they can accept
+/// or override, and a confirmation for whether tombstoned rows are included. The bundle carries no
+/// plaintext — only the encrypted rows and the source KEK id — so it is safe at rest and useless without
+/// the source key. Requires a KEK-capable session (the shell upgrades a degraded session before dispatch).
+///
+public sealed class BundleExportFlow : IInteractiveFlow
+{
+ private readonly TimeProvider _timeProvider;
+
+ /// Creates the flow.
+ ///
+ /// Clock used for the default filename's date stamp and the bundle's
+ /// timestamp; defaults to .
+ ///
+ public BundleExportFlow(TimeProvider? timeProvider = null) =>
+ _timeProvider = timeProvider ?? TimeProvider.System;
+
+ ///
+ public string Title => "Export bundle (ciphertext-only)";
+
+ ///
+ public bool RequiresKek => true;
+
+ ///
+ public async Task RunAsync(IAnsiConsole console, SecretsSession session, CancellationToken ct)
+ {
+ ArgumentNullException.ThrowIfNull(console);
+ ArgumentNullException.ThrowIfNull(session);
+
+ string defaultPath = DefaultBundleFileName(session.Target.Name, _timeProvider.GetUtcNow());
+ string path = console.Prompt(
+ new TextPrompt("Bundle [green]path[/]:").DefaultValue(defaultPath));
+
+ bool includeDeleted = console.Prompt(
+ new ConfirmationPrompt("Include tombstoned (deleted) rows?") { DefaultValue = false });
+
+ int count;
+ try
+ {
+ count = await new BundleService(_timeProvider)
+ .ExportAsync(session, path, includeDeleted, ct).ConfigureAwait(false);
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ console.MarkupLineInterpolated($"[red]Could not write bundle to '{path}': {ex.Message}[/]");
+ return;
+ }
+
+ console.MarkupLineInterpolated($"[green]Exported {count} row(s) to '{path}'.[/]");
+ }
+
+ // Builds a filesystem-safe default name: secrets-bundle--.json, with any character
+ // illegal in a file name collapsed to '-' so the offered default is always a valid path segment.
+ private static string DefaultBundleFileName(string targetName, DateTimeOffset now)
+ {
+ string sanitized = Sanitize(targetName);
+ string date = now.ToString("yyyyMMdd", CultureInfo.InvariantCulture);
+ return $"secrets-bundle-{sanitized}-{date}.json";
+ }
+
+ private static string Sanitize(string name)
+ {
+ if (string.IsNullOrWhiteSpace(name))
+ {
+ return "store";
+ }
+
+ char[] invalid = Path.GetInvalidFileNameChars();
+ char[] chars = name.Trim().Select(c => invalid.Contains(c) ? '-' : c).ToArray();
+ return new string(chars);
+ }
+}
diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/Flows/BundleImportFlow.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/Flows/BundleImportFlow.cs
new file mode 100644
index 0000000..a77c987
--- /dev/null
+++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/Flows/BundleImportFlow.cs
@@ -0,0 +1,225 @@
+using System.Globalization;
+using System.Text.Json;
+using Spectre.Console;
+using ZB.MOM.WW.Secrets.Abstractions;
+using ZB.MOM.WW.Secrets.MasterKey;
+
+namespace ZB.MOM.WW.Secrets.Cli.Interactive.Flows;
+
+///
+/// Imports a ciphertext-only into the session's store. The bundle is read and
+/// parsed before any write so its can be inspected: when it
+/// differs from the session KEK the operator is asked to supply the source key (paste / env var / key
+/// file) so the rows can be re-wrapped under the session KEK on import — otherwise foreign-KEK rows are
+/// skipped and reported. The operator may opt into a per-conflict prompt (default off = pure
+/// last-writer-wins); when on, each name collision renders both rows' timestamp/revision and asks whether
+/// to take the bundle row. A tally table (Imported / Skipped / Conflicts) closes the flow. Requires a
+/// KEK-capable session (the shell upgrades a degraded session before dispatch); a pasted key is masked.
+///
+public sealed class BundleImportFlow : IInteractiveFlow
+{
+ private const string PasteKeyLabel = "Paste base64 key";
+ private const string EnvVarLabel = "Environment variable";
+ private const string KeyFileLabel = "Key file path";
+ private const string CancelLabel = "Cancel";
+
+ ///
+ public string Title => "Import bundle";
+
+ ///
+ public bool RequiresKek => true;
+
+ ///
+ public async Task RunAsync(IAnsiConsole console, SecretsSession session, CancellationToken ct)
+ {
+ ArgumentNullException.ThrowIfNull(console);
+ ArgumentNullException.ThrowIfNull(session);
+
+ string sessionKekId = session.MasterKey?.KekId
+ ?? throw new InvalidOperationException("flow requires an unlocked KEK session");
+
+ string path = console.Prompt(new TextPrompt("Bundle [green]path[/]:"));
+ if (!File.Exists(path))
+ {
+ console.MarkupLineInterpolated($"[red]No bundle found at '{path}'.[/]");
+ return;
+ }
+
+ // Read + parse FIRST so the format version and source KEK can be inspected before any write —
+ // or any prompting — is attempted.
+ SecretBundle bundle;
+ try
+ {
+ string json = await File.ReadAllTextAsync(path, ct).ConfigureAwait(false);
+ bundle = SecretBundleCodec.Deserialize(json);
+ }
+ catch (Exception ex) when (ex is InvalidOperationException or JsonException or IOException or UnauthorizedAccessException)
+ {
+ console.MarkupLineInterpolated($"[red]Could not read bundle '{path}': {ex.Message}[/]");
+ return;
+ }
+
+ // Format gate up front: reject an unreadable version before consuming any operator input. The
+ // service applies the same gate as defense-in-depth, but failing here keeps the flow honest.
+ if (bundle.FormatVersion != SecretBundle.CurrentFormatVersion)
+ {
+ console.MarkupLineInterpolated(
+ $"[red]Bundle '{path}' has unsupported format version {bundle.FormatVersion}; this build supports version {SecretBundle.CurrentFormatVersion}.[/]");
+ return;
+ }
+
+ // Foreign-KEK detection: a bundle exported under another KEK must be re-wrapped, which needs the
+ // source key. Prompt for it up front; without it every row would be skipped as foreign-KEK.
+ IMasterKeyProvider? sourceKek = null;
+ if (!string.Equals(bundle.SourceKekId, sessionKekId, StringComparison.Ordinal))
+ {
+ console.MarkupLineInterpolated(
+ $"[yellow]This bundle was exported under KEK '{bundle.SourceKekId}', but this session uses '{sessionKekId}'.[/]");
+ console.MarkupLine(
+ "Supply the bundle's source key so its rows can be re-wrapped under this session's KEK.");
+
+ sourceKek = PromptSourceKey(console);
+ if (sourceKek is null)
+ {
+ console.MarkupLine("[yellow]Import cancelled.[/]");
+ return;
+ }
+
+ // The first KekId read is where an env/file source actually resolves the key: it lazily throws
+ // MasterKeyUnavailableException (an unset var, a missing file) or, for a file source, a raw
+ // IOException/UnauthorizedAccessException straight out of File.ReadAllBytes (e.g. a
+ // permission-denied key file), since FileMasterKeyProvider does not wrap those. All must render
+ // a friendly line and return here — not escape to the shell.
+ string sourceKekId;
+ try
+ {
+ sourceKekId = sourceKek.KekId;
+ }
+ catch (Exception ex) when (ex is MasterKeyUnavailableException or IOException or UnauthorizedAccessException)
+ {
+ console.MarkupLineInterpolated($"[red]Source KEK unavailable:[/] {ex.Message}");
+ return;
+ }
+
+ // A valid-but-wrong key would otherwise silently land every row in SkippedForeignKek. Verify
+ // the supplied key actually derives the bundle's source KEK id before importing.
+ if (!string.Equals(sourceKekId, bundle.SourceKekId, StringComparison.Ordinal))
+ {
+ console.MarkupLineInterpolated(
+ $"[red]That key does not match this bundle's source KEK ('{bundle.SourceKekId}' expected, got '{sourceKekId}').[/]");
+ return;
+ }
+ }
+
+ bool perConflict = console.Prompt(
+ new ConfirmationPrompt("Prompt per conflict? [grey](otherwise last-writer-wins)[/]")
+ {
+ DefaultValue = false,
+ });
+
+ Func? conflictOverride =
+ perConflict ? (existing, incoming) => PromptConflict(console, existing, incoming) : null;
+
+ BundleImportReport report;
+ try
+ {
+ report = await new BundleService()
+ .ImportAsync(session, path, sourceKek, conflictOverride, ct).ConfigureAwait(false);
+ }
+ catch (InvalidOperationException ex)
+ {
+ console.MarkupLineInterpolated($"[red]Import failed: {ex.Message}[/]");
+ return;
+ }
+
+ RenderReport(console, report, bundle.SourceKekId);
+ }
+
+ // Builds the source-KEK provider the operator selects: a pasted base64 key (masked), an environment
+ // variable, or a key file. Cancel (or a bad key) returns null and the caller aborts the import.
+ private static IMasterKeyProvider? PromptSourceKey(IAnsiConsole console)
+ {
+ string choice;
+ try
+ {
+ choice = console.Prompt(new SelectionPrompt()
+ .Title("Provide the bundle's source key")
+ .AddChoices(PasteKeyLabel, EnvVarLabel, KeyFileLabel, CancelLabel));
+ }
+ catch (OperationCanceledException)
+ {
+ return null;
+ }
+
+ if (choice == CancelLabel)
+ {
+ return null;
+ }
+
+ try
+ {
+ switch (choice)
+ {
+ case PasteKeyLabel:
+ string base64 = console.Prompt(new TextPrompt("Paste base64 source KEK:").Secret());
+ return new LiteralMasterKeyProvider(base64);
+
+ case EnvVarLabel:
+ string envVar = console.Prompt(new TextPrompt("Source-key environment variable name:"));
+ return MasterKeyProviderFactory.Create(new MasterKeyOptions
+ {
+ Source = MasterKeySource.Environment,
+ EnvVarName = envVar,
+ });
+
+ default:
+ string filePath = console.Prompt(new TextPrompt("Source-key file path:"));
+ return MasterKeyProviderFactory.Create(new MasterKeyOptions
+ {
+ Source = MasterKeySource.File,
+ FilePath = filePath,
+ });
+ }
+ }
+ catch (ArgumentException ex)
+ {
+ // LiteralMasterKeyProvider rejects a bad paste eagerly; name the problem and abort.
+ console.MarkupLineInterpolated($"[red]Invalid source key: {ex.Message}[/]");
+ return null;
+ }
+ }
+
+ // Renders a name collision — both rows' last-update timestamp and revision — and asks the operator
+ // whether to take the incoming bundle row (true) or keep the local one (false).
+ private static bool PromptConflict(IAnsiConsole console, StoredSecret existing, StoredSecret incoming)
+ {
+ console.MarkupLineInterpolated($"[yellow]Conflict on '{incoming.Name.Value}':[/]");
+ console.MarkupLineInterpolated(
+ $" local: updated {existing.UpdatedUtc.ToString("O", CultureInfo.InvariantCulture)} revision {existing.Revision}");
+ console.MarkupLineInterpolated(
+ $" bundle: updated {incoming.UpdatedUtc.ToString("O", CultureInfo.InvariantCulture)} revision {incoming.Revision}");
+ return console.Prompt(new ConfirmationPrompt(" Take the bundle row?") { DefaultValue = false });
+ }
+
+ // A compact tally table of the import outcome. When rows were skipped as foreign-KEK, the bundle's
+ // source KEK id is appended so a wrong-key paste is self-diagnosable from the report alone.
+ private static void RenderReport(IAnsiConsole console, BundleImportReport report, string sourceKekId)
+ {
+ var table = new Table()
+ .AddColumn("Result")
+ .AddColumn("Rows");
+
+ string foreign = report.SkippedForeignKek.ToString(CultureInfo.InvariantCulture);
+ if (report.SkippedForeignKek > 0)
+ {
+ foreign += $" (source KEK {Markup.Escape(sourceKekId)})";
+ }
+
+ table.AddRow("Imported", report.Imported.ToString(CultureInfo.InvariantCulture));
+ table.AddRow("Skipped (older)", report.SkippedOlder.ToString(CultureInfo.InvariantCulture));
+ table.AddRow("Skipped (foreign KEK)", foreign);
+ table.AddRow("Conflicts", report.Conflicts.ToString(CultureInfo.InvariantCulture));
+
+ console.Write(table);
+ }
+}
diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/Flows/DeleteSecretFlow.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/Flows/DeleteSecretFlow.cs
new file mode 100644
index 0000000..44a70b2
--- /dev/null
+++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/Flows/DeleteSecretFlow.cs
@@ -0,0 +1,48 @@
+using Spectre.Console;
+using ZB.MOM.WW.Secrets.Abstractions;
+
+namespace ZB.MOM.WW.Secrets.Cli.Interactive.Flows;
+
+///
+/// Soft-deletes (tombstones) a secret after an explicit confirmation. Tombstoning only rewrites metadata
+/// columns — it never touches ciphertext — so it needs no KEK and runs on a degraded session. A declined
+/// confirmation leaves the row untouched; a confirmed delete reports whether a matching row was found.
+///
+public sealed class DeleteSecretFlow : IInteractiveFlow
+{
+ ///
+ public string Title => "Delete a secret";
+
+ ///
+ public bool RequiresKek => false;
+
+ ///
+ public async Task RunAsync(IAnsiConsole console, SecretsSession session, CancellationToken ct)
+ {
+ ArgumentNullException.ThrowIfNull(console);
+ ArgumentNullException.ThrowIfNull(session);
+
+ var name = new SecretName(console.Prompt(
+ new TextPrompt("Secret [green]name[/]:").Validate(FlowPrompts.ValidateSecretName)));
+
+ // Check existence first, so an unknown name is reported without a pointless confirm prompt.
+ StoredSecret? existing = await session.Store.GetAsync(name, ct).ConfigureAwait(false);
+ if (existing is null || existing.IsDeleted)
+ {
+ console.MarkupLineInterpolated($"[red]No secret named '{name.Value}'.[/]");
+ return;
+ }
+
+ if (!console.Prompt(new ConfirmationPrompt($"Delete (tombstone) '{name.Value}'?") { DefaultValue = false }))
+ {
+ console.MarkupLine("[yellow]Left in place.[/]");
+ return;
+ }
+
+ bool removed = await session.Store.DeleteAsync(name, FlowPrompts.Actor, ct).ConfigureAwait(false);
+
+ console.MarkupLine(removed
+ ? $"[green]Tombstoned '{Markup.Escape(name.Value)}'.[/]"
+ : $"[red]No secret named '{Markup.Escape(name.Value)}'.[/]");
+ }
+}
diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/Flows/FlowPrompts.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/Flows/FlowPrompts.cs
new file mode 100644
index 0000000..0021e33
--- /dev/null
+++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/Flows/FlowPrompts.cs
@@ -0,0 +1,40 @@
+using Spectre.Console;
+using ZB.MOM.WW.Secrets.Abstractions;
+
+namespace ZB.MOM.WW.Secrets.Cli.Interactive.Flows;
+
+///
+/// Shared prompt/console helpers reused across the CRUD flows: a re-prompting secret-name validator and
+/// the CLI actor-stamp fallback. Centralized so the validation and audit-actor conventions stay identical
+/// in every flow.
+///
+internal static class FlowPrompts
+{
+ ///
+ /// A validation callback that accepts a raw name iff it is a legal
+ /// , otherwise re-prompts with the constructor's own diagnostic. The
+ /// diagnostic is d because it embeds bracketed text (e.g. the
+ /// allowed-character class [a-z0-9._/-]) that Spectre would otherwise parse as markup and throw on.
+ ///
+ /// The raw name entered at the prompt.
+ /// Success when is a valid name; an escaped error otherwise.
+ public static ValidationResult ValidateSecretName(string raw)
+ {
+ try
+ {
+ _ = new SecretName(raw);
+ return ValidationResult.Success();
+ }
+ catch (ArgumentException ex)
+ {
+ return ValidationResult.Error(Markup.Escape(ex.Message));
+ }
+ }
+
+ ///
+ /// The principal recorded as created_by/updated_by for CLI-originated writes — the OS
+ /// user name, or the literal "cli" when it is unavailable (mirrors Program.cs).
+ ///
+ public static string Actor =>
+ string.IsNullOrWhiteSpace(Environment.UserName) ? "cli" : Environment.UserName;
+}
diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/Flows/KekDoctorFlow.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/Flows/KekDoctorFlow.cs
new file mode 100644
index 0000000..74fbed6
--- /dev/null
+++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/Flows/KekDoctorFlow.cs
@@ -0,0 +1,275 @@
+using System.Globalization;
+using Spectre.Console;
+using ZB.MOM.WW.Secrets.Abstractions;
+using ZB.MOM.WW.Secrets.MasterKey;
+using ZB.MOM.WW.Secrets.Rotation;
+
+namespace ZB.MOM.WW.Secrets.Cli.Interactive.Flows;
+
+///
+/// The lockout-recovery flow. An operator whose app can no longer decrypt its secrets runs the doctor to
+/// learn, per row, which KEK each row is wrapped under and whether the session KEK opens it (via
+/// ), then chooses a remedy for the wrong-KEK (and corrupt) rows:
+///
+/// - Rewrap from old KEK — the operator supplies the KEK the rows are currently wrapped under
+/// (from an environment variable, a key file, or a pasted base64 key) and the flow re-wraps every row onto
+/// the session KEK after an explicit, exactly-scoped confirmation. This is the non-destructive remedy: the
+/// secret bodies are preserved, only the DEK wrap changes.
+/// - Old KEK is lost — re-set affected secrets — when the old KEK is unrecoverable, each affected
+/// row is offered a masked re-set (a fresh value sealed under the session KEK). This is the destructive
+/// last resort and the only remedy for a row (whose body cannot be
+/// re-wrapped or preserved).
+///
+/// Requires a KEK-capable session (the shell upgrades a degraded session before dispatch). No key material
+/// or typed value is ever echoed: the pasted old KEK and re-set values use masked prompts, and every
+/// operator-controlled string (secret names, KEK ids, error messages) is markup-escaped.
+///
+public sealed class KekDoctorFlow : IInteractiveFlow
+{
+ private const string RewrapChoice = "Rewrap from old KEK";
+ private const string LostKekChoice = "Old KEK is lost — re-set affected secrets";
+ private const string BackChoice = "Back";
+
+ private const string PasteSource = "Paste base64 key";
+ private const string EnvSource = "Environment variable";
+ private const string FileSource = "Key file";
+
+ ///
+ public string Title => "KEK doctor (lockout recovery)";
+
+ ///
+ public bool RequiresKek => true;
+
+ ///
+ public async Task RunAsync(IAnsiConsole console, SecretsSession session, CancellationToken ct)
+ {
+ ArgumentNullException.ThrowIfNull(console);
+ ArgumentNullException.ThrowIfNull(session);
+
+ var doctor = new KekDoctor();
+
+ // Diagnose is deliberately not wrapped: a degraded-session or store fault is the shell's to contain.
+ KekDoctorReport report = await doctor.DiagnoseAsync(session, ct).ConfigureAwait(false);
+ RenderSummary(console, report);
+
+ if (report.Healthy)
+ {
+ console.MarkupLine("[green]All rows open under the session KEK — no lockout. Nothing to remedy.[/]");
+ return;
+ }
+
+ string choice = console.Prompt(new SelectionPrompt()
+ .Title("Choose a [yellow]remedy[/]")
+ .AddChoices(RewrapChoice, LostKekChoice, BackChoice));
+
+ switch (choice)
+ {
+ case RewrapChoice:
+ await RunRewrapAsync(console, session, doctor, report, ct).ConfigureAwait(false);
+ break;
+ case LostKekChoice:
+ await RunLostKekResetAsync(console, session, report, ct).ConfigureAwait(false);
+ break;
+ default:
+ console.MarkupLine("[grey]No changes made.[/]");
+ return;
+ }
+
+ // Re-diagnose so the operator sees the store's post-remedy state in one closing line.
+ KekDoctorReport after = await doctor.DiagnoseAsync(session, ct).ConfigureAwait(false);
+ console.MarkupLineInterpolated($"Re-diagnosis: {Summarize(after)}.");
+ }
+
+ // Renders the session KEK, the row total, the per-status counts, and the distinct foreign kek_ids the
+ // operator must hunt down. Every operator-controlled value rides an interpolated (auto-escaped) hole.
+ private static void RenderSummary(IAnsiConsole console, KekDoctorReport report)
+ {
+ console.MarkupLineInterpolated(
+ $"Session KEK [cyan]{report.SessionKekId}[/] — {report.Total} row(s) scanned.");
+
+ int ok = Count(report, RowKekStatus.Ok);
+ int wrong = Count(report, RowKekStatus.WrongKek);
+ int corrupt = Count(report, RowKekStatus.Corrupt);
+ console.MarkupLineInterpolated(
+ $"[green]{ok} Ok[/], [yellow]{wrong} wrong-KEK[/], [red]{corrupt} corrupt[/].");
+
+ IReadOnlyList foreign = ForeignKekIds(report);
+ if (foreign.Count > 0)
+ {
+ console.MarkupLineInterpolated($"Foreign KEK id(s) seen: {string.Join(", ", foreign)}.");
+ }
+ }
+
+ // The guided rewrap remedy: acquire the old KEK provider, show EXACTLY what will change, and re-wrap
+ // only after an explicit confirmation (defaulting false). The rewrap itself is the only guarded region —
+ // a wrong pasted key surfaces as a SecretDecryptionException, an old==session KEK as an ArgumentException.
+ private static async Task RunRewrapAsync(
+ IAnsiConsole console, SecretsSession session, KekDoctor doctor, KekDoctorReport report, CancellationToken ct)
+ {
+ // Acquire the old KEK and resolve its id under their OWN guard: a malformed pasted key throws
+ // ArgumentException from LiteralMasterKeyProvider, and an env/file source lazily throws
+ // MasterKeyUnavailableException the moment KekId is read (an unset var, a missing file) — or, for
+ // a file source, a raw IOException/UnauthorizedAccessException straight out of File.ReadAllBytes
+ // (e.g. a permission-denied key file), since FileMasterKeyProvider does not wrap those. All must
+ // render a friendly line and return here — not escape to the shell — so the closing re-diagnosis
+ // in RunAsync still runs. This is distinct from the rewrap guard below (a wrong key vs a valid one).
+ IMasterKeyProvider oldKek;
+ string oldKekId;
+ try
+ {
+ oldKek = PromptForOldKek(console);
+ oldKekId = oldKek.KekId;
+ }
+ catch (ArgumentException ex)
+ {
+ console.MarkupLineInterpolated($"[red]Invalid old KEK:[/] {ex.Message}");
+ return;
+ }
+ catch (Exception ex) when (ex is MasterKeyUnavailableException or IOException or UnauthorizedAccessException)
+ {
+ console.MarkupLineInterpolated($"[red]Old KEK unavailable:[/] {ex.Message}");
+ return;
+ }
+
+ // Confirm OUTSIDE the rewrap guard: only the rewrap call itself is caught there, so a stray
+ // ArgumentException from setup/prompt code is never mis-reported as a "rewrap failed" message.
+ int moving = report.Rows.Count(r => string.Equals(r.RowKekId, oldKekId, StringComparison.Ordinal));
+
+ string confirmText =
+ $"Re-wrap {moving} row(s) from KEK '{Markup.Escape(oldKekId)}' → session KEK " +
+ $"'{Markup.Escape(report.SessionKekId)}'? (rewraps every row on the old KEK)";
+ if (!console.Prompt(new ConfirmationPrompt(confirmText) { DefaultValue = false }))
+ {
+ console.MarkupLine("[grey]Rewrap cancelled — no rows changed.[/]");
+ return;
+ }
+
+ RewrapReport result;
+ try
+ {
+ result = await doctor.RewrapAllAsync(session, oldKek, ct).ConfigureAwait(false);
+ }
+ catch (ArgumentException ex)
+ {
+ // Old and session KEK are identical — nothing to rotate.
+ console.MarkupLineInterpolated($"[red]Rewrap failed:[/] {ex.Message}");
+ return;
+ }
+ catch (SecretDecryptionException ex)
+ {
+ // Wrong old key, or a row wrapped by neither the old nor the session KEK (the pass aborted;
+ // any rows re-wrapped before the anomaly stay persisted).
+ console.MarkupLineInterpolated($"[red]Rewrap failed:[/] {ex.Message}");
+ return;
+ }
+
+ console.MarkupLineInterpolated(
+ $"[green]Re-wrapped {result.Rewrapped} row(s)[/] onto the session KEK ({result.AlreadyCurrent} already current) of {result.Total} total.");
+ }
+
+ // Prompts for the old-KEK source and builds the matching provider. Paste feeds a masked base64 entry to
+ // the LiteralMasterKeyProvider; env/file defer to the shared MasterKeyProviderFactory.
+ private static IMasterKeyProvider PromptForOldKek(IAnsiConsole console)
+ {
+ string source = console.Prompt(new SelectionPrompt()
+ .Title("Where is the [yellow]old KEK[/]?")
+ .AddChoices(EnvSource, FileSource, PasteSource));
+
+ switch (source)
+ {
+ case EnvSource:
+ string envVar = console.Prompt(new TextPrompt(" Environment variable name:"));
+ return MasterKeyProviderFactory.Create(new MasterKeyOptions
+ {
+ Source = MasterKeySource.Environment,
+ EnvVarName = envVar,
+ });
+ case FileSource:
+ string path = console.Prompt(new TextPrompt(" Key file path:"));
+ return MasterKeyProviderFactory.Create(new MasterKeyOptions
+ {
+ Source = MasterKeySource.File,
+ FilePath = path,
+ });
+ default:
+ string base64 = console.Prompt(
+ new TextPrompt(" Paste the base64 old KEK:").Secret());
+ return new LiteralMasterKeyProvider(base64);
+ }
+ }
+
+ // The destructive last resort: for every wrong-KEK or corrupt row, offer a masked re-set of a fresh
+ // value sealed under the session KEK. A corrupt row is flagged first (re-set is its only remedy).
+ private static async Task RunLostKekResetAsync(
+ IAnsiConsole console, SecretsSession session, KekDoctorReport report, CancellationToken ct)
+ {
+ IReadOnlyList affected = report.Rows
+ .Where(r => r.Status is RowKekStatus.WrongKek or RowKekStatus.Corrupt)
+ .ToList();
+
+ if (affected.Count == 0)
+ {
+ console.MarkupLine("[grey]No wrong-KEK or corrupt rows to re-set.[/]");
+ return;
+ }
+
+ foreach (KekDiagnosis row in affected)
+ {
+ string escapedName = Markup.Escape(row.SecretName);
+
+ if (row.Status == RowKekStatus.Corrupt)
+ {
+ console.MarkupLineInterpolated(
+ $"[red]'{row.SecretName}' is corrupt under the session KEK — it cannot be re-wrapped or preserved; re-setting a fresh value is the only remedy.[/]");
+ }
+
+ if (!console.Prompt(new ConfirmationPrompt(
+ $"Re-set '{escapedName}' with a new value? (overwrites)") { DefaultValue = false }))
+ {
+ continue;
+ }
+
+ await ResetAsync(console, session, new SecretName(row.SecretName), ct).ConfigureAwait(false);
+ console.MarkupLineInterpolated($"[green]Re-set '{row.SecretName}' under the session KEK.[/]");
+ }
+ }
+
+ // Prompts for a masked value + content type and seals a fresh row under the session cipher, overwriting
+ // the wrong-KEK/corrupt row in place. Mirrors the seed-and-stamp shape used by the other flows.
+ private static async Task ResetAsync(
+ IAnsiConsole console, SecretsSession session, SecretName name, CancellationToken ct)
+ {
+ string value = console.Prompt(
+ new TextPrompt($" New value for [green]{Markup.Escape(name.Value)}[/]:").Secret());
+ SecretContentType contentType = console.Prompt(
+ new SelectionPrompt().Title(" Content type").AddChoices(Enum.GetValues()));
+
+ ISecretCipher cipher = session.Cipher
+ ?? throw new InvalidOperationException("flow requires an unlocked KEK session");
+ StoredSecret row = cipher.Encrypt(name, value, contentType) with
+ {
+ CreatedBy = FlowPrompts.Actor,
+ UpdatedBy = FlowPrompts.Actor,
+ };
+ await session.Store.UpsertAsync(row, ct).ConfigureAwait(false);
+ }
+
+ private static int Count(KekDoctorReport report, RowKekStatus status) =>
+ report.Rows.Count(r => r.Status == status);
+
+ // The distinct foreign kek_ids (wrong-KEK rows only), ordered for a deterministic render.
+ private static IReadOnlyList ForeignKekIds(KekDoctorReport report) =>
+ report.Rows
+ .Where(r => r.Status == RowKekStatus.WrongKek)
+ .Select(r => r.RowKekId)
+ .Distinct(StringComparer.Ordinal)
+ .OrderBy(id => id, StringComparer.Ordinal)
+ .ToList();
+
+ // A deterministic "N Status" count-by-status roll-up, ordered by the status enum.
+ private static string Summarize(KekDoctorReport report) =>
+ string.Join(", ", report.Rows
+ .GroupBy(r => r.Status)
+ .OrderBy(g => g.Key)
+ .Select(g => string.Create(CultureInfo.InvariantCulture, $"{g.Count()} {g.Key}")));
+}
diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/Flows/ListSecretsFlow.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/Flows/ListSecretsFlow.cs
new file mode 100644
index 0000000..fe52e18
--- /dev/null
+++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/Flows/ListSecretsFlow.cs
@@ -0,0 +1,56 @@
+using Spectre.Console;
+using ZB.MOM.WW.Secrets.Abstractions;
+
+namespace ZB.MOM.WW.Secrets.Cli.Interactive.Flows;
+
+///
+/// Lists the safe metadata projection of every live secret as a table — name, content type, KEK id,
+/// revision, last-updated stamp, and updater. It renders only (which carries
+/// no ciphertext or plaintext), needs no KEK, and never decrypts, so it is the one CRUD flow that runs on
+/// a degraded session.
+///
+public sealed class ListSecretsFlow : IInteractiveFlow
+{
+ ///
+ public string Title => "List secrets";
+
+ ///
+ public bool RequiresKek => false;
+
+ ///
+ public async Task RunAsync(IAnsiConsole console, SecretsSession session, CancellationToken ct)
+ {
+ ArgumentNullException.ThrowIfNull(console);
+ ArgumentNullException.ThrowIfNull(session);
+
+ IReadOnlyList items =
+ await session.Store.ListAsync(includeDeleted: false, ct).ConfigureAwait(false);
+
+ if (items.Count == 0)
+ {
+ console.MarkupLine("[grey](none)[/]");
+ return;
+ }
+
+ var table = new Table()
+ .AddColumn("Name")
+ .AddColumn("ContentType")
+ .AddColumn("KekId")
+ .AddColumn("Revision")
+ .AddColumn("Updated")
+ .AddColumn("UpdatedBy");
+
+ foreach (SecretMetadata m in items)
+ {
+ table.AddRow(
+ Markup.Escape(m.Name.Value),
+ Markup.Escape(m.ContentType.ToString()),
+ Markup.Escape(m.KekId),
+ m.Revision.ToString(System.Globalization.CultureInfo.InvariantCulture),
+ Markup.Escape(m.UpdatedUtc.ToString("u", System.Globalization.CultureInfo.InvariantCulture)),
+ Markup.Escape(m.UpdatedBy ?? "—"));
+ }
+
+ console.Write(table);
+ }
+}
diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/Flows/ReferenceAuditFlow.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/Flows/ReferenceAuditFlow.cs
new file mode 100644
index 0000000..0c3aa62
--- /dev/null
+++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/Flows/ReferenceAuditFlow.cs
@@ -0,0 +1,144 @@
+using System.Globalization;
+using Spectre.Console;
+using ZB.MOM.WW.Secrets.Abstractions;
+
+namespace ZB.MOM.WW.Secrets.Cli.Interactive.Flows;
+
+///
+/// The deployment-setup flow: audits every ${secret:NAME} reference in the target app's composed
+/// configuration, renders a colour-coded status table, then walks the operator through seeding each gap so
+/// the app can start. For every reference that is not already resolvable — and that can be seeded
+/// (, , or
+/// ) — the operator is offered a masked value prompt and the
+/// new secret is sealed under a fresh per-secret DEK via the session cipher. Seeding a
+/// reference revives it: the store's
+/// overwrites the row in place and clears its tombstone. Seeding an
+/// reference overwrites the unreadable row with a fresh value
+/// (the confirm text says so). An reference cannot be looked up
+/// or seeded, so it renders a fix-the-token hint instead of a prompt;
+/// only occurs on a degraded session and is unreachable
+/// here (the flow requires a KEK). A closing re-audit prints a one-line count-by-status summary. Requires a
+/// KEK-capable session (the shell upgrades a degraded session before dispatch); typed values are masked and
+/// never echoed.
+///
+public sealed class ReferenceAuditFlow : IInteractiveFlow
+{
+ ///
+ public string Title => "Reference audit & seed";
+
+ ///
+ public bool RequiresKek => true;
+
+ ///
+ public async Task RunAsync(IAnsiConsole console, SecretsSession session, CancellationToken ct)
+ {
+ ArgumentNullException.ThrowIfNull(console);
+ ArgumentNullException.ThrowIfNull(session);
+
+ ISecretCipher cipher = session.Cipher
+ ?? throw new InvalidOperationException("flow requires an unlocked KEK session");
+
+ var auditor = new ReferenceAuditor();
+ IReadOnlyList findings = await auditor.AuditAsync(session, ct).ConfigureAwait(false);
+ if (findings.Count == 0)
+ {
+ console.MarkupLine("[grey]No ${secret:} references found in the target configuration.[/]");
+ return;
+ }
+
+ RenderTable(console, findings);
+
+ foreach (ReferenceFinding finding in findings)
+ {
+ if (finding.Status == ReferenceStatus.InvalidName)
+ {
+ console.MarkupLineInterpolated(
+ $"[red]'{finding.SecretName}'[/] is not a valid secret name — fix the ${{secret:…}} token in the config; it cannot be seeded.");
+ continue;
+ }
+
+ if (!IsSeedable(finding.Status))
+ {
+ continue; // Ok (nothing to do) or PresentUnverified (unreachable on a KEK-capable session).
+ }
+
+ string escapedName = Markup.Escape(finding.SecretName);
+ string question = finding.Status == ReferenceStatus.Undecryptable
+ ? $"'{escapedName}' exists but does not decrypt — overwrite with a fresh value now?"
+ : $"Seed '{escapedName}' now?";
+
+ if (!console.Prompt(new ConfirmationPrompt(question)
+ {
+ DefaultValue = finding.Status != ReferenceStatus.Undecryptable,
+ }))
+ {
+ continue;
+ }
+
+ await SeedAsync(console, session, cipher, new SecretName(finding.SecretName), ct).ConfigureAwait(false);
+ console.MarkupLineInterpolated($"[green]Sealed '{finding.SecretName}'.[/]");
+ }
+
+ IReadOnlyList after = await auditor.AuditAsync(session, ct).ConfigureAwait(false);
+ console.MarkupLineInterpolated($"Audit complete: {Summarize(after)}.");
+ }
+
+ // Seedable ⇔ a valid, non-resolvable reference we can fill: absent, tombstoned, or present-but-unreadable.
+ // Ok needs nothing; InvalidName cannot be looked up; PresentUnverified only occurs on a degraded session.
+ private static bool IsSeedable(ReferenceStatus status) =>
+ status is ReferenceStatus.Missing or ReferenceStatus.Tombstoned or ReferenceStatus.Undecryptable;
+
+ // Prompts for a masked value + content type + optional description and seals the secret under a fresh
+ // per-secret DEK. Mirrors SetSecretFlow's seal-and-stamp shape without coupling to it.
+ private static async Task SeedAsync(
+ IAnsiConsole console, SecretsSession session, ISecretCipher cipher, SecretName name, CancellationToken ct)
+ {
+ string value = console.Prompt(
+ new TextPrompt($" Value for [green]{Markup.Escape(name.Value)}[/]:").Secret());
+ SecretContentType contentType = console.Prompt(
+ new SelectionPrompt().Title(" Content type").AddChoices(Enum.GetValues()));
+ string description = console.Prompt(
+ new TextPrompt(" Description [grey](optional)[/]:").AllowEmpty());
+
+ StoredSecret row = cipher.Encrypt(name, value, contentType) with
+ {
+ Description = string.IsNullOrWhiteSpace(description) ? null : description,
+ CreatedBy = FlowPrompts.Actor,
+ UpdatedBy = FlowPrompts.Actor,
+ };
+ await session.Store.UpsertAsync(row, ct).ConfigureAwait(false); // revives a tombstone: overwrite clears IsDeleted.
+ }
+
+ // A colour-coded audit table: green Ok, yellow PresentUnverified, red for every unresolvable status.
+ private static void RenderTable(IAnsiConsole console, IReadOnlyList findings)
+ {
+ var table = new Table()
+ .AddColumn("Secret")
+ .AddColumn("Status")
+ .AddColumn("Config paths");
+
+ foreach (ReferenceFinding finding in findings)
+ {
+ table.AddRow(
+ Markup.Escape(finding.SecretName),
+ $"[{StatusColor(finding.Status)}]{finding.Status}[/]",
+ Markup.Escape(string.Join(Environment.NewLine, finding.ConfigPaths)));
+ }
+
+ console.Write(table);
+ }
+
+ private static string StatusColor(ReferenceStatus status) => status switch
+ {
+ ReferenceStatus.Ok => "green",
+ ReferenceStatus.PresentUnverified => "yellow",
+ _ => "red", // Missing / Tombstoned / Undecryptable / InvalidName
+ };
+
+ // A deterministic "N Status" count-by-status roll-up, ordered by the status enum.
+ private static string Summarize(IReadOnlyList findings) =>
+ string.Join(", ", findings
+ .GroupBy(f => f.Status)
+ .OrderBy(g => g.Key)
+ .Select(g => string.Create(CultureInfo.InvariantCulture, $"{g.Count()} {g.Key}")));
+}
diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/Flows/RevealSecretFlow.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/Flows/RevealSecretFlow.cs
new file mode 100644
index 0000000..4099374
--- /dev/null
+++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/Flows/RevealSecretFlow.cs
@@ -0,0 +1,46 @@
+using Spectre.Console;
+using ZB.MOM.WW.Secrets.Abstractions;
+
+namespace ZB.MOM.WW.Secrets.Cli.Interactive.Flows;
+
+///
+/// Reveals a secret's decrypted plaintext on screen — the one flow whose purpose is to surface a value.
+/// It gates the disclosure behind an explicit confirmation: on decline nothing is printed; on accept the
+/// value is decrypted with the session cipher and written exactly once (no caching, no resolver). Requires
+/// a KEK-capable session.
+///
+public sealed class RevealSecretFlow : IInteractiveFlow
+{
+ ///
+ public string Title => "Get (reveal) a secret";
+
+ ///
+ public bool RequiresKek => true;
+
+ ///
+ public async Task RunAsync(IAnsiConsole console, SecretsSession session, CancellationToken ct)
+ {
+ ArgumentNullException.ThrowIfNull(console);
+ ArgumentNullException.ThrowIfNull(session);
+
+ ISecretCipher cipher = session.Cipher
+ ?? throw new InvalidOperationException("flow requires an unlocked KEK session");
+
+ var name = new SecretName(console.Prompt(
+ new TextPrompt("Secret [green]name[/]:").Validate(FlowPrompts.ValidateSecretName)));
+
+ StoredSecret? row = await session.Store.GetAsync(name, ct).ConfigureAwait(false);
+ if (row is null || row.IsDeleted)
+ {
+ console.MarkupLineInterpolated($"[red]No secret named '{name.Value}'.[/]");
+ return;
+ }
+
+ if (!console.Prompt(new ConfirmationPrompt("Reveal the value on screen?") { DefaultValue = false }))
+ {
+ return;
+ }
+
+ console.WriteLine(cipher.Decrypt(row));
+ }
+}
diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/Flows/SetSecretFlow.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/Flows/SetSecretFlow.cs
new file mode 100644
index 0000000..411d4b7
--- /dev/null
+++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/Flows/SetSecretFlow.cs
@@ -0,0 +1,57 @@
+using Spectre.Console;
+using ZB.MOM.WW.Secrets.Abstractions;
+
+namespace ZB.MOM.WW.Secrets.Cli.Interactive.Flows;
+
+///
+/// Creates or rotates a secret: prompts for a validated name, confirms before overwriting an existing
+/// live row, reads the value through a masked prompt, and seals it under a fresh per-secret DEK via the
+/// session cipher. The value is never echoed and the confirmation line carries the name only. Requires a
+/// KEK-capable session (the shell upgrades a degraded session before dispatch).
+///
+public sealed class SetSecretFlow : IInteractiveFlow
+{
+ ///
+ public string Title => "Set / rotate a secret";
+
+ ///
+ public bool RequiresKek => true;
+
+ ///
+ public async Task RunAsync(IAnsiConsole console, SecretsSession session, CancellationToken ct)
+ {
+ ArgumentNullException.ThrowIfNull(console);
+ ArgumentNullException.ThrowIfNull(session);
+
+ ISecretCipher cipher = session.Cipher
+ ?? throw new InvalidOperationException("flow requires an unlocked KEK session");
+
+ var name = new SecretName(console.Prompt(
+ new TextPrompt("Secret [green]name[/]:").Validate(FlowPrompts.ValidateSecretName)));
+
+ StoredSecret? existing = await session.Store.GetAsync(name, ct).ConfigureAwait(false);
+ if (existing is { IsDeleted: false } &&
+ !console.Prompt(new ConfirmationPrompt($"'{name.Value}' exists — overwrite in place?") { DefaultValue = false }))
+ {
+ console.MarkupLine("[yellow]Left unchanged.[/]");
+ return;
+ }
+
+ string value = console.Prompt(new TextPrompt("Secret [green]value[/]:").Secret());
+ SecretContentType contentType = console.Prompt(
+ new SelectionPrompt().Title("Content type").AddChoices(Enum.GetValues()));
+ string description = console.Prompt(
+ new TextPrompt("Description [grey](optional)[/]:").AllowEmpty());
+
+ string actor = FlowPrompts.Actor;
+ StoredSecret row = cipher.Encrypt(name, value, contentType) with
+ {
+ Description = string.IsNullOrWhiteSpace(description) ? null : description,
+ CreatedBy = actor,
+ UpdatedBy = actor,
+ };
+ await session.Store.UpsertAsync(row, ct).ConfigureAwait(false);
+
+ console.MarkupLineInterpolated($"[green]Sealed '{name.Value}'.[/]");
+ }
+}
diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/IInteractiveFlow.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/IInteractiveFlow.cs
new file mode 100644
index 0000000..991a5df
--- /dev/null
+++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/IInteractiveFlow.cs
@@ -0,0 +1,26 @@
+using Spectre.Console;
+
+namespace ZB.MOM.WW.Secrets.Cli.Interactive;
+
+///
+/// One menu action of the interactive console. Implementations are UI-owning (they render to the
+/// supplied ) but store-logic-free — they operate through the seams on the
+/// the shell hands them and never open stores or resolve keys themselves.
+///
+public interface IInteractiveFlow
+{
+ /// The menu label shown for this flow in the shell's action list.
+ string Title { get; }
+
+ ///
+ /// Whether the flow needs a decrypt/seal-capable session; the shell upgrades a degraded session
+ /// (by prompting the operator for a KEK) before dispatch when this is .
+ ///
+ bool RequiresKek { get; }
+
+ /// Runs the flow against the current session, rendering its own UI to .
+ /// The console the flow renders to (the shell owns lifecycle; flows only draw).
+ /// The open session — guaranteed KEK-capable when is .
+ /// A token to cancel the flow.
+ Task RunAsync(IAnsiConsole console, SecretsSession session, CancellationToken ct);
+}
diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/InteractiveEntry.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/InteractiveEntry.cs
new file mode 100644
index 0000000..a51ad57
--- /dev/null
+++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/InteractiveEntry.cs
@@ -0,0 +1,62 @@
+using Spectre.Console;
+using ZB.MOM.WW.Secrets.Cli.Interactive.Flows;
+
+namespace ZB.MOM.WW.Secrets.Cli.Interactive;
+
+///
+/// Composition root for the interactive console: the non-TTY gate that decides whether a bare
+/// secret invocation launches the console, and the default wiring (flow set, recents store,
+/// target reader, session factory) that hands to an .
+///
+public static class InteractiveEntry
+{
+ ///
+ /// Whether a bare secret invocation should launch the interactive console. True only when no
+ /// verb was supplied AND neither standard stream is redirected — a redirected stdin/stdout means the
+ /// invocation is scripted/piped, where the console's prompts would hang or corrupt the pipe, so the
+ /// caller falls through to headless usage instead.
+ ///
+ /// The process command-line arguments.
+ /// at process start.
+ /// at process start.
+ public static bool ShouldEnterInteractive(string[] args, bool inputRedirected, bool outputRedirected)
+ => args.Length == 0 && !inputRedirected && !outputRedirected;
+
+ ///
+ /// Builds the ordered flow set the shell is wired with — one instance of each menu action, in the
+ /// order they appear on the menu. Exposed (and used by ) so the composition
+ /// is assertable without running the shell.
+ ///
+ public static IReadOnlyList CreateFlows() =>
+ [
+ new ListSecretsFlow(),
+ new SetSecretFlow(),
+ new RevealSecretFlow(),
+ new DeleteSecretFlow(),
+ new ReferenceAuditFlow(),
+ new KekDoctorFlow(),
+ new BundleExportFlow(),
+ new BundleImportFlow(),
+ ];
+
+ ///
+ /// Wires an with its default collaborators: a per-user recents store
+ /// under ~/.zb-secrets/recent-targets.json, the target-config reader, the session factory, and
+ /// . The shell's own ctor re-validates flow-title uniqueness.
+ ///
+ /// The console surface the shell (and its flows) render to.
+ public static InteractiveShell CreateShell(IAnsiConsole console)
+ {
+ string recentsPath = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
+ ".zb-secrets",
+ "recent-targets.json");
+
+ return new InteractiveShell(
+ console,
+ new RecentTargetsStore(recentsPath),
+ new TargetConfigReader(),
+ new SecretsSessionFactory(),
+ CreateFlows());
+ }
+}
diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/InteractiveShell.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/InteractiveShell.cs
new file mode 100644
index 0000000..6911323
--- /dev/null
+++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/InteractiveShell.cs
@@ -0,0 +1,371 @@
+using System.Data.Common;
+using Spectre.Console;
+using ZB.MOM.WW.Secrets.Abstractions;
+using ZB.MOM.WW.Secrets.MasterKey;
+
+namespace ZB.MOM.WW.Secrets.Cli.Interactive;
+
+///
+/// The interactive secret console: pick a store target, open a session, then loop a header +
+/// action menu that dispatches to pluggable s. This is the ONLY class
+/// that touches — services stay UI-free and flows, though UI-owning, carry
+/// no store logic. Flow failures are contained (rendered as an error panel) so one bad action never
+/// tears down the session; a degraded (no-KEK) session is upgraded on demand before a KEK-requiring
+/// flow runs.
+///
+public sealed class InteractiveShell
+{
+ private const string EnterPathLabel = "Enter an appsettings.json path…";
+ private const string ManualEntryLabel = "Manual entry (SQLite db + KEK env var)…";
+ private const string SwitchLabel = "Switch store target";
+ private const string QuitLabel = "Quit";
+ private const string PasteKeyLabel = "Paste base64 key";
+ private const string KeyFileLabel = "Key file path";
+ private const string CancelLabel = "Cancel";
+
+ private readonly IAnsiConsole _console;
+ private readonly RecentTargetsStore _recents;
+ private readonly TargetConfigReader _reader;
+ private readonly SecretsSessionFactory _sessions;
+ private readonly IReadOnlyList _flows;
+
+ /// Creates the shell over its console, recents store, target reader, session factory, and flow set.
+ /// The single console surface the shell (and its flows) render to.
+ /// Recently-used target store, offered first in the picker and re-stamped on selection.
+ /// Composes a target's Secrets configuration from appsettings or manual input.
+ /// Opens (and, on KEK upgrade, re-opens) a for a target.
+ /// The pluggable menu actions, listed in order above the built-in switch/quit entries.
+ public InteractiveShell(
+ IAnsiConsole console,
+ RecentTargetsStore recents,
+ TargetConfigReader reader,
+ SecretsSessionFactory sessions,
+ IReadOnlyList flows)
+ {
+ ArgumentNullException.ThrowIfNull(console);
+ ArgumentNullException.ThrowIfNull(recents);
+ ArgumentNullException.ThrowIfNull(reader);
+ ArgumentNullException.ThrowIfNull(sessions);
+ ArgumentNullException.ThrowIfNull(flows);
+
+ // A flow's Title is the menu routing key (BuildMenu emits it; the loop dispatches with a
+ // .First() title match), so a duplicate — or a collision with a reserved built-in label —
+ // would silently misroute selections. Reject both at construction rather than at click time.
+ HashSet seenTitles = new(StringComparer.Ordinal);
+ foreach (IInteractiveFlow flow in flows)
+ {
+ if (flow.Title is SwitchLabel or QuitLabel)
+ {
+ throw new ArgumentException(
+ $"Flow title '{flow.Title}' collides with a reserved menu action.", nameof(flows));
+ }
+
+ if (!seenTitles.Add(flow.Title))
+ {
+ throw new ArgumentException($"Duplicate flow title '{flow.Title}'.", nameof(flows));
+ }
+ }
+
+ _console = console;
+ _recents = recents;
+ _reader = reader;
+ _sessions = sessions;
+ _flows = flows;
+ }
+
+ /// Runs the pick-target → menu loop until the operator quits.
+ /// A token to cancel session opens and flow dispatch.
+ /// 0 on a clean quit.
+ public async Task RunAsync(CancellationToken ct)
+ {
+ try
+ {
+ while (true)
+ {
+ if (ct.IsCancellationRequested)
+ {
+ return 0;
+ }
+
+ StoreTarget? target = PickTarget();
+ if (target is null)
+ {
+ return 0; // operator cancelled the picker (Ctrl-C) → exit
+ }
+
+ SecretsSession session = await _sessions.OpenAsync(target, overrideKek: null, ct).ConfigureAwait(false);
+ RenderWarnings(session);
+
+ if (await RunMenuLoopAsync(session, ct).ConfigureAwait(false) == MenuOutcome.Quit)
+ {
+ return 0;
+ }
+
+ // MenuOutcome.SwitchTarget → fall through and re-open the picker.
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ // Cooperative cancellation of the shell's token (Ctrl-C, or a flow/host cancelling it) is a
+ // clean shutdown, not an error — surfaced e.g. from a pre-cancelled session open.
+ return 0;
+ }
+ }
+
+ // The outcome of the inner menu loop: the operator either quit outright or asked to switch targets.
+ private enum MenuOutcome
+ {
+ Quit,
+ SwitchTarget,
+ }
+
+ private async Task RunMenuLoopAsync(SecretsSession session, CancellationToken ct)
+ {
+ while (true)
+ {
+ // Honor cooperative cancellation between actions. Spectre's blocking prompt cannot observe
+ // the token itself (TestConsole likewise cannot inject a Ctrl-C into a prompt), so this
+ // top-of-loop check is what turns a token cancelled during a flow into a clean exit.
+ if (ct.IsCancellationRequested)
+ {
+ return MenuOutcome.Quit;
+ }
+
+ RenderHeader(session);
+
+ string choice;
+ try
+ {
+ choice = _console.Prompt(BuildMenu());
+ }
+ catch (OperationCanceledException)
+ {
+ return MenuOutcome.Quit; // Ctrl-C at the menu → clean exit
+ }
+
+ switch (choice)
+ {
+ case QuitLabel:
+ return MenuOutcome.Quit;
+ case SwitchLabel:
+ return MenuOutcome.SwitchTarget;
+ }
+
+ IInteractiveFlow flow = _flows.First(f => f.Title == choice);
+ try
+ {
+ if (flow.RequiresKek && !session.KekAvailable)
+ {
+ SecretsSession? upgraded = await TryUpgradeSessionAsync(session, ct).ConfigureAwait(false);
+ if (upgraded is null)
+ {
+ continue; // cancelled or key rejected → back to the menu
+ }
+
+ session = upgraded; // keep the upgraded session for the rest of the loop
+ }
+
+ await flow.RunAsync(_console, session, ct).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ // Ctrl-C mid-flow → abandon the action and return to the menu.
+ }
+ catch (Exception ex) when (IsContainable(ex))
+ {
+ RenderErrorPanel(ex);
+ }
+ }
+ }
+
+ // The store/key faults a flow may realistically surface. SecretDecryptionException and
+ // MasterKeyUnavailableException both derive from InvalidOperationException; DbException (base of both
+ // Microsoft.Data.Sqlite and Microsoft.Data.SqlClient exceptions) covers store-provider faults;
+ // UnauthorizedAccessException (a permission-denied key file or appsettings — NOT an IOException
+ // subtype) is exactly the lockout path this console exists to recover, so it must be contained too.
+ // A blanket catch is deliberately avoided so genuinely unexpected faults still surface.
+ private static bool IsContainable(Exception ex) =>
+ ex is InvalidOperationException or IOException or ArgumentException or DbException
+ or UnauthorizedAccessException;
+
+ // Presents the target picker: recents newest-first, then the two entry options. Returns the resolved
+ // target, or null if the operator cancelled the selection (Ctrl-C). Re-prompts on a recoverable
+ // read error so a bad path does not drop the operator out of the console.
+ private StoreTarget? PickTarget()
+ {
+ while (true)
+ {
+ IReadOnlyList recents = _recents.Load();
+
+ List choices = [];
+ Dictionary byLabel = new(StringComparer.Ordinal);
+ foreach (RecentTargetsStore.RecentTarget recent in recents)
+ {
+ string label = $"{recent.Name} — {recent.AppSettingsPath}";
+ if (byLabel.ContainsKey(label))
+ {
+ continue; // Load() already de-dups by path, but guard against colliding labels.
+ }
+
+ choices.Add(label);
+ byLabel[label] = recent;
+ }
+
+ choices.Add(EnterPathLabel);
+ choices.Add(ManualEntryLabel);
+
+ string picked;
+ try
+ {
+ picked = _console.Prompt(new SelectionPrompt()
+ .Title("Select a store target")
+ .AddChoices(choices));
+ }
+ catch (OperationCanceledException)
+ {
+ return null;
+ }
+
+ try
+ {
+ if (picked == ManualEntryLabel)
+ {
+ return ReadManualTarget();
+ }
+
+ string appSettingsPath = picked == EnterPathLabel
+ ? _console.Prompt(new TextPrompt("appsettings.json path:"))
+ : byLabel[picked].AppSettingsPath;
+
+ StoreTarget target = _reader.Read(appSettingsPath);
+ _recents.Touch(target.Name, target.AppSettingsPath!); // Read targets always carry a path
+ return target;
+ }
+ catch (OperationCanceledException)
+ {
+ return null;
+ }
+ catch (Exception ex) when (IsContainable(ex))
+ {
+ RenderErrorPanel(ex); // e.g. FileNotFoundException — show it and re-open the picker
+ }
+ }
+ }
+
+ private StoreTarget ReadManualTarget()
+ {
+ string dbPath = _console.Prompt(new TextPrompt("SQLite database path:"));
+ string envVar = _console.Prompt(new TextPrompt("Master-key environment variable name:")
+ .DefaultValue("ZB_SECRETS_MASTER_KEY"));
+
+ // Manual targets have no appsettings.json, so they are not remembered as recents.
+ return _reader.Manual(dbPath, new MasterKeyOptions
+ {
+ Source = MasterKeySource.Environment,
+ EnvVarName = envVar,
+ });
+ }
+
+ // Prompts the operator for a KEK to lift a degraded session, re-opening it with the supplied key.
+ // Returns the upgraded (KEK-capable) session, or null if the operator cancelled or the key was
+ // rejected — in which case the caller stays on the menu.
+ private async Task TryUpgradeSessionAsync(SecretsSession session, CancellationToken ct)
+ {
+ _console.MarkupLine("[yellow]This action needs a KEK, but the session is degraded.[/]");
+
+ string source;
+ try
+ {
+ source = _console.Prompt(new SelectionPrompt()
+ .Title("Provide the key")
+ .AddChoices(PasteKeyLabel, KeyFileLabel, CancelLabel));
+ }
+ catch (OperationCanceledException)
+ {
+ return null;
+ }
+
+ if (source == CancelLabel)
+ {
+ return null;
+ }
+
+ IMasterKeyProvider provider;
+ try
+ {
+ if (source == PasteKeyLabel)
+ {
+ string base64 = _console.Prompt(new TextPrompt("Paste base64 KEK:").Secret());
+ provider = new LiteralMasterKeyProvider(base64);
+ }
+ else
+ {
+ string filePath = _console.Prompt(new TextPrompt("Key file path:"));
+ provider = MasterKeyProviderFactory.Create(new MasterKeyOptions
+ {
+ Source = MasterKeySource.File,
+ FilePath = filePath,
+ });
+ }
+ }
+ catch (ArgumentException ex)
+ {
+ // LiteralMasterKeyProvider rejects a bad paste eagerly; name the problem and stay on the menu.
+ _console.MarkupLineInterpolated($"[red]Invalid key: {ex.Message}[/]");
+ return null;
+ }
+
+ SecretsSession upgraded = await _sessions.OpenAsync(session.Target, provider, ct).ConfigureAwait(false);
+ if (!upgraded.KekAvailable)
+ {
+ RenderWarnings(upgraded);
+ _console.MarkupLine("[red]That key did not unlock the session.[/]");
+ return null;
+ }
+
+ return upgraded;
+ }
+
+ private SelectionPrompt BuildMenu()
+ {
+ List actions = [];
+ actions.AddRange(_flows.Select(f => f.Title));
+ actions.Add(SwitchLabel);
+ actions.Add(QuitLabel);
+
+ return new SelectionPrompt()
+ .Title("Action")
+ .AddChoices(actions);
+ }
+
+ private void RenderHeader(SecretsSession session)
+ {
+ string kek = session.KekAvailable
+ ? "[green]KEK OK[/]"
+ : "[red]KEK UNAVAILABLE (degraded)[/]";
+
+ Markup body = new(
+ $"[bold]{Markup.Escape(session.Target.Name)}[/] " +
+ $"store: {Markup.Escape(session.StoreKind)} {kek}");
+
+ _console.Write(new Panel(body).Header("Target"));
+ }
+
+ private void RenderWarnings(SecretsSession session)
+ {
+ foreach (string warning in session.Warnings)
+ {
+ _console.MarkupLineInterpolated($"[yellow]{warning}[/]");
+ }
+ }
+
+ private void RenderErrorPanel(Exception ex)
+ {
+ Markup body = new(
+ $"[red]{Markup.Escape(ex.Message)}[/]\n\nIf this is a key problem, run the KEK doctor.");
+
+ _console.Write(new Panel(body)
+ .Header("[red]Action failed[/]")
+ .BorderColor(Color.Red));
+ }
+}
diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/KekDoctor.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/KekDoctor.cs
new file mode 100644
index 0000000..98afb1e
--- /dev/null
+++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/KekDoctor.cs
@@ -0,0 +1,167 @@
+using ZB.MOM.WW.Secrets.Abstractions;
+using ZB.MOM.WW.Secrets.Rotation;
+
+namespace ZB.MOM.WW.Secrets.Cli.Interactive;
+
+/// The per-row verdict of a KEK diagnosis.
+public enum RowKekStatus
+{
+ /// The session KEK matches the row and successfully unwraps it — the row is healthy.
+ Ok,
+
+ /// The row is wrapped under a different KEK than the session's — the session cannot open it.
+ WrongKek,
+
+ ///
+ /// The row's kek_id matches the session KEK, yet the DEK unwrap / decrypt fails closed —
+ /// the wrap envelope or sealed body is damaged.
+ ///
+ Corrupt,
+}
+
+/// One row's KEK verdict: its name, status, and the kek_id it is actually wrapped under.
+/// The secret's normalized name.
+/// Whether the session KEK opens the row ().
+///
+/// The kek_id stamped on the row — the key an operator must hunt down when the status is
+/// .
+///
+public sealed record KekDiagnosis(string SecretName, RowKekStatus Status, string RowKekId);
+
+///
+/// The full diagnosis: the session's KEK id and the per-row verdicts (ordered by name), including
+/// tombstoned rows since they still carry a KEK-wrapped DEK and block a rewrap-all.
+///
+/// The kek_id of the KEK the session is currently using.
+/// The per-row verdicts, ordered by secret name.
+///
+/// The number of rows scanned from the store (including tombstones). A value greater than
+/// count means some rows vanished between the metadata list and the per-row fetch
+/// (a concurrent hard-absence) and were dropped — the discrepancy makes that visible rather than silent.
+///
+public sealed record KekDoctorReport(string SessionKekId, IReadOnlyList Rows, int Total)
+{
+ /// Whether every diagnosed row opens cleanly under the session KEK.
+ public bool Healthy => Rows.All(r => r.Status == RowKekStatus.Ok);
+}
+
+///
+/// Lockout triage: verifies the session KEK actually opens every stored row and drives the rewrap
+/// remedy. An operator whose app can no longer decrypt its secrets runs the doctor to learn, per row,
+/// which KEK each row is wrapped under and whether the session KEK opens it, then remedies the
+/// wrong-KEK rows with a guided rewrap-all onto the session KEK.
+///
+///
+/// The store surface makes a full decrypt probe possible even for tombstoned rows:
+/// enumerates all rows' metadata (including tombstones with
+/// includeDeleted: true) and returns the full ciphertext
+/// row for any name including a tombstoned one, so every row — live or dead — is probed the
+/// same way. Plaintext produced by the probe is discarded immediately and never surfaced.
+///
+public sealed class KekDoctor
+{
+ ///
+ /// Diagnoses every stored row (including tombstones) against the session KEK: a row whose
+ /// kek_id differs from the session KEK is reported
+ /// with no decrypt attempted; a row whose kek_id matches is decrypt-probed and reported
+ /// or, if the unwrap fails closed, .
+ ///
+ /// The open session; must not be degraded (a KEK is required).
+ /// A token to cancel the diagnosis.
+ /// A with the per-row verdicts, ordered by name.
+ /// is .
+ ///
+ /// The session is degraded (no KEK / cipher). The operator must re-open the session supplying a
+ /// master key before the doctor can probe rows.
+ ///
+ public async Task DiagnoseAsync(SecretsSession session, CancellationToken ct)
+ {
+ ArgumentNullException.ThrowIfNull(session);
+
+ if (session.MasterKey is null || session.Cipher is null)
+ {
+ throw new InvalidOperationException(
+ "The session is degraded: no master key (KEK) is available, so rows cannot be probed. " +
+ "Re-open the session and supply the key material (for example, paste it at the prompt).");
+ }
+
+ string sessionKekId = session.MasterKey.KekId;
+
+ // ListAsync gives metadata only; GetAsync fetches the full ciphertext row (tombstones included)
+ // for the decrypt probe. Enumerate ALL rows so tombstones — which still block a rewrap-all — show.
+ IReadOnlyList all =
+ await session.Store.ListAsync(includeDeleted: true, ct).ConfigureAwait(false);
+
+ List rows = [];
+ foreach (SecretMetadata meta in all.OrderBy(m => m.Name.Value, StringComparer.Ordinal))
+ {
+ ct.ThrowIfCancellationRequested();
+
+ // Fetch the FRESH row and classify from ITS kek_id — never the (possibly stale) list
+ // metadata. A concurrent re-wrap between the list and this fetch would otherwise mislabel a
+ // moved row (a benign WrongKek row read as Corrupt, or a re-homed row read as WrongKek).
+ StoredSecret? row = await session.Store.GetAsync(meta.Name, ct).ConfigureAwait(false);
+ if (row is null)
+ {
+ // Vanished between the list and the fetch (a concurrent hard-absence). Not added to
+ // rows; the Total-vs-Rows.Count gap in the report keeps the drop visible.
+ continue;
+ }
+
+ rows.Add(Classify(row, sessionKekId, session.Cipher));
+ }
+
+ return new KekDoctorReport(sessionKekId, rows, all.Count);
+ }
+
+ // Classifies a single FRESH row against the session KEK. A row under a different kek_id is
+ // WrongKek (reported with its own id so the operator knows which key to hunt) and is NOT decrypted;
+ // a matching row is decrypt-probed (plaintext discarded at once) → Ok, or Corrupt if it fails closed.
+ private static KekDiagnosis Classify(StoredSecret row, string sessionKekId, ISecretCipher cipher)
+ {
+ if (!string.Equals(row.KekId, sessionKekId, StringComparison.Ordinal))
+ {
+ return new KekDiagnosis(row.Name.Value, RowKekStatus.WrongKek, row.KekId);
+ }
+
+ try
+ {
+ _ = cipher.Decrypt(row);
+ return new KekDiagnosis(row.Name.Value, RowKekStatus.Ok, row.KekId);
+ }
+ catch (SecretDecryptionException)
+ {
+ return new KekDiagnosis(row.Name.Value, RowKekStatus.Corrupt, row.KekId);
+ }
+ }
+
+ ///
+ /// Remedies wrong-KEK rows by re-wrapping every row from onto the
+ /// session's KEK, delegating to (idempotent, and
+ /// fail-closed on rows wrapped by neither the old nor the session KEK).
+ ///
+ /// The open session; must not be degraded (its KEK is the rewrap target).
+ /// The provider for the KEK the wrong-KEK rows are currently wrapped under.
+ /// A token to cancel the pass (already-persisted re-wraps are retained).
+ /// A summarizing the pass (no secret material).
+ ///
+ /// or is .
+ ///
+ /// The session is degraded (no KEK / cipher).
+ public Task RewrapAllAsync(
+ SecretsSession session, IMasterKeyProvider oldKek, CancellationToken ct)
+ {
+ ArgumentNullException.ThrowIfNull(session);
+ ArgumentNullException.ThrowIfNull(oldKek);
+
+ if (session.MasterKey is null || session.Cipher is null)
+ {
+ throw new InvalidOperationException(
+ "The session is degraded: no master key (KEK) is available to re-wrap onto. " +
+ "Re-open the session and supply the key material before running the rewrap remedy.");
+ }
+
+ return new KekRotationService(session.Store, session.Cipher)
+ .RewrapAllAsync(oldKek, session.MasterKey, ct);
+ }
+}
diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/LiteralMasterKeyProvider.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/LiteralMasterKeyProvider.cs
new file mode 100644
index 0000000..7a81a5c
--- /dev/null
+++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/LiteralMasterKeyProvider.cs
@@ -0,0 +1,64 @@
+using ZB.MOM.WW.Secrets.Abstractions;
+using ZB.MOM.WW.Secrets.MasterKey;
+
+namespace ZB.MOM.WW.Secrets.Cli.Interactive;
+
+///
+/// An over master-key material an operator pastes at the console —
+/// the interactive lockout-recovery path when the configured KEK source (an environment variable or a
+/// file) is not visible in the CLI's own shell.
+///
+///
+/// Subclasses so the non-secret
+/// is derived by the exact same scheme every other provider uses. That interop is load-bearing: a key
+/// entered here must resolve to the same kek_id the service wrote rows under, or every decrypt
+/// and re-wrap fails closed on a KEK mismatch. Pinned by
+/// LiteralMasterKeyProviderTests.Derives_same_kekid_as_environment_provider_for_same_material.
+///
+public sealed class LiteralMasterKeyProvider : MasterKeyProviderBase
+{
+ private readonly byte[] _key;
+
+ ///
+ /// Creates the provider from base64-encoded 32-byte key material, validated eagerly so a bad paste
+ /// is rejected at entry rather than on the first decrypt.
+ ///
+ /// The base64-encoded 32-byte master key (KEK).
+ ///
+ /// An optional explicit key id used verbatim as ; when
+ /// or empty the id is derived from the key material, matching the other
+ /// providers.
+ ///
+ ///
+ /// is null/whitespace, is not valid base64, or does not decode to
+ /// exactly 32 bytes.
+ ///
+ public LiteralMasterKeyProvider(string base64Key, string? kekId = null)
+ : base(new MasterKeyOptions { KekId = kekId })
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(base64Key);
+
+ byte[] decoded;
+ try
+ {
+ decoded = Convert.FromBase64String(base64Key.Trim());
+ }
+ catch (FormatException ex)
+ {
+ throw new ArgumentException(
+ $"Master key is not valid base64: {ex.Message}", nameof(base64Key), ex);
+ }
+
+ if (decoded.Length != KeySizeBytes)
+ {
+ throw new ArgumentException(
+ $"Master key must be exactly {KeySizeBytes} bytes (AES-256) but decoded to {decoded.Length} bytes.",
+ nameof(base64Key));
+ }
+
+ _key = decoded;
+ }
+
+ ///
+ protected override byte[] ResolveKeyBytes() => (byte[])_key.Clone();
+}
diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/RecentTargetsStore.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/RecentTargetsStore.cs
new file mode 100644
index 0000000..2df8cd1
--- /dev/null
+++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/RecentTargetsStore.cs
@@ -0,0 +1,92 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace ZB.MOM.WW.Secrets.Cli.Interactive;
+
+///
+/// Persists the operator's recently-used store targets (names + appsettings paths ONLY — never
+/// key material or secret values) so the interactive console can offer quick re-selection.
+///
+public sealed class RecentTargetsStore
+{
+ private const int MaxEntries = 10;
+
+ private static readonly JsonSerializerOptions SerializerOptions = new()
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ WriteIndented = true,
+ };
+
+ private readonly string _filePath;
+ private readonly TimeProvider _timeProvider;
+
+ /// Creates the store bound to a JSON file path, using an optional injected clock.
+ /// The JSON file to load from and save to.
+ ///
+ /// Clock used to stamp . Defaults to
+ /// when omitted.
+ ///
+ public RecentTargetsStore(string filePath, TimeProvider? timeProvider = null)
+ {
+ _filePath = filePath;
+ _timeProvider = timeProvider ?? TimeProvider.System;
+ }
+
+ /// One remembered target: a display name, its appsettings.json path, and when it was last used.
+ /// Display name for the target (e.g. the app name).
+ /// Filesystem path to the target's appsettings.json.
+ /// Timestamp of the most recent selection of this target.
+ public sealed record RecentTarget(string Name, string AppSettingsPath, DateTimeOffset LastUsedUtc);
+
+ /// Loads the recents list, newest first. A missing or unreadable/corrupt file yields an empty list.
+ public IReadOnlyList Load()
+ {
+ if (!File.Exists(_filePath))
+ {
+ return [];
+ }
+
+ try
+ {
+ var json = File.ReadAllText(_filePath);
+ var entries = JsonSerializer.Deserialize>(json, SerializerOptions);
+ return entries ?? [];
+ }
+ catch (JsonException)
+ {
+ return [];
+ }
+ catch (IOException)
+ {
+ return [];
+ }
+ }
+
+ /// Upserts a target by path, moves it to the front, stamps now, caps the list at 10, and saves.
+ /// Display name for the target.
+ /// Filesystem path to the target's appsettings.json — the dedup key.
+ public void Touch(string name, string appSettingsPath)
+ {
+ var existing = Load();
+ var remaining = existing.Where(e => !string.Equals(e.AppSettingsPath, appSettingsPath, StringComparison.Ordinal));
+ var touched = new RecentTarget(name, appSettingsPath, _timeProvider.GetUtcNow());
+ var updated = new List { touched };
+ updated.AddRange(remaining.Take(MaxEntries - 1));
+
+ Save(updated);
+ }
+
+ private void Save(IReadOnlyList entries)
+ {
+ var directory = Path.GetDirectoryName(_filePath);
+ if (!string.IsNullOrEmpty(directory))
+ {
+ Directory.CreateDirectory(directory);
+ }
+
+ var json = JsonSerializer.Serialize(entries, SerializerOptions);
+ var tmpPath = _filePath + ".tmp";
+ File.WriteAllText(tmpPath, json);
+ File.Move(tmpPath, _filePath, overwrite: true);
+ }
+}
diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/ReferenceAuditor.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/ReferenceAuditor.cs
new file mode 100644
index 0000000..e99e311
--- /dev/null
+++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/ReferenceAuditor.cs
@@ -0,0 +1,181 @@
+using System.Text.RegularExpressions;
+using Microsoft.Extensions.Configuration;
+using ZB.MOM.WW.Secrets.Abstractions;
+
+namespace ZB.MOM.WW.Secrets.Cli.Interactive;
+
+/// The resolvability of a single ${secret:NAME} reference against the target's store.
+public enum ReferenceStatus
+{
+ /// The referenced secret exists and decrypts under the session's KEK.
+ Ok,
+
+ ///
+ /// The referenced secret exists, but the session is degraded (no KEK) so decryptability could not
+ /// be verified — presence is all that could be established.
+ ///
+ PresentUnverified,
+
+ /// No row exists for the referenced secret — a fail-closed expansion would throw at startup.
+ Missing,
+
+ /// A row exists but is soft-deleted (tombstoned) — treated as absent by the resolver.
+ Tombstoned,
+
+ /// The row exists but does not decrypt under the session's KEK (wrong or rotated KEK, or tampering).
+ Undecryptable,
+
+ ///
+ /// The token names a value that is not a valid (illegal characters, an
+ /// unfilled placeholder like REPLACE ME, and so on), so it cannot be looked up at all.
+ ///
+ InvalidName,
+}
+
+///
+/// One audited secret reference: the referenced name, its resolvability , and every
+/// configuration key path that references it.
+///
+/// The referenced secret name, exactly as it appears in the token (trimmed).
+/// The reference's resolvability against the target's store.
+/// The configuration key paths that reference this name, sorted (ordinal).
+public sealed record ReferenceFinding(
+ string SecretName, ReferenceStatus Status, IReadOnlyList ConfigPaths);
+
+/// Scans a target app's composed configuration for ${secret:NAME} tokens and checks each against the store.
+public sealed class ReferenceAuditor
+{
+ // Pinned to SecretReferenceExpander.TokenPattern — MUST stay identical so the auditor sees exactly
+ // the references the expander would resolve at startup. Do NOT diverge from the expander's regex.
+ private static readonly Regex TokenPattern = new(@"\$\{secret:([^}]+)\}", RegexOptions.Compiled);
+
+ ///
+ /// Walks every configuration value, collects each distinct ${secret:NAME} reference with the
+ /// config key paths that use it, and classifies each name against the session's store. References are
+ /// keyed by their normalized so case/whitespace variants of one
+ /// secret collapse to a single finding whose name matches what a get would resolve; a token that
+ /// is not a valid name is reported once as under its raw text
+ /// rather than aborting the whole audit. Findings are ordered by name (ordinal); each finding's config
+ /// paths are likewise sorted and de-duplicated.
+ ///
+ /// The open store session whose target configuration is audited.
+ /// A token to cancel the store lookups.
+ /// One finding per distinct referenced secret.
+ public async Task> AuditAsync(SecretsSession session, CancellationToken ct)
+ {
+ ArgumentNullException.ThrowIfNull(session);
+
+ // group key -> collected reference. The key is the normalized SecretName.Value for a valid name
+ // (so variants collapse), or the raw trimmed token text for an invalid one. SortedDictionary keeps
+ // findings ordered by name; each group's SortedSet keeps its paths deterministic.
+ var groups = new SortedDictionary(StringComparer.Ordinal);
+ foreach (KeyValuePair entry in session.Target.Configuration.AsEnumerable())
+ {
+ // Mirror SecretReferenceExpander.IsCommentKey: an entry whose leaf key segment starts with '_'
+ // (the "_comment" convention) may legitimately document the ${secret:...} syntax with a literal
+ // example token and is never resolved — so the auditor must not report it either.
+ if (entry.Value is not { } value || IsCommentKey(entry.Key))
+ {
+ continue;
+ }
+
+ foreach (Match match in TokenPattern.Matches(value))
+ {
+ string raw = match.Groups[1].Value.Trim();
+ ReferenceGroup group = ResolveGroup(groups, raw);
+ group.Paths.Add(entry.Key);
+ }
+ }
+
+ var findings = new List(groups.Count);
+ foreach ((string key, ReferenceGroup group) in groups)
+ {
+ ReferenceStatus status = group.Name is { } name
+ ? await ClassifyAsync(session, name, ct).ConfigureAwait(false)
+ : ReferenceStatus.InvalidName;
+ findings.Add(new ReferenceFinding(key, status, [.. group.Paths]));
+ }
+
+ return findings;
+ }
+
+ // Finds (or creates) the group a raw token belongs to. A valid name groups under its normalized value
+ // and carries the parsed SecretName; an invalid token (illegal chars, an unfilled placeholder, ...)
+ // groups under its raw text with no name, so one bad token becomes one InvalidName finding instead of
+ // throwing and aborting the entire audit.
+ private static ReferenceGroup ResolveGroup(
+ SortedDictionary groups, string raw)
+ {
+ string key;
+ SecretName? name;
+ try
+ {
+ var parsed = new SecretName(raw);
+ key = parsed.Value;
+ name = parsed;
+ }
+ catch (ArgumentException)
+ {
+ key = raw;
+ name = null;
+ }
+
+ if (!groups.TryGetValue(key, out ReferenceGroup? group))
+ {
+ group = new ReferenceGroup(name);
+ groups[key] = group;
+ }
+
+ return group;
+ }
+
+ // A configuration key is a comment/documentation key when its leaf segment (after the last ':') starts
+ // with '_'. Duplicated from SecretReferenceExpander.IsCommentKey — keep the two in lockstep.
+ private static bool IsCommentKey(string key)
+ {
+ int lastSeparator = key.LastIndexOf(':');
+ string leaf = lastSeparator >= 0 ? key[(lastSeparator + 1)..] : key;
+ return leaf.StartsWith('_');
+ }
+
+ // Present ⇒ tombstone-check ⇒ (degraded ⇒ presence-only) ⇒ decrypt-probe. The decrypted plaintext is
+ // discarded immediately (never stored in a finding); only the success/failure signal is kept.
+ private static async Task ClassifyAsync(
+ SecretsSession session, SecretName name, CancellationToken ct)
+ {
+ StoredSecret? row = await session.Store.GetAsync(name, ct).ConfigureAwait(false);
+ if (row is null)
+ {
+ return ReferenceStatus.Missing;
+ }
+
+ if (row.IsDeleted)
+ {
+ return ReferenceStatus.Tombstoned;
+ }
+
+ if (session.Cipher is not { } cipher)
+ {
+ return ReferenceStatus.PresentUnverified;
+ }
+
+ try
+ {
+ _ = cipher.Decrypt(row);
+ return ReferenceStatus.Ok;
+ }
+ catch (SecretDecryptionException)
+ {
+ return ReferenceStatus.Undecryptable;
+ }
+ }
+
+ // A collated reference: the config key paths that use it, plus the parsed name (null when the token is
+ // not a valid SecretName — an InvalidName finding).
+ private sealed class ReferenceGroup(SecretName? name)
+ {
+ public SortedSet Paths { get; } = new(StringComparer.Ordinal);
+
+ public SecretName? Name { get; } = name;
+ }
+}
diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/SecretBundle.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/SecretBundle.cs
new file mode 100644
index 0000000..c6ef50f
--- /dev/null
+++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/SecretBundle.cs
@@ -0,0 +1,164 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using ZB.MOM.WW.Secrets.Abstractions;
+
+namespace ZB.MOM.WW.Secrets.Cli.Interactive;
+
+///
+/// Ciphertext-only export format for moving secret rows between deployment stores (cloning a
+/// deployment, staging a recovery). A bundle carries only the encrypted
+/// representation — never plaintext — so it is safe at rest and useless
+/// to anyone without the source KEK. Every array rides as a base64 string and
+/// every timestamp as an ISO-8601 , so a parsed bundle round-trips
+/// byte-identical to what was exported.
+///
+public sealed record SecretBundle
+{
+ /// The only bundle format version this build can read or write.
+ public const int CurrentFormatVersion = 1;
+
+ /// The on-disk bundle format version (currently 1).
+ public int FormatVersion { get; init; } = CurrentFormatVersion;
+
+ /// When the bundle was exported (UTC).
+ public DateTimeOffset ExportedUtc { get; init; }
+
+ ///
+ /// The of the store the bundle was exported from — the KEK
+ /// an operator must supply to import into a store on a different KEK (so rows can be re-wrapped).
+ ///
+ public string SourceKekId { get; init; } = "";
+
+ /// The exported rows, each mirroring a with base64 crypto fields.
+ public IReadOnlyList Entries { get; init; } = [];
+}
+
+///
+/// One exported row — a faithful, ciphertext-only mirror of a . The six
+/// crypto BLOBs are carried as base64 strings and the name as its normalized string form; all other
+/// fields keep their native JSON representation so the row survives an export/parse round-trip.
+///
+/// The normalized secret name ().
+/// Optional human-readable description.
+/// The name.
+/// Base64 of the AES-256-GCM ciphertext.
+/// Base64 of the body nonce.
+/// Base64 of the body authentication tag.
+/// Base64 of the KEK-wrapped data-encryption key.
+/// Base64 of the DEK-wrap nonce.
+/// Base64 of the DEK-wrap authentication tag.
+/// Identifier of the KEK that wrapped the DEK.
+/// The row's monotonic revision.
+/// Whether the row is a tombstone.
+/// When the row was tombstoned, if it is deleted.
+/// When the row was first created (UTC).
+/// When the row was last updated (UTC).
+/// Principal that created the row, if known.
+/// Principal that last updated the row, if known.
+public sealed record BundleEntry(
+ string Name,
+ string? Description,
+ string ContentType,
+ string Ciphertext,
+ string Nonce,
+ string Tag,
+ string WrappedDek,
+ string WrapNonce,
+ string WrapTag,
+ string KekId,
+ long Revision,
+ bool IsDeleted,
+ DateTimeOffset? DeletedUtc,
+ DateTimeOffset CreatedUtc,
+ DateTimeOffset UpdatedUtc,
+ string? CreatedBy,
+ string? UpdatedBy);
+
+///
+/// Serializes and parses documents and converts between a
+/// row and its mirror. The conversion is
+/// lossless in both directions: ((row)) reproduces every
+/// field of byte-identically.
+///
+public static class SecretBundleCodec
+{
+ private static readonly JsonSerializerOptions Options = new()
+ {
+ WriteIndented = true,
+ DefaultIgnoreCondition = JsonIgnoreCondition.Never,
+ };
+
+ /// Serializes to indented JSON.
+ /// The bundle to serialize.
+ /// The indented JSON document.
+ public static string Serialize(SecretBundle bundle)
+ {
+ ArgumentNullException.ThrowIfNull(bundle);
+ return JsonSerializer.Serialize(bundle, Options);
+ }
+
+ /// Parses a from its JSON representation.
+ /// The JSON document produced by .
+ /// The parsed bundle.
+ /// The JSON does not represent a bundle.
+ public static SecretBundle Deserialize(string json)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(json);
+ return JsonSerializer.Deserialize(json, Options)
+ ?? throw new InvalidOperationException("Bundle JSON deserialized to null.");
+ }
+
+ /// Projects a stored row into its ciphertext-only bundle entry.
+ /// The stored row to export.
+ /// The bundle entry mirroring .
+ public static BundleEntry FromRow(StoredSecret row)
+ {
+ ArgumentNullException.ThrowIfNull(row);
+ return new BundleEntry(
+ Name: row.Name.Value,
+ Description: row.Description,
+ ContentType: row.ContentType.ToString(),
+ Ciphertext: Convert.ToBase64String(row.Ciphertext),
+ Nonce: Convert.ToBase64String(row.Nonce),
+ Tag: Convert.ToBase64String(row.Tag),
+ WrappedDek: Convert.ToBase64String(row.WrappedDek),
+ WrapNonce: Convert.ToBase64String(row.WrapNonce),
+ WrapTag: Convert.ToBase64String(row.WrapTag),
+ KekId: row.KekId,
+ Revision: row.Revision,
+ IsDeleted: row.IsDeleted,
+ DeletedUtc: row.DeletedUtc,
+ CreatedUtc: row.CreatedUtc,
+ UpdatedUtc: row.UpdatedUtc,
+ CreatedBy: row.CreatedBy,
+ UpdatedBy: row.UpdatedBy);
+ }
+
+ /// Rebuilds a stored row from a bundle entry.
+ /// The bundle entry to rehydrate.
+ /// The the entry was projected from.
+ public static StoredSecret ToRow(BundleEntry entry)
+ {
+ ArgumentNullException.ThrowIfNull(entry);
+ return new StoredSecret
+ {
+ Name = new SecretName(entry.Name),
+ Description = entry.Description,
+ ContentType = Enum.Parse(entry.ContentType),
+ Ciphertext = Convert.FromBase64String(entry.Ciphertext),
+ Nonce = Convert.FromBase64String(entry.Nonce),
+ Tag = Convert.FromBase64String(entry.Tag),
+ WrappedDek = Convert.FromBase64String(entry.WrappedDek),
+ WrapNonce = Convert.FromBase64String(entry.WrapNonce),
+ WrapTag = Convert.FromBase64String(entry.WrapTag),
+ KekId = entry.KekId,
+ Revision = entry.Revision,
+ IsDeleted = entry.IsDeleted,
+ DeletedUtc = entry.DeletedUtc,
+ CreatedUtc = entry.CreatedUtc,
+ UpdatedUtc = entry.UpdatedUtc,
+ CreatedBy = entry.CreatedBy,
+ UpdatedBy = entry.UpdatedBy,
+ };
+ }
+}
diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/SecretsSession.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/SecretsSession.cs
new file mode 100644
index 0000000..e20db2f
--- /dev/null
+++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/SecretsSession.cs
@@ -0,0 +1,31 @@
+using ZB.MOM.WW.Secrets.Abstractions;
+
+namespace ZB.MOM.WW.Secrets.Cli.Interactive;
+
+/// An open store session for one target. Degraded when no KEK is available (metadata ops only).
+public sealed class SecretsSession
+{
+ /// The target this session operates on.
+ public required StoreTarget Target { get; init; }
+
+ /// The store backing this session (SQLite or SQL Server).
+ public required ISecretStore Store { get; init; }
+
+ /// The migrator matching ; already run once when the session was opened.
+ public required ISecretsStoreMigrator Migrator { get; init; }
+
+ /// The resolved master-key provider, or when the session is degraded.
+ public IMasterKeyProvider? MasterKey { get; init; }
+
+ /// The envelope cipher, or when the session is degraded (no KEK).
+ public ISecretCipher? Cipher { get; init; }
+
+ /// The concrete store kind: "sqlite" or "sqlserver".
+ public required string StoreKind { get; init; }
+
+ /// Non-fatal warnings raised while opening (KEK unavailable, store fall-back, and so on).
+ public IReadOnlyList Warnings { get; init; } = [];
+
+ /// Whether a KEK (and therefore a cipher) is available — in degraded mode.
+ public bool KekAvailable => Cipher is not null;
+}
diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/SecretsSessionFactory.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/SecretsSessionFactory.cs
new file mode 100644
index 0000000..3be253f
--- /dev/null
+++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/SecretsSessionFactory.cs
@@ -0,0 +1,132 @@
+using ZB.MOM.WW.Secrets.Abstractions;
+using ZB.MOM.WW.Secrets.Crypto;
+using ZB.MOM.WW.Secrets.MasterKey;
+using ZB.MOM.WW.Secrets.Replicator.SqlServer;
+using ZB.MOM.WW.Secrets.Sqlite;
+
+namespace ZB.MOM.WW.Secrets.Cli.Interactive;
+
+///
+/// Opens a for a target by constructing the same store stack the app's DI
+/// extensions build — but directly, without a host, and (unlike the app) tolerant of a missing KEK:
+/// resolution failure yields a DEGRADED session the shell can later upgrade with an operator-supplied
+/// key, rather than throwing and leaving the operator unable to even list what is in the store.
+///
+public sealed class SecretsSessionFactory
+{
+ /// Marker for an unresolved ${secret:} reference embedded in a connection string.
+ private const string SecretRefMarker = "${secret:";
+
+ /// Builds the store stack for a target. KEK resolution failure yields a DEGRADED session, never a throw.
+ /// The resolved store target.
+ ///
+ /// An operator-supplied KEK that pre-empts the target's configured source, or
+ /// to use the configured provider.
+ ///
+ /// A token to cancel the schema migration.
+ /// An open session — full when a KEK resolves, degraded otherwise.
+ public async Task OpenAsync(
+ StoreTarget target, IMasterKeyProvider? overrideKek, CancellationToken ct)
+ {
+ ArgumentNullException.ThrowIfNull(target);
+
+ List warnings = [];
+
+ (ISecretStore store, ISecretsStoreMigrator migrator, string storeKind) = BuildStore(target, warnings);
+ (IMasterKeyProvider? masterKey, ISecretCipher? cipher) = ResolveKek(target, overrideKek, warnings);
+
+ // Migrate regardless of KEK state: metadata/list ops must work in a degraded session.
+ await migrator.MigrateAsync(ct).ConfigureAwait(false);
+
+ return new SecretsSession
+ {
+ Target = target,
+ Store = store,
+ Migrator = migrator,
+ MasterKey = masterKey,
+ Cipher = cipher,
+ StoreKind = storeKind,
+ Warnings = warnings,
+ };
+ }
+
+ // Mirrors the DI extensions' store selection: a configured SQL-Server hub wins, EXCEPT when its
+ // connection string is an unresolved '${secret:}' reference (the app resolves that from its own
+ // bootstrapped store at startup; the CLI has no such resolver), in which case fall back to SQLite.
+ //
+ // The CLI deliberately treats a configured Secrets:SqlServer section as the authoritative target
+ // regardless of the app's replication topology: in hub-replication mode the SQL-Server hub is the
+ // shared source of truth, so editing it here is correct — the change converges to each node's local
+ // store via the sync sweep, rather than being written to one node's local SQLite and racing the hub.
+ private static (ISecretStore Store, ISecretsStoreMigrator Migrator, string StoreKind) BuildStore(
+ StoreTarget target, List warnings)
+ {
+ if (target.SqlServer is { } sql && !string.IsNullOrEmpty(sql.ConnectionString))
+ {
+ if (sql.ConnectionString.Contains(SecretRefMarker, StringComparison.Ordinal))
+ {
+ warnings.Add(
+ "SQL-Server connection string is an unresolved '${secret:}' reference the console " +
+ "cannot resolve; falling back to the local SQLite store.");
+ }
+ else
+ {
+ var sqlFactory = new SecretsSqlServerConnectionFactory(sql);
+ return (
+ new SqlServerSecretStore(sqlFactory),
+ new SqlServerSecretsStoreMigrator(sqlFactory),
+ "sqlserver");
+ }
+ }
+
+ var sqliteFactory = new SecretsSqliteConnectionFactory(target.Secrets.SqlitePath);
+ return (
+ new SqliteSecretStore(sqliteFactory),
+ new SqliteSecretsStoreMigrator(sqliteFactory),
+ "sqlite");
+ }
+
+ // Resolves the KEK (override, else the configured provider) and forces material resolution now. A
+ // documented resolution failure degrades the session here instead of throwing on the first seal.
+ private static (IMasterKeyProvider? MasterKey, ISecretCipher? Cipher) ResolveKek(
+ StoreTarget target, IMasterKeyProvider? overrideKek, List warnings)
+ {
+ try
+ {
+ IMasterKeyProvider provider =
+ overrideKek ?? MasterKeyProviderFactory.Create(target.Secrets.MasterKey);
+
+ // GetMasterKey (not KekId) is the probe: KekId short-circuits when an explicit id is
+ // configured (MasterKeyProviderBase.KekId) and so would not touch the key source, whereas
+ // GetMasterKey always resolves and length-validates the material — exactly what must
+ // succeed for the cipher to work.
+ _ = provider.GetMasterKey();
+
+ return (provider, new AesGcmEnvelopeCipher(provider));
+ }
+ catch (ArgumentOutOfRangeException ex)
+ {
+ // MasterKeyProviderFactory.Create throws this when MasterKeySource is out of range — a
+ // malformed config value. This is operator-facing lockout tooling, so degrade rather than
+ // crash; name the invalid value so the operator can see what to fix.
+ warnings.Add(
+ $"Master-key source '{target.Secrets.MasterKey.Source}' is not a recognized value — " +
+ $"session is degraded (metadata only): {ex.Message}");
+ return (null, null);
+ }
+ catch (MasterKeyUnavailableException ex)
+ {
+ // Thrown by every provider (Environment/File/Dpapi) and the base length check when the key
+ // source is missing, empty, malformed, or the wrong length.
+ warnings.Add($"Master key unavailable — session is degraded (metadata only): {ex.Message}");
+ return (null, null);
+ }
+ catch (PlatformNotSupportedException ex)
+ {
+ // Thrown by DpapiMasterKeyProvider.ResolveKeyBytes off Windows.
+ warnings.Add(
+ $"Master key source is not supported on this platform — session is degraded (metadata only): {ex.Message}");
+ return (null, null);
+ }
+ }
+}
diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/StoreTarget.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/StoreTarget.cs
new file mode 100644
index 0000000..9721b9e
--- /dev/null
+++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/StoreTarget.cs
@@ -0,0 +1,17 @@
+using Microsoft.Extensions.Configuration;
+using ZB.MOM.WW.Secrets.Replicator.SqlServer;
+
+namespace ZB.MOM.WW.Secrets.Cli.Interactive;
+
+/// A resolved deployment store target: the app's composed Secrets options + where they came from.
+/// Display name (defaults to the appsettings parent directory name).
+/// The base appsettings.json path, or for manual-entry targets.
+/// The bound application secrets options, with already made absolute.
+/// The bound SQL-Server hub options when a Secrets:SqlServer section is present; otherwise .
+/// The full composed configuration, consumed later by the reference auditor.
+public sealed record StoreTarget(
+ string Name,
+ string? AppSettingsPath,
+ SecretsOptions Secrets,
+ SqlServerSecretsOptions? SqlServer,
+ IConfigurationRoot Configuration);
diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/TargetConfigReader.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/TargetConfigReader.cs
new file mode 100644
index 0000000..50949a1
--- /dev/null
+++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/TargetConfigReader.cs
@@ -0,0 +1,85 @@
+using Microsoft.Extensions.Configuration;
+using ZB.MOM.WW.Secrets.MasterKey;
+using ZB.MOM.WW.Secrets.Replicator.SqlServer;
+
+namespace ZB.MOM.WW.Secrets.Cli.Interactive;
+
+///
+/// Composes a deployment target's Secrets configuration the same way the target app would at
+/// startup — base appsettings.json, optional per-environment overlay, and environment
+/// variables — so the interactive console operates on exactly the store the app reads.
+///
+public sealed class TargetConfigReader
+{
+ ///
+ /// Loads the target app's config exactly the way the app would (base json + optional environment
+ /// overlay + environment variables) and binds the Secrets options out of it.
+ ///
+ /// Absolute or relative path to the target app's base appsettings.json.
+ ///
+ /// Optional ASP.NET environment name; when non-empty an appsettings.{environment}.json
+ /// overlay is layered on top of the base file (optional — absent overlays are ignored).
+ ///
+ /// The configuration section the Secrets options bind from. Defaults to Secrets.
+ /// The resolved , with the SQLite path made absolute against the app directory.
+ /// The base does not exist.
+ public StoreTarget Read(string appSettingsPath, string? environment = null, string sectionPath = "Secrets")
+ {
+ string fullPath = Path.GetFullPath(appSettingsPath);
+ if (!File.Exists(fullPath))
+ {
+ throw new FileNotFoundException(
+ $"Target appsettings file not found: {fullPath}", fullPath);
+ }
+
+ string dir = Path.GetDirectoryName(fullPath)!;
+ string fileName = Path.GetFileName(fullPath);
+
+ ConfigurationBuilder builder = new();
+ builder.SetBasePath(dir)
+ .AddJsonFile(fileName, optional: false);
+
+ if (!string.IsNullOrEmpty(environment))
+ {
+ builder.AddJsonFile($"appsettings.{environment}.json", optional: true);
+ }
+
+ builder.AddEnvironmentVariables();
+
+ IConfigurationRoot config = builder.Build();
+
+ SecretsOptions secrets = config.GetSection(sectionPath).Get() ?? new SecretsOptions();
+ secrets.SqlitePath = Path.GetFullPath(secrets.SqlitePath, dir);
+
+ SqlServerSecretsOptions? sqlServer = null;
+ IConfigurationSection sqlSection = config.GetSection(sectionPath + ":SqlServer");
+ if (sqlSection.GetChildren().Any())
+ {
+ sqlServer = sqlSection.Get();
+ }
+
+ string name = new DirectoryInfo(dir).Name;
+
+ return new StoreTarget(name, fullPath, secrets, sqlServer, config);
+ }
+
+ ///
+ /// Builds a manual target from an explicit db path + master-key options, with no backing
+ /// appsettings file (for ad-hoc stores the console operates on directly).
+ ///
+ /// Path to the SQLite store; made absolute against the current directory.
+ /// The master-key resolution options for this manual store.
+ /// A named manual with an empty composed configuration and no SQL-Server hub.
+ public StoreTarget Manual(string sqlitePath, MasterKeyOptions masterKey)
+ {
+ SecretsOptions secrets = new()
+ {
+ SqlitePath = Path.GetFullPath(sqlitePath),
+ MasterKey = masterKey,
+ };
+
+ IConfigurationRoot config = new ConfigurationBuilder().Build();
+
+ return new StoreTarget("manual", AppSettingsPath: null, secrets, SqlServer: null, config);
+ }
+}
diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Program.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Program.cs
index 5423f81..223d255 100644
--- a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Program.cs
+++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Program.cs
@@ -1,8 +1,10 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
+using Spectre.Console;
using ZB.MOM.WW.Secrets.Abstractions;
using ZB.MOM.WW.Secrets.Cli;
+using ZB.MOM.WW.Secrets.Cli.Interactive;
using ZB.MOM.WW.Secrets.DependencyInjection;
using ZB.MOM.WW.Secrets.MasterKey;
@@ -10,6 +12,12 @@ using ZB.MOM.WW.Secrets.MasterKey;
// The heavy lifting lives in SecretCommands; Program only builds the host, runs the schema
// migration, parses the verb + positional args, and dispatches.
+// A bare `secret` on a real TTY launches the interactive console, which builds its OWN per-target
+// sessions — so this must run BEFORE the host build + schema migration below, which the headless
+// verbs (and only they) require against the CLI's own appsettings-configured store and KEK.
+if (InteractiveEntry.ShouldEnterInteractive(args, Console.IsInputRedirected, Console.IsOutputRedirected))
+ return await InteractiveEntry.CreateShell(AnsiConsole.Console).RunAsync(CancellationToken.None);
+
HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);
builder.Configuration
.AddJsonFile("appsettings.json", optional: true)
diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/ZB.MOM.WW.Secrets.Cli.csproj b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/ZB.MOM.WW.Secrets.Cli.csproj
index 3302fcc..bf14302 100644
--- a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/ZB.MOM.WW.Secrets.Cli.csproj
+++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/ZB.MOM.WW.Secrets.Cli.csproj
@@ -10,10 +10,12 @@
+
+
diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/BundleServiceTests.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/BundleServiceTests.cs
new file mode 100644
index 0000000..da6efef
--- /dev/null
+++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/BundleServiceTests.cs
@@ -0,0 +1,377 @@
+using System.Security.Cryptography;
+using ZB.MOM.WW.Secrets.Abstractions;
+using ZB.MOM.WW.Secrets.Cli.Interactive;
+using ZB.MOM.WW.Secrets.Crypto;
+using ZB.MOM.WW.Secrets.MasterKey;
+using ZB.MOM.WW.Secrets.Sqlite;
+
+namespace ZB.MOM.WW.Secrets.Tests.Cli.Interactive;
+
+///
+/// Exercises against real temp-dir SQLite stores: the same-KEK
+/// export/import round-trip, the no-plaintext-at-rest guarantee, tombstone filtering,
+/// last-writer-wins conflict resolution (and the per-row override that can force a bundle row),
+/// and cross-KEK import (rewrap when the source KEK is supplied, skip-and-report when it is not).
+///
+public sealed class BundleServiceTests : IDisposable
+{
+ private readonly string _dir =
+ Path.Combine(Path.GetTempPath(), $"zb-secrets-bundle-{Guid.NewGuid():N}");
+
+ public BundleServiceTests() => Directory.CreateDirectory(_dir);
+
+ public void Dispose()
+ {
+ if (Directory.Exists(_dir))
+ {
+ try { Directory.Delete(_dir, recursive: true); } catch (IOException) { /* best-effort temp cleanup */ }
+ }
+ }
+
+ private string Db(string name) => Path.Combine(_dir, name);
+
+ private string BundlePath => Path.Combine(_dir, "bundle.json");
+
+ private static LiteralMasterKeyProvider NewKek()
+ {
+ byte[] key = new byte[32];
+ RandomNumberGenerator.Fill(key);
+ return new LiteralMasterKeyProvider(Convert.ToBase64String(key));
+ }
+
+ private static SecretsSession BuildSession(string dbPath, LiteralMasterKeyProvider kek)
+ {
+ var factory = new SecretsSqliteConnectionFactory(dbPath);
+ var migrator = new SqliteSecretsStoreMigrator(factory);
+ migrator.MigrateAsync(CancellationToken.None).GetAwaiter().GetResult();
+ return new SecretsSession
+ {
+ Target = new TargetConfigReader().Manual(dbPath, new MasterKeyOptions()),
+ Store = new SqliteSecretStore(factory),
+ Migrator = migrator,
+ MasterKey = kek,
+ Cipher = new AesGcmEnvelopeCipher(kek),
+ StoreKind = "sqlite",
+ };
+ }
+
+ private static async Task SealAsync(SecretsSession session, string name, string value)
+ {
+ StoredSecret row = session.Cipher!.Encrypt(new SecretName(name), value, SecretContentType.Text);
+ await session.Store.UpsertAsync(row, CancellationToken.None);
+ }
+
+ private void WriteBundle(string sourceKekId, params StoredSecret[] rows)
+ {
+ var bundle = new SecretBundle
+ {
+ ExportedUtc = DateTimeOffset.UtcNow,
+ SourceKekId = sourceKekId,
+ Entries = rows.Select(SecretBundleCodec.FromRow).ToList(),
+ };
+ File.WriteAllText(BundlePath, SecretBundleCodec.Serialize(bundle));
+ }
+
+ [Fact]
+ public async Task Export_then_import_into_empty_store_roundtrips_rows()
+ {
+ LiteralMasterKeyProvider kek = NewKek();
+ SecretsSession source = BuildSession(Db("a.db"), kek);
+
+ // Seed non-trivial metadata: created_by/updated_by set, and a second update so Revision == 1
+ // and created_utc != updated_utc — makes the "verbatim" assertion below actually load-bearing.
+ StoredSecret seed = source.Cipher!.Encrypt(new SecretName("svc/one"), "value-one", SecretContentType.Text)
+ with { Description = "the one", CreatedBy = "creator", UpdatedBy = "updater" };
+ await source.Store.UpsertAsync(seed, CancellationToken.None);
+ await source.Store.UpsertAsync(seed, CancellationToken.None);
+ await SealAsync(source, "svc/two", "value-two");
+
+ var service = new BundleService();
+ int exported = await service.ExportAsync(source, BundlePath, includeDeleted: false, CancellationToken.None);
+ Assert.Equal(2, exported);
+
+ // Same KEK, fresh empty store.
+ SecretsSession target = BuildSession(Db("b.db"), kek);
+ BundleImportReport report =
+ await service.ImportAsync(target, BundlePath, sourceKek: null, conflictOverride: null, CancellationToken.None);
+ Assert.Equal(2, report.Imported);
+ Assert.Equal(0, report.SkippedForeignKek);
+
+ foreach ((string name, string value) in new[] { ("svc/one", "value-one"), ("svc/two", "value-two") })
+ {
+ StoredSecret? src = await source.Store.GetAsync(new SecretName(name), CancellationToken.None);
+ StoredSecret? dst = await target.Store.GetAsync(new SecretName(name), CancellationToken.None);
+ Assert.NotNull(src);
+ Assert.NotNull(dst);
+
+ // Crypto + identity fields.
+ Assert.Equal(src!.Name.Value, dst!.Name.Value);
+ Assert.Equal(src.ContentType, dst.ContentType);
+ Assert.Equal(src.Description, dst.Description);
+ Assert.Equal(src.KekId, dst.KekId);
+ Assert.Equal(src.Ciphertext, dst.Ciphertext);
+ Assert.Equal(src.Nonce, dst.Nonce);
+ Assert.Equal(src.Tag, dst.Tag);
+ Assert.Equal(src.WrappedDek, dst.WrappedDek);
+ Assert.Equal(src.WrapNonce, dst.WrapNonce);
+ Assert.Equal(src.WrapTag, dst.WrapTag);
+
+ // Verbatim import: LWW metadata + audit stamps survive unchanged (ApplyReplicatedAsync).
+ Assert.Equal(src.Revision, dst.Revision);
+ Assert.Equal(src.CreatedUtc, dst.CreatedUtc);
+ Assert.Equal(src.UpdatedUtc, dst.UpdatedUtc);
+ Assert.Equal(src.IsDeleted, dst.IsDeleted);
+ Assert.Equal(src.DeletedUtc, dst.DeletedUtc);
+ Assert.Equal(src.CreatedBy, dst.CreatedBy);
+ Assert.Equal(src.UpdatedBy, dst.UpdatedBy);
+
+ // And it decrypts to the same plaintext under the same KEK.
+ Assert.Equal(value, target.Cipher!.Decrypt(dst));
+ }
+
+ // The twice-updated row genuinely exercised non-default metadata.
+ StoredSecret one = (await target.Store.GetAsync(new SecretName("svc/one"), CancellationToken.None))!;
+ Assert.Equal(1, one.Revision);
+ Assert.Equal("creator", one.CreatedBy);
+ Assert.Equal("updater", one.UpdatedBy);
+ }
+
+ [Fact]
+ public async Task Import_preserves_tombstones_verbatim()
+ {
+ LiteralMasterKeyProvider kek = NewKek();
+ SecretsSession source = BuildSession(Db("a.db"), kek);
+ await SealAsync(source, "svc/dead", "dead-value");
+ await source.Store.DeleteAsync(new SecretName("svc/dead"), actor: "remover", CancellationToken.None);
+ StoredSecret src = (await source.Store.GetAsync(new SecretName("svc/dead"), CancellationToken.None))!;
+
+ var service = new BundleService();
+ int exported = await service.ExportAsync(source, BundlePath, includeDeleted: true, CancellationToken.None);
+ Assert.Equal(1, exported);
+
+ SecretsSession target = BuildSession(Db("b.db"), kek);
+ BundleImportReport report =
+ await service.ImportAsync(target, BundlePath, sourceKek: null, conflictOverride: null, CancellationToken.None);
+ Assert.Equal(1, report.Imported);
+
+ // The imported row stays a tombstone (no resurrection) with its delete metadata intact.
+ StoredSecret dst = (await target.Store.GetAsync(new SecretName("svc/dead"), CancellationToken.None))!;
+ Assert.True(dst.IsDeleted);
+ Assert.Equal(src.DeletedUtc, dst.DeletedUtc);
+ Assert.Equal(src.Revision, dst.Revision);
+ Assert.Equal(src.UpdatedUtc, dst.UpdatedUtc);
+
+ // And it is not listed as a live secret.
+ IReadOnlyList live = await target.Store.ListAsync(includeDeleted: false, CancellationToken.None);
+ Assert.Empty(live);
+ }
+
+ [Fact]
+ public async Task Import_rejects_unknown_format_version()
+ {
+ LiteralMasterKeyProvider kek = NewKek();
+ SecretsSession target = BuildSession(Db("b.db"), kek);
+
+ const string json = """
+ {
+ "FormatVersion": 99,
+ "ExportedUtc": "2026-01-01T00:00:00+00:00",
+ "SourceKekId": "kek",
+ "Entries": []
+ }
+ """;
+ await File.WriteAllTextAsync(BundlePath, json, CancellationToken.None);
+
+ var service = new BundleService();
+ InvalidOperationException ex = await Assert.ThrowsAsync(
+ () => service.ImportAsync(target, BundlePath, sourceKek: null, conflictOverride: null, CancellationToken.None));
+ Assert.Contains("99", ex.Message, StringComparison.Ordinal);
+ Assert.Contains("1", ex.Message, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task Import_malformed_entry_fails_with_entry_context()
+ {
+ LiteralMasterKeyProvider kek = NewKek();
+ SecretsSession target = BuildSession(Db("b.db"), kek);
+
+ // A well-formed bundle envelope carrying one entry with non-base64 ciphertext.
+ const string json = """
+ {
+ "FormatVersion": 1,
+ "ExportedUtc": "2026-01-01T00:00:00+00:00",
+ "SourceKekId": "kek",
+ "Entries": [
+ {
+ "Name": "svc/broken", "Description": null, "ContentType": "Text",
+ "Ciphertext": "@@not-base64@@", "Nonce": "AAAA", "Tag": "AAAA",
+ "WrappedDek": "AAAA", "WrapNonce": "AAAA", "WrapTag": "AAAA",
+ "KekId": "kek", "Revision": 0, "IsDeleted": false, "DeletedUtc": null,
+ "CreatedUtc": "2026-01-01T00:00:00+00:00", "UpdatedUtc": "2026-01-01T00:00:00+00:00",
+ "CreatedBy": null, "UpdatedBy": null
+ }
+ ]
+ }
+ """;
+ await File.WriteAllTextAsync(BundlePath, json, CancellationToken.None);
+
+ var service = new BundleService();
+ InvalidOperationException ex = await Assert.ThrowsAsync(
+ () => service.ImportAsync(target, BundlePath, sourceKek: null, conflictOverride: null, CancellationToken.None));
+ Assert.Contains("svc/broken", ex.Message, StringComparison.Ordinal);
+ Assert.Contains(BundlePath, ex.Message, StringComparison.Ordinal);
+
+ // The store was left untouched.
+ Assert.Null(await target.Store.GetAsync(new SecretName("svc/broken"), CancellationToken.None));
+ }
+
+ [Fact]
+ public async Task Bundle_json_contains_no_plaintext()
+ {
+ const string sentinel = "SENTINEL-3f9a2c-DO-NOT-LEAK";
+ LiteralMasterKeyProvider kek = NewKek();
+ SecretsSession source = BuildSession(Db("a.db"), kek);
+ await SealAsync(source, "svc/secret", sentinel);
+
+ var service = new BundleService();
+ await service.ExportAsync(source, BundlePath, includeDeleted: false, CancellationToken.None);
+
+ string raw = await File.ReadAllTextAsync(BundlePath, CancellationToken.None);
+ Assert.DoesNotContain(sentinel, raw, StringComparison.Ordinal);
+
+ // The base64 ciphertext of the sealed row is present in the file. Assert structurally through
+ // the codec rather than by raw-text match: System.Text.Json escapes '+' as +, so a raw
+ // Contains on the base64 string is flaky whenever the ciphertext base64 contains a '+'.
+ StoredSecret? row = await source.Store.GetAsync(new SecretName("svc/secret"), CancellationToken.None);
+ Assert.NotNull(row);
+ Assert.Contains("\"Ciphertext\"", raw, StringComparison.Ordinal);
+ SecretBundle parsed = SecretBundleCodec.Deserialize(raw);
+ BundleEntry parsedEntry = Assert.Single(parsed.Entries);
+ Assert.Equal(Convert.ToBase64String(row!.Ciphertext), parsedEntry.Ciphertext);
+ }
+
+ [Fact]
+ public async Task Export_excludes_tombstones_by_default_and_includes_them_when_asked()
+ {
+ LiteralMasterKeyProvider kek = NewKek();
+ SecretsSession source = BuildSession(Db("a.db"), kek);
+ await SealAsync(source, "svc/live", "live-value");
+ await SealAsync(source, "svc/dead", "dead-value");
+ await source.Store.DeleteAsync(new SecretName("svc/dead"), actor: null, CancellationToken.None);
+
+ var service = new BundleService();
+
+ int excluded = await service.ExportAsync(source, BundlePath, includeDeleted: false, CancellationToken.None);
+ Assert.Equal(1, excluded);
+ SecretBundle withoutDeleted = SecretBundleCodec.Deserialize(await File.ReadAllTextAsync(BundlePath, CancellationToken.None));
+ Assert.Equal(["svc/live"], withoutDeleted.Entries.Select(e => e.Name).OrderBy(n => n).ToArray());
+
+ int included = await service.ExportAsync(source, BundlePath, includeDeleted: true, CancellationToken.None);
+ Assert.Equal(2, included);
+ SecretBundle withDeleted = SecretBundleCodec.Deserialize(await File.ReadAllTextAsync(BundlePath, CancellationToken.None));
+ Assert.Equal(["svc/dead", "svc/live"], withDeleted.Entries.Select(e => e.Name).OrderBy(n => n).ToArray());
+ }
+
+ [Fact]
+ public async Task Import_conflict_resolved_by_last_writer_wins()
+ {
+ LiteralMasterKeyProvider kek = NewKek();
+ SecretsSession target = BuildSession(Db("b.db"), kek);
+ await SealAsync(target, "svc/conf", "existing-value");
+ StoredSecret existing = (await target.Store.GetAsync(new SecretName("svc/conf"), CancellationToken.None))!;
+
+ var service = new BundleService();
+
+ // Incoming OLDER than the existing row -> existing survives.
+ StoredSecret older = target.Cipher!.Encrypt(new SecretName("svc/conf"), "older-bundle-value", SecretContentType.Text)
+ with { UpdatedUtc = existing.UpdatedUtc.AddMinutes(-1), Revision = existing.Revision };
+ WriteBundle(kek.KekId, older);
+ BundleImportReport olderReport =
+ await service.ImportAsync(target, BundlePath, sourceKek: null, conflictOverride: null, CancellationToken.None);
+ Assert.Equal(0, olderReport.Imported);
+ Assert.Equal(1, olderReport.SkippedOlder);
+ Assert.Equal(1, olderReport.Conflicts);
+ StoredSecret afterOlder = (await target.Store.GetAsync(new SecretName("svc/conf"), CancellationToken.None))!;
+ Assert.Equal("existing-value", target.Cipher!.Decrypt(afterOlder));
+
+ // Incoming NEWER than the existing row -> bundle row replaces it.
+ StoredSecret newer = target.Cipher!.Encrypt(new SecretName("svc/conf"), "newer-bundle-value", SecretContentType.Text)
+ with { UpdatedUtc = existing.UpdatedUtc.AddMinutes(1), Revision = existing.Revision };
+ WriteBundle(kek.KekId, newer);
+ BundleImportReport newerReport =
+ await service.ImportAsync(target, BundlePath, sourceKek: null, conflictOverride: null, CancellationToken.None);
+ Assert.Equal(1, newerReport.Imported);
+ Assert.Equal(0, newerReport.SkippedOlder);
+ Assert.Equal(1, newerReport.Conflicts);
+ StoredSecret afterNewer = (await target.Store.GetAsync(new SecretName("svc/conf"), CancellationToken.None))!;
+ Assert.Equal("newer-bundle-value", target.Cipher!.Decrypt(afterNewer));
+ }
+
+ [Fact]
+ public async Task Import_conflict_callback_can_force_bundle_row()
+ {
+ LiteralMasterKeyProvider kek = NewKek();
+ SecretsSession target = BuildSession(Db("b.db"), kek);
+ await SealAsync(target, "svc/conf", "existing-value");
+ StoredSecret existing = (await target.Store.GetAsync(new SecretName("svc/conf"), CancellationToken.None))!;
+
+ // Incoming is OLDER, so LWW would skip it — but the override forces take-incoming.
+ StoredSecret older = target.Cipher!.Encrypt(new SecretName("svc/conf"), "forced-bundle-value", SecretContentType.Text)
+ with { UpdatedUtc = existing.UpdatedUtc.AddMinutes(-1), Revision = existing.Revision };
+ WriteBundle(kek.KekId, older);
+
+ var service = new BundleService();
+ BundleImportReport report = await service.ImportAsync(
+ target, BundlePath, sourceKek: null, conflictOverride: (_, _) => true, CancellationToken.None);
+
+ Assert.Equal(1, report.Imported);
+ Assert.Equal(0, report.SkippedOlder);
+ Assert.Equal(1, report.Conflicts);
+ StoredSecret after = (await target.Store.GetAsync(new SecretName("svc/conf"), CancellationToken.None))!;
+ Assert.Equal("forced-bundle-value", target.Cipher!.Decrypt(after));
+ }
+
+ [Fact]
+ public async Task Cross_kek_import_rewraps_when_source_kek_supplied()
+ {
+ LiteralMasterKeyProvider kekA = NewKek();
+ LiteralMasterKeyProvider kekB = NewKek();
+ SecretsSession source = BuildSession(Db("a.db"), kekA);
+ await SealAsync(source, "svc/cross", "cross-value");
+
+ var service = new BundleService();
+ await service.ExportAsync(source, BundlePath, includeDeleted: false, CancellationToken.None);
+
+ SecretsSession target = BuildSession(Db("b.db"), kekB);
+ BundleImportReport report =
+ await service.ImportAsync(target, BundlePath, sourceKek: kekA, conflictOverride: null, CancellationToken.None);
+
+ Assert.Equal(1, report.Imported);
+ Assert.Equal(0, report.SkippedForeignKek);
+ StoredSecret? dst = await target.Store.GetAsync(new SecretName("svc/cross"), CancellationToken.None);
+ Assert.NotNull(dst);
+ Assert.Equal(kekB.KekId, dst!.KekId);
+ Assert.Equal("cross-value", target.Cipher!.Decrypt(dst));
+ }
+
+ [Fact]
+ public async Task Cross_kek_import_without_source_kek_skips_rows_and_reports_them()
+ {
+ LiteralMasterKeyProvider kekA = NewKek();
+ LiteralMasterKeyProvider kekB = NewKek();
+ SecretsSession source = BuildSession(Db("a.db"), kekA);
+ await SealAsync(source, "svc/cross", "cross-value");
+
+ var service = new BundleService();
+ await service.ExportAsync(source, BundlePath, includeDeleted: false, CancellationToken.None);
+
+ SecretsSession target = BuildSession(Db("b.db"), kekB);
+ BundleImportReport report =
+ await service.ImportAsync(target, BundlePath, sourceKek: null, conflictOverride: null, CancellationToken.None);
+
+ Assert.Equal(0, report.Imported);
+ Assert.Equal(1, report.SkippedForeignKek);
+ StoredSecret? dst = await target.Store.GetAsync(new SecretName("svc/cross"), CancellationToken.None);
+ Assert.Null(dst);
+ }
+}
diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/Flows/BundleFlowTests.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/Flows/BundleFlowTests.cs
new file mode 100644
index 0000000..8cd826a
--- /dev/null
+++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/Flows/BundleFlowTests.cs
@@ -0,0 +1,308 @@
+using System.Security.Cryptography;
+using Spectre.Console;
+using Spectre.Console.Testing;
+using ZB.MOM.WW.Secrets.Abstractions;
+using ZB.MOM.WW.Secrets.Cli.Interactive;
+using ZB.MOM.WW.Secrets.Cli.Interactive.Flows;
+using ZB.MOM.WW.Secrets.MasterKey;
+
+namespace ZB.MOM.WW.Secrets.Tests.Cli.Interactive.Flows;
+
+///
+/// Drives the two bundle flows (, ) directly
+/// against real temp-SQLite full sessions. Export writes a ciphertext-only bundle to a scripted path;
+/// import reconciles into a second store — exercising last-writer-wins, the per-row conflict prompt, and
+/// the foreign-KEK detection that prompts for the source key and re-wraps. Each flow renders to a scripted
+/// , so the tests assert on rendered and on the
+/// resulting store state — the invariant under test is that a pasted source key never reaches the screen.
+///
+public sealed class BundleFlowTests : IDisposable
+{
+ private readonly string _dir =
+ Path.Combine(Path.GetTempPath(), $"zb-secrets-bundleflow-{Guid.NewGuid():N}");
+
+ public BundleFlowTests() => Directory.CreateDirectory(_dir);
+
+ public void Dispose()
+ {
+ if (Directory.Exists(_dir))
+ {
+ try { Directory.Delete(_dir, recursive: true); } catch (IOException) { /* best-effort temp cleanup */ }
+ }
+ }
+
+ private string Db(string name) => Path.Combine(_dir, name);
+
+ private string BundlePath => Path.Combine(_dir, "bundle.json");
+
+ private static TestConsole NewConsole()
+ {
+ var console = (TestConsole)new TestConsole().Interactive();
+ console.Profile.Width = 240; // wide enough that the report table never truncates a cell
+ return console;
+ }
+
+ private static LiteralMasterKeyProvider NewKek(out string base64)
+ {
+ byte[] key = new byte[32];
+ RandomNumberGenerator.Fill(key);
+ base64 = Convert.ToBase64String(key);
+ return new LiteralMasterKeyProvider(base64);
+ }
+
+ // A full (KEK-capable) session on a fresh temp SQLite store, keyed by an operator-override KEK so the
+ // test never depends on ambient environment state.
+ private async Task NewSessionAsync(string dbName, LiteralMasterKeyProvider kek)
+ {
+ StoreTarget target = new TargetConfigReader().Manual(Db(dbName), new MasterKeyOptions
+ {
+ Source = MasterKeySource.Environment,
+ EnvVarName = $"ZB_TEST_UNSET_{Guid.NewGuid():N}",
+ });
+
+ return await new SecretsSessionFactory().OpenAsync(target, kek, CancellationToken.None);
+ }
+
+ private static async Task SealAsync(SecretsSession session, string name, string value)
+ {
+ StoredSecret row = session.Cipher!.Encrypt(new SecretName(name), value, SecretContentType.Text);
+ await session.Store.UpsertAsync(row, CancellationToken.None);
+ }
+
+ // Writes a row into a store verbatim (bypassing the revision bump) so a test can pin an explicit
+ // UpdatedUtc/Revision — used to make a target row deterministically newer than an incoming bundle row.
+ private static async Task ApplyNewerAsync(SecretsSession session, string name, string value)
+ {
+ StoredSecret row = session.Cipher!.Encrypt(new SecretName(name), value, SecretContentType.Text) with
+ {
+ UpdatedUtc = DateTimeOffset.UtcNow.AddDays(1),
+ CreatedUtc = DateTimeOffset.UtcNow.AddDays(1),
+ Revision = 7,
+ };
+ await session.Store.ApplyReplicatedAsync(row, CancellationToken.None);
+ }
+
+ [Fact]
+ public async Task Export_prompts_for_path_and_reports_row_count()
+ {
+ LiteralMasterKeyProvider kek = NewKek(out _);
+ SecretsSession session = await NewSessionAsync("a.db", kek);
+ await SealAsync(session, "svc/one", "value-one");
+ await SealAsync(session, "svc/two", "value-two");
+
+ string outPath = Path.Combine(_dir, "typed-bundle.json");
+
+ TestConsole console = NewConsole();
+ console.Input.PushTextWithEnter(outPath); // path (overrides the offered default)
+ console.Input.PushTextWithEnter("n"); // include tombstoned? no
+
+ await new BundleExportFlow().RunAsync(console, session, CancellationToken.None);
+
+ Assert.True(new BundleExportFlow().RequiresKek);
+ Assert.True(File.Exists(outPath));
+ Assert.Contains("Exported 2 row(s)", console.Output, StringComparison.Ordinal);
+ // The offered default filename carries the target name and the .json extension.
+ Assert.Contains($"secrets-bundle-{session.Target.Name}", console.Output, StringComparison.Ordinal);
+ Assert.Contains(".json", console.Output, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task Import_reports_counts_and_applies_lww()
+ {
+ LiteralMasterKeyProvider kek = NewKek(out _);
+ SecretsSession source = await NewSessionAsync("a.db", kek);
+ await SealAsync(source, "svc/x", "bundle-older-value");
+
+ // Export the bundle from the source store (same KEK as the target).
+ var exportConsole = NewConsole();
+ exportConsole.Input.PushTextWithEnter(BundlePath);
+ exportConsole.Input.PushTextWithEnter("n");
+ await new BundleExportFlow().RunAsync(exportConsole, source, CancellationToken.None);
+
+ // Target holds a newer conflicting row: LWW must keep it and skip the bundle row.
+ SecretsSession target = await NewSessionAsync("b.db", kek);
+ await ApplyNewerAsync(target, "svc/x", "local-newer-value");
+
+ TestConsole console = NewConsole();
+ console.Input.PushTextWithEnter(BundlePath); // path (same KEK -> no source-key prompt)
+ console.Input.PushTextWithEnter("n"); // prompt per conflict? no -> pure LWW
+
+ await new BundleImportFlow().RunAsync(console, target, CancellationToken.None);
+
+ Assert.Contains("Imported", console.Output, StringComparison.Ordinal); // report table rendered
+ StoredSecret? after = await target.Store.GetAsync(new SecretName("svc/x"), CancellationToken.None);
+ Assert.NotNull(after);
+ Assert.Equal("local-newer-value", target.Cipher!.Decrypt(after!)); // LWW kept the newer local row
+ }
+
+ [Fact]
+ public async Task Import_conflict_prompt_lets_operator_pick_per_row()
+ {
+ LiteralMasterKeyProvider kek = NewKek(out _);
+ SecretsSession source = await NewSessionAsync("a.db", kek);
+ await SealAsync(source, "svc/a", "bundle-a");
+ await SealAsync(source, "svc/b", "bundle-b");
+
+ var exportConsole = NewConsole();
+ exportConsole.Input.PushTextWithEnter(BundlePath);
+ exportConsole.Input.PushTextWithEnter("n");
+ await new BundleExportFlow().RunAsync(exportConsole, source, CancellationToken.None);
+
+ // Both names already exist in the target (as newer rows) -> both are conflicts.
+ SecretsSession target = await NewSessionAsync("b.db", kek);
+ await ApplyNewerAsync(target, "svc/a", "local-a");
+ await ApplyNewerAsync(target, "svc/b", "local-b");
+
+ TestConsole console = NewConsole();
+ console.Input.PushTextWithEnter(BundlePath); // path
+ console.Input.PushTextWithEnter("y"); // prompt per conflict? yes
+ // Bundle entries arrive in name order: svc/a first, svc/b second.
+ console.Input.PushTextWithEnter("n"); // svc/a: take the bundle row? no -> keep local
+ console.Input.PushTextWithEnter("y"); // svc/b: take the bundle row? yes -> take bundle
+
+ await new BundleImportFlow().RunAsync(console, target, CancellationToken.None);
+
+ StoredSecret? a = await target.Store.GetAsync(new SecretName("svc/a"), CancellationToken.None);
+ StoredSecret? b = await target.Store.GetAsync(new SecretName("svc/b"), CancellationToken.None);
+ Assert.Equal("local-a", target.Cipher!.Decrypt(a!)); // kept local
+ Assert.Equal("bundle-b", target.Cipher!.Decrypt(b!)); // took the bundle
+ }
+
+ [Fact]
+ public async Task Import_foreign_kek_prompts_for_source_key_then_rewraps()
+ {
+ LiteralMasterKeyProvider kekA = NewKek(out string base64A);
+ LiteralMasterKeyProvider kekB = NewKek(out _);
+ Assert.NotEqual(kekA.KekId, kekB.KekId);
+
+ SecretsSession source = await NewSessionAsync("a.db", kekA);
+ await SealAsync(source, "svc/cross", "cross-value");
+
+ var exportConsole = NewConsole();
+ exportConsole.Input.PushTextWithEnter(BundlePath);
+ exportConsole.Input.PushTextWithEnter("n");
+ await new BundleExportFlow().RunAsync(exportConsole, source, CancellationToken.None);
+
+ // Session on KEK-B: the flow must detect the foreign source KEK and prompt for KEK-A.
+ SecretsSession target = await NewSessionAsync("b.db", kekB);
+
+ TestConsole console = NewConsole();
+ console.Input.PushTextWithEnter(BundlePath); // path
+ console.Input.PushKey(ConsoleKey.Enter); // source-key selection: first choice (paste base64)
+ console.Input.PushTextWithEnter(base64A); // pasted KEK-A material (masked)
+ console.Input.PushTextWithEnter("n"); // prompt per conflict? no (empty target anyway)
+
+ await new BundleImportFlow().RunAsync(console, target, CancellationToken.None);
+
+ StoredSecret? dst = await target.Store.GetAsync(new SecretName("svc/cross"), CancellationToken.None);
+ Assert.NotNull(dst);
+ Assert.Equal(kekB.KekId, dst!.KekId); // re-wrapped under the session KEK
+ Assert.Equal("cross-value", target.Cipher!.Decrypt(dst)); // and decrypts
+
+ Assert.DoesNotContain(base64A, console.Output, StringComparison.Ordinal); // pasted key never echoed
+ }
+
+ [Fact]
+ public async Task Import_wrong_source_key_is_rejected_without_importing()
+ {
+ LiteralMasterKeyProvider kekA = NewKek(out _);
+ LiteralMasterKeyProvider kekB = NewKek(out _);
+ LiteralMasterKeyProvider wrong = NewKek(out string wrongBase64); // valid format, unrelated key
+ Assert.NotEqual(kekA.KekId, wrong.KekId);
+
+ SecretsSession source = await NewSessionAsync("a.db", kekA);
+ await SealAsync(source, "svc/cross", "cross-value");
+
+ var exportConsole = NewConsole();
+ exportConsole.Input.PushTextWithEnter(BundlePath);
+ exportConsole.Input.PushTextWithEnter("n");
+ await new BundleExportFlow().RunAsync(exportConsole, source, CancellationToken.None);
+
+ SecretsSession target = await NewSessionAsync("b.db", kekB);
+
+ TestConsole console = NewConsole();
+ console.Input.PushTextWithEnter(BundlePath); // path
+ console.Input.PushKey(ConsoleKey.Enter); // source-key selection: paste base64
+ console.Input.PushTextWithEnter(wrongBase64); // a valid-format but wrong key
+
+ await new BundleImportFlow().RunAsync(console, target, CancellationToken.None);
+
+ Assert.Contains("does not match this bundle's source KEK", console.Output, StringComparison.Ordinal);
+ Assert.DoesNotContain("Imported", console.Output, StringComparison.Ordinal); // no report rendered
+ Assert.Null(await target.Store.GetAsync(new SecretName("svc/cross"), CancellationToken.None)); // untouched
+ }
+
+ [Fact]
+ public async Task Import_source_key_file_permission_denied_renders_friendly_error_without_importing()
+ {
+ Skip.IfNot(!OperatingSystem.IsWindows(), "Unix permission bits are exercised via UnixFileMode.");
+
+ LiteralMasterKeyProvider kekA = NewKek(out _);
+ LiteralMasterKeyProvider kekB = NewKek(out _);
+
+ SecretsSession source = await NewSessionAsync("a.db", kekA);
+ await SealAsync(source, "svc/cross", "cross-value");
+
+ var exportConsole = NewConsole();
+ exportConsole.Input.PushTextWithEnter(BundlePath);
+ exportConsole.Input.PushTextWithEnter("n");
+ await new BundleExportFlow().RunAsync(exportConsole, source, CancellationToken.None);
+
+ SecretsSession target = await NewSessionAsync("b.db", kekB);
+
+ // A source-key file that exists (so FileMasterKeyProvider's File.Exists gate passes) but has no
+ // read permission: File.ReadAllBytes inside ResolveKeyBytes throws UnauthorizedAccessException,
+ // which FileMasterKeyProvider does not itself wrap into MasterKeyUnavailableException.
+ string keyFilePath = Path.Combine(_dir, $"nokey-{Guid.NewGuid():N}");
+ await File.WriteAllBytesAsync(keyFilePath, new byte[32], CancellationToken.None);
+ File.SetUnixFileMode(keyFilePath, UnixFileMode.None);
+ try
+ {
+ TestConsole console = NewConsole();
+ console.Input.PushTextWithEnter(BundlePath); // path
+ console.Input.PushKey(ConsoleKey.DownArrow); // source-key selection: skip "Paste base64 key"
+ console.Input.PushKey(ConsoleKey.DownArrow); // skip "Environment variable"
+ console.Input.PushKey(ConsoleKey.Enter); // choose "Key file path"
+ console.Input.PushTextWithEnter(keyFilePath);
+
+ await new BundleImportFlow().RunAsync(console, target, CancellationToken.None);
+
+ Assert.Contains("Source KEK unavailable", console.Output, StringComparison.Ordinal); // contained
+ Assert.DoesNotContain("Imported", console.Output, StringComparison.Ordinal); // no report rendered
+ Assert.Null(await target.Store.GetAsync(new SecretName("svc/cross"), CancellationToken.None)); // untouched
+ }
+ finally
+ {
+ File.SetUnixFileMode(keyFilePath, UnixFileMode.UserRead | UnixFileMode.UserWrite);
+ File.Delete(keyFilePath);
+ }
+ }
+
+ [Fact]
+ public async Task Import_rejects_unknown_format_version_before_prompting()
+ {
+ LiteralMasterKeyProvider kek = NewKek(out _);
+ SecretsSession target = await NewSessionAsync("b.db", kek);
+
+ // A hand-built bundle envelope on an unreadable format version. Only the path is scripted: if the
+ // flow prompted for anything past the format gate, the missing input would fail the test.
+ const string json = """
+ {
+ "FormatVersion": 99,
+ "ExportedUtc": "2026-01-01T00:00:00+00:00",
+ "SourceKekId": "some-kek",
+ "Entries": []
+ }
+ """;
+ await File.WriteAllTextAsync(BundlePath, json, CancellationToken.None);
+
+ TestConsole console = NewConsole();
+ console.Input.PushTextWithEnter(BundlePath); // path only — no further prompt must be consumed
+
+ await new BundleImportFlow().RunAsync(console, target, CancellationToken.None);
+
+ Assert.Contains("99", console.Output, StringComparison.Ordinal);
+ Assert.Contains("format version", console.Output, StringComparison.Ordinal);
+ Assert.DoesNotContain("Imported", console.Output, StringComparison.Ordinal); // no report rendered
+ }
+}
diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/Flows/CrudFlowTests.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/Flows/CrudFlowTests.cs
new file mode 100644
index 0000000..7b108c4
--- /dev/null
+++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/Flows/CrudFlowTests.cs
@@ -0,0 +1,257 @@
+using System.Security.Cryptography;
+using Spectre.Console;
+using Spectre.Console.Testing;
+using ZB.MOM.WW.Secrets.Abstractions;
+using ZB.MOM.WW.Secrets.Cli.Interactive;
+using ZB.MOM.WW.Secrets.Cli.Interactive.Flows;
+using ZB.MOM.WW.Secrets.MasterKey;
+
+namespace ZB.MOM.WW.Secrets.Tests.Cli.Interactive.Flows;
+
+///
+/// Drives the four CRUD flows (, ,
+/// , ) directly against a real temp-SQLite
+/// full session (KEK supplied as a override). Each flow renders to
+/// a scripted , so the tests assert on rendered
+/// and on the resulting store state — the invariant under test is that value plaintext only ever reaches
+/// the screen through the reveal flow's explicit confirmation.
+///
+public sealed class CrudFlowTests : IDisposable
+{
+ private readonly string _dir =
+ Path.Combine(Path.GetTempPath(), $"zb-secrets-crud-{Guid.NewGuid():N}");
+
+ private string DbPath => Path.Combine(_dir, "secrets.db");
+
+ public CrudFlowTests() => Directory.CreateDirectory(_dir);
+
+ public void Dispose()
+ {
+ if (Directory.Exists(_dir))
+ {
+ try { Directory.Delete(_dir, recursive: true); } catch (IOException) { /* best-effort temp cleanup */ }
+ }
+ }
+
+ private static TestConsole NewConsole()
+ {
+ var console = (TestConsole)new TestConsole().Interactive();
+ console.Profile.Width = 240; // wide enough that the metadata table never truncates a cell
+ return console;
+ }
+
+ // A full (KEK-capable) session on a fresh temp SQLite store, keyed by an operator-override KEK so the
+ // test never depends on ambient environment state.
+ private async Task NewFullSessionAsync()
+ {
+ byte[] key = new byte[32];
+ RandomNumberGenerator.Fill(key);
+ var kek = new LiteralMasterKeyProvider(Convert.ToBase64String(key));
+
+ StoreTarget target = new TargetConfigReader().Manual(DbPath, new MasterKeyOptions
+ {
+ Source = MasterKeySource.Environment,
+ EnvVarName = $"ZB_TEST_UNSET_{Guid.NewGuid():N}",
+ });
+
+ return await new SecretsSessionFactory().OpenAsync(target, kek, CancellationToken.None);
+ }
+
+ private static async Task SeedAsync(
+ SecretsSession session, string name, string value, SecretContentType contentType = SecretContentType.Text)
+ {
+ var secretName = new SecretName(name);
+ StoredSecret row = session.Cipher!.Encrypt(secretName, value, contentType);
+ await session.Store.UpsertAsync(row, CancellationToken.None);
+ }
+
+ private static int CountOccurrences(string haystack, string needle)
+ {
+ int count = 0;
+ for (int i = haystack.IndexOf(needle, StringComparison.Ordinal); i >= 0;
+ i = haystack.IndexOf(needle, i + needle.Length, StringComparison.Ordinal))
+ {
+ count++;
+ }
+
+ return count;
+ }
+
+ // --- List ---
+
+ [Fact]
+ public async Task Renders_metadata_table_never_values()
+ {
+ SecretsSession session = await NewFullSessionAsync();
+ await SeedAsync(session, "db/conn", "SENTINEL-CONN-VALUE", SecretContentType.ConnectionString);
+ await SeedAsync(session, "api/key", "SENTINEL-API-VALUE");
+
+ TestConsole console = NewConsole();
+ await new ListSecretsFlow().RunAsync(console, session, CancellationToken.None);
+
+ string output = console.Output;
+ Assert.Contains("db/conn", output, StringComparison.Ordinal);
+ Assert.Contains("api/key", output, StringComparison.Ordinal);
+ Assert.Contains("ConnectionString", output, StringComparison.Ordinal);
+ Assert.Contains(session.MasterKey!.KekId, output, StringComparison.Ordinal); // kek id column
+
+ Assert.DoesNotContain("SENTINEL-CONN-VALUE", output, StringComparison.Ordinal);
+ Assert.DoesNotContain("SENTINEL-API-VALUE", output, StringComparison.Ordinal);
+
+ Assert.False(new ListSecretsFlow().RequiresKek);
+ }
+
+ // --- Set / rotate ---
+
+ [Fact]
+ public async Task Masked_prompt_seals_and_upserts()
+ {
+ SecretsSession session = await NewFullSessionAsync();
+
+ TestConsole console = NewConsole();
+ console.Input.PushTextWithEnter("app/token"); // name
+ console.Input.PushTextWithEnter("TOPSECRET-PLAINTEXT"); // value (masked)
+ console.Input.PushKey(ConsoleKey.Enter); // content-type: first choice (Text)
+ console.Input.PushKey(ConsoleKey.Enter); // description: empty
+
+ await new SetSecretFlow().RunAsync(console, session, CancellationToken.None);
+
+ StoredSecret? row = await session.Store.GetAsync(new SecretName("app/token"), CancellationToken.None);
+ Assert.NotNull(row);
+ Assert.Equal("TOPSECRET-PLAINTEXT", session.Cipher!.Decrypt(row!));
+ Assert.DoesNotContain("TOPSECRET-PLAINTEXT", console.Output, StringComparison.Ordinal); // never echoed
+ }
+
+ [Fact]
+ public async Task Invalid_name_reprompts_then_completes()
+ {
+ SecretsSession session = await NewFullSessionAsync();
+
+ TestConsole console = NewConsole();
+ console.Input.PushTextWithEnter("bad name"); // invalid (space) → must re-prompt, not throw
+ console.Input.PushTextWithEnter("good/name"); // valid retry
+ console.Input.PushTextWithEnter("VALUE-AFTER-REPROMPT"); // value
+ console.Input.PushKey(ConsoleKey.Enter); // content-type: Text
+ console.Input.PushKey(ConsoleKey.Enter); // description: empty
+
+ // The bug this pins: SecretName's error text embeds "[a-z0-9._/-]", which Spectre parses as
+ // markup and throws InvalidOperationException on unless the validator escapes it. Reaching the
+ // assertion at all proves the re-prompt happened cleanly.
+ await new SetSecretFlow().RunAsync(console, session, CancellationToken.None);
+
+ StoredSecret? row = await session.Store.GetAsync(new SecretName("good/name"), CancellationToken.None);
+ Assert.NotNull(row);
+ Assert.Equal("VALUE-AFTER-REPROMPT", session.Cipher!.Decrypt(row!));
+ }
+
+ [Fact]
+ public async Task Existing_name_confirms_overwrite()
+ {
+ SecretsSession session = await NewFullSessionAsync();
+ await SeedAsync(session, "shared/api", "OLD-VALUE");
+
+ // Decline: the value must be left unchanged.
+ TestConsole declineConsole = NewConsole();
+ declineConsole.Input.PushTextWithEnter("shared/api"); // name (exists)
+ declineConsole.Input.PushTextWithEnter("n"); // overwrite? no
+ await new SetSecretFlow().RunAsync(declineConsole, session, CancellationToken.None);
+
+ StoredSecret? afterDecline = await session.Store.GetAsync(new SecretName("shared/api"), CancellationToken.None);
+ Assert.Equal("OLD-VALUE", session.Cipher!.Decrypt(afterDecline!));
+
+ // Accept: the value is overwritten in place.
+ TestConsole acceptConsole = NewConsole();
+ acceptConsole.Input.PushTextWithEnter("shared/api"); // name (exists)
+ acceptConsole.Input.PushTextWithEnter("y"); // overwrite? yes
+ acceptConsole.Input.PushTextWithEnter("NEW-VALUE"); // value (masked)
+ acceptConsole.Input.PushKey(ConsoleKey.Enter); // content-type: Text
+ acceptConsole.Input.PushKey(ConsoleKey.Enter); // description: empty
+ await new SetSecretFlow().RunAsync(acceptConsole, session, CancellationToken.None);
+
+ StoredSecret? afterAccept = await session.Store.GetAsync(new SecretName("shared/api"), CancellationToken.None);
+ Assert.Equal("NEW-VALUE", session.Cipher!.Decrypt(afterAccept!));
+ }
+
+ // --- Reveal ---
+
+ [Fact]
+ public async Task Confirm_then_prints_value_once()
+ {
+ SecretsSession session = await NewFullSessionAsync();
+ await SeedAsync(session, "reveal/me", "REVEAL-SENTINEL-XYZ");
+
+ TestConsole console = NewConsole();
+ console.Input.PushTextWithEnter("reveal/me"); // name
+ console.Input.PushTextWithEnter("y"); // reveal on screen? yes
+
+ await new RevealSecretFlow().RunAsync(console, session, CancellationToken.None);
+
+ Assert.Equal(1, CountOccurrences(console.Output, "REVEAL-SENTINEL-XYZ"));
+ }
+
+ [Fact]
+ public async Task Decline_prints_nothing()
+ {
+ SecretsSession session = await NewFullSessionAsync();
+ await SeedAsync(session, "reveal/me", "REVEAL-SENTINEL-XYZ");
+
+ TestConsole console = NewConsole();
+ console.Input.PushTextWithEnter("reveal/me"); // name
+ console.Input.PushTextWithEnter("n"); // reveal on screen? no
+
+ await new RevealSecretFlow().RunAsync(console, session, CancellationToken.None);
+
+ Assert.DoesNotContain("REVEAL-SENTINEL-XYZ", console.Output, StringComparison.Ordinal);
+ }
+
+ // --- Delete ---
+
+ [Fact]
+ public async Task Confirm_tombstones_row()
+ {
+ SecretsSession session = await NewFullSessionAsync();
+ await SeedAsync(session, "del/me", "GONE");
+
+ TestConsole console = NewConsole();
+ console.Input.PushTextWithEnter("del/me"); // name
+ console.Input.PushTextWithEnter("y"); // delete? yes
+
+ await new DeleteSecretFlow().RunAsync(console, session, CancellationToken.None);
+
+ StoredSecret? row = await session.Store.GetAsync(new SecretName("del/me"), CancellationToken.None);
+ Assert.NotNull(row);
+ Assert.True(row!.IsDeleted);
+ }
+
+ [Fact]
+ public async Task Unknown_name_reports_not_found_without_confirming()
+ {
+ SecretsSession session = await NewFullSessionAsync();
+
+ TestConsole console = NewConsole();
+ console.Input.PushTextWithEnter("never/existed"); // name only — no confirm input is scripted
+ // If the flow prompted for a confirm before checking existence, the missing input would fail.
+ await new DeleteSecretFlow().RunAsync(console, session, CancellationToken.None);
+
+ Assert.Contains("No secret named", console.Output, StringComparison.Ordinal);
+ StoredSecret? row = await session.Store.GetAsync(new SecretName("never/existed"), CancellationToken.None);
+ Assert.Null(row);
+ }
+
+ [Fact]
+ public async Task Decline_leaves_row()
+ {
+ SecretsSession session = await NewFullSessionAsync();
+ await SeedAsync(session, "del/me", "STAYS");
+
+ TestConsole console = NewConsole();
+ console.Input.PushTextWithEnter("del/me"); // name
+ console.Input.PushTextWithEnter("n"); // delete? no
+
+ await new DeleteSecretFlow().RunAsync(console, session, CancellationToken.None);
+
+ StoredSecret? row = await session.Store.GetAsync(new SecretName("del/me"), CancellationToken.None);
+ Assert.NotNull(row);
+ Assert.False(row!.IsDeleted);
+ }
+}
diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/Flows/KekDoctorFlowTests.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/Flows/KekDoctorFlowTests.cs
new file mode 100644
index 0000000..3331e61
--- /dev/null
+++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/Flows/KekDoctorFlowTests.cs
@@ -0,0 +1,341 @@
+using Microsoft.Data.Sqlite;
+using Spectre.Console.Testing;
+using ZB.MOM.WW.Secrets.Abstractions;
+using ZB.MOM.WW.Secrets.Cli.Interactive;
+using ZB.MOM.WW.Secrets.Cli.Interactive.Flows;
+using ZB.MOM.WW.Secrets.Crypto;
+using ZB.MOM.WW.Secrets.MasterKey;
+using ZB.MOM.WW.Secrets.Sqlite;
+
+namespace ZB.MOM.WW.Secrets.Tests.Cli.Interactive.Flows;
+
+///
+/// Drives the lockout-recovery against a REAL temp-SQLite store and REAL
+/// AES-256-GCM cipher. KEK-A and KEK-B are two distinct 32-byte
+/// keys (distinct derived kek_ids): rows are sealed under one and the session opened under the
+/// other to reproduce the wrong-KEK lockout. Each flow renders to a scripted
+/// so the tests assert both the rendered and the resulting store state.
+/// The invariant is that the operator recovers a locked-out store from one screen — either by re-wrapping
+/// from the recovered old KEK or by re-setting the affected secrets — without any key or value ever
+/// reaching the terminal.
+///
+public sealed class KekDoctorFlowTests : IAsyncLifetime, IDisposable
+{
+ private readonly string _dbPath =
+ Path.Combine(Path.GetTempPath(), $"zb-secrets-doctorflow-{Guid.NewGuid():N}.db");
+
+ private readonly SecretsSqliteConnectionFactory _factory;
+ private readonly SqliteSecretStore _store;
+
+ // Two distinct 32-byte keys → two distinct derived kek_ids (KEK-A / KEK-B). The base64 forms are
+ // retained so a test can paste KEK-A at the prompt and assert it never echoes back to the terminal.
+ private readonly string _kekABase64 = Convert.ToBase64String(FillKey(0xA1));
+ private readonly string _kekBBase64 = Convert.ToBase64String(FillKey(0xB2));
+
+ // A third, valid-format 32-byte key never used to seal a row — a "wrong but well-formed" paste.
+ private readonly string _kekCBase64 = Convert.ToBase64String(FillKey(0xC3));
+ private readonly LiteralMasterKeyProvider _kekA;
+ private readonly LiteralMasterKeyProvider _kekB;
+
+ public KekDoctorFlowTests()
+ {
+ _factory = new SecretsSqliteConnectionFactory(_dbPath);
+ _store = new SqliteSecretStore(_factory);
+ _kekA = new LiteralMasterKeyProvider(_kekABase64);
+ _kekB = new LiteralMasterKeyProvider(_kekBBase64);
+ }
+
+ public async Task InitializeAsync() =>
+ await new SqliteSecretsStoreMigrator(_factory).MigrateAsync(CancellationToken.None);
+
+ public Task DisposeAsync() => Task.CompletedTask;
+
+ private static byte[] FillKey(byte b)
+ {
+ byte[] key = new byte[32];
+ Array.Fill(key, b);
+ return key;
+ }
+
+ private static TestConsole NewConsole()
+ {
+ var console = (TestConsole)new TestConsole().Interactive();
+ console.Profile.Width = 240; // wide enough that the summary lines never truncate a kek_id
+ return console;
+ }
+
+ // A live session over the shared store, keyed by the supplied provider.
+ private SecretsSession Session(IMasterKeyProvider kek) => new()
+ {
+ Target = new TargetConfigReader().Manual(_dbPath, new MasterKeyOptions()),
+ Store = _store,
+ Migrator = new SqliteSecretsStoreMigrator(_factory),
+ MasterKey = kek,
+ Cipher = new AesGcmEnvelopeCipher(kek),
+ StoreKind = "sqlite",
+ };
+
+ private async Task SeedAsync(string name, string value, IMasterKeyProvider kek)
+ {
+ var cipher = new AesGcmEnvelopeCipher(kek);
+ StoredSecret row = cipher.Encrypt(new SecretName(name), value, SecretContentType.Text);
+ await _store.UpsertAsync(row, CancellationToken.None);
+ }
+
+ private async Task GetAsync(string name) =>
+ (await _store.GetAsync(new SecretName(name), CancellationToken.None))!;
+
+ [Fact]
+ public async Task Healthy_store_renders_all_ok_summary()
+ {
+ await SeedAsync("svc/a", "value-a", _kekA);
+ await SeedAsync("svc/b", "value-b", _kekA);
+
+ TestConsole console = NewConsole();
+ await new KekDoctorFlow().RunAsync(console, Session(_kekA), CancellationToken.None);
+
+ string output = console.Output;
+ Assert.Contains(_kekA.KekId, output, StringComparison.Ordinal); // the session KEK is surfaced
+ Assert.Contains("All rows open", output, StringComparison.Ordinal); // green all-clear
+ Assert.DoesNotContain("Rewrap from old KEK", output, StringComparison.Ordinal); // no remedy menu
+
+ Assert.True(new KekDoctorFlow().RequiresKek);
+ Assert.Equal("KEK doctor (lockout recovery)", new KekDoctorFlow().Title);
+ }
+
+ [Fact]
+ public async Task Wrong_kek_rows_offer_rewrap_and_rewrap_succeeds()
+ {
+ // Rows sealed under KEK-A; the session runs on KEK-B → both rows are wrong-KEK.
+ await SeedAsync("svc/a", "value-a", _kekA);
+ await SeedAsync("svc/b", "value-b", _kekA);
+ SecretsSession session = Session(_kekB);
+
+ TestConsole console = NewConsole();
+ console.Input.PushKey(ConsoleKey.Enter); // remedy menu: "Rewrap from old KEK" (index 0)
+ console.Input.PushKey(ConsoleKey.DownArrow); // old-key source: skip "Environment variable"
+ console.Input.PushKey(ConsoleKey.DownArrow); // skip "Key file"
+ console.Input.PushKey(ConsoleKey.Enter); // choose "Paste base64 key" (index 2)
+ console.Input.PushTextWithEnter(_kekABase64); // paste KEK-A material (masked)
+ console.Input.PushTextWithEnter("y"); // confirm the rewrap (default is false)
+
+ await new KekDoctorFlow().RunAsync(console, session, CancellationToken.None);
+
+ // Both rows now decrypt under the session KEK (B).
+ var cipherB = new AesGcmEnvelopeCipher(_kekB);
+ StoredSecret a = await GetAsync("svc/a");
+ StoredSecret b = await GetAsync("svc/b");
+ Assert.Equal(_kekB.KekId, a.KekId);
+ Assert.Equal(_kekB.KekId, b.KekId);
+ Assert.Equal("value-a", cipherB.Decrypt(a));
+ Assert.Equal("value-b", cipherB.Decrypt(b));
+
+ Assert.Contains("Re-wrapped", console.Output, StringComparison.Ordinal);
+ Assert.Contains("2", console.Output, StringComparison.Ordinal); // the rewrap count
+ Assert.DoesNotContain(_kekABase64, console.Output, StringComparison.Ordinal); // pasted key never echoed
+ }
+
+ [Fact]
+ public async Task Rewrap_requires_explicit_confirmation()
+ {
+ await SeedAsync("svc/a", "value-a", _kekA);
+ await SeedAsync("svc/b", "value-b", _kekA);
+ SecretsSession session = Session(_kekB);
+
+ TestConsole console = NewConsole();
+ console.Input.PushKey(ConsoleKey.Enter); // "Rewrap from old KEK"
+ console.Input.PushKey(ConsoleKey.DownArrow);
+ console.Input.PushKey(ConsoleKey.DownArrow);
+ console.Input.PushKey(ConsoleKey.Enter); // "Paste base64 key"
+ console.Input.PushTextWithEnter(_kekABase64);
+ console.Input.PushTextWithEnter("n"); // DECLINE the confirmation
+
+ await new KekDoctorFlow().RunAsync(console, session, CancellationToken.None);
+
+ // Nothing was re-wrapped: both rows remain under KEK-A.
+ Assert.Equal(_kekA.KekId, (await GetAsync("svc/a")).KekId);
+ Assert.Equal(_kekA.KekId, (await GetAsync("svc/b")).KekId);
+ }
+
+ [Fact]
+ public async Task Lost_kek_path_offers_reset_of_affected_secrets()
+ {
+ // Two wrong-KEK rows (sealed under KEK-A; session on KEK-B). Ordinal order: svc/a precedes svc/b.
+ await SeedAsync("svc/a", "value-a", _kekA);
+ await SeedAsync("svc/b", "value-b", _kekA);
+ SecretsSession session = Session(_kekB);
+
+ TestConsole console = NewConsole();
+ console.Input.PushKey(ConsoleKey.DownArrow); // remedy menu: move to
+ console.Input.PushKey(ConsoleKey.Enter); // "Old KEK is lost — re-set affected secrets" (index 1)
+ console.Input.PushTextWithEnter("y"); // svc/a: accept re-set
+ console.Input.PushTextWithEnter("NEW-A-VALUE"); // masked new value
+ console.Input.PushKey(ConsoleKey.Enter); // content type: Text (first choice)
+ console.Input.PushTextWithEnter("n"); // svc/b: decline re-set
+
+ await new KekDoctorFlow().RunAsync(console, session, CancellationToken.None);
+
+ // svc/a was re-set under the session KEK (B) with the new value.
+ var cipherB = new AesGcmEnvelopeCipher(_kekB);
+ StoredSecret a = await GetAsync("svc/a");
+ Assert.Equal(_kekB.KekId, a.KekId);
+ Assert.Equal("NEW-A-VALUE", cipherB.Decrypt(a));
+
+ // svc/b was left untouched → still wrong-KEK (under KEK-A).
+ Assert.Equal(_kekA.KekId, (await GetAsync("svc/b")).KekId);
+
+ Assert.DoesNotContain("NEW-A-VALUE", console.Output, StringComparison.Ordinal); // value never echoed
+ }
+
+ [Fact]
+ public async Task Malformed_pasted_old_key_renders_friendly_error_and_still_re_diagnoses()
+ {
+ await SeedAsync("svc/a", "value-a", _kekA);
+ SecretsSession session = Session(_kekB);
+
+ TestConsole console = NewConsole();
+ console.Input.PushKey(ConsoleKey.Enter); // "Rewrap from old KEK"
+ console.Input.PushKey(ConsoleKey.DownArrow);
+ console.Input.PushKey(ConsoleKey.DownArrow);
+ console.Input.PushKey(ConsoleKey.Enter); // "Paste base64 key"
+ console.Input.PushTextWithEnter("!!!not-valid-base64!!!"); // malformed → ArgumentException
+
+ await new KekDoctorFlow().RunAsync(console, session, CancellationToken.None);
+
+ Assert.Contains("Invalid old KEK", console.Output, StringComparison.Ordinal); // contained, not shell-escaped
+ Assert.Contains("Re-diagnosis", console.Output, StringComparison.Ordinal); // closing summary still runs
+ Assert.Equal(_kekA.KekId, (await GetAsync("svc/a")).KekId); // nothing changed
+ }
+
+ [Fact]
+ public async Task Env_var_old_key_source_naming_unset_var_renders_friendly_error_and_still_re_diagnoses()
+ {
+ await SeedAsync("svc/a", "value-a", _kekA);
+ SecretsSession session = Session(_kekB);
+
+ TestConsole console = NewConsole();
+ console.Input.PushKey(ConsoleKey.Enter); // "Rewrap from old KEK"
+ console.Input.PushKey(ConsoleKey.Enter); // old-key source: "Environment variable" (index 0)
+ // An env var guaranteed unset → KekId read lazily throws MasterKeyUnavailableException.
+ console.Input.PushTextWithEnter($"ZB_TEST_UNSET_{Guid.NewGuid():N}");
+
+ await new KekDoctorFlow().RunAsync(console, session, CancellationToken.None);
+
+ Assert.Contains("Old KEK unavailable", console.Output, StringComparison.Ordinal); // contained
+ Assert.Contains("Re-diagnosis", console.Output, StringComparison.Ordinal); // closing summary still runs
+ Assert.Equal(_kekA.KekId, (await GetAsync("svc/a")).KekId); // nothing changed
+ }
+
+ [Fact]
+ public async Task File_old_key_source_permission_denied_renders_friendly_error_and_still_re_diagnoses()
+ {
+ Skip.IfNot(!OperatingSystem.IsWindows(), "Unix permission bits are exercised via UnixFileMode.");
+
+ await SeedAsync("svc/a", "value-a", _kekA);
+ SecretsSession session = Session(_kekB);
+
+ // A key file that exists (so FileMasterKeyProvider's File.Exists gate passes) but has no read
+ // permission: File.ReadAllBytes inside ResolveKeyBytes throws UnauthorizedAccessException, which
+ // FileMasterKeyProvider does not itself wrap into MasterKeyUnavailableException.
+ string keyFilePath = Path.Combine(Path.GetTempPath(), $"zb-secrets-doctorflow-nokey-{Guid.NewGuid():N}");
+ await File.WriteAllBytesAsync(keyFilePath, new byte[32], CancellationToken.None);
+ File.SetUnixFileMode(keyFilePath, UnixFileMode.None);
+ try
+ {
+ TestConsole console = NewConsole();
+ console.Input.PushKey(ConsoleKey.Enter); // "Rewrap from old KEK"
+ console.Input.PushKey(ConsoleKey.DownArrow); // old-key source: skip "Environment variable"
+ console.Input.PushKey(ConsoleKey.Enter); // choose "Key file" (index 1)
+ console.Input.PushTextWithEnter(keyFilePath);
+
+ await new KekDoctorFlow().RunAsync(console, session, CancellationToken.None);
+
+ Assert.Contains("Old KEK unavailable", console.Output, StringComparison.Ordinal); // contained
+ Assert.Contains("Re-diagnosis", console.Output, StringComparison.Ordinal); // closing summary still runs
+ Assert.Equal(_kekA.KekId, (await GetAsync("svc/a")).KekId); // nothing changed
+ }
+ finally
+ {
+ File.SetUnixFileMode(keyFilePath, UnixFileMode.UserRead | UnixFileMode.UserWrite);
+ File.Delete(keyFilePath);
+ }
+ }
+
+ [Fact]
+ public async Task Corrupt_row_is_offered_reset_as_the_only_remedy()
+ {
+ // Seed under the session KEK, then tamper the DEK wrap tag: kek_id still matches the session KEK,
+ // but the decrypt probe fails closed → the row diagnoses Corrupt (not WrongKek).
+ await SeedAsync("svc/corrupt", "original", _kekA);
+ StoredSecret seeded = await GetAsync("svc/corrupt");
+ byte[] tag = (byte[])seeded.WrapTag.Clone();
+ tag[0] ^= 0xFF;
+ await _store.UpsertAsync(seeded with { WrapTag = tag }, CancellationToken.None);
+
+ SecretsSession session = Session(_kekA); // same KEK — so the row is Corrupt, not WrongKek
+
+ TestConsole console = NewConsole();
+ console.Input.PushKey(ConsoleKey.DownArrow); // remedy menu: move to
+ console.Input.PushKey(ConsoleKey.Enter); // "Old KEK is lost — re-set affected secrets"
+ console.Input.PushTextWithEnter("y"); // accept re-set of the corrupt row
+ console.Input.PushTextWithEnter("RESEEDED-VALUE"); // masked new value
+ console.Input.PushKey(ConsoleKey.Enter); // content type: Text
+
+ await new KekDoctorFlow().RunAsync(console, session, CancellationToken.None);
+
+ Assert.Contains("corrupt", console.Output, StringComparison.Ordinal); // the diagnosis surfaced it
+ Assert.Contains("only remedy", console.Output, StringComparison.Ordinal); // the re-set note
+
+ // The corrupt row was re-seeded and now decrypts cleanly under the session KEK.
+ var cipherA = new AesGcmEnvelopeCipher(_kekA);
+ StoredSecret after = await GetAsync("svc/corrupt");
+ Assert.Equal("RESEEDED-VALUE", cipherA.Decrypt(after));
+ Assert.DoesNotContain("RESEEDED-VALUE", console.Output, StringComparison.Ordinal); // never echoed
+ }
+
+ [Fact]
+ public async Task Wrong_but_wellformed_old_key_fails_the_rewrap_and_still_re_diagnoses()
+ {
+ // Rows under KEK-A; session on KEK-B. The operator pastes KEK-C — valid format, but neither the
+ // old (A) nor the session (B) KEK → RewrapAllAsync aborts with SecretDecryptionException.
+ await SeedAsync("svc/a", "value-a", _kekA);
+ await SeedAsync("svc/b", "value-b", _kekA);
+ SecretsSession session = Session(_kekB);
+
+ TestConsole console = NewConsole();
+ console.Input.PushKey(ConsoleKey.Enter); // "Rewrap from old KEK"
+ console.Input.PushKey(ConsoleKey.DownArrow);
+ console.Input.PushKey(ConsoleKey.DownArrow);
+ console.Input.PushKey(ConsoleKey.Enter); // "Paste base64 key"
+ console.Input.PushTextWithEnter(_kekCBase64); // valid format, wrong key
+ console.Input.PushTextWithEnter("y"); // confirm the rewrap
+
+ await new KekDoctorFlow().RunAsync(console, session, CancellationToken.None);
+
+ Assert.Contains("Rewrap failed", console.Output, StringComparison.Ordinal); // the SecretDecryptionException path
+ Assert.Contains("Re-diagnosis", console.Output, StringComparison.Ordinal); // closing summary still runs
+
+ // The pass aborted on the first row → both rows remain under KEK-A.
+ Assert.Equal(_kekA.KekId, (await GetAsync("svc/a")).KekId);
+ Assert.Equal(_kekA.KekId, (await GetAsync("svc/b")).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.
+ }
+ }
+ }
+}
diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/Flows/ReferenceAuditFlowTests.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/Flows/ReferenceAuditFlowTests.cs
new file mode 100644
index 0000000..dec20e8
--- /dev/null
+++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/Flows/ReferenceAuditFlowTests.cs
@@ -0,0 +1,239 @@
+using System.Security.Cryptography;
+using Microsoft.Extensions.Configuration;
+using Spectre.Console;
+using Spectre.Console.Testing;
+using ZB.MOM.WW.Secrets.Abstractions;
+using ZB.MOM.WW.Secrets.Cli.Interactive;
+using ZB.MOM.WW.Secrets.Cli.Interactive.Flows;
+using ZB.MOM.WW.Secrets.MasterKey;
+
+namespace ZB.MOM.WW.Secrets.Tests.Cli.Interactive.Flows;
+
+///
+/// Drives the deployment-setup against a real temp-SQLite full session
+/// whose references three ${secret:NAME} tokens — one
+/// seeded (Ok), one absent (Missing), and one tombstoned. Each flow renders to a scripted
+/// , so the tests assert both the rendered and
+/// the resulting store state. The invariant under test is that the operator can fill every startup gap
+/// from one screen without any typed secret value ever reaching the terminal.
+///
+public sealed class ReferenceAuditFlowTests : IDisposable
+{
+ private readonly string _dir =
+ Path.Combine(Path.GetTempPath(), $"zb-secrets-refaudit-{Guid.NewGuid():N}");
+
+ private string DbPath => Path.Combine(_dir, "secrets.db");
+
+ public ReferenceAuditFlowTests() => Directory.CreateDirectory(_dir);
+
+ public void Dispose()
+ {
+ if (Directory.Exists(_dir))
+ {
+ try { Directory.Delete(_dir, recursive: true); } catch (IOException) { /* best-effort temp cleanup */ }
+ }
+ }
+
+ private static TestConsole NewConsole()
+ {
+ var console = (TestConsole)new TestConsole().Interactive();
+ console.Profile.Width = 240; // wide enough that the status table never truncates a cell
+ return console;
+ }
+
+ // A full (KEK-capable) session on a fresh temp SQLite store, with an optional in-memory configuration
+ // layered onto the target so the reference auditor sees ${secret:...} tokens. Keyed by an operator-
+ // override KEK so the test never depends on ambient environment state.
+ private async Task NewSessionAsync(
+ IEnumerable>? configEntries = null)
+ {
+ byte[] key = new byte[32];
+ RandomNumberGenerator.Fill(key);
+ var kek = new LiteralMasterKeyProvider(Convert.ToBase64String(key));
+
+ StoreTarget target = new TargetConfigReader().Manual(DbPath, new MasterKeyOptions
+ {
+ Source = MasterKeySource.Environment,
+ EnvVarName = $"ZB_TEST_UNSET_{Guid.NewGuid():N}",
+ });
+
+ if (configEntries is not null)
+ {
+ IConfigurationRoot config = new ConfigurationBuilder().AddInMemoryCollection(configEntries).Build();
+ target = target with { Configuration = config };
+ }
+
+ return await new SecretsSessionFactory().OpenAsync(target, kek, CancellationToken.None);
+ }
+
+ // The canonical three-reference configuration: svc/ok (seeded), svc/missing (absent), svc/tomb
+ // (tombstoned). Ordinal name order is svc/missing < svc/ok < svc/tomb, so the non-Ok findings the
+ // flow prompts for arrive in the order missing, then tomb.
+ private static IEnumerable> ThreeReferenceConfig() =>
+ [
+ new("ConnectionStrings:Ok", "${secret:svc/ok}"),
+ new("Api:MissingKey", "${secret:svc/missing}"),
+ new("Feature:TombValue", "${secret:svc/tomb}"),
+ ];
+
+ private static async Task SeedAsync(SecretsSession session, string name, string value)
+ {
+ var secretName = new SecretName(name);
+ StoredSecret row = session.Cipher!.Encrypt(secretName, value, SecretContentType.Text);
+ await session.Store.UpsertAsync(row, CancellationToken.None);
+ }
+
+ // Seeds svc/ok live and leaves svc/missing absent, then seeds + tombstones svc/tomb.
+ private static async Task SeedThreeReferenceStateAsync(SecretsSession session)
+ {
+ await SeedAsync(session, "svc/ok", "OK-SEEDED-VALUE");
+ await SeedAsync(session, "svc/tomb", "TOMB-ORIGINAL-VALUE");
+ await session.Store.DeleteAsync(new SecretName("svc/tomb"), "test", CancellationToken.None);
+ }
+
+ private static int CountOccurrences(string haystack, string needle)
+ {
+ int count = 0;
+ for (int i = haystack.IndexOf(needle, StringComparison.Ordinal); i >= 0;
+ i = haystack.IndexOf(needle, i + needle.Length, StringComparison.Ordinal))
+ {
+ count++;
+ }
+
+ return count;
+ }
+
+ [Fact]
+ public async Task Renders_status_table_for_all_references()
+ {
+ SecretsSession session = await NewSessionAsync(ThreeReferenceConfig());
+ await SeedThreeReferenceStateAsync(session);
+
+ TestConsole console = NewConsole();
+ console.Input.PushTextWithEnter("n"); // svc/missing: decline (this test only asserts the rendered table)
+ console.Input.PushTextWithEnter("n"); // svc/tomb: decline
+ await new ReferenceAuditFlow().RunAsync(console, session, CancellationToken.None);
+
+ string output = console.Output;
+ Assert.Contains("svc/ok", output, StringComparison.Ordinal);
+ Assert.Contains("svc/missing", output, StringComparison.Ordinal);
+ Assert.Contains("svc/tomb", output, StringComparison.Ordinal);
+ Assert.Contains("Missing", output, StringComparison.Ordinal);
+ Assert.Contains("Tombstoned", output, StringComparison.Ordinal);
+ Assert.Contains("ConnectionStrings:Ok", output, StringComparison.Ordinal);
+ Assert.Contains("Api:MissingKey", output, StringComparison.Ordinal);
+ Assert.Contains("Feature:TombValue", output, StringComparison.Ordinal);
+
+ Assert.True(new ReferenceAuditFlow().RequiresKek);
+ }
+
+ [Fact]
+ public async Task Offers_to_seed_each_non_ok_reference_and_seeds_accepted_ones()
+ {
+ SecretsSession session = await NewSessionAsync(ThreeReferenceConfig());
+ await SeedThreeReferenceStateAsync(session);
+
+ TestConsole console = NewConsole();
+ // svc/missing (first non-Ok): accept, value, Text (Enter), empty description.
+ console.Input.PushTextWithEnter("y");
+ console.Input.PushTextWithEnter("MISSING-SEED-SENTINEL");
+ console.Input.PushKey(ConsoleKey.Enter);
+ console.Input.PushKey(ConsoleKey.Enter);
+ // svc/tomb (second non-Ok): accept, value, Text (Enter), empty description.
+ console.Input.PushTextWithEnter("y");
+ console.Input.PushTextWithEnter("TOMB-SEED-SENTINEL");
+ console.Input.PushKey(ConsoleKey.Enter);
+ console.Input.PushKey(ConsoleKey.Enter);
+
+ await new ReferenceAuditFlow().RunAsync(console, session, CancellationToken.None);
+
+ IReadOnlyList after =
+ await new ReferenceAuditor().AuditAsync(session, CancellationToken.None);
+ Assert.All(after, f => Assert.Equal(ReferenceStatus.Ok, f.Status));
+
+ // The store now decrypts both freshly-seeded values.
+ StoredSecret? missing = await session.Store.GetAsync(new SecretName("svc/missing"), CancellationToken.None);
+ StoredSecret? tomb = await session.Store.GetAsync(new SecretName("svc/tomb"), CancellationToken.None);
+ Assert.Equal("MISSING-SEED-SENTINEL", session.Cipher!.Decrypt(missing!));
+ Assert.Equal("TOMB-SEED-SENTINEL", session.Cipher!.Decrypt(tomb!));
+ Assert.False(tomb!.IsDeleted); // upsert revived the tombstone
+
+ Assert.DoesNotContain("MISSING-SEED-SENTINEL", console.Output, StringComparison.Ordinal);
+ Assert.DoesNotContain("TOMB-SEED-SENTINEL", console.Output, StringComparison.Ordinal);
+
+ // The closing re-audit summary reflects the now-all-Ok state.
+ Assert.Contains("Audit complete: 3 Ok.", console.Output, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task Undecryptable_reference_is_offered_as_overwrite_and_reseeded()
+ {
+ KeyValuePair[] config = [new("Api:Bad", "${secret:svc/bad}")];
+
+ // Seal svc/bad under a DIFFERENT KEK on the same store, so the audited session cannot decrypt it.
+ SecretsSession writer = await NewSessionAsync(config);
+ await SeedAsync(writer, "svc/bad", "OLD-UNREADABLE-VALUE");
+
+ SecretsSession session = await NewSessionAsync(config); // fresh random KEK → svc/bad is Undecryptable
+
+ TestConsole console = NewConsole();
+ console.Input.PushTextWithEnter("y"); // overwrite confirm (defaults false for Undecryptable)
+ console.Input.PushTextWithEnter("NEW-READABLE-SENTINEL"); // fresh value (masked)
+ console.Input.PushKey(ConsoleKey.Enter); // content-type Text
+ console.Input.PushKey(ConsoleKey.Enter); // description empty
+
+ await new ReferenceAuditFlow().RunAsync(console, session, CancellationToken.None);
+
+ Assert.Contains("Undecryptable", console.Output, StringComparison.Ordinal); // rendered in the table
+
+ StoredSecret? row = await session.Store.GetAsync(new SecretName("svc/bad"), CancellationToken.None);
+ Assert.Equal("NEW-READABLE-SENTINEL", session.Cipher!.Decrypt(row!)); // now decrypts under the session KEK
+ Assert.DoesNotContain("NEW-READABLE-SENTINEL", console.Output, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task Invalid_name_reference_renders_hint_and_is_not_prompted()
+ {
+ KeyValuePair[] config = [new("Api:Broken", "${secret:REPLACE ME}")];
+ SecretsSession session = await NewSessionAsync(config);
+
+ TestConsole console = NewConsole(); // push NO input: an InvalidName reference must never prompt
+ await new ReferenceAuditFlow().RunAsync(console, session, CancellationToken.None);
+
+ Assert.Contains("InvalidName", console.Output, StringComparison.Ordinal); // table status
+ Assert.Contains("not a valid secret name", console.Output, StringComparison.Ordinal); // fix-the-token hint
+ Assert.Equal(0, CountOccurrences(console.Output, "Seed '")); // no seed prompt
+ Assert.Contains("Audit complete: 1 InvalidName.", console.Output, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task Skipped_references_are_left_untouched()
+ {
+ SecretsSession session = await NewSessionAsync(ThreeReferenceConfig());
+ await SeedThreeReferenceStateAsync(session);
+
+ TestConsole console = NewConsole();
+ console.Input.PushTextWithEnter("n"); // svc/missing: decline
+ console.Input.PushTextWithEnter("n"); // svc/tomb: decline
+
+ await new ReferenceAuditFlow().RunAsync(console, session, CancellationToken.None);
+
+ StoredSecret? missing = await session.Store.GetAsync(new SecretName("svc/missing"), CancellationToken.None);
+ StoredSecret? tomb = await session.Store.GetAsync(new SecretName("svc/tomb"), CancellationToken.None);
+ Assert.Null(missing);
+ Assert.NotNull(tomb);
+ Assert.True(tomb!.IsDeleted);
+ }
+
+ [Fact]
+ public async Task Manual_target_without_appsettings_reports_nothing_to_audit()
+ {
+ SecretsSession session = await NewSessionAsync(); // Manual target: empty composed configuration
+
+ TestConsole console = NewConsole();
+ await new ReferenceAuditFlow().RunAsync(console, session, CancellationToken.None);
+
+ Assert.Contains("No ${secret:} references found", console.Output, StringComparison.Ordinal);
+ Assert.Equal(0, CountOccurrences(console.Output, "Seed '")); // no seed prompt was rendered
+ }
+}
diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/InteractiveEntryTests.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/InteractiveEntryTests.cs
new file mode 100644
index 0000000..6a33d60
--- /dev/null
+++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/InteractiveEntryTests.cs
@@ -0,0 +1,57 @@
+using Spectre.Console.Testing;
+using ZB.MOM.WW.Secrets.Cli.Interactive;
+
+namespace ZB.MOM.WW.Secrets.Tests.Cli.Interactive;
+
+///
+/// Pins the composition root of the interactive console: the non-TTY gate that decides whether a bare
+/// secret invocation launches the console, and the exact flow set (order + uniqueness) the shell
+/// is wired with. These are the two things Program.cs delegates to, so they are asserted here directly
+/// rather than by driving the whole shell.
+///
+public sealed class InteractiveEntryTests
+{
+ [Theory]
+ // Only a bare invocation on a real TTY (neither stream redirected) enters the interactive console.
+ [InlineData(new string[0], false, false, true)]
+ [InlineData(new string[0], true, false, false)]
+ [InlineData(new string[0], false, true, false)]
+ [InlineData(new string[0], true, true, false)]
+ [InlineData(new[] { "list" }, false, false, false)]
+ public void ShouldEnterInteractive_true_only_for_no_args_and_real_tty(
+ string[] args, bool inputRedirected, bool outputRedirected, bool expected)
+ {
+ Assert.Equal(
+ expected,
+ InteractiveEntry.ShouldEnterInteractive(args, inputRedirected, outputRedirected));
+ }
+
+ [Fact]
+ public void CreateShell_composes_all_flows_exactly_once()
+ {
+ // CreateShell must succeed — its InteractiveShell ctor re-validates flow-title uniqueness, so a
+ // duplicate or reserved collision would throw here.
+ InteractiveShell shell = InteractiveEntry.CreateShell(new TestConsole());
+ Assert.NotNull(shell);
+
+ // The composed flow set is asserted through the same seam CreateShell uses.
+ string[] titles = InteractiveEntry.CreateFlows().Select(f => f.Title).ToArray();
+
+ Assert.Equal(
+ new[]
+ {
+ "List secrets",
+ "Set / rotate a secret",
+ "Get (reveal) a secret",
+ "Delete a secret",
+ "Reference audit & seed",
+ "KEK doctor (lockout recovery)",
+ "Export bundle (ciphertext-only)",
+ "Import bundle",
+ },
+ titles);
+
+ // Each title appears exactly once.
+ Assert.Equal(titles.Length, titles.Distinct(StringComparer.Ordinal).Count());
+ }
+}
diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/InteractiveShellTests.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/InteractiveShellTests.cs
new file mode 100644
index 0000000..5d9381e
--- /dev/null
+++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/InteractiveShellTests.cs
@@ -0,0 +1,345 @@
+using System.Security.Cryptography;
+using Spectre.Console;
+using Spectre.Console.Testing;
+using ZB.MOM.WW.Secrets.Abstractions;
+using ZB.MOM.WW.Secrets.Cli.Interactive;
+using ZB.MOM.WW.Secrets.MasterKey;
+
+namespace ZB.MOM.WW.Secrets.Tests.Cli.Interactive;
+
+///
+/// Drives end-to-end through a scripted : the
+/// target picker (recents-first + manual entry), the header/menu loop, flow dispatch, the degraded-session
+/// KEK-upgrade prompt, and the error-containment panel. The shell is the only UI-owning class, so these
+/// tests assert on rendered and on what a fake flow observed.
+///
+[Collection("Process environment")]
+public sealed class InteractiveShellTests : IDisposable
+{
+ private readonly string _dir =
+ Path.Combine(Path.GetTempPath(), $"zb-secrets-shell-{Guid.NewGuid():N}");
+ private readonly List _envVars = [];
+
+ public InteractiveShellTests() => Directory.CreateDirectory(_dir);
+
+ public void Dispose()
+ {
+ foreach (string name in _envVars)
+ {
+ Environment.SetEnvironmentVariable(name, null);
+ }
+
+ if (Directory.Exists(_dir))
+ {
+ try { Directory.Delete(_dir, recursive: true); } catch (IOException) { /* best-effort temp cleanup */ }
+ }
+ }
+
+ private string DbPath => Path.Combine(_dir, "secrets.db");
+
+ private static string NewKeyBase64()
+ {
+ byte[] key = new byte[32];
+ RandomNumberGenerator.Fill(key);
+ return Convert.ToBase64String(key);
+ }
+
+ private string SetTempKeyEnv()
+ {
+ string name = $"ZB_TEST_KEK_{Guid.NewGuid():N}";
+ Environment.SetEnvironmentVariable(name, NewKeyBase64());
+ _envVars.Add(name);
+ return name;
+ }
+
+ private string UnsetEnvVarName()
+ {
+ string name = $"ZB_TEST_UNSET_{Guid.NewGuid():N}";
+ _envVars.Add(name); // ensure cleanup even though never set
+ return name;
+ }
+
+ private string WriteAppSettings(string envVarName)
+ {
+ string path = Path.Combine(_dir, "appsettings.json");
+ File.WriteAllText(path, $$"""
+ {
+ "Secrets": {
+ "SqlitePath": "secrets.db",
+ "MasterKey": { "Source": "Environment", "EnvVarName": "{{envVarName}}" }
+ }
+ }
+ """);
+ return path;
+ }
+
+ private static TestConsole NewConsole() => new TestConsole().Interactive();
+
+ private static InteractiveShell NewShell(
+ TestConsole console, RecentTargetsStore recents, params IInteractiveFlow[] flows) =>
+ new(console, recents, new TargetConfigReader(), new SecretsSessionFactory(), flows);
+
+ private RecentTargetsStore NewRecents(TimeProvider? clock = null) =>
+ new(Path.Combine(_dir, "recents.json"), clock);
+
+ /// Records what the shell dispatched to it without touching store logic.
+ private sealed class FakeFlow(string title, bool requiresKek = false, Action? onRun = null)
+ : IInteractiveFlow
+ {
+ public string Title { get; } = title;
+ public bool RequiresKek { get; } = requiresKek;
+ public int InvocationCount { get; private set; }
+ public SecretsSession? LastSession { get; private set; }
+
+ public Task RunAsync(IAnsiConsole console, SecretsSession session, CancellationToken ct)
+ {
+ InvocationCount++;
+ LastSession = session;
+ onRun?.Invoke(session);
+ return Task.CompletedTask;
+ }
+ }
+
+ // --- Picker input helpers (SelectionPrompt: DownArrow to move, Enter to accept) ---
+
+ private static void SelectIndex(TestConsole console, int index)
+ {
+ for (int i = 0; i < index; i++)
+ {
+ console.Input.PushKey(ConsoleKey.DownArrow);
+ }
+
+ console.Input.PushKey(ConsoleKey.Enter);
+ }
+
+ // Manual-entry target: with no recents the picker is [Enter appsettings(0), Manual entry(1)].
+ private void ScriptManualEntry(TestConsole console, string envVarName)
+ {
+ SelectIndex(console, 1); // "Manual entry…"
+ console.Input.PushTextWithEnter(DbPath);
+ console.Input.PushTextWithEnter(envVarName);
+ }
+
+ [Fact]
+ public async Task Shell_shows_target_picker_then_menu_and_quits()
+ {
+ TestConsole console = NewConsole();
+ InteractiveShell shell = NewShell(console, NewRecents());
+
+ ScriptManualEntry(console, UnsetEnvVarName());
+ // No flows: menu is [Switch store target(0), Quit(1)].
+ SelectIndex(console, 1); // Quit
+
+ int result = await shell.RunAsync(CancellationToken.None);
+
+ Assert.Equal(0, result);
+ Assert.Contains("Manual entry", console.Output, StringComparison.Ordinal);
+ Assert.Contains("appsettings", console.Output, StringComparison.Ordinal);
+ Assert.Contains("Switch store target", console.Output, StringComparison.Ordinal);
+ Assert.Contains("Quit", console.Output, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task Recents_appear_first_in_target_picker_and_selection_opens_session()
+ {
+ string envVar = SetTempKeyEnv();
+ string appSettings = WriteAppSettings(envVar);
+
+ var clock = new MutableClock(DateTimeOffset.Parse("2026-01-01T00:00:00Z"));
+ RecentTargetsStore recents = NewRecents(clock);
+ recents.Touch("prod-app", appSettings);
+ DateTimeOffset original = recents.Load()[0].LastUsedUtc;
+
+ clock.Advance(TimeSpan.FromHours(1));
+
+ TestConsole console = NewConsole();
+ InteractiveShell shell = NewShell(console, recents);
+
+ SelectIndex(console, 0); // recent is first
+ SelectIndex(console, 1); // menu (no flows): Quit
+
+ int result = await shell.RunAsync(CancellationToken.None);
+
+ Assert.Equal(0, result);
+ Assert.Contains("prod-app", console.Output, StringComparison.Ordinal);
+
+ IReadOnlyList after = recents.Load();
+ Assert.Equal(appSettings, after[0].AppSettingsPath); // still first
+ Assert.True(after[0].LastUsedUtc > original); // re-Touched with a newer stamp
+ }
+
+ [Fact]
+ public async Task Menu_lists_registered_flows_by_title_plus_builtins()
+ {
+ var alpha = new FakeFlow("Alpha flow");
+ var beta = new FakeFlow("Beta flow");
+
+ TestConsole console = NewConsole();
+ InteractiveShell shell = NewShell(console, NewRecents(), alpha, beta);
+
+ ScriptManualEntry(console, UnsetEnvVarName());
+ // Menu is [Alpha(0), Beta(1), Switch(2), Quit(3)].
+ SelectIndex(console, 3); // Quit
+
+ int result = await shell.RunAsync(CancellationToken.None);
+
+ Assert.Equal(0, result);
+ Assert.Contains("Alpha flow", console.Output, StringComparison.Ordinal);
+ Assert.Contains("Beta flow", console.Output, StringComparison.Ordinal);
+ Assert.Contains("Switch store target", console.Output, StringComparison.Ordinal);
+ Assert.Contains("Quit", console.Output, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task Degraded_session_flow_requiring_kek_prompts_for_key_first()
+ {
+ var flow = new FakeFlow("Seal something", requiresKek: true);
+ string pastedKey = NewKeyBase64();
+
+ TestConsole console = NewConsole();
+ InteractiveShell shell = NewShell(console, NewRecents(), flow);
+
+ ScriptManualEntry(console, UnsetEnvVarName()); // degraded session
+ // Menu is [Seal something(0), Switch(1), Quit(2)].
+ SelectIndex(console, 0); // choose the KEK-requiring flow
+ SelectIndex(console, 0); // key source: "Paste base64 key"
+ console.Input.PushTextWithEnter(pastedKey);
+ SelectIndex(console, 2); // back at the menu → Quit
+
+ int result = await shell.RunAsync(CancellationToken.None);
+
+ Assert.Equal(0, result);
+ Assert.Equal(1, flow.InvocationCount);
+ Assert.NotNull(flow.LastSession);
+ Assert.True(flow.LastSession!.KekAvailable); // session was upgraded before dispatch
+ Assert.DoesNotContain(pastedKey, console.Output, StringComparison.Ordinal); // masked
+ }
+
+ [Fact]
+ public async Task Flow_not_requiring_kek_runs_on_degraded_session()
+ {
+ var flow = new FakeFlow("List metadata", requiresKek: false);
+
+ TestConsole console = NewConsole();
+ InteractiveShell shell = NewShell(console, NewRecents(), flow);
+
+ ScriptManualEntry(console, UnsetEnvVarName()); // degraded session
+ SelectIndex(console, 0); // run the flow
+ SelectIndex(console, 2); // menu [flow(0), Switch(1), Quit(2)] → Quit
+
+ int result = await shell.RunAsync(CancellationToken.None);
+
+ Assert.Equal(0, result);
+ Assert.Equal(1, flow.InvocationCount);
+ Assert.NotNull(flow.LastSession);
+ Assert.False(flow.LastSession!.KekAvailable); // ran on the degraded session
+ }
+
+ [Fact]
+ public async Task Flow_exception_renders_error_panel_and_returns_to_menu()
+ {
+ var flow = new FakeFlow(
+ "Boom",
+ requiresKek: false,
+ onRun: _ => throw new SecretDecryptionException("tag mismatch"));
+
+ TestConsole console = NewConsole();
+ InteractiveShell shell = NewShell(console, NewRecents(), flow);
+
+ ScriptManualEntry(console, UnsetEnvVarName());
+ SelectIndex(console, 0); // run the throwing flow
+ SelectIndex(console, 2); // loop returned to menu → Quit
+
+ int result = await shell.RunAsync(CancellationToken.None);
+
+ Assert.Equal(0, result);
+ Assert.Equal(1, flow.InvocationCount);
+ Assert.Contains("KEK doctor", console.Output, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task Flow_throwing_UnauthorizedAccess_is_contained_and_returns_to_menu()
+ {
+ var flow = new FakeFlow(
+ "Read key file",
+ requiresKek: false,
+ onRun: _ => throw new UnauthorizedAccessException("permission denied"));
+
+ TestConsole console = NewConsole();
+ InteractiveShell shell = NewShell(console, NewRecents(), flow);
+
+ ScriptManualEntry(console, UnsetEnvVarName());
+ SelectIndex(console, 0); // run the throwing flow
+ SelectIndex(console, 2); // loop returned to menu → Quit
+
+ int result = await shell.RunAsync(CancellationToken.None);
+
+ Assert.Equal(0, result);
+ Assert.Equal(1, flow.InvocationCount);
+ Assert.Contains("KEK doctor", console.Output, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void Constructor_rejects_duplicate_flow_titles()
+ {
+ var first = new FakeFlow("Same title");
+ var second = new FakeFlow("Same title");
+
+ Assert.Throws(() => NewShell(NewConsole(), NewRecents(), first, second));
+ }
+
+ [Fact]
+ public void Constructor_rejects_flow_title_colliding_with_a_builtin()
+ {
+ var reserved = new FakeFlow("Quit");
+
+ Assert.Throws(() => NewShell(NewConsole(), NewRecents(), reserved));
+ }
+
+ [Fact]
+ public async Task Cancelled_token_exits_menu_loop_cleanly_with_zero()
+ {
+ using var cts = new CancellationTokenSource();
+ // The flow cancels the shell's own token mid-action; the menu loop's top-of-loop cancellation
+ // check must then exit 0 without prompting for (unscripted) further input or throwing. This is
+ // the testable stand-in for a menu-level Ctrl-C, which TestConsole cannot inject into a prompt.
+ var flow = new FakeFlow("Cancel the shell", requiresKek: false, onRun: _ => cts.Cancel());
+
+ TestConsole console = NewConsole();
+ InteractiveShell shell = NewShell(console, NewRecents(), flow);
+
+ ScriptManualEntry(console, UnsetEnvVarName());
+ SelectIndex(console, 0); // run the flow, which cancels the token — no more input is scripted
+
+ int result = await shell.RunAsync(cts.Token);
+
+ Assert.Equal(0, result);
+ Assert.Equal(1, flow.InvocationCount);
+ }
+
+ [Fact]
+ public async Task Switch_target_reopens_picker()
+ {
+ TestConsole console = NewConsole();
+ InteractiveShell shell = NewShell(console, NewRecents());
+
+ ScriptManualEntry(console, UnsetEnvVarName()); // first target
+ SelectIndex(console, 0); // menu [Switch(0), Quit(1)] → Switch
+ ScriptManualEntry(console, UnsetEnvVarName()); // picker reopened → second target
+ SelectIndex(console, 1); // Quit
+
+ int result = await shell.RunAsync(CancellationToken.None);
+
+ Assert.Equal(0, result);
+ }
+
+ /// A hand-advanced clock so a re-Touch produces a strictly newer timestamp.
+ private sealed class MutableClock(DateTimeOffset start) : TimeProvider
+ {
+ private DateTimeOffset _now = start;
+
+ public void Advance(TimeSpan by) => _now += by;
+
+ public override DateTimeOffset GetUtcNow() => _now;
+ }
+}
diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/KekDoctorTests.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/KekDoctorTests.cs
new file mode 100644
index 0000000..a36b8a3
--- /dev/null
+++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/KekDoctorTests.cs
@@ -0,0 +1,223 @@
+using Microsoft.Data.Sqlite;
+using ZB.MOM.WW.Secrets.Abstractions;
+using ZB.MOM.WW.Secrets.Cli.Interactive;
+using ZB.MOM.WW.Secrets.Crypto;
+using ZB.MOM.WW.Secrets.MasterKey;
+using ZB.MOM.WW.Secrets.Rotation;
+using ZB.MOM.WW.Secrets.Sqlite;
+
+namespace ZB.MOM.WW.Secrets.Tests.Cli.Interactive;
+
+///
+/// Exercises against the REAL SQLite store and REAL AES-256-GCM cipher — the
+/// lockout-triage path an operator runs to learn, per row, which KEK it is wrapped under and whether
+/// the session KEK actually opens it, then remedies via a guided rewrap-all. KEK-A and KEK-B are two
+/// distinct 32-byte keys (distinct derived kek_ids).
+///
+public sealed class KekDoctorTests : IAsyncLifetime, IDisposable
+{
+ private readonly string _dbPath =
+ Path.Combine(Path.GetTempPath(), $"zb-secrets-doctor-{Guid.NewGuid():N}.db");
+
+ private readonly SecretsSqliteConnectionFactory _factory;
+ private readonly SqliteSecretStore _store;
+
+ // Two distinct 32-byte keys → two distinct derived kek_ids (KEK-A / KEK-B).
+ private readonly LiteralMasterKeyProvider _kekA =
+ new(Convert.ToBase64String(FillKey(0xA1)));
+ private readonly LiteralMasterKeyProvider _kekB =
+ new(Convert.ToBase64String(FillKey(0xB2)));
+
+ public KekDoctorTests()
+ {
+ _factory = new SecretsSqliteConnectionFactory(_dbPath);
+ _store = new SqliteSecretStore(_factory);
+ }
+
+ public async Task InitializeAsync() =>
+ await new SqliteSecretsStoreMigrator(_factory).MigrateAsync(CancellationToken.None);
+
+ public Task DisposeAsync() => Task.CompletedTask;
+
+ private static byte[] FillKey(byte b)
+ {
+ byte[] key = new byte[32];
+ Array.Fill(key, b);
+ return key;
+ }
+
+ // A live session over the shared store, keyed by the supplied provider.
+ private SecretsSession Session(IMasterKeyProvider kek) => new()
+ {
+ Target = new TargetConfigReader().Manual(_dbPath, new MasterKeyOptions()),
+ Store = _store,
+ Migrator = new SqliteSecretsStoreMigrator(_factory),
+ MasterKey = kek,
+ Cipher = new AesGcmEnvelopeCipher(kek),
+ StoreKind = "sqlite",
+ };
+
+ // A degraded session: no KEK, no cipher (metadata-only).
+ private SecretsSession DegradedSession() => new()
+ {
+ Target = new TargetConfigReader().Manual(_dbPath, new MasterKeyOptions()),
+ Store = _store,
+ Migrator = new SqliteSecretsStoreMigrator(_factory),
+ MasterKey = null,
+ Cipher = null,
+ StoreKind = "sqlite",
+ };
+
+ private async Task SeedAsync(string name, string value, IMasterKeyProvider kek)
+ {
+ var cipher = new AesGcmEnvelopeCipher(kek);
+ StoredSecret row = cipher.Encrypt(new SecretName(name), value, SecretContentType.Text);
+ await _store.UpsertAsync(row, CancellationToken.None);
+ }
+
+ [Fact]
+ public async Task Diagnose_reports_ok_rows_under_session_kek()
+ {
+ await SeedAsync("sql/a", "value-a", _kekA);
+ await SeedAsync("ldap/b", "value-b", _kekA);
+
+ KekDoctorReport report = await new KekDoctor().DiagnoseAsync(Session(_kekA), CancellationToken.None);
+
+ Assert.True(report.Healthy);
+ Assert.Equal(_kekA.KekId, report.SessionKekId);
+ Assert.Equal(2, report.Rows.Count);
+ Assert.Equal(2, report.Total); // no rows vanished → Total equals Rows.Count.
+ Assert.All(report.Rows, r => Assert.Equal(RowKekStatus.Ok, r.Status));
+ // Rows ordered by name: "ldap/b" precedes "sql/a".
+ Assert.Equal(["ldap/b", "sql/a"], report.Rows.Select(r => r.SecretName));
+ }
+
+ [Fact]
+ public async Task Diagnose_verdict_reads_fresh_row_kek_not_stale_metadata()
+ {
+ // Seed under A; a first diagnosis on session A reports Ok.
+ await SeedAsync("sql/a", "value-a", _kekA);
+ KekDoctorReport first = await new KekDoctor().DiagnoseAsync(Session(_kekA), CancellationToken.None);
+ Assert.Equal(RowKekStatus.Ok, Assert.Single(first.Rows).Status);
+
+ // Re-seal the SAME row under B (simulates a concurrent re-wrap/re-set moving its KEK). A second
+ // diagnosis on session A must classify from the FRESH row.KekId (B) → WrongKek surfacing B's id,
+ // never a false Corrupt from probing a row the session cannot open.
+ await SeedAsync("sql/a", "value-a", _kekB);
+ KekDoctorReport second = await new KekDoctor().DiagnoseAsync(Session(_kekA), CancellationToken.None);
+
+ KekDiagnosis row = Assert.Single(second.Rows);
+ Assert.Equal(RowKekStatus.WrongKek, row.Status);
+ Assert.Equal(_kekB.KekId, row.RowKekId);
+ }
+
+ [Fact]
+ public async Task Diagnose_reports_wrong_kek_rows_with_their_kekid()
+ {
+ // Rows sealed under A; the session runs on B → the operator must learn A is the KEK to hunt.
+ await SeedAsync("sql/a", "value-a", _kekA);
+
+ KekDoctorReport report = await new KekDoctor().DiagnoseAsync(Session(_kekB), CancellationToken.None);
+
+ Assert.False(report.Healthy);
+ Assert.Equal(_kekB.KekId, report.SessionKekId);
+ KekDiagnosis row = Assert.Single(report.Rows);
+ Assert.Equal(RowKekStatus.WrongKek, row.Status);
+ Assert.Equal(_kekA.KekId, row.RowKekId);
+ }
+
+ [Fact]
+ public async Task Diagnose_reports_corrupt_row_when_kekid_matches_but_unwrap_fails()
+ {
+ await SeedAsync("sql/a", "value-a", _kekA);
+
+ // Tamper one byte of the DEK wrap tag: kek_id still matches the session KEK, but the unwrap
+ // (and therefore the decrypt probe) now fails closed. UpsertAsync bumps revision — irrelevant.
+ StoredSecret row = (await _store.GetAsync(new SecretName("sql/a"), CancellationToken.None))!;
+ byte[] tag = (byte[])row.WrapTag.Clone();
+ tag[0] ^= 0xFF;
+ await _store.UpsertAsync(row with { WrapTag = tag }, CancellationToken.None);
+
+ KekDoctorReport report = await new KekDoctor().DiagnoseAsync(Session(_kekA), CancellationToken.None);
+
+ Assert.False(report.Healthy);
+ KekDiagnosis diag = Assert.Single(report.Rows);
+ Assert.Equal(RowKekStatus.Corrupt, diag.Status);
+ Assert.Equal(_kekA.KekId, diag.RowKekId);
+ }
+
+ [Fact]
+ public async Task Diagnose_includes_tombstoned_rows()
+ {
+ await SeedAsync("sql/live", "live", _kekA);
+ await SeedAsync("sql/dead", "dead", _kekA);
+ await _store.DeleteAsync(new SecretName("sql/dead"), "carol", CancellationToken.None);
+
+ KekDoctorReport report = await new KekDoctor().DiagnoseAsync(Session(_kekA), CancellationToken.None);
+
+ // A tombstoned row still appears — it blocks rewrap-all (it carries a KEK-wrapped DEK).
+ Assert.Equal(2, report.Rows.Count);
+ Assert.Contains(report.Rows, r => r.SecretName == "sql/dead");
+ }
+
+ [Fact]
+ public async Task Diagnose_throws_on_degraded_session()
+ {
+ InvalidOperationException ex = await Assert.ThrowsAsync(
+ () => new KekDoctor().DiagnoseAsync(DegradedSession(), CancellationToken.None));
+
+ Assert.Contains("key", ex.Message, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public async Task RewrapAll_throws_on_degraded_session()
+ {
+ InvalidOperationException ex = await Assert.ThrowsAsync(
+ () => new KekDoctor().RewrapAllAsync(DegradedSession(), _kekA, CancellationToken.None));
+
+ Assert.Contains("key", ex.Message, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public async Task RewrapAll_moves_wrong_kek_rows_onto_session_kek()
+ {
+ await SeedAsync("sql/a", "value-a", _kekA);
+ await SeedAsync("sql/b", "value-b", _kekA);
+
+ // Session runs on B; rows are on A → rewrap A onto the session KEK (B).
+ SecretsSession session = Session(_kekB);
+ RewrapReport report = await new KekDoctor().RewrapAllAsync(session, _kekA, CancellationToken.None);
+
+ Assert.Equal(2, report.Total);
+ Assert.Equal(2, report.Rewrapped);
+ Assert.Equal(0, report.AlreadyCurrent);
+
+ // Rows now decrypt under the session KEK (B), and the doctor re-diagnoses healthy.
+ var cipherB = new AesGcmEnvelopeCipher(_kekB);
+ StoredSecret a = (await _store.GetAsync(new SecretName("sql/a"), CancellationToken.None))!;
+ Assert.Equal(_kekB.KekId, a.KekId);
+ Assert.Equal("value-a", cipherB.Decrypt(a));
+
+ KekDoctorReport after = await new KekDoctor().DiagnoseAsync(session, CancellationToken.None);
+ Assert.True(after.Healthy);
+ }
+
+ 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.
+ }
+ }
+ }
+}
diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/LiteralMasterKeyProviderTests.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/LiteralMasterKeyProviderTests.cs
new file mode 100644
index 0000000..b1400bd
--- /dev/null
+++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/LiteralMasterKeyProviderTests.cs
@@ -0,0 +1,75 @@
+using System.Security.Cryptography;
+using ZB.MOM.WW.Secrets.Abstractions;
+using ZB.MOM.WW.Secrets.Cli.Interactive;
+using ZB.MOM.WW.Secrets.MasterKey;
+
+namespace ZB.MOM.WW.Secrets.Tests.Cli.Interactive;
+
+///
+/// Pins — the operator-pasted-key provider used to recover a
+/// locked-out session — to the family's key contract: 32-byte enforcement and, critically, a
+/// derived identically to the configured providers so a key
+/// entered by hand can decrypt (and re-wrap) rows another provider kind sealed.
+///
+[Collection("Process environment")]
+public sealed class LiteralMasterKeyProviderTests
+{
+ private static string NewKeyBase64()
+ {
+ byte[] key = new byte[32];
+ RandomNumberGenerator.Fill(key);
+ return Convert.ToBase64String(key);
+ }
+
+ [Fact]
+ public void Accepts_base64_32_byte_key()
+ {
+ string b64 = NewKeyBase64();
+
+ var sut = new LiteralMasterKeyProvider(b64);
+
+ Assert.Equal(32, sut.GetMasterKey().Length);
+ Assert.StartsWith("sha256:", sut.KekId);
+ }
+
+ [Fact]
+ public void Rejects_wrong_length()
+ {
+ string sixteenBytes = Convert.ToBase64String(new byte[16]);
+
+ Assert.Throws(() => new LiteralMasterKeyProvider(sixteenBytes));
+ }
+
+ [Fact]
+ public void Derives_same_kekid_as_environment_provider_for_same_material()
+ {
+ byte[] key = new byte[32];
+ RandomNumberGenerator.Fill(key);
+ string b64 = Convert.ToBase64String(key);
+ string envVar = $"ZB_TEST_KEK_{Guid.NewGuid():N}";
+ Environment.SetEnvironmentVariable(envVar, b64);
+ try
+ {
+ IMasterKeyProvider environment = MasterKeyProviderFactory.Create(new MasterKeyOptions
+ {
+ Source = MasterKeySource.Environment,
+ EnvVarName = envVar,
+ });
+ var literal = new LiteralMasterKeyProvider(b64);
+
+ Assert.Equal(environment.KekId, literal.KekId);
+ }
+ finally
+ {
+ Environment.SetEnvironmentVariable(envVar, null);
+ }
+ }
+
+ [Fact]
+ public void Explicit_kekid_wins()
+ {
+ var sut = new LiteralMasterKeyProvider(NewKeyBase64(), "kek:operator-override");
+
+ Assert.Equal("kek:operator-override", sut.KekId);
+ }
+}
diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/RecentTargetsStoreTests.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/RecentTargetsStoreTests.cs
new file mode 100644
index 0000000..8da0722
--- /dev/null
+++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/RecentTargetsStoreTests.cs
@@ -0,0 +1,127 @@
+using ZB.MOM.WW.Secrets.Cli.Interactive;
+
+namespace ZB.MOM.WW.Secrets.Tests.Cli.Interactive;
+
+///
+/// Exercises against a real temp file, verifying round-trip
+/// persistence, ordering/dedup/cap behavior, corrupt-file tolerance, and — the hard security
+/// invariant — that the persisted JSON never carries anything beyond name/path/timestamp.
+///
+public sealed class RecentTargetsStoreTests : IDisposable
+{
+ private readonly string _dir =
+ Path.Combine(Path.GetTempPath(), $"zb-secrets-recent-{Guid.NewGuid():N}");
+
+ private string FilePath => Path.Combine(_dir, "recent-targets.json");
+
+ public void Dispose()
+ {
+ if (Directory.Exists(_dir))
+ {
+ try { Directory.Delete(_dir, recursive: true); } catch (IOException) { /* best-effort temp cleanup */ }
+ }
+ }
+
+ /// A minimal, manually-advanceable fake for deterministic ordering assertions.
+ private sealed class FakeTimeProvider : TimeProvider
+ {
+ private DateTimeOffset _now;
+
+ public FakeTimeProvider(DateTimeOffset start) => _now = start;
+
+ public void Advance(TimeSpan by) => _now += by;
+
+ public override DateTimeOffset GetUtcNow() => _now;
+ }
+
+ [Fact]
+ public void Load_returns_empty_when_file_missing()
+ {
+ var sut = new RecentTargetsStore(FilePath);
+
+ var result = sut.Load();
+
+ Assert.Empty(result);
+ }
+
+ [Fact]
+ public void Touch_then_load_roundtrips_name_and_path()
+ {
+ var sut = new RecentTargetsStore(FilePath);
+
+ sut.Touch("OtOpcUa", "/apps/otopcua/appsettings.json");
+ var result = sut.Load();
+
+ var entry = Assert.Single(result);
+ Assert.Equal("OtOpcUa", entry.Name);
+ Assert.Equal("/apps/otopcua/appsettings.json", entry.AppSettingsPath);
+ }
+
+ [Fact]
+ public void Touch_existing_path_moves_it_first_and_updates_stamp()
+ {
+ var time = new FakeTimeProvider(new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero));
+ var sut = new RecentTargetsStore(FilePath, time);
+
+ sut.Touch("OtOpcUa", "/apps/otopcua/appsettings.json");
+ time.Advance(TimeSpan.FromMinutes(1));
+ sut.Touch("ScadaBridge", "/apps/scadabridge/appsettings.json");
+ time.Advance(TimeSpan.FromMinutes(1));
+ var beforeRetouch = time.GetUtcNow();
+ sut.Touch("OtOpcUa", "/apps/otopcua/appsettings.json");
+
+ var result = sut.Load();
+
+ Assert.Equal(2, result.Count);
+ Assert.Equal("OtOpcUa", result[0].Name);
+ Assert.Equal("/apps/otopcua/appsettings.json", result[0].AppSettingsPath);
+ Assert.Equal(beforeRetouch, result[0].LastUsedUtc);
+ Assert.Equal("ScadaBridge", result[1].Name);
+ }
+
+ [Fact]
+ public void Load_tolerates_corrupt_json_by_returning_empty()
+ {
+ Directory.CreateDirectory(_dir);
+ File.WriteAllText(FilePath, "{ this is not valid json ][");
+ var sut = new RecentTargetsStore(FilePath);
+
+ var result = sut.Load();
+
+ Assert.Empty(result);
+ }
+
+ [Fact]
+ public void Touch_caps_list_at_ten_entries()
+ {
+ var sut = new RecentTargetsStore(FilePath);
+
+ for (var i = 0; i < 12; i++)
+ {
+ sut.Touch($"App{i}", $"/apps/app{i}/appsettings.json");
+ }
+
+ var result = sut.Load();
+
+ Assert.Equal(10, result.Count);
+ Assert.Equal("App11", result[0].Name);
+ Assert.Equal("App2", result[9].Name);
+ }
+
+ [Fact]
+ public void Saved_json_contains_no_key_material_fields()
+ {
+ var sut = new RecentTargetsStore(FilePath);
+
+ sut.Touch("OtOpcUa", "/apps/otopcua/appsettings.json");
+ var json = File.ReadAllText(FilePath);
+
+ Assert.Contains("name", json, StringComparison.Ordinal);
+ Assert.Contains("appSettingsPath", json, StringComparison.Ordinal);
+ Assert.Contains("lastUsedUtc", json, StringComparison.Ordinal);
+ Assert.DoesNotContain("key", json, StringComparison.OrdinalIgnoreCase);
+ Assert.DoesNotContain("kek", json, StringComparison.OrdinalIgnoreCase);
+ Assert.DoesNotContain("secret", json, StringComparison.OrdinalIgnoreCase);
+ Assert.DoesNotContain("password", json, StringComparison.OrdinalIgnoreCase);
+ }
+}
diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/ReferenceAuditorTests.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/ReferenceAuditorTests.cs
new file mode 100644
index 0000000..1878f79
--- /dev/null
+++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/ReferenceAuditorTests.cs
@@ -0,0 +1,232 @@
+using System.Security.Cryptography;
+using Microsoft.Extensions.Configuration;
+using ZB.MOM.WW.Secrets.Abstractions;
+using ZB.MOM.WW.Secrets.Cli.Interactive;
+using ZB.MOM.WW.Secrets.Crypto;
+using ZB.MOM.WW.Secrets.MasterKey;
+
+namespace ZB.MOM.WW.Secrets.Tests.Cli.Interactive;
+
+///
+/// Exercises against real temp-dir SQLite sessions: distinct-reference
+/// collation (one finding per name, every config path listed), the four full-session classifications
+/// (present/decryptable → Ok, absent → Missing, tombstoned → Tombstoned, wrong-KEK → Undecryptable),
+/// and the degraded-session presence-only downgrade (present → PresentUnverified).
+///
+[Collection("Process environment")]
+public sealed class ReferenceAuditorTests : IDisposable
+{
+ private readonly string _dir =
+ Path.Combine(Path.GetTempPath(), $"zb-secrets-audit-{Guid.NewGuid():N}");
+ private readonly List _envVars = [];
+
+ private string DbPath => Path.Combine(_dir, "secrets.db");
+
+ public ReferenceAuditorTests() => Directory.CreateDirectory(_dir);
+
+ public void Dispose()
+ {
+ foreach (string name in _envVars)
+ {
+ Environment.SetEnvironmentVariable(name, null);
+ }
+
+ if (Directory.Exists(_dir))
+ {
+ try { Directory.Delete(_dir, recursive: true); } catch (IOException) { /* best-effort temp cleanup */ }
+ }
+ }
+
+ private string SetTempKeyEnv()
+ {
+ byte[] key = new byte[32];
+ RandomNumberGenerator.Fill(key);
+ string name = $"ZB_TEST_KEK_{Guid.NewGuid():N}";
+ Environment.SetEnvironmentVariable(name, Convert.ToBase64String(key));
+ _envVars.Add(name);
+ return name;
+ }
+
+ private static IConfigurationRoot Config(params (string Key, string Value)[] pairs) =>
+ new ConfigurationBuilder()
+ .AddInMemoryCollection(pairs.ToDictionary(p => p.Key, p => (string?)p.Value))
+ .Build();
+
+ private StoreTarget Target(MasterKeyOptions masterKey, IConfigurationRoot config) =>
+ new(
+ "test",
+ AppSettingsPath: null,
+ new SecretsOptions { SqlitePath = DbPath, MasterKey = masterKey },
+ SqlServer: null,
+ config);
+
+ private MasterKeyOptions EnvKey() => new()
+ {
+ Source = MasterKeySource.Environment,
+ EnvVarName = SetTempKeyEnv(),
+ };
+
+ private static MasterKeyOptions UnsetEnvKey() => new()
+ {
+ Source = MasterKeySource.Environment,
+ EnvVarName = $"ZB_TEST_UNSET_{Guid.NewGuid():N}",
+ };
+
+ private Task Open(MasterKeyOptions masterKey, IConfigurationRoot config) =>
+ new SecretsSessionFactory().OpenAsync(Target(masterKey, config), overrideKek: null, CancellationToken.None);
+
+ [Fact]
+ public async Task Finds_all_distinct_secret_references_with_their_config_paths()
+ {
+ IConfigurationRoot config = Config(
+ ("Data:Primary:ConnectionString", "Server=db;Password=${secret:db/pw}"),
+ ("Data:Replica:ConnectionString", "Server=db2;Password=${secret:db/pw}"),
+ ("PlainValue", "not a reference"));
+
+ SecretsSession session = await Open(EnvKey(), config);
+
+ IReadOnlyList findings =
+ await new ReferenceAuditor().AuditAsync(session, CancellationToken.None);
+
+ ReferenceFinding finding = Assert.Single(findings);
+ Assert.Equal("db/pw", finding.SecretName);
+ Assert.Equal(
+ ["Data:Primary:ConnectionString", "Data:Replica:ConnectionString"],
+ finding.ConfigPaths);
+ }
+
+ [Fact]
+ public async Task Classifies_present_decryptable_secret_as_Ok()
+ {
+ SecretsSession session = await Open(EnvKey(), Config(("Api:Key", "${secret:api/key}")));
+
+ var name = new SecretName("api/key");
+ await session.Store.UpsertAsync(
+ session.Cipher!.Encrypt(name, "s3cret", SecretContentType.Text), CancellationToken.None);
+
+ IReadOnlyList findings =
+ await new ReferenceAuditor().AuditAsync(session, CancellationToken.None);
+
+ Assert.Equal(ReferenceStatus.Ok, Assert.Single(findings).Status);
+ }
+
+ [Fact]
+ public async Task Classifies_absent_secret_as_Missing()
+ {
+ SecretsSession session = await Open(EnvKey(), Config(("Api:Key", "${secret:api/key}")));
+
+ IReadOnlyList findings =
+ await new ReferenceAuditor().AuditAsync(session, CancellationToken.None);
+
+ Assert.Equal(ReferenceStatus.Missing, Assert.Single(findings).Status);
+ }
+
+ [Fact]
+ public async Task Classifies_tombstoned_secret_as_Tombstoned()
+ {
+ SecretsSession session = await Open(EnvKey(), Config(("Api:Key", "${secret:api/key}")));
+
+ var name = new SecretName("api/key");
+ await session.Store.UpsertAsync(
+ session.Cipher!.Encrypt(name, "s3cret", SecretContentType.Text), CancellationToken.None);
+ Assert.True(await session.Store.DeleteAsync(name, actor: "test", CancellationToken.None));
+
+ IReadOnlyList findings =
+ await new ReferenceAuditor().AuditAsync(session, CancellationToken.None);
+
+ Assert.Equal(ReferenceStatus.Tombstoned, Assert.Single(findings).Status);
+ }
+
+ [Fact]
+ public async Task Classifies_wrong_kek_row_as_Undecryptable()
+ {
+ SecretsSession session = await Open(EnvKey(), Config(("Api:Key", "${secret:api/key}")));
+
+ // Seal a row under a DIFFERENT KEK (KEK-A) and persist it into the session's store; the
+ // session's own cipher (KEK-B) then cannot unwrap it → Undecryptable.
+ byte[] keyA = new byte[32];
+ RandomNumberGenerator.Fill(keyA);
+ var cipherA = new AesGcmEnvelopeCipher(new LiteralMasterKeyProvider(Convert.ToBase64String(keyA)));
+
+ var name = new SecretName("api/key");
+ await session.Store.UpsertAsync(
+ cipherA.Encrypt(name, "s3cret", SecretContentType.Text), CancellationToken.None);
+
+ IReadOnlyList findings =
+ await new ReferenceAuditor().AuditAsync(session, CancellationToken.None);
+
+ Assert.Equal(ReferenceStatus.Undecryptable, Assert.Single(findings).Status);
+ }
+
+ [Fact]
+ public async Task Malformed_reference_yields_InvalidName_finding_without_aborting_audit()
+ {
+ SecretsSession session = await Open(EnvKey(), Config(
+ ("Bad:Ref", "${secret:REPLACE ME!}"),
+ ("Good:Ref", "${secret:api/key}")));
+
+ IReadOnlyList findings =
+ await new ReferenceAuditor().AuditAsync(session, CancellationToken.None);
+
+ Assert.Equal(2, findings.Count);
+ Assert.Equal(ReferenceStatus.InvalidName, findings.Single(f => f.SecretName == "REPLACE ME!").Status);
+ Assert.Equal(ReferenceStatus.Missing, findings.Single(f => f.SecretName == "api/key").Status);
+ }
+
+ [Fact]
+ public async Task Comment_keys_are_skipped_like_the_expander()
+ {
+ SecretsSession session = await Open(EnvKey(), Config(
+ ("Docs:_comment", "Reference a secret like ${secret:example/thing}"),
+ ("Real:Key", "${secret:api/key}")));
+
+ IReadOnlyList findings =
+ await new ReferenceAuditor().AuditAsync(session, CancellationToken.None);
+
+ ReferenceFinding finding = Assert.Single(findings);
+ Assert.Equal("api/key", finding.SecretName);
+ }
+
+ [Fact]
+ public async Task Case_variant_references_collapse_to_one_normalized_finding()
+ {
+ SecretsSession session = await Open(EnvKey(), Config(
+ ("A:Conn", "${secret:Db/Pw}"),
+ ("B:Conn", "${secret:db/pw}")));
+
+ IReadOnlyList findings =
+ await new ReferenceAuditor().AuditAsync(session, CancellationToken.None);
+
+ ReferenceFinding finding = Assert.Single(findings);
+ Assert.Equal("db/pw", finding.SecretName);
+ Assert.Equal(["A:Conn", "B:Conn"], finding.ConfigPaths);
+ }
+
+ [Fact]
+ public async Task Degraded_session_reports_PresenceOnly_status()
+ {
+ // Seed a present row and a tombstoned row through a FULL session first.
+ var present = new SecretName("svc/token");
+ var gone = new SecretName("old/key");
+ SecretsSession seed = await Open(EnvKey(), new ConfigurationBuilder().Build());
+ await seed.Store.UpsertAsync(
+ seed.Cipher!.Encrypt(present, "tok", SecretContentType.Text), CancellationToken.None);
+ await seed.Store.UpsertAsync(
+ seed.Cipher!.Encrypt(gone, "old", SecretContentType.Text), CancellationToken.None);
+ Assert.True(await seed.Store.DeleteAsync(gone, actor: "test", CancellationToken.None));
+
+ // Re-open the SAME store degraded (KEK unresolvable) and audit references to all three.
+ SecretsSession degraded = await Open(UnsetEnvKey(), Config(
+ ("A:Token", "${secret:svc/token}"),
+ ("B:Old", "${secret:old/key}"),
+ ("C:Missing", "${secret:no/such}")));
+ Assert.False(degraded.KekAvailable);
+
+ IReadOnlyList findings =
+ await new ReferenceAuditor().AuditAsync(degraded, CancellationToken.None);
+
+ Assert.Equal(ReferenceStatus.PresentUnverified, findings.Single(f => f.SecretName == "svc/token").Status);
+ Assert.Equal(ReferenceStatus.Tombstoned, findings.Single(f => f.SecretName == "old/key").Status);
+ Assert.Equal(ReferenceStatus.Missing, findings.Single(f => f.SecretName == "no/such").Status);
+ }
+}
diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/SecretsSessionFactoryTests.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/SecretsSessionFactoryTests.cs
new file mode 100644
index 0000000..3ddf696
--- /dev/null
+++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/SecretsSessionFactoryTests.cs
@@ -0,0 +1,158 @@
+using System.Security.Cryptography;
+using Microsoft.Extensions.Configuration;
+using ZB.MOM.WW.Secrets.Abstractions;
+using ZB.MOM.WW.Secrets.Cli.Interactive;
+using ZB.MOM.WW.Secrets.MasterKey;
+using ZB.MOM.WW.Secrets.Replicator.SqlServer;
+
+namespace ZB.MOM.WW.Secrets.Tests.Cli.Interactive;
+
+///
+/// Exercises against real temp-dir SQLite targets: the happy path
+/// (env KEK present → full session + seal/upsert/get/decrypt round-trip), the lockout-recovery path
+/// (KEK unresolvable → degraded session that still lists metadata), the operator-override upgrade, and
+/// the SQL-Server-with-unresolved-secret-ref fall-back to the local store.
+///
+[Collection("Process environment")]
+public sealed class SecretsSessionFactoryTests : IDisposable
+{
+ private readonly string _dir =
+ Path.Combine(Path.GetTempPath(), $"zb-secrets-session-{Guid.NewGuid():N}");
+ private readonly List _envVars = [];
+
+ private string DbPath => Path.Combine(_dir, "secrets.db");
+
+ public SecretsSessionFactoryTests() => Directory.CreateDirectory(_dir);
+
+ public void Dispose()
+ {
+ foreach (string name in _envVars)
+ {
+ Environment.SetEnvironmentVariable(name, null);
+ }
+
+ if (Directory.Exists(_dir))
+ {
+ try { Directory.Delete(_dir, recursive: true); } catch (IOException) { /* best-effort temp cleanup */ }
+ }
+ }
+
+ private string SetTempKeyEnv()
+ {
+ byte[] key = new byte[32];
+ RandomNumberGenerator.Fill(key);
+ string name = $"ZB_TEST_KEK_{Guid.NewGuid():N}";
+ Environment.SetEnvironmentVariable(name, Convert.ToBase64String(key));
+ _envVars.Add(name);
+ return name;
+ }
+
+ private static MasterKeyOptions UnsetEnvKey() => new()
+ {
+ Source = MasterKeySource.Environment,
+ EnvVarName = $"ZB_TEST_UNSET_{Guid.NewGuid():N}",
+ };
+
+ [Fact]
+ public async Task Opens_full_session_when_env_kek_present()
+ {
+ string envVar = SetTempKeyEnv();
+ StoreTarget target = new TargetConfigReader().Manual(DbPath, new MasterKeyOptions
+ {
+ Source = MasterKeySource.Environment,
+ EnvVarName = envVar,
+ });
+
+ SecretsSession session =
+ await new SecretsSessionFactory().OpenAsync(target, overrideKek: null, CancellationToken.None);
+
+ Assert.True(session.KekAvailable);
+ Assert.Equal("sqlite", session.StoreKind);
+ Assert.NotNull(session.Cipher);
+
+ var name = new SecretName("test/roundtrip");
+ StoredSecret row = session.Cipher!.Encrypt(name, "hunter2", SecretContentType.Text);
+ await session.Store.UpsertAsync(row, CancellationToken.None);
+ StoredSecret? fetched = await session.Store.GetAsync(name, CancellationToken.None);
+ Assert.NotNull(fetched);
+ Assert.Equal("hunter2", session.Cipher!.Decrypt(fetched!));
+ }
+
+ [Fact]
+ public async Task Opens_degraded_session_when_env_var_unset()
+ {
+ StoreTarget target = new TargetConfigReader().Manual(DbPath, UnsetEnvKey());
+
+ SecretsSession session =
+ await new SecretsSessionFactory().OpenAsync(target, overrideKek: null, CancellationToken.None);
+
+ Assert.False(session.KekAvailable);
+ Assert.Null(session.Cipher);
+ Assert.Null(session.MasterKey);
+ Assert.NotEmpty(session.Warnings);
+
+ // Store is still usable for metadata ops even without a KEK.
+ IReadOnlyList list = await session.Store.ListAsync(includeDeleted: false, CancellationToken.None);
+ Assert.Empty(list);
+ }
+
+ [Fact]
+ public async Task Opens_degraded_session_when_master_key_source_is_out_of_range()
+ {
+ StoreTarget target = new TargetConfigReader().Manual(DbPath, new MasterKeyOptions
+ {
+ Source = (MasterKeySource)999,
+ });
+
+ SecretsSession session =
+ await new SecretsSessionFactory().OpenAsync(target, overrideKek: null, CancellationToken.None);
+
+ Assert.False(session.KekAvailable);
+ Assert.Null(session.Cipher);
+ Assert.NotEmpty(session.Warnings);
+ Assert.Contains(session.Warnings, w => w.Contains("999", StringComparison.Ordinal));
+
+ // Store is still usable despite the malformed KEK source.
+ IReadOnlyList list = await session.Store.ListAsync(includeDeleted: false, CancellationToken.None);
+ Assert.Empty(list);
+ }
+
+ [Fact]
+ public async Task Override_kek_upgrades_degraded_session()
+ {
+ StoreTarget target = new TargetConfigReader().Manual(DbPath, UnsetEnvKey());
+ byte[] key = new byte[32];
+ RandomNumberGenerator.Fill(key);
+ var overrideKek = new LiteralMasterKeyProvider(Convert.ToBase64String(key));
+
+ SecretsSession session =
+ await new SecretsSessionFactory().OpenAsync(target, overrideKek, CancellationToken.None);
+
+ Assert.True(session.KekAvailable);
+ Assert.NotNull(session.Cipher);
+ Assert.Same(overrideKek, session.MasterKey);
+ }
+
+ [Fact]
+ public async Task SqlServer_target_with_unresolved_secret_ref_connstr_falls_back_to_sqlite_with_warning()
+ {
+ string envVar = SetTempKeyEnv();
+ SecretsOptions secrets = new()
+ {
+ SqlitePath = DbPath,
+ MasterKey = new MasterKeyOptions { Source = MasterKeySource.Environment, EnvVarName = envVar },
+ };
+ SqlServerSecretsOptions sqlServer = new()
+ {
+ ConnectionString = "Server=hub;Database=secrets;User Id=app;Password=${secret:sql/hub-pw}",
+ };
+ StoreTarget target = new(
+ "sql-hub", AppSettingsPath: null, secrets, sqlServer, new ConfigurationBuilder().Build());
+
+ SecretsSession session =
+ await new SecretsSessionFactory().OpenAsync(target, overrideKek: null, CancellationToken.None);
+
+ Assert.Equal("sqlite", session.StoreKind);
+ Assert.Contains(session.Warnings, w => w.Contains("SQLite", StringComparison.OrdinalIgnoreCase));
+ }
+}
diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/TargetConfigReaderTests.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/TargetConfigReaderTests.cs
new file mode 100644
index 0000000..6d1b573
--- /dev/null
+++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/TargetConfigReaderTests.cs
@@ -0,0 +1,160 @@
+using ZB.MOM.WW.Secrets.Cli.Interactive;
+using ZB.MOM.WW.Secrets.MasterKey;
+
+namespace ZB.MOM.WW.Secrets.Tests.Cli.Interactive;
+
+///
+/// Tests for : it must compose the target app's configuration
+/// (base json + optional environment overlay + environment variables) exactly the way the app
+/// would at startup, so fixes land in the store the app actually reads.
+///
+public sealed class TargetConfigReaderTests : IDisposable
+{
+ private readonly List _tempDirs = [];
+
+ public void Dispose()
+ {
+ foreach (string dir in _tempDirs)
+ {
+ if (Directory.Exists(dir))
+ {
+ try { Directory.Delete(dir, recursive: true); } catch (IOException) { /* best-effort temp cleanup */ }
+ }
+ }
+ }
+
+ private string NewTempDir()
+ {
+ string dir = Path.Combine(Path.GetTempPath(), "zb-secrets-cfg-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(dir);
+ _tempDirs.Add(dir);
+ return dir;
+ }
+
+ [Fact]
+ public void Reads_secrets_section_and_binds_options()
+ {
+ string dir = NewTempDir();
+ string path = Path.Combine(dir, "appsettings.json");
+ File.WriteAllText(path, """
+ {
+ "Secrets": {
+ "SqlitePath": "custom.db",
+ "MasterKey": {
+ "Source": "Environment",
+ "EnvVarName": "MY_KEY"
+ }
+ }
+ }
+ """);
+
+ StoreTarget target = new TargetConfigReader().Read(path);
+
+ Assert.Equal(MasterKeySource.Environment, target.Secrets.MasterKey.Source);
+ Assert.Equal("MY_KEY", target.Secrets.MasterKey.EnvVarName);
+ Assert.Equal(Path.Combine(dir, "custom.db"), target.Secrets.SqlitePath);
+ }
+
+ [Fact]
+ public void Environment_overlay_wins_over_base()
+ {
+ string dir = NewTempDir();
+ File.WriteAllText(Path.Combine(dir, "appsettings.json"), """
+ {
+ "Secrets": { "SqlitePath": "base.db" }
+ }
+ """);
+ File.WriteAllText(Path.Combine(dir, "appsettings.Production.json"), """
+ {
+ "Secrets": { "SqlitePath": "prod.db" }
+ }
+ """);
+
+ StoreTarget target = new TargetConfigReader().Read(
+ Path.Combine(dir, "appsettings.json"), environment: "Production");
+
+ Assert.Equal(Path.Combine(dir, "prod.db"), target.Secrets.SqlitePath);
+ }
+
+ [Fact]
+ public void Binds_sqlserver_options_when_section_present()
+ {
+ string dir = NewTempDir();
+ File.WriteAllText(Path.Combine(dir, "appsettings.json"), """
+ {
+ "Secrets": {
+ "SqlitePath": "secrets.db",
+ "SqlServer": {
+ "ConnectionString": "Server=hub;Database=ZbSecrets;Trusted_Connection=True"
+ }
+ }
+ }
+ """);
+
+ StoreTarget withSql = new TargetConfigReader().Read(Path.Combine(dir, "appsettings.json"));
+
+ Assert.NotNull(withSql.SqlServer);
+ Assert.Equal(
+ "Server=hub;Database=ZbSecrets;Trusted_Connection=True",
+ withSql.SqlServer!.ConnectionString);
+
+ string dir2 = NewTempDir();
+ File.WriteAllText(Path.Combine(dir2, "appsettings.json"), """
+ {
+ "Secrets": { "SqlitePath": "secrets.db" }
+ }
+ """);
+
+ StoreTarget noSql = new TargetConfigReader().Read(Path.Combine(dir2, "appsettings.json"));
+
+ Assert.Null(noSql.SqlServer);
+ }
+
+ [Fact]
+ public void SqlitePath_is_resolved_relative_to_the_app_directory()
+ {
+ string dir = NewTempDir();
+ File.WriteAllText(Path.Combine(dir, "appsettings.json"), """
+ {
+ "Secrets": { "SqlitePath": "secrets.db" }
+ }
+ """);
+
+ StoreTarget target = new TargetConfigReader().Read(Path.Combine(dir, "appsettings.json"));
+
+ Assert.True(Path.IsPathRooted(target.Secrets.SqlitePath));
+ Assert.Equal(Path.Combine(dir, "secrets.db"), target.Secrets.SqlitePath);
+ }
+
+ [Fact]
+ public void Missing_file_throws_FileNotFoundException_with_path()
+ {
+ string dir = NewTempDir();
+ string path = Path.Combine(dir, "appsettings.json");
+
+ FileNotFoundException ex = Assert.Throws(
+ () => new TargetConfigReader().Read(path));
+
+ Assert.Contains(path, ex.Message);
+ }
+
+ [Fact]
+ public void Custom_section_path_is_honored()
+ {
+ string dir = NewTempDir();
+ File.WriteAllText(Path.Combine(dir, "appsettings.json"), """
+ {
+ "MySecrets": {
+ "SqlitePath": "elsewhere.db",
+ "MasterKey": { "EnvVarName": "OTHER_KEY" }
+ }
+ }
+ """);
+
+ StoreTarget target = new TargetConfigReader().Read(
+ Path.Combine(dir, "appsettings.json"), sectionPath: "MySecrets");
+
+ Assert.Equal("OTHER_KEY", target.Secrets.MasterKey.EnvVarName);
+ Assert.Equal(Path.Combine(dir, "elsewhere.db"), target.Secrets.SqlitePath);
+ }
+}
diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/DependencyInjection/AddZbSecretsTests.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/DependencyInjection/AddZbSecretsTests.cs
index bea5dff..058cd3b 100644
--- a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/DependencyInjection/AddZbSecretsTests.cs
+++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/DependencyInjection/AddZbSecretsTests.cs
@@ -12,6 +12,7 @@ namespace ZB.MOM.WW.Secrets.Tests.DependencyInjection;
/// core: every seam resolves, a real secret round-trips through the composed graph, and a missing
/// master key fails closed.
///
+[Collection("Process environment")]
public sealed class AddZbSecretsTests
{
private static string NewTempDbPath() =>
diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/MasterKey/MasterKeyProviderTests.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/MasterKey/MasterKeyProviderTests.cs
index 4450466..a0b40b8 100644
--- a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/MasterKey/MasterKeyProviderTests.cs
+++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/MasterKey/MasterKeyProviderTests.cs
@@ -4,6 +4,7 @@ using ZB.MOM.WW.Secrets.MasterKey;
namespace ZB.MOM.WW.Secrets.Tests.MasterKey;
+[Collection("Process environment")]
public class MasterKeyProviderTests
{
// ---- Environment ----------------------------------------------------------------------
diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/ProcessEnvironmentCollection.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/ProcessEnvironmentCollection.cs
new file mode 100644
index 0000000..e6e31c3
--- /dev/null
+++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/ProcessEnvironmentCollection.cs
@@ -0,0 +1,10 @@
+namespace ZB.MOM.WW.Secrets.Tests;
+
+///
+/// Serializes every test class that mutates process environment variables. setenv/getenv operate
+/// on the single process-wide environ, which is not safe under concurrent mutation on glibc —
+/// unique variable names do not remove the race, only serialization does. Classes that never
+/// touch the environment stay fully parallel.
+///
+[CollectionDefinition("Process environment", DisableParallelization = true)]
+public sealed class ProcessEnvironmentCollection;
diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/ZB.MOM.WW.Secrets.Tests.csproj b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/ZB.MOM.WW.Secrets.Tests.csproj
index 5094891..9066d8f 100644
--- a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/ZB.MOM.WW.Secrets.Tests.csproj
+++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/ZB.MOM.WW.Secrets.Tests.csproj
@@ -10,6 +10,7 @@
+
diff --git a/docs/plans/2026-07-19-secrets-interactive-cli-design.md b/docs/plans/2026-07-19-secrets-interactive-cli-design.md
new file mode 100644
index 0000000..bfd1539
--- /dev/null
+++ b/docs/plans/2026-07-19-secrets-interactive-cli-design.md
@@ -0,0 +1,104 @@
+# Design: `secret` interactive console (ZB.MOM.WW.Secrets CLI, 0.3.0)
+
+**Date:** 2026-07-19 · **Status:** Approved · **Execution:** git worktree `worktree-secrets-interactive-cli` (another agent is active on `main`)
+
+## 1. Purpose & posture
+
+Extend the existing headless `secret` CLI (`src/ZB.MOM.WW.Secrets.Cli`) with a menu-driven
+Spectre.Console TUI for two operator jobs:
+
+1. **Deployment setup** — seed a deployment's secrets before first startup (apps fail closed
+ when a `${secret:...}` reference is missing).
+2. **Lockout recovery** — diagnose and fix wrong/lost-KEK and missing-secret states.
+
+No authentication: the tool only runs locally with direct file/DB access — the same trust
+model as the existing headless verbs. All existing verbs stay byte-identical for scripts.
+
+## 2. Entry point
+
+`secret` with **no args in a real terminal** (`!Console.IsInputRedirected &&
+!Console.IsOutputRedirected`) launches the TUI. Redirected I/O keeps today's
+usage-and-exit-2 behavior, so nothing scriptable changes. All other `Program.cs` paths are
+untouched.
+
+## 3. Target selection & sessions
+
+First screen picks a **store target**:
+
+- **Recent targets** — persisted to `~/.zb-secrets/recent-targets.json` (names and paths
+ only; NEVER key material).
+- **Browse** to an app's `appsettings.json`.
+- **Manual entry** — db path + KEK source.
+
+`TargetConfigReader` loads the app's real config the way the app would
+(`appsettings.json` + optional `appsettings..json` overlay + environment
+variables; section `Secrets` by default, overridable) and binds `SecretsOptions`
+(+ `SqlServerSecretsOptions` when present) — operating on exactly what the app will read at
+startup, so a fix cannot land in the wrong store.
+
+`SecretsSessionFactory` constructs the stack directly — `SecretsSqliteConnectionFactory` /
+`SqlServerSecretStore`, `MasterKeyProviderFactory`, `AesGcmEnvelopeCipher`, the matching
+migrator — no per-target DI host. If the configured KEK env var isn't set in the CLI's
+shell (common: it exists only in the service's environment), the session opens **degraded**:
+metadata ops work; decrypt/seal ops prompt for the key via masked input or file path, held
+in memory only.
+
+Dependency changes to `ZB.MOM.WW.Secrets.Cli`: add `Spectre.Console` and a project
+reference to `ZB.MOM.WW.Secrets.Replicator.SqlServer` (to open SQL-hub stores such as
+ScadaBridge central).
+
+## 4. Menu actions
+
+| Action | Behavior |
+|---|---|
+| List / set-rotate / get / delete | Thin TUI wrappers over the same core seams `SecretCommands` uses. Masked value entry; tables for listings; **get** reveals once after confirm, never cached. |
+| **Reference audit + seeding** | Scan the target app's fully-composed config for `${secret:NAME}` tokens (same regex as `SecretReferenceExpander`); report each OK / missing / tombstoned / undecryptable; walk through seeding every non-OK one with masked prompts. This is the deployment-setup flow. |
+| **KEK doctor** | Verify the session KEK against the store: per-row KekId match + actual DEK-unwrap probe; report row classes (OK / wrong-KEK / corrupt). Guided remedies: `rewrap-all` (old key from env/file/masked paste, reusing `KekRotationService`) or re-set affected secrets when the old KEK is truly lost. This is the lockout flow. |
+| **Bundle export / import** | Export rows as ciphertext-only JSON (name, metadata, wrapped DEK, kekId, ciphertext — safe at rest, useless without the KEK). Import into another store; conflicts resolved by core `SecretLastWriterWins` with per-row override prompt. Cross-KEK import offers inline rewrap given the source KEK. |
+| Switch target / quit | Rebuild session for a new target; quit exits 0. |
+
+## 5. Structure
+
+New `Interactive/` layer inside `ZB.MOM.WW.Secrets.Cli`:
+
+- `InteractiveShell` — menu loop; the ONLY class that touches `IAnsiConsole`.
+- UI-free services: `TargetConfigReader`, `SecretsSessionFactory`, `ReferenceAuditor`,
+ `KekDoctor`, `SecretBundleCodec` + import/export service, `RecentTargetsStore`.
+
+Services never touch the console; the shell never touches the store directly. Secret values
+are never written to recents, bundles (plaintext), logs, or screen except the explicit
+reveal action.
+
+## 6. Error handling
+
+Every action catches `SecretDecryptionException` / `ArgumentException` / IO / SQL failures
+and renders a red panel with a remedy hint (usually "run KEK doctor"), returning to the
+menu — the shell never crashes out mid-recovery. Ctrl-C at a prompt cancels back to the
+menu; at the menu it exits 0.
+
+## 7. Testing
+
+- Unit tests for all services in `tests/ZB.MOM.WW.Secrets.Tests/Cli/` (temp SQLite stores,
+ fake KEK providers — same style as existing tests).
+- Shell flows scripted via `Spectre.Console.Testing`'s `TestConsole`: menu navigation,
+ masked entry, degraded-KEK path, audit→seed walk-through, doctor→rewrap.
+- Pin test: non-TTY no-args still prints usage / exit 2.
+- Suite stays 0-warning (`TreatWarningsAsErrors` posture of the repo).
+
+## 8. Versioning & execution
+
+- `Directory.Build.props` version bumps `0.2.3` → `0.3.0` (new feature; CLI is unpacked but
+ the 5 packages ship together — publish decision deferred to rollout).
+- Implemented on worktree branch `worktree-secrets-interactive-cli`; merge to `main` when
+ the full suite is green.
+
+## Alternatives considered
+
+- **Separate `ZB.MOM.WW.Secrets.Cli.Interactive` library** — cleaner layering if another
+ front-end ever hosts the flows; rejected as YAGNI for a one-exe operator tool.
+- **Prompts inside `SecretCommands`** — least code but couples UI into the tested headless
+ layer and breaks its TextWriter-only contract; rejected.
+- **REPL shell / wizards-only UX** — rejected in favor of discoverable menus (operators
+ under lockout stress shouldn't need to remember verbs).
+- **Own profiles file as source of truth** — rejected: can drift from what the app actually
+ reads; parsing the app's own config is the honest source.
diff --git a/docs/plans/2026-07-19-secrets-interactive-cli.md b/docs/plans/2026-07-19-secrets-interactive-cli.md
new file mode 100644
index 0000000..4350f1f
--- /dev/null
+++ b/docs/plans/2026-07-19-secrets-interactive-cli.md
@@ -0,0 +1,505 @@
+# `secret` Interactive Console Implementation Plan
+
+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
+> Design (approved): `docs/plans/2026-07-19-secrets-interactive-cli-design.md`
+> Execute in worktree branch `worktree-secrets-interactive-cli` (root: `/Users/dohertj2/Desktop/scadaproj/.claude/worktrees/secrets-interactive-cli`). All paths below are relative to `ZB.MOM.WW.Secrets/` inside that worktree. Run all commands from that `ZB.MOM.WW.Secrets/` directory.
+
+**Goal:** Menu-driven Spectre.Console TUI inside the existing `secret` CLI for deployment secret-seeding and lockout recovery, launched by no-args-in-a-TTY; all headless verbs unchanged.
+
+**Architecture:** New `Interactive/` layer in `src/ZB.MOM.WW.Secrets.Cli`: UI-free services (`TargetConfigReader`, `SecretsSessionFactory`, `ReferenceAuditor`, `KekDoctor`, `BundleService`, `RecentTargetsStore`) + an `InteractiveShell` that owns `IAnsiConsole` and dispatches to `IInteractiveFlow` implementations (one file per flow, so flows parallelize). Sessions are built per target by constructing the store stack directly (SQLite or SQL-Server) — no per-target DI host. Degraded mode when the KEK is unavailable in the CLI's shell.
+
+**Tech Stack:** .NET 10, Spectre.Console (+ Spectre.Console.Testing), xunit 2.9.3, existing core seams (`ISecretStore`, `ISecretCipher`, `IMasterKeyProvider`, `KekRotationService`, `SecretLastWriterWins`).
+
+**Global rules for every task:**
+- TDD: write the failing test first, see it fail, implement, see it pass, commit.
+- Test command: `dotnet test ZB.MOM.WW.Secrets.slnx` (fully offline; must end 0-warning — the repo treats warnings as errors in CI posture).
+- Secret values must NEVER appear in recents JSON, bundles, logs, or non-reveal screen output. XML-doc every public member (family CommentChecker convention).
+- Match surrounding code style: file-scoped namespaces, `sealed` classes, records for data, `ConfigureAwait(false)` in library-ish code.
+
+---
+
+### Task 1: Package + project wiring
+
+**Classification:** trivial
+**Estimated implement time:** ~3 min
+**Parallelizable with:** none (everything depends on it)
+
+**Files:**
+- Modify: `Directory.Packages.props`
+- Modify: `src/ZB.MOM.WW.Secrets.Cli/ZB.MOM.WW.Secrets.Cli.csproj`
+- Modify: `tests/ZB.MOM.WW.Secrets.Tests/ZB.MOM.WW.Secrets.Tests.csproj`
+
+**Step 1:** Add to `Directory.Packages.props` (new `` group next to the Test group):
+
+```xml
+
+
+```
+
+(If restore says 0.50.0 doesn't exist on the feed, use the latest stable `dotnet package search Spectre.Console` reports and keep both packages on the same version.)
+
+**Step 2:** In the Cli csproj add `` to its ItemGroup and a ProjectReference to `..\ZB.MOM.WW.Secrets.Replicator.SqlServer\ZB.MOM.WW.Secrets.Replicator.SqlServer.csproj` (SQL-hub targets like ScadaBridge central).
+
+**Step 3:** In the Tests csproj add ``.
+
+**Step 4:** Run: `dotnet build ZB.MOM.WW.Secrets.slnx` → Expected: Build succeeded, 0 warnings.
+
+**Step 5:** Commit: `git add -A && git commit -m "feat(secrets-cli): wire Spectre.Console + SqlServer store refs for interactive console"`
+
+---
+
+### Task 2: RecentTargetsStore
+
+**Classification:** small
+**Estimated implement time:** ~4 min
+**Parallelizable with:** Task 3
+
+**Files:**
+- Create: `src/ZB.MOM.WW.Secrets.Cli/Interactive/RecentTargetsStore.cs`
+- Test: `tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/RecentTargetsStoreTests.cs`
+
+**Step 1 (failing tests):** `RecentTargetsStoreTests` against a temp file path (constructor takes the JSON file path; production default `Path.Combine(Environment.GetFolderPath(SpecialFolder.UserProfile), ".zb-secrets", "recent-targets.json")` supplied by the caller, tests pass a temp path):
+- `Load_returns_empty_when_file_missing`
+- `Touch_then_load_roundtrips_name_and_path`
+- `Touch_existing_path_moves_it_first_and_updates_stamp` (inject a `TimeProvider`)
+- `Load_tolerates_corrupt_json_by_returning_empty`
+- `Touch_caps_list_at_ten_entries`
+- `Saved_json_contains_no_key_material_fields` (serialize, assert raw text has only `name`/`appSettingsPath`/`lastUsedUtc` properties)
+
+**Step 2:** Run `dotnet test ZB.MOM.WW.Secrets.slnx --filter "FullyQualifiedName~RecentTargetsStoreTests"` → FAIL (type missing).
+
+**Step 3 (implement):**
+
+```csharp
+/// Persists the operator's recently-used store targets (names + appsettings paths ONLY — never key material).
+public sealed class RecentTargetsStore
+{
+ public sealed record RecentTarget(string Name, string AppSettingsPath, DateTimeOffset LastUsedUtc);
+
+ public RecentTargetsStore(string filePath, TimeProvider? timeProvider = null) { ... }
+ public IReadOnlyList Load() { ... } // missing/corrupt file => []
+ public void Touch(string name, string appSettingsPath) { ... } // upsert by path, newest-first, cap 10, CreateDirectory, atomic write (temp+move)
+}
+```
+
+**Step 4:** Re-run the filter → PASS. **Step 5:** Commit `feat(secrets-cli): recent-targets store for the interactive console`.
+
+---
+
+### Task 3: TargetConfigReader + StoreTarget
+
+**Classification:** standard
+**Estimated implement time:** ~5 min
+**Parallelizable with:** Task 2
+
+**Files:**
+- Create: `src/ZB.MOM.WW.Secrets.Cli/Interactive/StoreTarget.cs`
+- Create: `src/ZB.MOM.WW.Secrets.Cli/Interactive/TargetConfigReader.cs`
+- Read for reference: `src/ZB.MOM.WW.Secrets/SecretsOptions.cs`, `src/ZB.MOM.WW.Secrets.Replicator.SqlServer/SqlServerSecretsOptions.cs`
+- Test: `tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/TargetConfigReaderTests.cs`
+
+**Step 1 (failing tests):** temp dir fixtures writing real `appsettings.json` files:
+- `Reads_secrets_section_and_binds_options` (SqlitePath, MasterKey source/env-var)
+- `Environment_overlay_wins_over_base` (`appsettings.Production.json` overrides SqlitePath when `environment: "Production"` passed)
+- `Binds_sqlserver_options_when_section_present` (`Secrets:SqlServer:ConnectionString`)
+- `SqlitePath_is_resolved_relative_to_the_app_directory` (relative `secrets.db` → absolute under the appsettings dir — the app's CWD at runtime; this is the honest-store guarantee)
+- `Missing_file_throws_FileNotFoundException_with_path`
+- `Custom_section_path_is_honored` (e.g. `MySecrets`)
+
+**Step 2:** Run filter `TargetConfigReaderTests` → FAIL.
+
+**Step 3 (implement):**
+
+```csharp
+/// A resolved deployment store target: the app's composed Secrets options + where they came from.
+public sealed record StoreTarget(
+ string Name, // display name (defaults to appsettings parent dir name)
+ string? AppSettingsPath, // null for manual-entry targets
+ SecretsOptions Secrets, // SqlitePath already made absolute
+ SqlServerSecretsOptions? SqlServer,
+ IConfigurationRoot Configuration); // full composed config, consumed by ReferenceAuditor
+
+public sealed class TargetConfigReader
+{
+ /// Loads the target app's config exactly the way the app would (base json + optional env overlay + env vars).
+ public StoreTarget Read(string appSettingsPath, string? environment = null, string sectionPath = "Secrets") { ... }
+ /// Builds a manual target from explicit db path + master-key options (no appsettings).
+ public StoreTarget Manual(string sqlitePath, MasterKeyOptions masterKey) { ... }
+}
+```
+
+`Read` uses `new ConfigurationBuilder().SetBasePath(dir).AddJsonFile(fileName).AddJsonFile($"appsettings.{environment}.json", optional: true).AddEnvironmentVariables().Build()`, binds `SecretsOptions` from `sectionPath`, binds `SqlServerSecretsOptions` from `sectionPath + ":SqlServer"` only when that section has children, and absolutizes `SqlitePath` against `dir`.
+
+**Step 4:** filter → PASS. **Step 5:** Commit `feat(secrets-cli): target config reader — operate on the app's own composed Secrets config`.
+
+---
+
+### Task 4: SecretsSession + SecretsSessionFactory + LiteralMasterKeyProvider
+
+**Classification:** high-risk (crypto/KEK wiring; degraded-mode correctness is the lockout path)
+**Estimated implement time:** ~5 min
+**Parallelizable with:** none (Tasks 5-12 depend on it)
+
+**Files:**
+- Create: `src/ZB.MOM.WW.Secrets.Cli/Interactive/SecretsSession.cs`
+- Create: `src/ZB.MOM.WW.Secrets.Cli/Interactive/SecretsSessionFactory.cs`
+- Create: `src/ZB.MOM.WW.Secrets.Cli/Interactive/LiteralMasterKeyProvider.cs`
+- Read for reference: `src/ZB.MOM.WW.Secrets/MasterKey/MasterKeyProviderBase.cs` (KekId derivation to mirror), `src/ZB.MOM.WW.Secrets/MasterKey/MasterKeyProviderFactory.cs`, `src/ZB.MOM.WW.Secrets.Replicator.SqlServer/DependencyInjection/` (how SqlServer store + migrator are constructed), `src/ZB.MOM.WW.Secrets/DependencyInjection/SecretsServiceCollectionExtensions.cs`
+- Test: `tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/SecretsSessionFactoryTests.cs`, `.../LiteralMasterKeyProviderTests.cs`
+
+**Step 1 (failing tests):**
+- `LiteralMasterKeyProvider`: `Accepts_base64_32_byte_key`, `Rejects_wrong_length`, `Derives_same_kekid_as_environment_provider_for_same_material` (set a temp env var, compare `KekId` — pins the sha256 derivation match so rewraps interoperate), `Explicit_kekid_wins`.
+- `SecretsSessionFactory` (temp SQLite targets):
+ - `Opens_full_session_when_env_kek_present` (`KekAvailable == true`, cipher + store usable, schema migrated — assert a `set`-style upsert roundtrips)
+ - `Opens_degraded_session_when_env_var_unset` (`KekAvailable == false`, `Store` still lists, `Cipher` is null)
+ - `Override_kek_upgrades_degraded_session` (create with `LiteralMasterKeyProvider` override → full)
+ - `SqlServer_target_with_unresolved_secret_ref_connstr_falls_back_to_sqlite_with_warning` (connstr containing `${secret:` → `session.Warnings` non-empty, store is the SQLite one)
+
+**Step 2:** filter `SecretsSessionFactoryTests|LiteralMasterKeyProviderTests` → FAIL.
+
+**Step 3 (implement):**
+
+```csharp
+/// An open store session for one target. Degraded when no KEK is available (metadata ops only).
+public sealed class SecretsSession
+{
+ public required StoreTarget Target { get; init; }
+ public required ISecretStore Store { get; init; }
+ public required ISecretsStoreMigrator Migrator { get; init; }
+ public IMasterKeyProvider? MasterKey { get; init; } // null => degraded
+ public ISecretCipher? Cipher { get; init; } // null => degraded
+ public required string StoreKind { get; init; } // "sqlite" | "sqlserver"
+ public IReadOnlyList Warnings { get; init; } = [];
+ public bool KekAvailable => Cipher is not null;
+}
+
+public sealed class SecretsSessionFactory
+{
+ /// Builds the store stack for a target. KEK resolution failure yields a DEGRADED session, never a throw.
+ public async Task OpenAsync(StoreTarget target, IMasterKeyProvider? overrideKek, CancellationToken ct) { ... }
+}
+```
+
+Factory logic: pick SqlServer store when `target.SqlServer` is set AND its `ConnectionString` doesn't contain `${secret:` (else warn + SQLite fallback); construct exactly what the DI extensions construct (`SecretsSqliteConnectionFactory`/`SqliteSecretStore`/`SqliteSecretsStoreMigrator`, or the SqlServer equivalents); KEK = `overrideKek ?? try MasterKeyProviderFactory.Create(...)` — wrap creation AND a probe call (compute `KekId`) in try/catch, catching only the provider's documented failure exceptions; on failure record the reason in `Warnings` and open degraded. Always `await migrator.MigrateAsync(ct)` before returning. `LiteralMasterKeyProvider` holds the 32 raw bytes pasted by the operator, mirrors the base class's derived-KekId scheme.
+
+**Step 4:** filter → PASS. **Step 5:** Commit `feat(secrets-cli): per-target session factory with degraded-KEK mode + literal KEK provider`.
+
+---
+
+### Task 5: ReferenceAuditor
+
+**Classification:** standard
+**Estimated implement time:** ~4 min
+**Parallelizable with:** Task 6, Task 7
+
+**Files:**
+- Create: `src/ZB.MOM.WW.Secrets.Cli/Interactive/ReferenceAuditor.cs`
+- Read for reference: `src/ZB.MOM.WW.Secrets/Configuration/SecretReferenceExpander.cs` (token regex — reuse the exact pattern `\$\{secret:([^}]+)\}`)
+- Test: `tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/ReferenceAuditorTests.cs`
+
+**Step 1 (failing tests):** in-memory `IConfigurationRoot` + temp SQLite session:
+- `Finds_all_distinct_secret_references_with_their_config_paths` (two keys referencing the same name → one finding, two paths)
+- `Classifies_present_decryptable_secret_as_Ok`
+- `Classifies_absent_secret_as_Missing`
+- `Classifies_tombstoned_secret_as_Tombstoned`
+- `Classifies_wrong_kek_row_as_Undecryptable` (seed row via a cipher on KEK-A, audit with session on KEK-B)
+- `Degraded_session_reports_PresenceOnly_status` (KEK unavailable → Ok becomes `PresentUnverified`)
+
+**Step 2:** filter → FAIL. **Step 3 (implement):**
+
+```csharp
+public enum ReferenceStatus { Ok, PresentUnverified, Missing, Tombstoned, Undecryptable }
+public sealed record ReferenceFinding(string SecretName, ReferenceStatus Status, IReadOnlyList ConfigPaths);
+
+/// Scans a target app's composed configuration for ${secret:NAME} tokens and checks each against the store.
+public sealed class ReferenceAuditor
+{
+ public async Task> AuditAsync(SecretsSession session, CancellationToken ct) { ... }
+}
+```
+
+Walk `session.Target.Configuration.AsEnumerable()` values with the regex; for each distinct name: `store.GetAsync` → null ⇒ Missing; `IsDeleted` ⇒ Tombstoned; degraded ⇒ PresentUnverified; else `cipher.Decrypt` in try/catch(`SecretDecryptionException`) ⇒ Ok/Undecryptable (discard plaintext immediately; never store it in the finding).
+
+**Step 4:** PASS. **Step 5:** Commit `feat(secrets-cli): reference auditor — classify every ${secret:} the target app needs`.
+
+---
+
+### Task 6: KekDoctor
+
+**Classification:** high-risk (drives rewrap remediation)
+**Estimated implement time:** ~4 min
+**Parallelizable with:** Task 5, Task 7
+
+**Files:**
+- Create: `src/ZB.MOM.WW.Secrets.Cli/Interactive/KekDoctor.cs`
+- Read for reference: `src/ZB.MOM.WW.Secrets/Rotation/KekRotationService.cs`, `src/ZB.MOM.WW.Secrets/Rotation/RewrapReport.cs`
+- Test: `tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/KekDoctorTests.cs`
+
+**Step 1 (failing tests):** temp store seeded under KEK-A and KEK-B:
+- `Diagnose_reports_ok_rows_under_session_kek`
+- `Diagnose_reports_wrong_kek_rows_with_their_kekid` (session on B, rows on A → `WrongKek`, foreign KekId surfaced so the operator knows which key to hunt for)
+- `Diagnose_reports_corrupt_row_when_kekid_matches_but_unwrap_fails` (tamper `WrapTag` byte)
+- `Diagnose_throws_on_degraded_session` (`InvalidOperationException` — doctor needs a KEK)
+- `RewrapAll_moves_wrong_kek_rows_onto_session_kek` (delegate to `KekRotationService`; assert report counts + rows now decrypt)
+
+**Step 2:** FAIL. **Step 3 (implement):**
+
+```csharp
+public enum RowKekStatus { Ok, WrongKek, Corrupt }
+public sealed record KekDiagnosis(string SecretName, RowKekStatus Status, string RowKekId);
+public sealed record KekDoctorReport(string SessionKekId, IReadOnlyList Rows)
+{ public bool Healthy => Rows.All(r => r.Status == RowKekStatus.Ok); }
+
+/// Lockout triage: verifies the session KEK actually opens every stored row and drives the rewrap remedy.
+public sealed class KekDoctor
+{
+ public async Task DiagnoseAsync(SecretsSession session, CancellationToken ct) { ... }
+ public Task RewrapAllAsync(SecretsSession session, IMasterKeyProvider oldKek, CancellationToken ct)
+ => new KekRotationService(session.Store, session.Cipher!).RewrapAllAsync(oldKek, session.MasterKey!, ct);
+}
+```
+
+Diagnosis probe: KekId mismatch ⇒ WrongKek (no decrypt attempt); match ⇒ `cipher.Decrypt` try/catch ⇒ Ok/Corrupt. Include tombstoned rows (they still block rewrap-all).
+
+**Step 4:** PASS. **Step 5:** Commit `feat(secrets-cli): KEK doctor — per-row diagnosis + guided rewrap remedy`.
+
+---
+
+### Task 7: SecretBundle export/import
+
+**Classification:** high-risk (data movement between stores, LWW + cross-KEK rewrap)
+**Estimated implement time:** ~5 min
+**Parallelizable with:** Task 5, Task 6
+
+**Files:**
+- Create: `src/ZB.MOM.WW.Secrets.Cli/Interactive/SecretBundle.cs` (DTOs + codec)
+- Create: `src/ZB.MOM.WW.Secrets.Cli/Interactive/BundleService.cs`
+- Read for reference: `src/ZB.MOM.WW.Secrets.Abstractions/SecretLastWriterWins.cs`, `src/ZB.MOM.WW.Secrets.Abstractions/StoredSecret.cs`
+- Test: `tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/BundleServiceTests.cs`
+
+**Step 1 (failing tests):**
+- `Export_then_import_into_empty_store_roundtrips_rows_byte_identical`
+- `Bundle_json_contains_no_plaintext` (export a known value; assert raw file text doesn't contain it)
+- `Import_conflict_resolved_by_last_writer_wins` (newer local row survives; newer bundle row replaces)
+- `Import_conflict_callback_can_force_bundle_row` (per-row override delegate)
+- `Cross_kek_import_rewraps_when_source_kek_supplied` (bundle from KEK-A into session on KEK-B + `oldKek` → rows land on B and decrypt)
+- `Cross_kek_import_without_source_kek_skips_rows_and_reports_them`
+
+**Step 2:** FAIL. **Step 3 (implement):** DTO `SecretBundle { int FormatVersion = 1; DateTimeOffset ExportedUtc; string SourceKekId; List Entries; }` where `BundleEntry` mirrors every `StoredSecret` field (byte[] as base64, `SecretName` as string). `BundleService`:
+
+```csharp
+public sealed class BundleService
+{
+ public async Task ExportAsync(SecretsSession session, string path, bool includeDeleted, CancellationToken ct) { ... }
+ public async Task ImportAsync(
+ SecretsSession session, string path, IMasterKeyProvider? sourceKek,
+ Func? conflictOverride,
+ CancellationToken ct) { ... }
+}
+public sealed record BundleImportReport(int Imported, int SkippedOlder, int SkippedForeignKek, int Conflicts);
+```
+
+Import per entry: rewrap via `session.Cipher.Rewrap(row, sourceKek, session.MasterKey)` when `row.KekId != session.MasterKey.KekId` and `sourceKek` matches, else count `SkippedForeignKek`; conflict when the store already has the name → default `SecretLastWriterWins`, `conflictOverride` (when supplied) decides instead; upsert winners. `ExportedUtc` from an injected `TimeProvider`.
+
+**Step 4:** PASS. **Step 5:** Commit `feat(secrets-cli): ciphertext-only bundle export/import with LWW + cross-KEK rewrap`.
+
+---
+
+### Task 8: InteractiveShell skeleton + IInteractiveFlow + target picker
+
+**Classification:** standard
+**Estimated implement time:** ~5 min
+**Parallelizable with:** none (Tasks 9-12 depend on the flow seam)
+
+**Files:**
+- Create: `src/ZB.MOM.WW.Secrets.Cli/Interactive/IInteractiveFlow.cs`
+- Create: `src/ZB.MOM.WW.Secrets.Cli/Interactive/InteractiveShell.cs`
+- Test: `tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/InteractiveShellTests.cs`
+
+**Step 1 (failing tests):** with `Spectre.Console.Testing.TestConsole` (`Interactive()` profile, `PushKey`/`PushTextWithEnter` scripted input) and a fake flow:
+- `Shell_shows_target_picker_then_menu_and_quits` (recents + manual entry path; select Quit → returns 0)
+- `Menu_lists_registered_flows_by_title_plus_builtins` (Switch target, Quit)
+- `Degraded_session_flow_requiring_kek_prompts_for_key_first` (fake flow `RequiresKek = true`, degraded session → shell asks masked key/file prompt, upgrades session via factory override, then runs the flow)
+- `Flow_exception_renders_error_panel_and_returns_to_menu` (fake flow throws `SecretDecryptionException` → output contains remedy hint text `KEK doctor`, shell loops, next Quit exits cleanly)
+- `Ctrl_c_at_menu_exits_zero`
+
+**Step 2:** FAIL. **Step 3 (implement):**
+
+```csharp
+/// One menu action of the interactive console. Implementations are UI-owning but store-logic-free.
+public interface IInteractiveFlow
+{
+ string Title { get; }
+ bool RequiresKek { get; }
+ Task RunAsync(IAnsiConsole console, SecretsSession session, CancellationToken ct);
+}
+
+/// The menu loop: target selection, session lifecycle, flow dispatch, error containment.
+public sealed class InteractiveShell
+{
+ public InteractiveShell(IAnsiConsole console, RecentTargetsStore recents, TargetConfigReader reader,
+ SecretsSessionFactory sessions, IReadOnlyList flows) { ... }
+ public async Task RunAsync(CancellationToken ct) { ... }
+}
+```
+
+Header panel per loop: target name, store kind, `KEK: OK` / `KEK: UNAVAILABLE (degraded)`. Menu = flows' titles + `Switch store target` + `Quit`. Degraded + `RequiresKek` ⇒ prompt (choice: base64 paste via `TextPrompt().Secret()`, or key-file path) → `LiteralMasterKeyProvider`/file provider → reopen session with override. Every flow call wrapped in try/catch rendering `new Panel(...)` in red with the remedy hint; loop continues.
+
+**Step 4:** PASS. **Step 5:** Commit `feat(secrets-cli): interactive shell skeleton — target picker, degraded-KEK upgrade, flow dispatch`.
+
+---
+
+### Task 9: CRUD flows (list / set-rotate / reveal / delete)
+
+**Classification:** standard
+**Estimated implement time:** ~5 min
+**Parallelizable with:** Task 10, Task 11, Task 12
+
+**Files:**
+- Create: `src/ZB.MOM.WW.Secrets.Cli/Interactive/Flows/ListSecretsFlow.cs`
+- Create: `src/ZB.MOM.WW.Secrets.Cli/Interactive/Flows/SetSecretFlow.cs`
+- Create: `src/ZB.MOM.WW.Secrets.Cli/Interactive/Flows/RevealSecretFlow.cs`
+- Create: `src/ZB.MOM.WW.Secrets.Cli/Interactive/Flows/DeleteSecretFlow.cs`
+- Test: `tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/Flows/CrudFlowTests.cs`
+
+**Step 1 (failing tests):** each flow run against a TestConsole + real temp-SQLite session:
+- List: `Renders_metadata_table_never_values` (name/contentType/kekId/revision/updated columns; `RequiresKek == false`)
+- Set: `Masked_prompt_seals_and_upserts` (typed value lands decryptable; typed value NOT in console output), `Existing_name_confirms_overwrite`
+- Reveal: `Confirm_then_prints_value_once`, `Decline_prints_nothing`
+- Delete: `Confirm_tombstones_row`, `Decline_leaves_row`
+
+**Step 2:** FAIL. **Step 3:** Implement the four flows (each ~40 lines): name via `TextPrompt` (validated through `SecretName`), value via `.Secret()`, content type via `SelectionPrompt`, description optional; seal with `session.Cipher.Encrypt` + actor `Environment.UserName` (same stamps as `SecretCommands.UpsertSealedAsync`). Reveal decrypts via `session.Cipher.Decrypt(store.GetAsync(...))` — no resolver, no cache.
+
+**Step 4:** PASS. **Step 5:** Commit `feat(secrets-cli): CRUD flows for the interactive console`.
+
+---
+
+### Task 10: Reference-audit + seeding flow
+
+**Classification:** standard
+**Estimated implement time:** ~4 min
+**Parallelizable with:** Task 9, Task 11, Task 12
+
+**Files:**
+- Create: `src/ZB.MOM.WW.Secrets.Cli/Interactive/Flows/ReferenceAuditFlow.cs`
+- Test: `tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/Flows/ReferenceAuditFlowTests.cs`
+
+**Step 1 (failing tests):** TestConsole + session whose target config references three secrets (one Ok, one Missing, one Tombstoned):
+- `Renders_status_table_for_all_references`
+- `Offers_to_seed_each_non_ok_reference_and_seeds_accepted_ones` (masked prompts; afterwards re-audit reports all Ok)
+- `Skipped_references_are_left_untouched`
+- `Manual_target_without_appsettings_reports_nothing_to_audit`
+
+**Step 2:** FAIL. **Step 3:** Implement: run `ReferenceAuditor.AuditAsync`, render table (status color-coded: green Ok / yellow PresentUnverified / red Missing/Tombstoned/Undecryptable + the config paths), then for each non-Ok finding `ConfirmationPrompt("Seed '' now?")` → masked value + content-type prompts → seal + upsert (tombstoned: upsert revives), finish with a re-audit summary line. `RequiresKek == true`.
+
+**Step 4:** PASS. **Step 5:** Commit `feat(secrets-cli): deployment seeding flow — audit ${secret:} refs and fill the gaps`.
+
+---
+
+### Task 11: KEK-doctor flow
+
+**Classification:** high-risk
+**Estimated implement time:** ~4 min
+**Parallelizable with:** Task 9, Task 10, Task 12
+
+**Files:**
+- Create: `src/ZB.MOM.WW.Secrets.Cli/Interactive/Flows/KekDoctorFlow.cs`
+- Test: `tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/Flows/KekDoctorFlowTests.cs`
+
+**Step 1 (failing tests):**
+- `Healthy_store_renders_all_ok_summary`
+- `Wrong_kek_rows_offer_rewrap_and_rewrap_succeeds` (script: choose "Rewrap from old KEK", choose env-var source pointing at KEK-A, confirm → rows decrypt under session KEK; output shows report counts)
+- `Rewrap_requires_explicit_confirmation` (decline → store untouched)
+- `Lost_kek_path_offers_reset_of_affected_secrets` (choose "Old KEK is lost" → per-row confirm+masked re-set; declined rows stay wrong-KEK)
+
+**Step 2:** FAIL. **Step 3:** Implement: `DiagnoseAsync` → summary table (session KekId, per-status counts, the foreign KekIds seen); if unhealthy, `SelectionPrompt`: *Rewrap from old KEK* (old key via env-var name / file path / masked base64 → provider; `ConfirmationPrompt` showing exactly what will happen; then `KekDoctor.RewrapAllAsync`, render `RewrapReport` counts) / *Old KEK is lost — re-set affected secrets* (per wrong-KEK row: confirm + masked new value → seal under session KEK, overwriting) / *Back*. `RequiresKek == true`.
+
+**Step 4:** PASS. **Step 5:** Commit `feat(secrets-cli): lockout-recovery flow — KEK diagnosis, guided rewrap, lost-KEK reset`.
+
+---
+
+### Task 12: Bundle flows (export / import)
+
+**Classification:** standard
+**Estimated implement time:** ~4 min
+**Parallelizable with:** Task 9, Task 10, Task 11
+
+**Files:**
+- Create: `src/ZB.MOM.WW.Secrets.Cli/Interactive/Flows/BundleExportFlow.cs`
+- Create: `src/ZB.MOM.WW.Secrets.Cli/Interactive/Flows/BundleImportFlow.cs`
+- Test: `tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/Flows/BundleFlowTests.cs`
+
+**Step 1 (failing tests):**
+- `Export_prompts_for_path_and_reports_row_count`
+- `Import_reports_counts_and_applies_lww`
+- `Import_conflict_prompt_lets_operator_pick_per_row` (script both choices)
+- `Import_foreign_kek_prompts_for_source_key_then_rewraps`
+
+**Step 2:** FAIL. **Step 3:** Implement over `BundleService`; export path prompt defaults to `secrets-bundle--.json`; import wires the flow's per-row `ConfirmationPrompt` into `conflictOverride` and, when `SkippedForeignKek` would be non-zero (bundle `SourceKekId` ≠ session KekId), first offers the source-key prompt (env/file/masked). Render final `BundleImportReport` as a table.
+
+**Step 4:** PASS. **Step 5:** Commit `feat(secrets-cli): bundle export/import flows`.
+
+---
+
+### Task 13: Program.cs entry point + non-TTY pin
+
+**Classification:** small
+**Estimated implement time:** ~4 min
+**Parallelizable with:** none (integrates everything)
+
+**Files:**
+- Modify: `src/ZB.MOM.WW.Secrets.Cli/Program.cs` (ONLY the `args.Length == 0` branch + composition)
+- Create: `src/ZB.MOM.WW.Secrets.Cli/Interactive/InteractiveEntry.cs`
+- Test: `tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/InteractiveEntryTests.cs`
+
+**Step 1 (failing tests):**
+- `ShouldEnterInteractive_true_only_for_no_args_and_real_tty` (pure static: `(args, inputRedirected, outputRedirected)` truth table — pins that any redirection keeps usage/exit-2)
+- `CreateShell_composes_all_five_flows_plus_builtins` (flow titles present exactly once: List, Set/rotate, Reveal, Delete, Reference audit & seed, KEK doctor, Bundle export, Bundle import)
+
+**Step 2:** FAIL. **Step 3:** `InteractiveEntry` exposes `ShouldEnterInteractive(...)` and `CreateShell(IAnsiConsole console)` (news up recents store at `~/.zb-secrets/recent-targets.json`, reader, factory, all flows). `Program.cs`: replace the bare `return Usage();` with
+
+```csharp
+if (args.Length == 0)
+{
+ if (InteractiveEntry.ShouldEnterInteractive(args, Console.IsInputRedirected, Console.IsOutputRedirected))
+ return await InteractiveEntry.CreateShell(AnsiConsole.Console).RunAsync(ct);
+ return Usage();
+}
+```
+
+(The interactive path builds its own sessions per target, so move the host-based migration call so it only runs for the headless verbs — verify with the existing headless tests.)
+
+**Step 4:** `dotnet test ZB.MOM.WW.Secrets.slnx` (FULL suite — protects the headless verbs) → all pass. Manual smoke: `dotnet run --project src/ZB.MOM.WW.Secrets.Cli < /dev/null` → usage, exit 2. **Step 5:** Commit `feat(secrets-cli): launch interactive console on bare secret in a TTY`.
+
+---
+
+### Task 14: Version bump, docs, final green run
+
+**Classification:** small
+**Estimated implement time:** ~4 min
+**Parallelizable with:** none
+
+**Files:**
+- Modify: `Directory.Build.props` (`` 0.2.3 → 0.3.0)
+- Modify: `README.md` (CLI section: interactive console paragraph + screenshot-style text sample)
+- Create: `docs/operations/interactive-console.md` (operator runbook: launching, deployment-seeding walk-through, lockout playbook incl. degraded mode + rewrap, bundle notes; mirror the tone of `docs/operations/kek-rotation.md`)
+
+**Step 1:** Make the edits. **Step 2:** `dotnet build ZB.MOM.WW.Secrets.slnx` → 0 warnings; `dotnet test ZB.MOM.WW.Secrets.slnx` → full suite green. **Step 3:** Commit `docs(secrets): interactive console runbook + 0.3.0 version bump`.
+
+**Step 4 (handoff, not merge):** Do NOT merge to `main` from inside this task — another agent is active on `main`. Leave the worktree branch `worktree-secrets-interactive-cli` fully committed; merging/publishing is a separate user-decision step (family convention: publish the 5 packages + app bumps only on explicit rollout).
+
+---
+
+## Dependency graph
+
+```
+1 ─┬─ 2 ─┐
+ └─ 3 ─┴─ 4 ─┬─ 5 ─┐
+ ├─ 6 ─┤
+ ├─ 7 ─┤
+ └─ 8 ─┼─ 9 ──┐
+ ├─ 10 ─┤
+ ├─ 11 ─┼─ 13 ── 14
+ └─ 12 ─┘
+```
+(9-12 also depend on their service tasks: 10→5, 11→6, 12→7.)
diff --git a/docs/plans/2026-07-19-secrets-interactive-cli.md.tasks.json b/docs/plans/2026-07-19-secrets-interactive-cli.md.tasks.json
new file mode 100644
index 0000000..74008ab
--- /dev/null
+++ b/docs/plans/2026-07-19-secrets-interactive-cli.md.tasks.json
@@ -0,0 +1,20 @@
+{
+ "planPath": "docs/plans/2026-07-19-secrets-interactive-cli.md",
+ "tasks": [
+ {"id": 1, "subject": "Task 1: Package + project wiring", "status": "pending"},
+ {"id": 2, "subject": "Task 2: RecentTargetsStore", "status": "pending", "blockedBy": [1]},
+ {"id": 3, "subject": "Task 3: TargetConfigReader + StoreTarget", "status": "pending", "blockedBy": [1]},
+ {"id": 4, "subject": "Task 4: SecretsSession + SecretsSessionFactory + LiteralMasterKeyProvider", "status": "pending", "blockedBy": [2, 3]},
+ {"id": 5, "subject": "Task 5: ReferenceAuditor", "status": "pending", "blockedBy": [4]},
+ {"id": 6, "subject": "Task 6: KekDoctor", "status": "pending", "blockedBy": [4]},
+ {"id": 7, "subject": "Task 7: SecretBundle export/import", "status": "pending", "blockedBy": [4]},
+ {"id": 8, "subject": "Task 8: InteractiveShell skeleton + IInteractiveFlow + target picker", "status": "pending", "blockedBy": [4]},
+ {"id": 9, "subject": "Task 9: CRUD flows (list / set-rotate / reveal / delete)", "status": "pending", "blockedBy": [8]},
+ {"id": 10, "subject": "Task 10: Reference-audit + seeding flow", "status": "pending", "blockedBy": [5, 8]},
+ {"id": 11, "subject": "Task 11: KEK-doctor flow", "status": "pending", "blockedBy": [6, 8]},
+ {"id": 12, "subject": "Task 12: Bundle flows (export / import)", "status": "pending", "blockedBy": [7, 8]},
+ {"id": 13, "subject": "Task 13: Program.cs entry point + non-TTY pin", "status": "pending", "blockedBy": [9, 10, 11, 12]},
+ {"id": 14, "subject": "Task 14: Version bump, docs, final green run", "status": "pending", "blockedBy": [13]}
+ ],
+ "lastUpdated": "2026-07-19T00:00:00Z"
+}