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