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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user