From 000d283c99119c4e0301ccb6d5ddac266bc2db06 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 19 Jul 2026 09:52:31 -0400 Subject: [PATCH] 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 + } +}