Merge branch 'worktree-secrets-interactive-cli'

This commit is contained in:
Joseph Doherty
2026-07-19 11:27:50 -04:00
48 changed files with 6453 additions and 2 deletions
+1 -1
View File
@@ -5,7 +5,7 @@
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion> <LangVersion>latest</LangVersion>
<Version>0.2.3</Version> <Version>0.3.0</Version>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally> <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<PackageReadmeFile>README.md</PackageReadmeFile> <PackageReadmeFile>README.md</PackageReadmeFile>
</PropertyGroup> </PropertyGroup>
@@ -57,6 +57,10 @@
<PackageVersion Include="ZB.MOM.WW.Theme" Version="0.2.0" /> <PackageVersion Include="ZB.MOM.WW.Theme" Version="0.2.0" />
<PackageVersion Include="ZB.MOM.WW.Auth.AspNetCore" Version="0.1.4" /> <PackageVersion Include="ZB.MOM.WW.Auth.AspNetCore" Version="0.1.4" />
<!-- Interactive console -->
<PackageVersion Include="Spectre.Console" Version="0.50.0" />
<PackageVersion Include="Spectre.Console.Testing" Version="0.50.0" />
<!-- Test --> <!-- Test -->
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" /> <PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageVersion Include="xunit" Version="2.9.3" /> <PackageVersion Include="xunit" Version="2.9.3" />
+42 -1
View File
@@ -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). 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.<env>.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 ## How it protects secrets
Each value is encrypted with a fresh **data key (DEK)** using AES-256-GCM; the DEK is then 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. 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 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).
@@ -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 `<name> — <appsettings path>`. 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.<env>.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-<target>-<yyyyMMdd>.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). |
@@ -0,0 +1,237 @@
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.Secrets.Cli.Interactive;
/// <summary>
/// Tally of an <see cref="BundleService.ImportAsync"/> run.
/// </summary>
/// <param name="Imported">Rows written (new inserts plus conflict winners).</param>
/// <param name="SkippedOlder">Rows a last-writer-wins (or override) decision rejected as not newer.</param>
/// <param name="SkippedForeignKek">
/// Rows wrapped under a KEK other than the target's, with no matching source KEK supplied to re-wrap them.
/// </param>
/// <param name="Conflicts">Rows whose name already existed in the target store (won or lost).</param>
public sealed record BundleImportReport(int Imported, int SkippedOlder, int SkippedForeignKek, int Conflicts);
/// <summary>
/// Exports and imports <see cref="SecretBundle"/> 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 <b>full</b> (non-degraded) session: import needs the target cipher to
/// re-wrap, and export is a data-movement operation that must not run half-configured.
/// </summary>
public sealed class BundleService
{
private readonly TimeProvider _timeProvider;
/// <summary>Creates the service.</summary>
/// <param name="timeProvider">Clock used to stamp <see cref="SecretBundle.ExportedUtc"/>; defaults to <see cref="TimeProvider.System"/>.</param>
public BundleService(TimeProvider? timeProvider = null) =>
_timeProvider = timeProvider ?? TimeProvider.System;
/// <summary>
/// Exports every secret in <paramref name="session"/>'s store to a ciphertext-only bundle at
/// <paramref name="path"/>, written atomically (temp file then move). Tombstones are excluded unless
/// <paramref name="includeDeleted"/> is set.
/// </summary>
/// <param name="session">The full session to export from.</param>
/// <param name="path">Destination bundle path.</param>
/// <param name="includeDeleted">When <see langword="true"/>, tombstoned rows are exported too.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>The number of rows written to the bundle.</returns>
/// <exception cref="InvalidOperationException">The session is degraded (no KEK).</exception>
public async Task<int> 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<SecretMetadata> metadata =
await session.Store.ListAsync(includeDeleted, ct).ConfigureAwait(false);
var entries = new List<BundleEntry>(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;
}
/// <summary>
/// Imports the bundle at <paramref name="path"/> into <paramref name="session"/>'s store.
/// </summary>
/// <remarks>
/// <para>
/// Per row: a row wrapped under a foreign KEK is re-wrapped under the target KEK when
/// <paramref name="sourceKek"/> matches its <see cref="StoredSecret.KekId"/>, otherwise it is
/// skipped (<see cref="BundleImportReport.SkippedForeignKek"/>). A row whose name does not yet
/// exist is imported. A row whose name already exists is a conflict resolved by
/// <paramref name="conflictOverride"/> when supplied, else by <see cref="SecretLastWriterWins"/>;
/// the winner is written and the loser skipped (<see cref="BundleImportReport.SkippedOlder"/>).
/// No plaintext is ever handled — the bundle is ciphertext only.
/// </para>
/// <para>
/// Writes go through the store's <see cref="ISecretStore.ApplyReplicatedAsync"/> so the row lands
/// <b>verbatim</b> — 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 <paramref name="conflictOverride"/> that
/// forces the incoming row against the LWW ordering (operator adopts an <em>older</em> row): a
/// verbatim replicate would be silently rejected by the LWW gate, so that single case is written
/// through <see cref="ISecretStore.UpsertAsync"/> 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
/// <paramref name="conflictOverride"/> that forces an <em>older, tombstoned</em> bundle row over a
/// newer live local row resurrects it, because that forced <see cref="ISecretStore.UpsertAsync"/>
/// path clears the tombstone.
/// </para>
/// </remarks>
/// <param name="session">The full session to import into.</param>
/// <param name="path">Source bundle path.</param>
/// <param name="sourceKek">The KEK the bundle was exported under, to re-wrap foreign-KEK rows; may be <see langword="null"/>.</param>
/// <param name="conflictOverride">
/// Optional per-row decision for an existing row: given (existing, incoming), return
/// <see langword="true"/> to take the incoming row. When <see langword="null"/>, last-writer-wins decides.
/// </param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>A tally of the import.</returns>
/// <exception cref="InvalidOperationException">The session is degraded (no KEK/cipher).</exception>
public async Task<BundleImportReport> ImportAsync(
SecretsSession session,
string path,
IMasterKeyProvider? sourceKek,
Func<StoredSecret, StoredSecret, bool>? 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 */ }
}
}
}
}
@@ -0,0 +1,79 @@
using System.Globalization;
using Spectre.Console;
namespace ZB.MOM.WW.Secrets.Cli.Interactive.Flows;
/// <summary>
/// Exports the session's store to a ciphertext-only <see cref="SecretBundle"/> on disk. The operator is
/// offered a default filename (<c>secrets-bundle-&lt;target&gt;-&lt;yyyyMMdd&gt;.json</c>) 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).
/// </summary>
public sealed class BundleExportFlow : IInteractiveFlow
{
private readonly TimeProvider _timeProvider;
/// <summary>Creates the flow.</summary>
/// <param name="timeProvider">
/// Clock used for the default filename's date stamp and the bundle's <see cref="SecretBundle.ExportedUtc"/>
/// timestamp; defaults to <see cref="TimeProvider.System"/>.
/// </param>
public BundleExportFlow(TimeProvider? timeProvider = null) =>
_timeProvider = timeProvider ?? TimeProvider.System;
/// <inheritdoc />
public string Title => "Export bundle (ciphertext-only)";
/// <inheritdoc />
public bool RequiresKek => true;
/// <inheritdoc />
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<string>("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-<target>-<yyyyMMdd>.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);
}
}
@@ -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;
/// <summary>
/// Imports a ciphertext-only <see cref="SecretBundle"/> into the session's store. The bundle is read and
/// parsed <b>before</b> any write so its <see cref="SecretBundle.SourceKekId"/> 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.
/// </summary>
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";
/// <inheritdoc />
public string Title => "Import bundle";
/// <inheritdoc />
public bool RequiresKek => true;
/// <inheritdoc />
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<string>("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<StoredSecret, StoredSecret, bool>? 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<string>()
.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<string>("Paste base64 source KEK:").Secret());
return new LiteralMasterKeyProvider(base64);
case EnvVarLabel:
string envVar = console.Prompt(new TextPrompt<string>("Source-key environment variable name:"));
return MasterKeyProviderFactory.Create(new MasterKeyOptions
{
Source = MasterKeySource.Environment,
EnvVarName = envVar,
});
default:
string filePath = console.Prompt(new TextPrompt<string>("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);
}
}
@@ -0,0 +1,48 @@
using Spectre.Console;
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.Secrets.Cli.Interactive.Flows;
/// <summary>
/// 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.
/// </summary>
public sealed class DeleteSecretFlow : IInteractiveFlow
{
/// <inheritdoc />
public string Title => "Delete a secret";
/// <inheritdoc />
public bool RequiresKek => false;
/// <inheritdoc />
public async Task RunAsync(IAnsiConsole console, SecretsSession session, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(console);
ArgumentNullException.ThrowIfNull(session);
var name = new SecretName(console.Prompt(
new TextPrompt<string>("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)}'.[/]");
}
}
@@ -0,0 +1,40 @@
using Spectre.Console;
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.Secrets.Cli.Interactive.Flows;
/// <summary>
/// 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.
/// </summary>
internal static class FlowPrompts
{
/// <summary>
/// A <see cref="TextPrompt{T}"/> validation callback that accepts a raw name iff it is a legal
/// <see cref="SecretName"/>, otherwise re-prompts with the constructor's own diagnostic. The
/// diagnostic is <see cref="Markup.Escape(string)"/>d because it embeds bracketed text (e.g. the
/// allowed-character class <c>[a-z0-9._/-]</c>) that Spectre would otherwise parse as markup and throw on.
/// </summary>
/// <param name="raw">The raw name entered at the prompt.</param>
/// <returns>Success when <paramref name="raw"/> is a valid name; an escaped error otherwise.</returns>
public static ValidationResult ValidateSecretName(string raw)
{
try
{
_ = new SecretName(raw);
return ValidationResult.Success();
}
catch (ArgumentException ex)
{
return ValidationResult.Error(Markup.Escape(ex.Message));
}
}
/// <summary>
/// The principal recorded as <c>created_by</c>/<c>updated_by</c> for CLI-originated writes — the OS
/// user name, or the literal <c>"cli"</c> when it is unavailable (mirrors <c>Program.cs</c>).
/// </summary>
public static string Actor =>
string.IsNullOrWhiteSpace(Environment.UserName) ? "cli" : Environment.UserName;
}
@@ -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;
/// <summary>
/// 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
/// <see cref="KekDoctor.DiagnoseAsync"/>), then chooses a remedy for the wrong-KEK (and corrupt) rows:
/// <list type="bullet">
/// <item><b>Rewrap from old KEK</b> — 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.</item>
/// <item><b>Old KEK is lost — re-set affected secrets</b> — 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 <see cref="RowKekStatus.Corrupt"/> row (whose body cannot be
/// re-wrapped or preserved).</item>
/// </list>
/// 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.
/// </summary>
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";
/// <inheritdoc />
public string Title => "KEK doctor (lockout recovery)";
/// <inheritdoc />
public bool RequiresKek => true;
/// <inheritdoc />
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<string>()
.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<string> 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<string>()
.Title("Where is the [yellow]old KEK[/]?")
.AddChoices(EnvSource, FileSource, PasteSource));
switch (source)
{
case EnvSource:
string envVar = console.Prompt(new TextPrompt<string>(" Environment variable name:"));
return MasterKeyProviderFactory.Create(new MasterKeyOptions
{
Source = MasterKeySource.Environment,
EnvVarName = envVar,
});
case FileSource:
string path = console.Prompt(new TextPrompt<string>(" Key file path:"));
return MasterKeyProviderFactory.Create(new MasterKeyOptions
{
Source = MasterKeySource.File,
FilePath = path,
});
default:
string base64 = console.Prompt(
new TextPrompt<string>(" 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<KekDiagnosis> 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<string>($" New value for [green]{Markup.Escape(name.Value)}[/]:").Secret());
SecretContentType contentType = console.Prompt(
new SelectionPrompt<SecretContentType>().Title(" Content type").AddChoices(Enum.GetValues<SecretContentType>()));
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<string> 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}")));
}
@@ -0,0 +1,56 @@
using Spectre.Console;
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.Secrets.Cli.Interactive.Flows;
/// <summary>
/// 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 <see cref="SecretMetadata"/> (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.
/// </summary>
public sealed class ListSecretsFlow : IInteractiveFlow
{
/// <inheritdoc />
public string Title => "List secrets";
/// <inheritdoc />
public bool RequiresKek => false;
/// <inheritdoc />
public async Task RunAsync(IAnsiConsole console, SecretsSession session, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(console);
ArgumentNullException.ThrowIfNull(session);
IReadOnlyList<SecretMetadata> 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);
}
}
@@ -0,0 +1,144 @@
using System.Globalization;
using Spectre.Console;
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.Secrets.Cli.Interactive.Flows;
/// <summary>
/// The deployment-setup flow: audits every <c>${secret:NAME}</c> 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 <em>can</em> be seeded
/// (<see cref="ReferenceStatus.Missing"/>, <see cref="ReferenceStatus.Tombstoned"/>, or
/// <see cref="ReferenceStatus.Undecryptable"/>) — 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
/// <see cref="ReferenceStatus.Tombstoned"/> reference revives it: the store's
/// <see cref="ISecretStore.UpsertAsync"/> overwrites the row in place and clears its tombstone. Seeding an
/// <see cref="ReferenceStatus.Undecryptable"/> reference overwrites the unreadable row with a fresh value
/// (the confirm text says so). An <see cref="ReferenceStatus.InvalidName"/> reference cannot be looked up
/// or seeded, so it renders a fix-the-token hint instead of a prompt;
/// <see cref="ReferenceStatus.PresentUnverified"/> 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.
/// </summary>
public sealed class ReferenceAuditFlow : IInteractiveFlow
{
/// <inheritdoc />
public string Title => "Reference audit & seed";
/// <inheritdoc />
public bool RequiresKek => true;
/// <inheritdoc />
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<ReferenceFinding> 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<ReferenceFinding> 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<string>($" Value for [green]{Markup.Escape(name.Value)}[/]:").Secret());
SecretContentType contentType = console.Prompt(
new SelectionPrompt<SecretContentType>().Title(" Content type").AddChoices(Enum.GetValues<SecretContentType>()));
string description = console.Prompt(
new TextPrompt<string>(" 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<ReferenceFinding> 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<ReferenceFinding> findings) =>
string.Join(", ", findings
.GroupBy(f => f.Status)
.OrderBy(g => g.Key)
.Select(g => string.Create(CultureInfo.InvariantCulture, $"{g.Count()} {g.Key}")));
}
@@ -0,0 +1,46 @@
using Spectre.Console;
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.Secrets.Cli.Interactive.Flows;
/// <summary>
/// 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.
/// </summary>
public sealed class RevealSecretFlow : IInteractiveFlow
{
/// <inheritdoc />
public string Title => "Get (reveal) a secret";
/// <inheritdoc />
public bool RequiresKek => true;
/// <inheritdoc />
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<string>("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));
}
}
@@ -0,0 +1,57 @@
using Spectre.Console;
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.Secrets.Cli.Interactive.Flows;
/// <summary>
/// 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).
/// </summary>
public sealed class SetSecretFlow : IInteractiveFlow
{
/// <inheritdoc />
public string Title => "Set / rotate a secret";
/// <inheritdoc />
public bool RequiresKek => true;
/// <inheritdoc />
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<string>("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<string>("Secret [green]value[/]:").Secret());
SecretContentType contentType = console.Prompt(
new SelectionPrompt<SecretContentType>().Title("Content type").AddChoices(Enum.GetValues<SecretContentType>()));
string description = console.Prompt(
new TextPrompt<string>("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}'.[/]");
}
}
@@ -0,0 +1,26 @@
using Spectre.Console;
namespace ZB.MOM.WW.Secrets.Cli.Interactive;
/// <summary>
/// One menu action of the interactive console. Implementations are UI-owning (they render to the
/// supplied <see cref="IAnsiConsole"/>) but store-logic-free — they operate through the seams on the
/// <see cref="SecretsSession"/> the shell hands them and never open stores or resolve keys themselves.
/// </summary>
public interface IInteractiveFlow
{
/// <summary>The menu label shown for this flow in the shell's action list.</summary>
string Title { get; }
/// <summary>
/// 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 <see langword="true"/>.
/// </summary>
bool RequiresKek { get; }
/// <summary>Runs the flow against the current session, rendering its own UI to <paramref name="console"/>.</summary>
/// <param name="console">The console the flow renders to (the shell owns lifecycle; flows only draw).</param>
/// <param name="session">The open session — guaranteed KEK-capable when <see cref="RequiresKek"/> is <see langword="true"/>.</param>
/// <param name="ct">A token to cancel the flow.</param>
Task RunAsync(IAnsiConsole console, SecretsSession session, CancellationToken ct);
}
@@ -0,0 +1,62 @@
using Spectre.Console;
using ZB.MOM.WW.Secrets.Cli.Interactive.Flows;
namespace ZB.MOM.WW.Secrets.Cli.Interactive;
/// <summary>
/// Composition root for the interactive console: the non-TTY gate that decides whether a bare
/// <c>secret</c> invocation launches the console, and the default wiring (flow set, recents store,
/// target reader, session factory) that <see cref="Program"/> hands to an <see cref="InteractiveShell"/>.
/// </summary>
public static class InteractiveEntry
{
/// <summary>
/// Whether a bare <c>secret</c> 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.
/// </summary>
/// <param name="args">The process command-line arguments.</param>
/// <param name="inputRedirected"><see cref="Console.IsInputRedirected"/> at process start.</param>
/// <param name="outputRedirected"><see cref="Console.IsOutputRedirected"/> at process start.</param>
public static bool ShouldEnterInteractive(string[] args, bool inputRedirected, bool outputRedirected)
=> args.Length == 0 && !inputRedirected && !outputRedirected;
/// <summary>
/// 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 <see cref="CreateShell"/>) so the composition
/// is assertable without running the shell.
/// </summary>
public static IReadOnlyList<IInteractiveFlow> CreateFlows() =>
[
new ListSecretsFlow(),
new SetSecretFlow(),
new RevealSecretFlow(),
new DeleteSecretFlow(),
new ReferenceAuditFlow(),
new KekDoctorFlow(),
new BundleExportFlow(),
new BundleImportFlow(),
];
/// <summary>
/// Wires an <see cref="InteractiveShell"/> with its default collaborators: a per-user recents store
/// under <c>~/.zb-secrets/recent-targets.json</c>, the target-config reader, the session factory, and
/// <see cref="CreateFlows"/>. The shell's own ctor re-validates flow-title uniqueness.
/// </summary>
/// <param name="console">The console surface the shell (and its flows) render to.</param>
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());
}
}
@@ -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;
/// <summary>
/// The interactive <c>secret</c> console: pick a store target, open a session, then loop a header +
/// action menu that dispatches to pluggable <see cref="IInteractiveFlow"/>s. This is the ONLY class
/// that touches <see cref="IAnsiConsole"/> — 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.
/// </summary>
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<IInteractiveFlow> _flows;
/// <summary>Creates the shell over its console, recents store, target reader, session factory, and flow set.</summary>
/// <param name="console">The single console surface the shell (and its flows) render to.</param>
/// <param name="recents">Recently-used target store, offered first in the picker and re-stamped on selection.</param>
/// <param name="reader">Composes a target's Secrets configuration from appsettings or manual input.</param>
/// <param name="sessions">Opens (and, on KEK upgrade, re-opens) a <see cref="SecretsSession"/> for a target.</param>
/// <param name="flows">The pluggable menu actions, listed in order above the built-in switch/quit entries.</param>
public InteractiveShell(
IAnsiConsole console,
RecentTargetsStore recents,
TargetConfigReader reader,
SecretsSessionFactory sessions,
IReadOnlyList<IInteractiveFlow> 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<string> 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;
}
/// <summary>Runs the pick-target → menu loop until the operator quits.</summary>
/// <param name="ct">A token to cancel session opens and flow dispatch.</param>
/// <returns><c>0</c> on a clean quit.</returns>
public async Task<int> 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<MenuOutcome> 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<RecentTargetsStore.RecentTarget> recents = _recents.Load();
List<string> choices = [];
Dictionary<string, RecentTargetsStore.RecentTarget> 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<string>()
.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<string>("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<string>("SQLite database path:"));
string envVar = _console.Prompt(new TextPrompt<string>("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<SecretsSession?> 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<string>()
.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<string>("Paste base64 KEK:").Secret());
provider = new LiteralMasterKeyProvider(base64);
}
else
{
string filePath = _console.Prompt(new TextPrompt<string>("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<string> BuildMenu()
{
List<string> actions = [];
actions.AddRange(_flows.Select(f => f.Title));
actions.Add(SwitchLabel);
actions.Add(QuitLabel);
return new SelectionPrompt<string>()
.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));
}
}
@@ -0,0 +1,167 @@
using ZB.MOM.WW.Secrets.Abstractions;
using ZB.MOM.WW.Secrets.Rotation;
namespace ZB.MOM.WW.Secrets.Cli.Interactive;
/// <summary>The per-row verdict of a KEK diagnosis.</summary>
public enum RowKekStatus
{
/// <summary>The session KEK matches the row and successfully unwraps it — the row is healthy.</summary>
Ok,
/// <summary>The row is wrapped under a different KEK than the session's — the session cannot open it.</summary>
WrongKek,
/// <summary>
/// The row's <c>kek_id</c> matches the session KEK, yet the DEK unwrap / decrypt fails closed —
/// the wrap envelope or sealed body is damaged.
/// </summary>
Corrupt,
}
/// <summary>One row's KEK verdict: its name, status, and the <c>kek_id</c> it is actually wrapped under.</summary>
/// <param name="SecretName">The secret's normalized name.</param>
/// <param name="Status">Whether the session KEK opens the row (<see cref="RowKekStatus"/>).</param>
/// <param name="RowKekId">
/// The <c>kek_id</c> stamped on the row — the key an operator must hunt down when the status is
/// <see cref="RowKekStatus.WrongKek"/>.
/// </param>
public sealed record KekDiagnosis(string SecretName, RowKekStatus Status, string RowKekId);
/// <summary>
/// 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.
/// </summary>
/// <param name="SessionKekId">The <c>kek_id</c> of the KEK the session is currently using.</param>
/// <param name="Rows">The per-row verdicts, ordered by secret name.</param>
/// <param name="Total">
/// The number of rows scanned from the store (including tombstones). A value greater than
/// <see cref="Rows"/> 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.
/// </param>
public sealed record KekDoctorReport(string SessionKekId, IReadOnlyList<KekDiagnosis> Rows, int Total)
{
/// <summary>Whether every diagnosed row opens cleanly under the session KEK.</summary>
public bool Healthy => Rows.All(r => r.Status == RowKekStatus.Ok);
}
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// The store surface makes a full decrypt probe possible even for tombstoned rows:
/// <see cref="ISecretStore.ListAsync"/> enumerates all rows' metadata (including tombstones with
/// <c>includeDeleted: true</c>) and <see cref="ISecretStore.GetAsync"/> returns the full ciphertext
/// row for any name <b>including a tombstoned one</b>, so every row — live or dead — is probed the
/// same way. Plaintext produced by the probe is discarded immediately and never surfaced.
/// </remarks>
public sealed class KekDoctor
{
/// <summary>
/// Diagnoses every stored row (including tombstones) against the session KEK: a row whose
/// <c>kek_id</c> differs from the session KEK is reported <see cref="RowKekStatus.WrongKek"/>
/// with no decrypt attempted; a row whose <c>kek_id</c> matches is decrypt-probed and reported
/// <see cref="RowKekStatus.Ok"/> or, if the unwrap fails closed, <see cref="RowKekStatus.Corrupt"/>.
/// </summary>
/// <param name="session">The open session; must not be degraded (a KEK is required).</param>
/// <param name="ct">A token to cancel the diagnosis.</param>
/// <returns>A <see cref="KekDoctorReport"/> with the per-row verdicts, ordered by name.</returns>
/// <exception cref="ArgumentNullException"><paramref name="session"/> is <see langword="null"/>.</exception>
/// <exception cref="InvalidOperationException">
/// The session is degraded (no KEK / cipher). The operator must re-open the session supplying a
/// master key before the doctor can probe rows.
/// </exception>
public async Task<KekDoctorReport> 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<SecretMetadata> all =
await session.Store.ListAsync(includeDeleted: true, ct).ConfigureAwait(false);
List<KekDiagnosis> 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);
}
}
/// <summary>
/// Remedies wrong-KEK rows by re-wrapping every row from <paramref name="oldKek"/> onto the
/// session's KEK, delegating to <see cref="KekRotationService.RewrapAllAsync"/> (idempotent, and
/// fail-closed on rows wrapped by neither the old nor the session KEK).
/// </summary>
/// <param name="session">The open session; must not be degraded (its KEK is the rewrap target).</param>
/// <param name="oldKek">The provider for the KEK the wrong-KEK rows are currently wrapped under.</param>
/// <param name="ct">A token to cancel the pass (already-persisted re-wraps are retained).</param>
/// <returns>A <see cref="RewrapReport"/> summarizing the pass (no secret material).</returns>
/// <exception cref="ArgumentNullException">
/// <paramref name="session"/> or <paramref name="oldKek"/> is <see langword="null"/>.
/// </exception>
/// <exception cref="InvalidOperationException">The session is degraded (no KEK / cipher).</exception>
public Task<RewrapReport> 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);
}
}
@@ -0,0 +1,64 @@
using ZB.MOM.WW.Secrets.Abstractions;
using ZB.MOM.WW.Secrets.MasterKey;
namespace ZB.MOM.WW.Secrets.Cli.Interactive;
/// <summary>
/// An <see cref="IMasterKeyProvider"/> 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.
/// </summary>
/// <remarks>
/// Subclasses <see cref="MasterKeyProviderBase"/> so the non-secret <see cref="IMasterKeyProvider.KekId"/>
/// 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 <c>kek_id</c> the service wrote rows under, or every decrypt
/// and re-wrap fails closed on a KEK mismatch. Pinned by
/// <c>LiteralMasterKeyProviderTests.Derives_same_kekid_as_environment_provider_for_same_material</c>.
/// </remarks>
public sealed class LiteralMasterKeyProvider : MasterKeyProviderBase
{
private readonly byte[] _key;
/// <summary>
/// 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.
/// </summary>
/// <param name="base64Key">The base64-encoded 32-byte master key (KEK).</param>
/// <param name="kekId">
/// An optional explicit key id used verbatim as <see cref="IMasterKeyProvider.KekId"/>; when
/// <see langword="null"/> or empty the id is derived from the key material, matching the other
/// providers.
/// </param>
/// <exception cref="ArgumentException">
/// <paramref name="base64Key"/> is null/whitespace, is not valid base64, or does not decode to
/// exactly 32 bytes.
/// </exception>
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;
}
/// <inheritdoc />
protected override byte[] ResolveKeyBytes() => (byte[])_key.Clone();
}
@@ -0,0 +1,92 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace ZB.MOM.WW.Secrets.Cli.Interactive;
/// <summary>
/// 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.
/// </summary>
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;
/// <summary>Creates the store bound to a JSON file path, using an optional injected clock.</summary>
/// <param name="filePath">The JSON file to load from and save to.</param>
/// <param name="timeProvider">
/// Clock used to stamp <see cref="RecentTarget.LastUsedUtc"/>. Defaults to
/// <see cref="TimeProvider.System"/> when omitted.
/// </param>
public RecentTargetsStore(string filePath, TimeProvider? timeProvider = null)
{
_filePath = filePath;
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <summary>One remembered target: a display name, its appsettings.json path, and when it was last used.</summary>
/// <param name="Name">Display name for the target (e.g. the app name).</param>
/// <param name="AppSettingsPath">Filesystem path to the target's appsettings.json.</param>
/// <param name="LastUsedUtc">Timestamp of the most recent selection of this target.</param>
public sealed record RecentTarget(string Name, string AppSettingsPath, DateTimeOffset LastUsedUtc);
/// <summary>Loads the recents list, newest first. A missing or unreadable/corrupt file yields an empty list.</summary>
public IReadOnlyList<RecentTarget> Load()
{
if (!File.Exists(_filePath))
{
return [];
}
try
{
var json = File.ReadAllText(_filePath);
var entries = JsonSerializer.Deserialize<List<RecentTarget>>(json, SerializerOptions);
return entries ?? [];
}
catch (JsonException)
{
return [];
}
catch (IOException)
{
return [];
}
}
/// <summary>Upserts a target by path, moves it to the front, stamps now, caps the list at 10, and saves.</summary>
/// <param name="name">Display name for the target.</param>
/// <param name="appSettingsPath">Filesystem path to the target's appsettings.json — the dedup key.</param>
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<RecentTarget> { touched };
updated.AddRange(remaining.Take(MaxEntries - 1));
Save(updated);
}
private void Save(IReadOnlyList<RecentTarget> 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);
}
}
@@ -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;
/// <summary>The resolvability of a single <c>${secret:NAME}</c> reference against the target's store.</summary>
public enum ReferenceStatus
{
/// <summary>The referenced secret exists and decrypts under the session's KEK.</summary>
Ok,
/// <summary>
/// The referenced secret exists, but the session is degraded (no KEK) so decryptability could not
/// be verified — presence is all that could be established.
/// </summary>
PresentUnverified,
/// <summary>No row exists for the referenced secret — a fail-closed expansion would throw at startup.</summary>
Missing,
/// <summary>A row exists but is soft-deleted (tombstoned) — treated as absent by the resolver.</summary>
Tombstoned,
/// <summary>The row exists but does not decrypt under the session's KEK (wrong or rotated KEK, or tampering).</summary>
Undecryptable,
/// <summary>
/// The token names a value that is not a valid <see cref="SecretName"/> (illegal characters, an
/// unfilled placeholder like <c>REPLACE ME</c>, and so on), so it cannot be looked up at all.
/// </summary>
InvalidName,
}
/// <summary>
/// One audited secret reference: the referenced name, its resolvability <see cref="Status"/>, and every
/// configuration key path that references it.
/// </summary>
/// <param name="SecretName">The referenced secret name, exactly as it appears in the token (trimmed).</param>
/// <param name="Status">The reference's resolvability against the target's store.</param>
/// <param name="ConfigPaths">The configuration key paths that reference this name, sorted (ordinal).</param>
public sealed record ReferenceFinding(
string SecretName, ReferenceStatus Status, IReadOnlyList<string> ConfigPaths);
/// <summary>Scans a target app's composed configuration for <c>${secret:NAME}</c> tokens and checks each against the store.</summary>
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);
/// <summary>
/// Walks every configuration value, collects each distinct <c>${secret:NAME}</c> reference with the
/// config key paths that use it, and classifies each name against the session's store. References are
/// keyed by their <em>normalized</em> <see cref="SecretName.Value"/> so case/whitespace variants of one
/// secret collapse to a single finding whose name matches what a <c>get</c> would resolve; a token that
/// is not a valid name is reported once as <see cref="ReferenceStatus.InvalidName"/> 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.
/// </summary>
/// <param name="session">The open store session whose target configuration is audited.</param>
/// <param name="ct">A token to cancel the store lookups.</param>
/// <returns>One finding per distinct referenced secret.</returns>
public async Task<IReadOnlyList<ReferenceFinding>> 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<string, ReferenceGroup>(StringComparer.Ordinal);
foreach (KeyValuePair<string, string?> 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<ReferenceFinding>(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<string, ReferenceGroup> 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<ReferenceStatus> 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<string> Paths { get; } = new(StringComparer.Ordinal);
public SecretName? Name { get; } = name;
}
}
@@ -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;
/// <summary>
/// Ciphertext-only export format for moving secret rows between deployment stores (cloning a
/// deployment, staging a recovery). A bundle carries <b>only</b> the encrypted
/// <see cref="StoredSecret"/> representation — never plaintext — so it is safe at rest and useless
/// to anyone without the source KEK. Every <see cref="byte"/> array rides as a base64 string and
/// every timestamp as an ISO-8601 <see cref="DateTimeOffset"/>, so a parsed bundle round-trips
/// byte-identical to what was exported.
/// </summary>
public sealed record SecretBundle
{
/// <summary>The only bundle format version this build can read or write.</summary>
public const int CurrentFormatVersion = 1;
/// <summary>The on-disk bundle format version (currently <c>1</c>).</summary>
public int FormatVersion { get; init; } = CurrentFormatVersion;
/// <summary>When the bundle was exported (UTC).</summary>
public DateTimeOffset ExportedUtc { get; init; }
/// <summary>
/// The <see cref="IMasterKeyProvider.KekId"/> 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).
/// </summary>
public string SourceKekId { get; init; } = "";
/// <summary>The exported rows, each mirroring a <see cref="StoredSecret"/> with base64 crypto fields.</summary>
public IReadOnlyList<BundleEntry> Entries { get; init; } = [];
}
/// <summary>
/// One exported row — a faithful, ciphertext-only mirror of a <see cref="StoredSecret"/>. 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.
/// </summary>
/// <param name="Name">The normalized secret name (<see cref="SecretName.Value"/>).</param>
/// <param name="Description">Optional human-readable description.</param>
/// <param name="ContentType">The <see cref="SecretContentType"/> name.</param>
/// <param name="Ciphertext">Base64 of the AES-256-GCM ciphertext.</param>
/// <param name="Nonce">Base64 of the body nonce.</param>
/// <param name="Tag">Base64 of the body authentication tag.</param>
/// <param name="WrappedDek">Base64 of the KEK-wrapped data-encryption key.</param>
/// <param name="WrapNonce">Base64 of the DEK-wrap nonce.</param>
/// <param name="WrapTag">Base64 of the DEK-wrap authentication tag.</param>
/// <param name="KekId">Identifier of the KEK that wrapped the DEK.</param>
/// <param name="Revision">The row's monotonic revision.</param>
/// <param name="IsDeleted">Whether the row is a tombstone.</param>
/// <param name="DeletedUtc">When the row was tombstoned, if it is deleted.</param>
/// <param name="CreatedUtc">When the row was first created (UTC).</param>
/// <param name="UpdatedUtc">When the row was last updated (UTC).</param>
/// <param name="CreatedBy">Principal that created the row, if known.</param>
/// <param name="UpdatedBy">Principal that last updated the row, if known.</param>
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);
/// <summary>
/// Serializes and parses <see cref="SecretBundle"/> documents and converts between a
/// <see cref="StoredSecret"/> row and its <see cref="BundleEntry"/> mirror. The conversion is
/// lossless in both directions: <see cref="ToRow"/>(<see cref="FromRow"/>(row)) reproduces every
/// field of <paramref name="row"/> byte-identically.
/// </summary>
public static class SecretBundleCodec
{
private static readonly JsonSerializerOptions Options = new()
{
WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
};
/// <summary>Serializes <paramref name="bundle"/> to indented JSON.</summary>
/// <param name="bundle">The bundle to serialize.</param>
/// <returns>The indented JSON document.</returns>
public static string Serialize(SecretBundle bundle)
{
ArgumentNullException.ThrowIfNull(bundle);
return JsonSerializer.Serialize(bundle, Options);
}
/// <summary>Parses a <see cref="SecretBundle"/> from its JSON representation.</summary>
/// <param name="json">The JSON document produced by <see cref="Serialize"/>.</param>
/// <returns>The parsed bundle.</returns>
/// <exception cref="InvalidOperationException">The JSON does not represent a bundle.</exception>
public static SecretBundle Deserialize(string json)
{
ArgumentException.ThrowIfNullOrWhiteSpace(json);
return JsonSerializer.Deserialize<SecretBundle>(json, Options)
?? throw new InvalidOperationException("Bundle JSON deserialized to null.");
}
/// <summary>Projects a stored row into its ciphertext-only bundle entry.</summary>
/// <param name="row">The stored row to export.</param>
/// <returns>The bundle entry mirroring <paramref name="row"/>.</returns>
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);
}
/// <summary>Rebuilds a stored row from a bundle entry.</summary>
/// <param name="entry">The bundle entry to rehydrate.</param>
/// <returns>The <see cref="StoredSecret"/> the entry was projected from.</returns>
public static StoredSecret ToRow(BundleEntry entry)
{
ArgumentNullException.ThrowIfNull(entry);
return new StoredSecret
{
Name = new SecretName(entry.Name),
Description = entry.Description,
ContentType = Enum.Parse<SecretContentType>(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,
};
}
}
@@ -0,0 +1,31 @@
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.Secrets.Cli.Interactive;
/// <summary>An open store session for one target. Degraded when no KEK is available (metadata ops only).</summary>
public sealed class SecretsSession
{
/// <summary>The target this session operates on.</summary>
public required StoreTarget Target { get; init; }
/// <summary>The store backing this session (SQLite or SQL Server).</summary>
public required ISecretStore Store { get; init; }
/// <summary>The migrator matching <see cref="Store"/>; already run once when the session was opened.</summary>
public required ISecretsStoreMigrator Migrator { get; init; }
/// <summary>The resolved master-key provider, or <see langword="null"/> when the session is degraded.</summary>
public IMasterKeyProvider? MasterKey { get; init; }
/// <summary>The envelope cipher, or <see langword="null"/> when the session is degraded (no KEK).</summary>
public ISecretCipher? Cipher { get; init; }
/// <summary>The concrete store kind: <c>"sqlite"</c> or <c>"sqlserver"</c>.</summary>
public required string StoreKind { get; init; }
/// <summary>Non-fatal warnings raised while opening (KEK unavailable, store fall-back, and so on).</summary>
public IReadOnlyList<string> Warnings { get; init; } = [];
/// <summary>Whether a KEK (and therefore a cipher) is available — <see langword="false"/> in degraded mode.</summary>
public bool KekAvailable => Cipher is not null;
}
@@ -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;
/// <summary>
/// Opens a <see cref="SecretsSession"/> 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.
/// </summary>
public sealed class SecretsSessionFactory
{
/// <summary>Marker for an unresolved <c>${secret:}</c> reference embedded in a connection string.</summary>
private const string SecretRefMarker = "${secret:";
/// <summary>Builds the store stack for a target. KEK resolution failure yields a DEGRADED session, never a throw.</summary>
/// <param name="target">The resolved store target.</param>
/// <param name="overrideKek">
/// An operator-supplied KEK that pre-empts the target's configured source, or <see langword="null"/>
/// to use the configured provider.
/// </param>
/// <param name="ct">A token to cancel the schema migration.</param>
/// <returns>An open session — full when a KEK resolves, degraded otherwise.</returns>
public async Task<SecretsSession> OpenAsync(
StoreTarget target, IMasterKeyProvider? overrideKek, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(target);
List<string> 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<string> 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<string> 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);
}
}
}
@@ -0,0 +1,17 @@
using Microsoft.Extensions.Configuration;
using ZB.MOM.WW.Secrets.Replicator.SqlServer;
namespace ZB.MOM.WW.Secrets.Cli.Interactive;
/// <summary>A resolved deployment store target: the app's composed Secrets options + where they came from.</summary>
/// <param name="Name">Display name (defaults to the appsettings parent directory name).</param>
/// <param name="AppSettingsPath">The base <c>appsettings.json</c> path, or <see langword="null"/> for manual-entry targets.</param>
/// <param name="Secrets">The bound application secrets options, with <see cref="SecretsOptions.SqlitePath"/> already made absolute.</param>
/// <param name="SqlServer">The bound SQL-Server hub options when a <c>Secrets:SqlServer</c> section is present; otherwise <see langword="null"/>.</param>
/// <param name="Configuration">The full composed configuration, consumed later by the reference auditor.</param>
public sealed record StoreTarget(
string Name,
string? AppSettingsPath,
SecretsOptions Secrets,
SqlServerSecretsOptions? SqlServer,
IConfigurationRoot Configuration);
@@ -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;
/// <summary>
/// Composes a deployment target's Secrets configuration the same way the target app would at
/// startup — base <c>appsettings.json</c>, optional per-environment overlay, and environment
/// variables — so the interactive console operates on exactly the store the app reads.
/// </summary>
public sealed class TargetConfigReader
{
/// <summary>
/// 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.
/// </summary>
/// <param name="appSettingsPath">Absolute or relative path to the target app's base <c>appsettings.json</c>.</param>
/// <param name="environment">
/// Optional ASP.NET environment name; when non-empty an <c>appsettings.{environment}.json</c>
/// overlay is layered on top of the base file (optional — absent overlays are ignored).
/// </param>
/// <param name="sectionPath">The configuration section the Secrets options bind from. Defaults to <c>Secrets</c>.</param>
/// <returns>The resolved <see cref="StoreTarget"/>, with the SQLite path made absolute against the app directory.</returns>
/// <exception cref="FileNotFoundException">The base <paramref name="appSettingsPath"/> does not exist.</exception>
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<SecretsOptions>() ?? 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<SqlServerSecretsOptions>();
}
string name = new DirectoryInfo(dir).Name;
return new StoreTarget(name, fullPath, secrets, sqlServer, config);
}
/// <summary>
/// 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).
/// </summary>
/// <param name="sqlitePath">Path to the SQLite store; made absolute against the current directory.</param>
/// <param name="masterKey">The master-key resolution options for this manual store.</param>
/// <returns>A <see cref="StoreTarget"/> named <c>manual</c> with an empty composed configuration and no SQL-Server hub.</returns>
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);
}
}
@@ -1,8 +1,10 @@
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Spectre.Console;
using ZB.MOM.WW.Secrets.Abstractions; using ZB.MOM.WW.Secrets.Abstractions;
using ZB.MOM.WW.Secrets.Cli; using ZB.MOM.WW.Secrets.Cli;
using ZB.MOM.WW.Secrets.Cli.Interactive;
using ZB.MOM.WW.Secrets.DependencyInjection; using ZB.MOM.WW.Secrets.DependencyInjection;
using ZB.MOM.WW.Secrets.MasterKey; 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 // The heavy lifting lives in SecretCommands; Program only builds the host, runs the schema
// migration, parses the verb + positional args, and dispatches. // 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); HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);
builder.Configuration builder.Configuration
.AddJsonFile("appsettings.json", optional: true) .AddJsonFile("appsettings.json", optional: true)
@@ -10,10 +10,12 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" /> <PackageReference Include="Microsoft.Extensions.Hosting" />
<PackageReference Include="Spectre.Console" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\ZB.MOM.WW.Secrets\ZB.MOM.WW.Secrets.csproj" /> <ProjectReference Include="..\ZB.MOM.WW.Secrets\ZB.MOM.WW.Secrets.csproj" />
<ProjectReference Include="..\ZB.MOM.WW.Secrets.Replicator.SqlServer\ZB.MOM.WW.Secrets.Replicator.SqlServer.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -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;
/// <summary>
/// Exercises <see cref="BundleService"/> 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).
/// </summary>
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<SecretMetadata> 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<InvalidOperationException>(
() => 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<InvalidOperationException>(
() => 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);
}
}
@@ -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;
/// <summary>
/// Drives the two bundle flows (<see cref="BundleExportFlow"/>, <see cref="BundleImportFlow"/>) 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
/// <see cref="TestConsole"/>, so the tests assert on rendered <see cref="TestConsole.Output"/> and on the
/// resulting store state — the invariant under test is that a pasted source key never reaches the screen.
/// </summary>
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<SecretsSession> 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
}
}
@@ -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;
/// <summary>
/// Drives the four CRUD flows (<see cref="ListSecretsFlow"/>, <see cref="SetSecretFlow"/>,
/// <see cref="RevealSecretFlow"/>, <see cref="DeleteSecretFlow"/>) directly against a real temp-SQLite
/// full session (KEK supplied as a <see cref="LiteralMasterKeyProvider"/> override). Each flow renders to
/// a scripted <see cref="TestConsole"/>, so the tests assert on rendered <see cref="TestConsole.Output"/>
/// 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.
/// </summary>
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<SecretsSession> 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);
}
}
@@ -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;
/// <summary>
/// Drives the lockout-recovery <see cref="KekDoctorFlow"/> against a REAL temp-SQLite store and REAL
/// AES-256-GCM cipher. KEK-A and KEK-B are two distinct 32-byte <see cref="LiteralMasterKeyProvider"/>
/// keys (distinct derived <c>kek_id</c>s): rows are sealed under one and the session opened under the
/// other to reproduce the wrong-KEK lockout. Each flow renders to a scripted <see cref="TestConsole"/>
/// so the tests assert both the rendered <see cref="TestConsole.Output"/> 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.
/// </summary>
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<StoredSecret> 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.
}
}
}
}
@@ -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;
/// <summary>
/// Drives the deployment-setup <see cref="ReferenceAuditFlow"/> against a real temp-SQLite full session
/// whose <see cref="StoreTarget.Configuration"/> references three <c>${secret:NAME}</c> tokens — one
/// seeded (Ok), one absent (Missing), and one tombstoned. Each flow renders to a scripted
/// <see cref="TestConsole"/>, so the tests assert both the rendered <see cref="TestConsole.Output"/> 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.
/// </summary>
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<SecretsSession> NewSessionAsync(
IEnumerable<KeyValuePair<string, string?>>? 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<KeyValuePair<string, string?>> 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<ReferenceFinding> 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<string, string?>[] 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<string, string?>[] 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
}
}
@@ -0,0 +1,57 @@
using Spectre.Console.Testing;
using ZB.MOM.WW.Secrets.Cli.Interactive;
namespace ZB.MOM.WW.Secrets.Tests.Cli.Interactive;
/// <summary>
/// Pins the composition root of the interactive console: the non-TTY gate that decides whether a bare
/// <c>secret</c> 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.
/// </summary>
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());
}
}
@@ -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;
/// <summary>
/// Drives <see cref="InteractiveShell"/> end-to-end through a scripted <see cref="TestConsole"/>: 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 <see cref="TestConsole.Output"/> and on what a fake flow observed.
/// </summary>
[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<string> _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);
/// <summary>Records what the shell dispatched to it without touching store logic.</summary>
private sealed class FakeFlow(string title, bool requiresKek = false, Action<SecretsSession>? 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<RecentTargetsStore.RecentTarget> 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<ArgumentException>(() => NewShell(NewConsole(), NewRecents(), first, second));
}
[Fact]
public void Constructor_rejects_flow_title_colliding_with_a_builtin()
{
var reserved = new FakeFlow("Quit");
Assert.Throws<ArgumentException>(() => 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);
}
/// <summary>A hand-advanced clock so a re-Touch produces a strictly newer timestamp.</summary>
private sealed class MutableClock(DateTimeOffset start) : TimeProvider
{
private DateTimeOffset _now = start;
public void Advance(TimeSpan by) => _now += by;
public override DateTimeOffset GetUtcNow() => _now;
}
}
@@ -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;
/// <summary>
/// Exercises <see cref="KekDoctor"/> 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 <see cref="LiteralMasterKeyProvider"/> keys (distinct derived <c>kek_id</c>s).
/// </summary>
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<InvalidOperationException>(
() => 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<InvalidOperationException>(
() => 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.
}
}
}
}
@@ -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;
/// <summary>
/// Pins <see cref="LiteralMasterKeyProvider"/> — the operator-pasted-key provider used to recover a
/// locked-out session — to the family's key contract: 32-byte enforcement and, critically, a
/// <see cref="IMasterKeyProvider.KekId"/> derived identically to the configured providers so a key
/// entered by hand can decrypt (and re-wrap) rows another provider kind sealed.
/// </summary>
[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<ArgumentException>(() => 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);
}
}
@@ -0,0 +1,127 @@
using ZB.MOM.WW.Secrets.Cli.Interactive;
namespace ZB.MOM.WW.Secrets.Tests.Cli.Interactive;
/// <summary>
/// Exercises <see cref="RecentTargetsStore"/> 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.
/// </summary>
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 */ }
}
}
/// <summary>A minimal, manually-advanceable <see cref="TimeProvider"/> fake for deterministic ordering assertions.</summary>
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);
}
}
@@ -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;
/// <summary>
/// Exercises <see cref="ReferenceAuditor"/> 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).
/// </summary>
[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<string> _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<SecretsSession> 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<ReferenceFinding> 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<ReferenceFinding> 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<ReferenceFinding> 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<ReferenceFinding> 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<ReferenceFinding> 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<ReferenceFinding> 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<ReferenceFinding> 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<ReferenceFinding> 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<ReferenceFinding> 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);
}
}
@@ -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;
/// <summary>
/// Exercises <see cref="SecretsSessionFactory"/> 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.
/// </summary>
[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<string> _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<SecretMetadata> 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<SecretMetadata> 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));
}
}
@@ -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;
/// <summary>
/// Tests for <see cref="TargetConfigReader"/>: 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.
/// </summary>
public sealed class TargetConfigReaderTests : IDisposable
{
private readonly List<string> _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<FileNotFoundException>(
() => 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);
}
}
@@ -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 /// core: every seam resolves, a real secret round-trips through the composed graph, and a missing
/// master key fails closed. /// master key fails closed.
/// </summary> /// </summary>
[Collection("Process environment")]
public sealed class AddZbSecretsTests public sealed class AddZbSecretsTests
{ {
private static string NewTempDbPath() => private static string NewTempDbPath() =>
@@ -4,6 +4,7 @@ using ZB.MOM.WW.Secrets.MasterKey;
namespace ZB.MOM.WW.Secrets.Tests.MasterKey; namespace ZB.MOM.WW.Secrets.Tests.MasterKey;
[Collection("Process environment")]
public class MasterKeyProviderTests public class MasterKeyProviderTests
{ {
// ---- Environment ---------------------------------------------------------------------- // ---- Environment ----------------------------------------------------------------------
@@ -0,0 +1,10 @@
namespace ZB.MOM.WW.Secrets.Tests;
/// <summary>
/// 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.
/// </summary>
[CollectionDefinition("Process environment", DisableParallelization = true)]
public sealed class ProcessEnvironmentCollection;
@@ -10,6 +10,7 @@
<PackageReference Include="xunit" /> <PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" /> <PackageReference Include="xunit.runner.visualstudio" />
<PackageReference Include="Xunit.SkippableFact" /> <PackageReference Include="Xunit.SkippableFact" />
<PackageReference Include="Spectre.Console.Testing" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Microsoft.Extensions.Configuration" /> <PackageReference Include="Microsoft.Extensions.Configuration" />
</ItemGroup> </ItemGroup>
@@ -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.<Environment>.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.
@@ -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 `<!-- Interactive console -->` group next to the Test group):
```xml
<PackageVersion Include="Spectre.Console" Version="0.50.0" />
<PackageVersion Include="Spectre.Console.Testing" Version="0.50.0" />
```
(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 `<PackageReference Include="Spectre.Console" />` 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 `<PackageReference Include="Spectre.Console.Testing" />`.
**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
/// <summary>Persists the operator's recently-used store targets (names + appsettings paths ONLY — never key material).</summary>
public sealed class RecentTargetsStore
{
public sealed record RecentTarget(string Name, string AppSettingsPath, DateTimeOffset LastUsedUtc);
public RecentTargetsStore(string filePath, TimeProvider? timeProvider = null) { ... }
public IReadOnlyList<RecentTarget> 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
/// <summary>A resolved deployment store target: the app's composed Secrets options + where they came from.</summary>
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
{
/// <summary>Loads the target app's config exactly the way the app would (base json + optional env overlay + env vars).</summary>
public StoreTarget Read(string appSettingsPath, string? environment = null, string sectionPath = "Secrets") { ... }
/// <summary>Builds a manual target from explicit db path + master-key options (no appsettings).</summary>
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
/// <summary>An open store session for one target. Degraded when no KEK is available (metadata ops only).</summary>
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<string> Warnings { get; init; } = [];
public bool KekAvailable => Cipher is not null;
}
public sealed class SecretsSessionFactory
{
/// <summary>Builds the store stack for a target. KEK resolution failure yields a DEGRADED session, never a throw.</summary>
public async Task<SecretsSession> 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<string> ConfigPaths);
/// <summary>Scans a target app's composed configuration for ${secret:NAME} tokens and checks each against the store.</summary>
public sealed class ReferenceAuditor
{
public async Task<IReadOnlyList<ReferenceFinding>> 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<KekDiagnosis> Rows)
{ public bool Healthy => Rows.All(r => r.Status == RowKekStatus.Ok); }
/// <summary>Lockout triage: verifies the session KEK actually opens every stored row and drives the rewrap remedy.</summary>
public sealed class KekDoctor
{
public async Task<KekDoctorReport> DiagnoseAsync(SecretsSession session, CancellationToken ct) { ... }
public Task<RewrapReport> 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<BundleEntry> Entries; }` where `BundleEntry` mirrors every `StoredSecret` field (byte[] as base64, `SecretName` as string). `BundleService`:
```csharp
public sealed class BundleService
{
public async Task<int> ExportAsync(SecretsSession session, string path, bool includeDeleted, CancellationToken ct) { ... }
public async Task<BundleImportReport> ImportAsync(
SecretsSession session, string path, IMasterKeyProvider? sourceKek,
Func<StoredSecret /*existing*/, StoredSecret /*incoming*/, bool /*takeIncoming*/>? 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
/// <summary>One menu action of the interactive console. Implementations are UI-owning but store-logic-free.</summary>
public interface IInteractiveFlow
{
string Title { get; }
bool RequiresKek { get; }
Task RunAsync(IAnsiConsole console, SecretsSession session, CancellationToken ct);
}
/// <summary>The menu loop: target selection, session lifecycle, flow dispatch, error containment.</summary>
public sealed class InteractiveShell
{
public InteractiveShell(IAnsiConsole console, RecentTargetsStore recents, TargetConfigReader reader,
SecretsSessionFactory sessions, IReadOnlyList<IInteractiveFlow> flows) { ... }
public async Task<int> 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<string>().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<string>` (validated through `SecretName`), value via `.Secret()`, content type via `SelectionPrompt<SecretContentType>`, 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 '<name>' 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-<target>-<yyyyMMdd>.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` (`<Version>` 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.)
@@ -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"
}