diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/BundleService.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/BundleService.cs
index 7f3e4fe..be6d8c9 100644
--- a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/BundleService.cs
+++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/BundleService.cs
@@ -80,6 +80,7 @@ public sealed class BundleService
/// Imports the bundle at into 's store.
///
///
+ ///
/// Per row: a row wrapped under a foreign KEK is re-wrapped under the target KEK when
/// matches its , otherwise it is
/// skipped (). A row whose name does not yet
@@ -87,6 +88,19 @@ public sealed class BundleService
/// when supplied, else by ;
/// the winner is written and the loser skipped ().
/// No plaintext is ever handled — the bundle is ciphertext only.
+ ///
+ ///
+ /// Writes go through the store's so the row lands
+ /// verbatim — its own revision, timestamps, and tombstone flag preserved — under a
+ /// serializable transaction with the shared last-writer-wins gate built in. This keeps LWW metadata
+ /// intact across further hops (a second import, or cluster anti-entropy) and means an imported
+ /// tombstone stays a tombstone. The one exception is a that
+ /// forces the incoming row against the LWW ordering (operator adopts an older row): a
+ /// verbatim replicate would be silently rejected by the LWW gate, so that single case is written
+ /// through instead — a local-write that restamps the row as
+ /// current, which is exactly what makes the operator's forced choice durable rather than flipped
+ /// back on the next reconciliation.
+ ///
///
/// The full session to import into.
/// Source bundle path.
@@ -118,11 +132,31 @@ public sealed class BundleService
string json = await File.ReadAllTextAsync(path, ct).ConfigureAwait(false);
SecretBundle bundle = SecretBundleCodec.Deserialize(json);
+ if (bundle.FormatVersion != SecretBundle.CurrentFormatVersion)
+ {
+ throw new InvalidOperationException(
+ $"Bundle '{path}' has unsupported format version {bundle.FormatVersion}; " +
+ $"this build supports version {SecretBundle.CurrentFormatVersion}.");
+ }
+
int imported = 0, skippedOlder = 0, skippedForeignKek = 0, conflicts = 0;
- foreach (BundleEntry entry in bundle.Entries)
+ for (int i = 0; i < bundle.Entries.Count; i++)
{
- StoredSecret row = SecretBundleCodec.ToRow(entry);
+ 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.
+ StoredSecret row;
+ try
+ {
+ row = SecretBundleCodec.ToRow(entry);
+ }
+ catch (Exception ex) when (ex is FormatException or ArgumentException)
+ {
+ throw new InvalidOperationException(
+ $"Bundle '{path}' entry #{i} ('{entry.Name}') is malformed: {ex.Message}", ex);
+ }
// Foreign KEK: re-wrap under the target KEK if the source KEK is available, else skip.
if (!string.Equals(row.KekId, targetKek.KekId, StringComparison.Ordinal))
@@ -141,25 +175,36 @@ public sealed class BundleService
StoredSecret? existing = await session.Store.GetAsync(row.Name, ct).ConfigureAwait(false);
if (existing is null)
{
- await session.Store.UpsertAsync(row, ct).ConfigureAwait(false);
+ // No local row: ApplyReplicatedAsync inserts it verbatim (its LWW read finds nothing).
+ await session.Store.ApplyReplicatedAsync(row, ct).ConfigureAwait(false);
imported++;
continue;
}
conflicts++;
- bool takeIncoming = conflictOverride is not null
- ? conflictOverride(existing, row)
- : SecretLastWriterWins.IsNewer(row.UpdatedUtc, row.Revision, existing.UpdatedUtc, existing.Revision);
+ bool lwwWin = SecretLastWriterWins.IsNewer(
+ row.UpdatedUtc, row.Revision, existing.UpdatedUtc, existing.Revision);
+ bool takeIncoming = conflictOverride is not null ? conflictOverride(existing, row) : lwwWin;
- if (takeIncoming)
+ if (!takeIncoming)
{
- await session.Store.UpsertAsync(row, ct).ConfigureAwait(false);
- imported++;
+ skippedOlder++;
+ continue;
+ }
+
+ if (lwwWin)
+ {
+ // Natural (or override-agreed) winner: apply verbatim, keeping LWW metadata intact.
+ await session.Store.ApplyReplicatedAsync(row, ct).ConfigureAwait(false);
}
else
{
- skippedOlder++;
+ // 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);
diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/SecretBundle.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/SecretBundle.cs
index 0e65815..c6ef50f 100644
--- a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/SecretBundle.cs
+++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/SecretBundle.cs
@@ -14,8 +14,11 @@ namespace ZB.MOM.WW.Secrets.Cli.Interactive;
///
public sealed record SecretBundle
{
+ /// The only bundle format version this build can read or write.
+ public const int CurrentFormatVersion = 1;
+
/// The on-disk bundle format version (currently 1).
- public int FormatVersion { get; init; } = 1;
+ public int FormatVersion { get; init; } = CurrentFormatVersion;
/// When the bundle was exported (UTC).
public DateTimeOffset ExportedUtc { get; init; }
diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/BundleServiceTests.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/BundleServiceTests.cs
index ad36656..8c159a6 100644
--- a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/BundleServiceTests.cs
+++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/BundleServiceTests.cs
@@ -77,7 +77,13 @@ public sealed class BundleServiceTests : IDisposable
{
LiteralMasterKeyProvider kek = NewKek();
SecretsSession source = BuildSession(Db("a.db"), kek);
- await SealAsync(source, "svc/one", "value-one");
+
+ // Seed non-trivial metadata: created_by/updated_by set, and a second update so Revision == 1
+ // and created_utc != updated_utc — makes the "verbatim" assertion below actually load-bearing.
+ StoredSecret seed = source.Cipher!.Encrypt(new SecretName("svc/one"), "value-one", SecretContentType.Text)
+ with { Description = "the one", CreatedBy = "creator", UpdatedBy = "updater" };
+ await source.Store.UpsertAsync(seed, CancellationToken.None);
+ await source.Store.UpsertAsync(seed, CancellationToken.None);
await SealAsync(source, "svc/two", "value-two");
var service = new BundleService();
@@ -98,7 +104,7 @@ public sealed class BundleServiceTests : IDisposable
Assert.NotNull(src);
Assert.NotNull(dst);
- // Fields UpsertAsync preserves verbatim.
+ // Crypto + identity fields.
Assert.Equal(src!.Name.Value, dst!.Name.Value);
Assert.Equal(src.ContentType, dst.ContentType);
Assert.Equal(src.Description, dst.Description);
@@ -110,9 +116,113 @@ public sealed class BundleServiceTests : IDisposable
Assert.Equal(src.WrapNonce, dst.WrapNonce);
Assert.Equal(src.WrapTag, dst.WrapTag);
+ // Verbatim import: LWW metadata + audit stamps survive unchanged (ApplyReplicatedAsync).
+ Assert.Equal(src.Revision, dst.Revision);
+ Assert.Equal(src.CreatedUtc, dst.CreatedUtc);
+ Assert.Equal(src.UpdatedUtc, dst.UpdatedUtc);
+ Assert.Equal(src.IsDeleted, dst.IsDeleted);
+ Assert.Equal(src.DeletedUtc, dst.DeletedUtc);
+ Assert.Equal(src.CreatedBy, dst.CreatedBy);
+ Assert.Equal(src.UpdatedBy, dst.UpdatedBy);
+
// And it decrypts to the same plaintext under the same KEK.
Assert.Equal(value, target.Cipher!.Decrypt(dst));
}
+
+ // The twice-updated row genuinely exercised non-default metadata.
+ StoredSecret one = (await target.Store.GetAsync(new SecretName("svc/one"), CancellationToken.None))!;
+ Assert.Equal(1, one.Revision);
+ Assert.Equal("creator", one.CreatedBy);
+ Assert.Equal("updater", one.UpdatedBy);
+ }
+
+ [Fact]
+ public async Task Import_preserves_tombstones_verbatim()
+ {
+ LiteralMasterKeyProvider kek = NewKek();
+ SecretsSession source = BuildSession(Db("a.db"), kek);
+ await SealAsync(source, "svc/dead", "dead-value");
+ await source.Store.DeleteAsync(new SecretName("svc/dead"), actor: "remover", CancellationToken.None);
+ StoredSecret src = (await source.Store.GetAsync(new SecretName("svc/dead"), CancellationToken.None))!;
+
+ var service = new BundleService();
+ int exported = await service.ExportAsync(source, BundlePath, includeDeleted: true, CancellationToken.None);
+ Assert.Equal(1, exported);
+
+ SecretsSession target = BuildSession(Db("b.db"), kek);
+ BundleImportReport report =
+ await service.ImportAsync(target, BundlePath, sourceKek: null, conflictOverride: null, CancellationToken.None);
+ Assert.Equal(1, report.Imported);
+
+ // The imported row stays a tombstone (no resurrection) with its delete metadata intact.
+ StoredSecret dst = (await target.Store.GetAsync(new SecretName("svc/dead"), CancellationToken.None))!;
+ Assert.True(dst.IsDeleted);
+ Assert.Equal(src.DeletedUtc, dst.DeletedUtc);
+ Assert.Equal(src.Revision, dst.Revision);
+ Assert.Equal(src.UpdatedUtc, dst.UpdatedUtc);
+
+ // And it is not listed as a live secret.
+ IReadOnlyList live = await target.Store.ListAsync(includeDeleted: false, CancellationToken.None);
+ Assert.Empty(live);
+ }
+
+ [Fact]
+ public async Task Import_rejects_unknown_format_version()
+ {
+ LiteralMasterKeyProvider kek = NewKek();
+ SecretsSession target = BuildSession(Db("b.db"), kek);
+
+ const string json = """
+ {
+ "FormatVersion": 99,
+ "ExportedUtc": "2026-01-01T00:00:00+00:00",
+ "SourceKekId": "kek",
+ "Entries": []
+ }
+ """;
+ await File.WriteAllTextAsync(BundlePath, json, CancellationToken.None);
+
+ var service = new BundleService();
+ InvalidOperationException ex = await Assert.ThrowsAsync(
+ () => service.ImportAsync(target, BundlePath, sourceKek: null, conflictOverride: null, CancellationToken.None));
+ Assert.Contains("99", ex.Message, StringComparison.Ordinal);
+ Assert.Contains("1", ex.Message, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task Import_malformed_entry_fails_with_entry_context()
+ {
+ LiteralMasterKeyProvider kek = NewKek();
+ SecretsSession target = BuildSession(Db("b.db"), kek);
+
+ // A well-formed bundle envelope carrying one entry with non-base64 ciphertext.
+ const string json = """
+ {
+ "FormatVersion": 1,
+ "ExportedUtc": "2026-01-01T00:00:00+00:00",
+ "SourceKekId": "kek",
+ "Entries": [
+ {
+ "Name": "svc/broken", "Description": null, "ContentType": "Text",
+ "Ciphertext": "@@not-base64@@", "Nonce": "AAAA", "Tag": "AAAA",
+ "WrappedDek": "AAAA", "WrapNonce": "AAAA", "WrapTag": "AAAA",
+ "KekId": "kek", "Revision": 0, "IsDeleted": false, "DeletedUtc": null,
+ "CreatedUtc": "2026-01-01T00:00:00+00:00", "UpdatedUtc": "2026-01-01T00:00:00+00:00",
+ "CreatedBy": null, "UpdatedBy": null
+ }
+ ]
+ }
+ """;
+ await File.WriteAllTextAsync(BundlePath, json, CancellationToken.None);
+
+ var service = new BundleService();
+ InvalidOperationException ex = await Assert.ThrowsAsync(
+ () => service.ImportAsync(target, BundlePath, sourceKek: null, conflictOverride: null, CancellationToken.None));
+ Assert.Contains("svc/broken", ex.Message, StringComparison.Ordinal);
+ Assert.Contains(BundlePath, ex.Message, StringComparison.Ordinal);
+
+ // The store was left untouched.
+ Assert.Null(await target.Store.GetAsync(new SecretName("svc/broken"), CancellationToken.None));
}
[Fact]