docs(secrets): interactive-console quickstart + discoverability sweep

- new task-first quickstart (deployment seeding + KEK-lockout walkthroughs)
- runbook: document the sixth ReferenceStatus (PresentUnverified) + quickstart link
- README + shared-contract + umbrella CLAUDE.md secrets row updated for 0.3.0 console
- CLI usage text now mentions the interactive console (was undiscoverable from --help)
This commit is contained in:
Joseph Doherty
2026-07-19 14:55:53 -04:00
parent 0139c90f89
commit baea6cc2e0
6 changed files with 206 additions and 5 deletions
+1 -1
View File
File diff suppressed because one or more lines are too long
+4 -2
View File
@@ -52,8 +52,10 @@ Once a target is open, the menu offers:
resolve last-writer-wins (with an optional per-row prompt), and the format is versioned. 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 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: as any other admin CLI. New to it? Start with the task-first
[`docs/operations/interactive-console.md`](docs/operations/interactive-console.md). [quickstart](docs/interactive-console-quickstart.md) (seed a new deployment; recover a KEK
lockout). For the full flow catalogue, exit-code/Ctrl-C semantics, and security model, see the
operator runbook: [`docs/operations/interactive-console.md`](docs/operations/interactive-console.md).
## How it protects secrets ## How it protects secrets
@@ -0,0 +1,187 @@
# Quickstart — the `secret` interactive console
**Two 5-minute jobs**, one per section: seeding a fresh deployment whose `${secret:}`
references won't resolve, and recovering from a KEK/decrypt lockout. This is the
task-oriented companion to the full reference,
[`docs/operations/interactive-console.md`](operations/interactive-console.md) — read that
for the complete flow catalogue, exit-code/Ctrl-C semantics, and security model.
> The console ships in the `secret` CLI tool (0.3.0+). You need a terminal on the box that
> runs the target app, with read/write access to the app's `appsettings.json` and its secrets
> store — the same access the app itself has. There is no login.
## Launch it
```bash
secret
```
Bare `secret`, **no verb**, at a real terminal (neither stdin nor stdout redirected) opens the
console. Any verb (`set` / `get` / `list` / `rm` / `rotate` / `rewrap-all`), or a redirected
stream (a pipe, a script, CI), falls straight through to the headless CLI — a redirected
no-verb invocation prints usage and exits **2**, so nothing scripted ever hangs on a prompt.
---
## Scenario A — "the app won't start, `${secret:}` references don't resolve"
You just deployed an app (or added a new `${secret:NAME}` reference to its config) and it fails
at startup because a referenced secret has no value in the store. Goal: get every reference to
**Ok** without hand-writing `secret set` commands.
**1. Point the console at the app.** The first screen is the target picker. Choose *Enter an
appsettings.json path…* and give the app's base `appsettings.json`. The console composes config
exactly like the app (base + `appsettings.<env>.json` + environment variables, binding the
`Secrets` section), so it operates on precisely the store the app reads.
```text
Select a store target
> MyApp — /opt/myapp/appsettings.json (a recent target, if you've been here before)
Enter an appsettings.json path…
Manual entry (SQLite db + KEK env var)…
appsettings.json path: /opt/myapp/appsettings.json
```
*(Illustrative transcript — actual menu rendering varies.)*
**2. Confirm the KEK banner.** If the master key resolves in your shell, the header shows
`KEK OK` (green). If it doesn't (env var unset, key file not mounted), it shows
`KEK UNAVAILABLE (degraded)` (red) — that's fine; the seed flow will prompt you for the key
when it needs it (paste base64 or a key-file path).
**3. Run *Reference audit & seed*.** Pick it from the action menu. It scans the composed config
for every `${secret:NAME}` token and classifies each name against the store:
```text
Action
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
Switch store target
Quit
┌───────────────────────────┬──────────────┬───────────────────────────────────────┐
│ Secret │ Status │ Config paths │
├───────────────────────────┼──────────────┼───────────────────────────────────────┤
│ sql/myapp/db-password │ Missing │ ConnectionStrings:Main │
│ api/myapp/upstream-token │ Missing │ Upstream:ApiKey │
│ sql/myapp/reporting-pw │ Ok │ ConnectionStrings:Reporting │
└───────────────────────────┴──────────────┴───────────────────────────────────────┘
```
The status column tells you exactly what's wrong per reference:
| Status | Meaning | Seedable here? |
|---|---|---|
| **Ok** | Exists and decrypts under the session KEK. | No — nothing to do. |
| **Missing** | No row at all; the app throws at startup. | Yes. |
| **Tombstoned** | Soft-deleted row; the resolver treats it as absent. | Yes — seeding revives it. |
| **Undecryptable** | Row exists but won't decrypt (wrong/rotated KEK, or damage). | Yes — seeding overwrites it (but consider the KEK doctor first — see Scenario B). |
| **InvalidName** | The token text isn't a legal secret name (illegal chars, an unfilled `REPLACE_ME`). | No — fix the `${secret:…}` token in the config file. |
*(A sixth status, **PresentUnverified**, only appears if you audit from a degraded session
elsewhere; the seed flow always holds a KEK, so you won't see it here.)*
**4. Seed each gap.** For every seedable gap the flow prompts in turn — a masked value, a
content type, and an optional description — and seals a fresh row under the session KEK:
```text
Seed 'sql/myapp/db-password' now? [Y/n] y
Value for sql/myapp/db-password: ******************** (masked; nothing echoed)
Content type: Text
Description (optional): production DB login
Sealed 'sql/myapp/db-password'.
```
A closing re-audit prints a one-line count so you can confirm the gaps closed:
```text
Audit complete: 3 Ok.
```
**5. Restart the app.** Every reference is now Ok. Re-run this flow any time you add a new
`${secret:}` reference or deploy to a fresh environment.
---
## Scenario B — "2am: the app is logging KEK / decrypt failures"
The app was fine; now it can't decrypt its secrets — typically after a KEK rotation was only
partially applied, a config value points at the wrong key source, or a store was copied from
another environment. Goal: find out *which* KEK each row is actually under and recover.
**1. Open the console on the affected store** (target picker, as above). You need the KEK you
*believe* is current; if the session opens degraded, pick the KEK doctor and paste/point at the
key when prompted.
**2. Run *KEK doctor (lockout recovery)*.** It probes every row (including tombstones) against
the session KEK and reports a verdict per row:
```text
Session KEK sha256:1a2b… — 4 row(s) scanned.
2 Ok, 2 wrong-KEK, 0 corrupt.
Foreign KEK id(s) seen: sha256:9f8e…
```
- **Ok** — the row's `kek_id` matches and it decrypts.
- **WrongKek** — the row is under a *different* KEK; reported with that KEK's own id (the
"Foreign KEK id" line) so you know which key to go find. Usually there's exactly one — the
store's real current key — which means the session simply has the wrong key configured.
- **Corrupt** — `kek_id` matches but the decrypt still fails: the wrap or body is damaged.
**Not** fixable by rewrapping (there's nothing to unwrap first).
If every row is Ok, the doctor says so — the lockout is elsewhere (check the app's actual
configured KEK source against the key you opened this session with).
**3a. The common case — WrongKek → rewrap from the old KEK (non-destructive).** Choose *Rewrap
from old KEK*, supply the KEK the affected rows are actually under (env var, key file, or a
masked paste), and confirm the exactly-scoped prompt (defaults to **No**):
```text
Choose a remedy
> Rewrap from old KEK
Old KEK is lost — re-set affected secrets
Back
Where is the old KEK?
> Environment variable
Key file
Paste base64 key
Environment variable name: ZB_SECRETS_OLD_KEY
Re-wrap 2 row(s) from KEK 'sha256:9f8e…' → session KEK 'sha256:1a2b…'? [y/N] y
Re-wrapped 2 row(s) onto the session KEK (0 already current) of 4 total.
Re-diagnosis: 4 Ok.
```
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 moved stay put; fix the key and re-run).
**3b. The last resort — the old KEK is truly lost, or a row is Corrupt.** Choose *Old KEK is
lost — re-set affected secrets*. For each WrongKek/Corrupt row (confirmed individually, default
**No**), type a fresh value; it's sealed under the session KEK, overwriting the unrecoverable
row. This is the **only** remedy for a Corrupt row — but you're re-entering the real value from
scratch, so use it only when the old key genuinely can't be recovered.
**4. Confirm.** The closing re-diagnosis line (e.g. `4 Ok`) shows the store's post-remedy state
in the same session.
---
## When *not* to use the console
- **A whole-fleet KEK rotation** (old → new across every store): the doctor's rewrap always
targets the *session's* KEK for one-store lockout recovery. For a planned rotation use the
headless `secret rewrap-all` verb — see
[`docs/operations/kek-rotation.md`](operations/kek-rotation.md).
- **Scripted / automated secret writes:** use the headless verbs (`secret set` / `get` /
`list` / `rm` / `rotate`). The console is deliberately interactive-only.
- **Moving rows between stores** (clone a deployment, stage a recovery): use *Export bundle
(ciphertext-only)* / *Import bundle* — covered in the
[runbook](operations/interactive-console.md#bundles-export--import).
@@ -8,6 +8,9 @@
[`kek-rotation.md`](kek-rotation.md) (the `rewrap-all` primitive the KEK-doctor remedy uses [`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 under the hood) and [`clustered-secrets.md`](clustered-secrets.md) (replication topologies the
console does not manage). console does not manage).
**For a task-first walkthrough** of the two headline jobs (pre-startup seeding of a new
deployment; a 2am KEK/decrypt lockout) with illustrative menu transcripts, start with the
[quickstart](../interactive-console-quickstart.md); this document is the full reference.
--- ---
@@ -115,7 +118,8 @@ whenever an app fails to start because a `${secret:NAME}` reference can't resolv
`SecretReferenceExpander` uses at app startup — so what you see here is exactly what the app `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}..."` would try to resolve, including config keys documented via a `"_comment": "...${secret:x}..."`
convention, which are skipped the same way the expander skips them. convention, which are skipped the same way the expander skips them.
2. Each distinct referenced name is looked up against the store and classified: 2. Each distinct referenced name is looked up against the store and classified into one of the
six `ReferenceStatus` values:
- **Ok** — exists and decrypts under the session KEK. Nothing to do. - **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. - **Missing** — no row at all. A fail-closed expansion would throw at app startup.
- **Tombstoned** — a soft-deleted row. Also unresolvable to the app. - **Tombstoned** — a soft-deleted row. Also unresolvable to the app.
@@ -124,6 +128,11 @@ whenever an app fails to start because a `${secret:NAME}` reference can't resolv
- **InvalidName** — the token text isn't a legal secret name (illegal characters, an - **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 unfilled placeholder like `REPLACE_ME`). Can't be looked up or seeded — fix the token in
the config file itself. the config file itself.
- **PresentUnverified** — the row exists but the session is degraded (no KEK), so presence
is all that could be established — decryptability wasn't checked. You will **not** see
this status in the seed flow, which requires a KEK (the shell upgrades a degraded session
before dispatch); it only surfaces if the auditor is run without a key. In the seed flow
an existing row therefore always resolves to Ok or Undecryptable, never PresentUnverified.
3. A color-coded table renders every finding with the config key paths that reference it (so 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 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 (Missing / Tombstoned / Undecryptable) in turn, asking to seed it now. Accepting prompts for
@@ -219,6 +219,9 @@ int Usage(string? error = null)
defaults to the configured Secrets:MasterKey provider. Supply key material only by env-var defaults to the configured Secrets:MasterKey provider. Supply key material only by env-var
NAME or file PATH never as a literal. Run it with resolve traffic quiesced; it is NAME or file PATH never as a literal. Run it with resolve traffic quiesced; it is
idempotent and safe to re-run. idempotent and safe to re-run.
Run `secret` with no arguments at an interactive terminal to open the interactive
console (deployment seeding, KEK-lockout recovery, ciphertext bundles).
"""); """);
return 2; return 2;
} }
@@ -12,7 +12,7 @@ lives in-repo at [`ZB.MOM.WW.Secrets/`](../../../ZB.MOM.WW.Secrets/).
| `ZB.MOM.WW.Secrets.Abstractions` | Contracts only — interfaces, records, enums, exceptions | BCL + `DI.Abstractions` | | `ZB.MOM.WW.Secrets.Abstractions` | Contracts only — interfaces, records, enums, exceptions | BCL + `DI.Abstractions` |
| `ZB.MOM.WW.Secrets` | Impl — cipher, master-key providers, SQLite store + migrator, resolver, `${secret:}` expander, `AddZbSecrets` | Abstractions, `Microsoft.Data.Sqlite`, `ZB.MOM.WW.Audit` | | `ZB.MOM.WW.Secrets` | Impl — cipher, master-key providers, SQLite store + migrator, resolver, `${secret:}` expander, `AddZbSecrets` | Abstractions, `Microsoft.Data.Sqlite`, `ZB.MOM.WW.Audit` |
| `ZB.MOM.WW.Secrets.Ui` | Blazor RCL on `ZB.MOM.WW.Theme``/admin/secrets` (list / add / rotate / delete / gated reveal) | Abstractions, `ZB.MOM.WW.Theme`, `Auth.AspNetCore` | | `ZB.MOM.WW.Secrets.Ui` | Blazor RCL on `ZB.MOM.WW.Theme``/admin/secrets` (list / add / rotate / delete / gated reveal) | Abstractions, `ZB.MOM.WW.Theme`, `Auth.AspNetCore` |
| `ZB.MOM.WW.Secrets.Cli` *(tool, not packed)* | `secret set / get / list / rm / rotate` | Secrets | | `ZB.MOM.WW.Secrets.Cli` *(tool, not packed)* | `secret set / get / list / rm / rotate / rewrap-all`; plus a menu-driven **interactive console** (bare `secret` on a TTY) for deployment seeding + KEK-lockout recovery — see the [runbook](../../../ZB.MOM.WW.Secrets/docs/operations/interactive-console.md) | Secrets |
| `ZB.MOM.WW.Secrets.Akka` *(deferred — not built)* | Cluster replication of encrypted rows | Secrets, Akka.Cluster | | `ZB.MOM.WW.Secrets.Akka` *(deferred — not built)* | Cluster replication of encrypted rows | Secrets, Akka.Cluster |
## Abstractions (contracts) ## Abstractions (contracts)