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:
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user