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