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);
@@ -374,4 +374,93 @@ public sealed class BundleServiceTests : IDisposable
StoredSecret? dst = await target.Store.GetAsync(new SecretName("svc/cross"), CancellationToken.None);
Assert.Null(dst);
}
// ---------- Secrets-018: a bad entry anywhere fails the import before anything is written ----------
[Fact]
public async Task A_tampered_foreign_kek_entry_late_in_the_bundle_leaves_the_store_untouched()
{
LiteralMasterKeyProvider kekA = NewKek();
LiteralMasterKeyProvider kekB = NewKek();
SecretsSession source = BuildSession(Db("a.db"), kekA);
await SealAsync(source, "svc/first", "v1");
await SealAsync(source, "svc/second", "v2");
StoredSecret first = (await source.Store.GetAsync(new SecretName("svc/first"), CancellationToken.None))!;
StoredSecret second = (await source.Store.GetAsync(new SecretName("svc/second"), CancellationToken.None))!;
byte[] tamperedTag = (byte[])second.WrapTag.Clone();
tamperedTag[0] ^= 0xFF;
WriteBundle(kekA.KekId, first, second with { WrapTag = tamperedTag });
SecretsSession target = BuildSession(Db("b.db"), kekB);
InvalidOperationException ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => new BundleService().ImportAsync(target, BundlePath, sourceKek: kekA, conflictOverride: null, CancellationToken.None));
Assert.Contains("svc/second", ex.Message, StringComparison.Ordinal);
Assert.Contains("Nothing was imported", ex.Message, StringComparison.Ordinal);
Assert.Null(await target.Store.GetAsync(new SecretName("svc/first"), CancellationToken.None));
}
[Fact]
public async Task A_malformed_entry_after_good_ones_leaves_the_store_untouched()
{
LiteralMasterKeyProvider kek = NewKek();
SecretsSession source = BuildSession(Db("a.db"), kek);
await SealAsync(source, "svc/good", "v");
StoredSecret good = (await source.Store.GetAsync(new SecretName("svc/good"), CancellationToken.None))!;
WriteBundle(kek.KekId, good, good with { Name = new SecretName("svc/bad") });
// Corrupt the second entry's ciphertext base64 in the file.
string json = await File.ReadAllTextAsync(BundlePath);
int at = json.LastIndexOf("\"Ciphertext\"", StringComparison.Ordinal);
int open = json.IndexOf('"', json.IndexOf(':', at)) + 1;
json = json[..open] + "@@not-base64@@" + json[json.IndexOf('"', open)..];
await File.WriteAllTextAsync(BundlePath, json);
SecretsSession target = BuildSession(Db("b.db"), kek);
await Assert.ThrowsAsync<InvalidOperationException>(
() => new BundleService().ImportAsync(target, BundlePath, sourceKek: null, conflictOverride: null, CancellationToken.None));
Assert.Null(await target.Store.GetAsync(new SecretName("svc/good"), CancellationToken.None));
}
private sealed class FailOnNameStore(ISecretStore inner, string failName) : ISecretStore
{
public Task<StoredSecret?> GetAsync(SecretName name, CancellationToken ct) => inner.GetAsync(name, ct);
public Task UpsertAsync(StoredSecret row, CancellationToken ct) => inner.UpsertAsync(row, ct);
public Task<bool> DeleteAsync(SecretName name, string? actor, CancellationToken ct) => inner.DeleteAsync(name, actor, ct);
public Task<IReadOnlyList<SecretMetadata>> ListAsync(bool includeDeleted, CancellationToken ct) => inner.ListAsync(includeDeleted, ct);
public Task<IReadOnlyList<SecretManifestEntry>> GetManifestAsync(CancellationToken ct) => inner.GetManifestAsync(ct);
public Task ApplyReplicatedAsync(StoredSecret row, CancellationToken ct) =>
row.Name.Value == failName ? throw new IOException("disk full") : inner.ApplyReplicatedAsync(row, ct);
public Task<bool> ApplyRewrapAsync(StoredSecret rewrappedRow, byte[] expectedCurrentWrappedDek, CancellationToken ct) =>
inner.ApplyRewrapAsync(rewrappedRow, expectedCurrentWrappedDek, ct);
}
[Fact]
public async Task A_store_fault_mid_import_reports_which_rows_were_already_written()
{
LiteralMasterKeyProvider kek = NewKek();
SecretsSession source = BuildSession(Db("a.db"), kek);
await SealAsync(source, "svc/a", "1");
await SealAsync(source, "svc/b", "2");
await SealAsync(source, "svc/c", "3");
await new BundleService().ExportAsync(source, BundlePath, includeDeleted: false, CancellationToken.None);
SecretsSession built = BuildSession(Db("b.db"), kek);
var target = new SecretsSession
{
Target = built.Target,
Store = new FailOnNameStore(built.Store, "svc/b"),
Migrator = built.Migrator,
MasterKey = built.MasterKey,
Cipher = built.Cipher,
StoreKind = built.StoreKind,
};
InvalidOperationException ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => new BundleService().ImportAsync(target, BundlePath, sourceKek: null, conflictOverride: null, CancellationToken.None));
Assert.Contains("stopped at 'svc/b'", ex.Message, StringComparison.Ordinal);
Assert.Contains("ALREADY written: svc/a", ex.Message, StringComparison.Ordinal);
}
}