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); + } +}