fix(secrets-cli): bundle import — source-key verification, format gate up front, access-denied containment

This commit is contained in:
Joseph Doherty
2026-07-19 10:00:59 -04:00
parent 4549b93d2a
commit 04018aab6e
2 changed files with 91 additions and 7 deletions
@@ -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);