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