feat(secrets-cli): bundle export/import flows

This commit is contained in:
Joseph Doherty
2026-07-19 09:52:31 -04:00
parent e5a4d0276a
commit 000d283c99
3 changed files with 466 additions and 0 deletions
@@ -0,0 +1,79 @@
using System.Globalization;
using Spectre.Console;
namespace ZB.MOM.WW.Secrets.Cli.Interactive.Flows;
/// <summary>
/// Exports the session's store to a ciphertext-only <see cref="SecretBundle"/> on disk. The operator is
/// offered a default filename (<c>secrets-bundle-&lt;target&gt;-&lt;yyyyMMdd&gt;.json</c>) 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).
/// </summary>
public sealed class BundleExportFlow : IInteractiveFlow
{
private readonly TimeProvider _timeProvider;
/// <summary>Creates the flow.</summary>
/// <param name="timeProvider">
/// Clock used for the default filename's date stamp and the bundle's <see cref="SecretBundle.ExportedUtc"/>
/// timestamp; defaults to <see cref="TimeProvider.System"/>.
/// </param>
public BundleExportFlow(TimeProvider? timeProvider = null) =>
_timeProvider = timeProvider ?? TimeProvider.System;
/// <inheritdoc />
public string Title => "Export bundle (ciphertext-only)";
/// <inheritdoc />
public bool RequiresKek => true;
/// <inheritdoc />
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<string>("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-<target>-<yyyyMMdd>.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);
}
}
@@ -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;
/// <summary>
/// Imports a ciphertext-only <see cref="SecretBundle"/> into the session's store. The bundle is read and
/// parsed <b>before</b> any write so its <see cref="SecretBundle.SourceKekId"/> 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.
/// </summary>
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";
/// <inheritdoc />
public string Title => "Import bundle";
/// <inheritdoc />
public bool RequiresKek => true;
/// <inheritdoc />
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<string>("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<StoredSecret, StoredSecret, bool>? 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<string>()
.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<string>("Paste base64 source KEK:").Secret());
return new LiteralMasterKeyProvider(base64);
case EnvVarLabel:
string envVar = console.Prompt(new TextPrompt<string>("Source-key environment variable name:"));
return MasterKeyProviderFactory.Create(new MasterKeyOptions
{
Source = MasterKeySource.Environment,
EnvVarName = envVar,
});
default:
string filePath = console.Prompt(new TextPrompt<string>("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);
}
}