fix(secrets-cli): bundle import validates every entry before writing (scadaproj#45)

Secrets-018: BundleService.ImportAsync now rehydrates every entry and re-wraps every foreign-KEK
row before the first write, so a malformed or tampered entry anywhere fails the import with the
store untouched ("Nothing was imported"). A store fault during the apply phase (no multi-row
transaction) now reports the row it stopped at and every row already written, which the
interactive flow prints, instead of a bare "Import failed".

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
Joseph Doherty
2026-11-09 15:21:18 -05:00
parent 649d4a0616
commit 3aa7c728d5
2 changed files with 156 additions and 32 deletions
@@ -144,12 +144,17 @@ public sealed class BundleService
int imported = 0, skippedOlder = 0, skippedForeignKek = 0, conflicts = 0;
// Phase 1 — validate EVERYTHING before writing anything (Secrets-018). Rehydrate every entry
// and re-wrap every foreign-KEK row up front, so a malformed or tampered entry anywhere in the
// bundle fails the import with the store untouched, instead of after the rows before it have
// already been written (and possibly won conflicts against local values).
var prepared = new List<StoredSecret>(bundle.Entries.Count);
for (int i = 0; i < bundle.Entries.Count; i++)
{
BundleEntry entry = bundle.Entries[i];
// Rehydrate the row; a malformed entry (bad base64, unknown content type, illegal name)
// surfaces as an entry-aware failure rather than a bare FormatException/ArgumentException.
// A malformed entry (bad base64, unknown content type, illegal name) surfaces as an
// entry-aware failure rather than a bare FormatException/ArgumentException.
StoredSecret row;
try
{
@@ -158,7 +163,7 @@ public sealed class BundleService
catch (Exception ex) when (ex is FormatException or ArgumentException)
{
throw new InvalidOperationException(
$"Bundle '{path}' entry #{i} ('{entry.Name}') is malformed: {ex.Message}", ex);
$"Bundle '{path}' entry #{i} ('{entry.Name}') is malformed: {ex.Message} Nothing was imported.", ex);
}
// Foreign KEK: re-wrap under the target KEK if the source KEK is available, else skip.
@@ -166,7 +171,16 @@ public sealed class BundleService
{
if (sourceKek is not null && string.Equals(sourceKek.KekId, row.KekId, StringComparison.Ordinal))
{
row = cipher.Rewrap(row, sourceKek, targetKek);
try
{
row = cipher.Rewrap(row, sourceKek, targetKek);
}
catch (SecretDecryptionException ex)
{
throw new InvalidOperationException(
$"Bundle '{path}' entry #{i} ('{entry.Name}') could not be re-wrapped under the " +
$"target KEK: {ex.Message} Nothing was imported.", ex);
}
}
else
{
@@ -175,39 +189,60 @@ public sealed class BundleService
}
}
StoredSecret? existing = await session.Store.GetAsync(row.Name, ct).ConfigureAwait(false);
if (existing is null)
prepared.Add(row);
}
// Phase 2 — apply. A store fault here can still leave the import partial (the store has no
// multi-row transaction), so the failure says exactly which rows were already written.
var written = new List<string>();
foreach (StoredSecret row in prepared)
{
try
{
// No local row: ApplyReplicatedAsync inserts it verbatim (its LWW read finds nothing).
await session.Store.ApplyReplicatedAsync(row, ct).ConfigureAwait(false);
StoredSecret? existing = await session.Store.GetAsync(row.Name, ct).ConfigureAwait(false);
if (existing is null)
{
// No local row: ApplyReplicatedAsync inserts it verbatim (its LWW read finds nothing).
await session.Store.ApplyReplicatedAsync(row, ct).ConfigureAwait(false);
imported++;
written.Add(row.Name.Value);
continue;
}
conflicts++;
bool lwwWin = SecretLastWriterWins.IsNewer(
row.UpdatedUtc, row.Revision, existing.UpdatedUtc, existing.Revision);
bool takeIncoming = conflictOverride is not null ? conflictOverride(existing, row) : lwwWin;
if (!takeIncoming)
{
skippedOlder++;
continue;
}
if (lwwWin)
{
// Natural (or override-agreed) winner: apply verbatim, keeping LWW metadata intact.
await session.Store.ApplyReplicatedAsync(row, ct).ConfigureAwait(false);
}
else
{
// Override forced an older row against LWW. A verbatim replicate would be rejected by
// the LWW gate, so adopt it as a local write — restamped current so the choice sticks.
await session.Store.UpsertAsync(row, ct).ConfigureAwait(false);
}
imported++;
continue;
written.Add(row.Name.Value);
}
conflicts++;
bool lwwWin = SecretLastWriterWins.IsNewer(
row.UpdatedUtc, row.Revision, existing.UpdatedUtc, existing.Revision);
bool takeIncoming = conflictOverride is not null ? conflictOverride(existing, row) : lwwWin;
if (!takeIncoming)
catch (Exception ex) when (ex is not OperationCanceledException)
{
skippedOlder++;
continue;
string already = written.Count == 0
? "No row had been written."
: $"{written.Count} row(s) were ALREADY written: {string.Join(", ", written)}.";
throw new InvalidOperationException(
$"Bundle '{path}' import stopped at '{row.Name.Value}': {ex.Message} {already}", ex);
}
if (lwwWin)
{
// Natural (or override-agreed) winner: apply verbatim, keeping LWW metadata intact.
await session.Store.ApplyReplicatedAsync(row, ct).ConfigureAwait(false);
}
else
{
// Override forced an older row against LWW. A verbatim replicate would be rejected by
// the LWW gate, so adopt it as a local write — restamped current so the choice sticks.
await session.Store.UpsertAsync(row, ct).ConfigureAwait(false);
}
imported++;
}
return new BundleImportReport(imported, skippedOlder, skippedForeignKek, conflicts);