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