From 6255409f169b44efa8ead1f839396ff2fbd157bf Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 19 Jul 2026 09:14:43 -0400 Subject: [PATCH] feat(secrets-cli): ciphertext-only bundle export/import with LWW + cross-KEK rewrap --- .../Interactive/BundleService.cs | 189 +++++++++++++ .../Interactive/SecretBundle.cs | 161 +++++++++++ .../Cli/Interactive/BundleServiceTests.cs | 263 ++++++++++++++++++ 3 files changed, 613 insertions(+) create mode 100644 ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/BundleService.cs create mode 100644 ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/SecretBundle.cs create mode 100644 ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/BundleServiceTests.cs 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 new file mode 100644 index 0000000..7f3e4fe --- /dev/null +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/BundleService.cs @@ -0,0 +1,189 @@ +using ZB.MOM.WW.Secrets.Abstractions; + +namespace ZB.MOM.WW.Secrets.Cli.Interactive; + +/// +/// Tally of an run. +/// +/// Rows written (new inserts plus conflict winners). +/// Rows a last-writer-wins (or override) decision rejected as not newer. +/// +/// Rows wrapped under a KEK other than the target's, with no matching source KEK supplied to re-wrap them. +/// +/// Rows whose name already existed in the target store (won or lost). +public sealed record BundleImportReport(int Imported, int SkippedOlder, int SkippedForeignKek, int Conflicts); + +/// +/// Exports and imports documents — ciphertext-only movement of secret rows +/// between deployment stores. Import reconciles per row with the shared last-writer-wins ordering (an +/// optional per-row override can force a decision) and, when a row is wrapped under a foreign KEK, +/// re-wraps it under the target's KEK given the source KEK — otherwise it is skipped and reported. +/// Both operations require a full (non-degraded) session: import needs the target cipher to +/// re-wrap, and export is a data-movement operation that must not run half-configured. +/// +public sealed class BundleService +{ + private readonly TimeProvider _timeProvider; + + /// Creates the service. + /// Clock used to stamp ; defaults to . + public BundleService(TimeProvider? timeProvider = null) => + _timeProvider = timeProvider ?? TimeProvider.System; + + /// + /// Exports every secret in 's store to a ciphertext-only bundle at + /// , written atomically (temp file then move). Tombstones are excluded unless + /// is set. + /// + /// The full session to export from. + /// Destination bundle path. + /// When , tombstoned rows are exported too. + /// A token to cancel the operation. + /// The number of rows written to the bundle. + /// The session is degraded (no KEK). + public async Task ExportAsync(SecretsSession session, string path, bool includeDeleted, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(session); + ArgumentException.ThrowIfNullOrWhiteSpace(path); + + IMasterKeyProvider masterKey = session.MasterKey + ?? throw new InvalidOperationException( + "Cannot export a bundle from a degraded session (no KEK available)."); + + IReadOnlyList metadata = + await session.Store.ListAsync(includeDeleted, ct).ConfigureAwait(false); + + var entries = new List(metadata.Count); + foreach (SecretMetadata meta in metadata) + { + StoredSecret? row = await session.Store.GetAsync(meta.Name, ct).ConfigureAwait(false); + if (row is null) + { + continue; + } + + entries.Add(SecretBundleCodec.FromRow(row)); + } + + var bundle = new SecretBundle + { + ExportedUtc = _timeProvider.GetUtcNow(), + SourceKekId = masterKey.KekId, + Entries = entries, + }; + + await WriteAtomicAsync(path, SecretBundleCodec.Serialize(bundle), ct).ConfigureAwait(false); + return entries.Count; + } + + /// + /// 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 + /// exist is imported. A row whose name already exists is a conflict resolved by + /// when supplied, else by ; + /// the winner is written and the loser skipped (). + /// No plaintext is ever handled — the bundle is ciphertext only. + /// + /// The full session to import into. + /// Source bundle path. + /// The KEK the bundle was exported under, to re-wrap foreign-KEK rows; may be . + /// + /// Optional per-row decision for an existing row: given (existing, incoming), return + /// to take the incoming row. When , last-writer-wins decides. + /// + /// A token to cancel the operation. + /// A tally of the import. + /// The session is degraded (no KEK/cipher). + public async Task ImportAsync( + SecretsSession session, + string path, + IMasterKeyProvider? sourceKek, + Func? conflictOverride, + CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(session); + ArgumentException.ThrowIfNullOrWhiteSpace(path); + + IMasterKeyProvider targetKek = session.MasterKey + ?? throw new InvalidOperationException( + "Cannot import a bundle into a degraded session (no KEK available)."); + ISecretCipher cipher = session.Cipher + ?? throw new InvalidOperationException( + "Cannot import a bundle into a degraded session (no cipher available)."); + + string json = await File.ReadAllTextAsync(path, ct).ConfigureAwait(false); + SecretBundle bundle = SecretBundleCodec.Deserialize(json); + + int imported = 0, skippedOlder = 0, skippedForeignKek = 0, conflicts = 0; + + foreach (BundleEntry entry in bundle.Entries) + { + StoredSecret row = SecretBundleCodec.ToRow(entry); + + // 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)) + { + if (sourceKek is not null && string.Equals(sourceKek.KekId, row.KekId, StringComparison.Ordinal)) + { + row = cipher.Rewrap(row, sourceKek, targetKek); + } + else + { + skippedForeignKek++; + continue; + } + } + + StoredSecret? existing = await session.Store.GetAsync(row.Name, ct).ConfigureAwait(false); + if (existing is null) + { + await session.Store.UpsertAsync(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); + + if (takeIncoming) + { + await session.Store.UpsertAsync(row, ct).ConfigureAwait(false); + imported++; + } + else + { + skippedOlder++; + } + } + + return new BundleImportReport(imported, skippedOlder, skippedForeignKek, conflicts); + } + + // Writes content to a sibling temp file then moves it over the target, so a reader never observes + // a half-written bundle and a crash mid-write cannot corrupt an existing bundle. + private static async Task WriteAtomicAsync(string path, string content, CancellationToken ct) + { + string directory = Path.GetDirectoryName(Path.GetFullPath(path)) ?? "."; + Directory.CreateDirectory(directory); + string temp = Path.Combine(directory, $".{Path.GetFileName(path)}.{Guid.NewGuid():N}.tmp"); + + try + { + await File.WriteAllTextAsync(temp, content, ct).ConfigureAwait(false); + File.Move(temp, path, overwrite: true); + } + finally + { + if (File.Exists(temp)) + { + try { File.Delete(temp); } catch (IOException) { /* best-effort cleanup of the temp file */ } + } + } + } +} 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 new file mode 100644 index 0000000..0e65815 --- /dev/null +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/SecretBundle.cs @@ -0,0 +1,161 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using ZB.MOM.WW.Secrets.Abstractions; + +namespace ZB.MOM.WW.Secrets.Cli.Interactive; + +/// +/// Ciphertext-only export format for moving secret rows between deployment stores (cloning a +/// deployment, staging a recovery). A bundle carries only the encrypted +/// representation — never plaintext — so it is safe at rest and useless +/// to anyone without the source KEK. Every array rides as a base64 string and +/// every timestamp as an ISO-8601 , so a parsed bundle round-trips +/// byte-identical to what was exported. +/// +public sealed record SecretBundle +{ + /// The on-disk bundle format version (currently 1). + public int FormatVersion { get; init; } = 1; + + /// When the bundle was exported (UTC). + public DateTimeOffset ExportedUtc { get; init; } + + /// + /// The of the store the bundle was exported from — the KEK + /// an operator must supply to import into a store on a different KEK (so rows can be re-wrapped). + /// + public string SourceKekId { get; init; } = ""; + + /// The exported rows, each mirroring a with base64 crypto fields. + public IReadOnlyList Entries { get; init; } = []; +} + +/// +/// One exported row — a faithful, ciphertext-only mirror of a . The six +/// crypto BLOBs are carried as base64 strings and the name as its normalized string form; all other +/// fields keep their native JSON representation so the row survives an export/parse round-trip. +/// +/// The normalized secret name (). +/// Optional human-readable description. +/// The name. +/// Base64 of the AES-256-GCM ciphertext. +/// Base64 of the body nonce. +/// Base64 of the body authentication tag. +/// Base64 of the KEK-wrapped data-encryption key. +/// Base64 of the DEK-wrap nonce. +/// Base64 of the DEK-wrap authentication tag. +/// Identifier of the KEK that wrapped the DEK. +/// The row's monotonic revision. +/// Whether the row is a tombstone. +/// When the row was tombstoned, if it is deleted. +/// When the row was first created (UTC). +/// When the row was last updated (UTC). +/// Principal that created the row, if known. +/// Principal that last updated the row, if known. +public sealed record BundleEntry( + string Name, + string? Description, + string ContentType, + string Ciphertext, + string Nonce, + string Tag, + string WrappedDek, + string WrapNonce, + string WrapTag, + string KekId, + long Revision, + bool IsDeleted, + DateTimeOffset? DeletedUtc, + DateTimeOffset CreatedUtc, + DateTimeOffset UpdatedUtc, + string? CreatedBy, + string? UpdatedBy); + +/// +/// Serializes and parses documents and converts between a +/// row and its mirror. The conversion is +/// lossless in both directions: ((row)) reproduces every +/// field of byte-identically. +/// +public static class SecretBundleCodec +{ + private static readonly JsonSerializerOptions Options = new() + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.Never, + }; + + /// Serializes to indented JSON. + /// The bundle to serialize. + /// The indented JSON document. + public static string Serialize(SecretBundle bundle) + { + ArgumentNullException.ThrowIfNull(bundle); + return JsonSerializer.Serialize(bundle, Options); + } + + /// Parses a from its JSON representation. + /// The JSON document produced by . + /// The parsed bundle. + /// The JSON does not represent a bundle. + public static SecretBundle Deserialize(string json) + { + ArgumentException.ThrowIfNullOrWhiteSpace(json); + return JsonSerializer.Deserialize(json, Options) + ?? throw new InvalidOperationException("Bundle JSON deserialized to null."); + } + + /// Projects a stored row into its ciphertext-only bundle entry. + /// The stored row to export. + /// The bundle entry mirroring . + public static BundleEntry FromRow(StoredSecret row) + { + ArgumentNullException.ThrowIfNull(row); + return new BundleEntry( + Name: row.Name.Value, + Description: row.Description, + ContentType: row.ContentType.ToString(), + Ciphertext: Convert.ToBase64String(row.Ciphertext), + Nonce: Convert.ToBase64String(row.Nonce), + Tag: Convert.ToBase64String(row.Tag), + WrappedDek: Convert.ToBase64String(row.WrappedDek), + WrapNonce: Convert.ToBase64String(row.WrapNonce), + WrapTag: Convert.ToBase64String(row.WrapTag), + KekId: row.KekId, + Revision: row.Revision, + IsDeleted: row.IsDeleted, + DeletedUtc: row.DeletedUtc, + CreatedUtc: row.CreatedUtc, + UpdatedUtc: row.UpdatedUtc, + CreatedBy: row.CreatedBy, + UpdatedBy: row.UpdatedBy); + } + + /// Rebuilds a stored row from a bundle entry. + /// The bundle entry to rehydrate. + /// The the entry was projected from. + public static StoredSecret ToRow(BundleEntry entry) + { + ArgumentNullException.ThrowIfNull(entry); + return new StoredSecret + { + Name = new SecretName(entry.Name), + Description = entry.Description, + ContentType = Enum.Parse(entry.ContentType), + Ciphertext = Convert.FromBase64String(entry.Ciphertext), + Nonce = Convert.FromBase64String(entry.Nonce), + Tag = Convert.FromBase64String(entry.Tag), + WrappedDek = Convert.FromBase64String(entry.WrappedDek), + WrapNonce = Convert.FromBase64String(entry.WrapNonce), + WrapTag = Convert.FromBase64String(entry.WrapTag), + KekId = entry.KekId, + Revision = entry.Revision, + IsDeleted = entry.IsDeleted, + DeletedUtc = entry.DeletedUtc, + CreatedUtc = entry.CreatedUtc, + UpdatedUtc = entry.UpdatedUtc, + CreatedBy = entry.CreatedBy, + UpdatedBy = entry.UpdatedBy, + }; + } +} 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 new file mode 100644 index 0000000..ad36656 --- /dev/null +++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/BundleServiceTests.cs @@ -0,0 +1,263 @@ +using System.Security.Cryptography; +using ZB.MOM.WW.Secrets.Abstractions; +using ZB.MOM.WW.Secrets.Cli.Interactive; +using ZB.MOM.WW.Secrets.Crypto; +using ZB.MOM.WW.Secrets.MasterKey; +using ZB.MOM.WW.Secrets.Sqlite; + +namespace ZB.MOM.WW.Secrets.Tests.Cli.Interactive; + +/// +/// Exercises against real temp-dir SQLite stores: the same-KEK +/// export/import round-trip, the no-plaintext-at-rest guarantee, tombstone filtering, +/// last-writer-wins conflict resolution (and the per-row override that can force a bundle row), +/// and cross-KEK import (rewrap when the source KEK is supplied, skip-and-report when it is not). +/// +public sealed class BundleServiceTests : IDisposable +{ + private readonly string _dir = + Path.Combine(Path.GetTempPath(), $"zb-secrets-bundle-{Guid.NewGuid():N}"); + + public BundleServiceTests() => Directory.CreateDirectory(_dir); + + public void Dispose() + { + if (Directory.Exists(_dir)) + { + try { Directory.Delete(_dir, recursive: true); } catch (IOException) { /* best-effort temp cleanup */ } + } + } + + private string Db(string name) => Path.Combine(_dir, name); + + private string BundlePath => Path.Combine(_dir, "bundle.json"); + + private static LiteralMasterKeyProvider NewKek() + { + byte[] key = new byte[32]; + RandomNumberGenerator.Fill(key); + return new LiteralMasterKeyProvider(Convert.ToBase64String(key)); + } + + private static SecretsSession BuildSession(string dbPath, LiteralMasterKeyProvider kek) + { + var factory = new SecretsSqliteConnectionFactory(dbPath); + var migrator = new SqliteSecretsStoreMigrator(factory); + migrator.MigrateAsync(CancellationToken.None).GetAwaiter().GetResult(); + return new SecretsSession + { + Target = new TargetConfigReader().Manual(dbPath, new MasterKeyOptions()), + Store = new SqliteSecretStore(factory), + Migrator = migrator, + MasterKey = kek, + Cipher = new AesGcmEnvelopeCipher(kek), + StoreKind = "sqlite", + }; + } + + private static async Task SealAsync(SecretsSession session, string name, string value) + { + StoredSecret row = session.Cipher!.Encrypt(new SecretName(name), value, SecretContentType.Text); + await session.Store.UpsertAsync(row, CancellationToken.None); + } + + private void WriteBundle(string sourceKekId, params StoredSecret[] rows) + { + var bundle = new SecretBundle + { + ExportedUtc = DateTimeOffset.UtcNow, + SourceKekId = sourceKekId, + Entries = rows.Select(SecretBundleCodec.FromRow).ToList(), + }; + File.WriteAllText(BundlePath, SecretBundleCodec.Serialize(bundle)); + } + + [Fact] + public async Task Export_then_import_into_empty_store_roundtrips_rows() + { + LiteralMasterKeyProvider kek = NewKek(); + SecretsSession source = BuildSession(Db("a.db"), kek); + await SealAsync(source, "svc/one", "value-one"); + await SealAsync(source, "svc/two", "value-two"); + + var service = new BundleService(); + int exported = await service.ExportAsync(source, BundlePath, includeDeleted: false, CancellationToken.None); + Assert.Equal(2, exported); + + // Same KEK, fresh empty store. + SecretsSession target = BuildSession(Db("b.db"), kek); + BundleImportReport report = + await service.ImportAsync(target, BundlePath, sourceKek: null, conflictOverride: null, CancellationToken.None); + Assert.Equal(2, report.Imported); + Assert.Equal(0, report.SkippedForeignKek); + + foreach ((string name, string value) in new[] { ("svc/one", "value-one"), ("svc/two", "value-two") }) + { + StoredSecret? src = await source.Store.GetAsync(new SecretName(name), CancellationToken.None); + StoredSecret? dst = await target.Store.GetAsync(new SecretName(name), CancellationToken.None); + Assert.NotNull(src); + Assert.NotNull(dst); + + // Fields UpsertAsync preserves verbatim. + Assert.Equal(src!.Name.Value, dst!.Name.Value); + Assert.Equal(src.ContentType, dst.ContentType); + Assert.Equal(src.Description, dst.Description); + Assert.Equal(src.KekId, dst.KekId); + Assert.Equal(src.Ciphertext, dst.Ciphertext); + Assert.Equal(src.Nonce, dst.Nonce); + Assert.Equal(src.Tag, dst.Tag); + Assert.Equal(src.WrappedDek, dst.WrappedDek); + Assert.Equal(src.WrapNonce, dst.WrapNonce); + Assert.Equal(src.WrapTag, dst.WrapTag); + + // And it decrypts to the same plaintext under the same KEK. + Assert.Equal(value, target.Cipher!.Decrypt(dst)); + } + } + + [Fact] + public async Task Bundle_json_contains_no_plaintext() + { + const string sentinel = "SENTINEL-3f9a2c-DO-NOT-LEAK"; + LiteralMasterKeyProvider kek = NewKek(); + SecretsSession source = BuildSession(Db("a.db"), kek); + await SealAsync(source, "svc/secret", sentinel); + + var service = new BundleService(); + await service.ExportAsync(source, BundlePath, includeDeleted: false, CancellationToken.None); + + string raw = await File.ReadAllTextAsync(BundlePath, CancellationToken.None); + Assert.DoesNotContain(sentinel, raw, StringComparison.Ordinal); + + // The base64 ciphertext of the sealed row is present in the file. + StoredSecret? row = await source.Store.GetAsync(new SecretName("svc/secret"), CancellationToken.None); + Assert.NotNull(row); + Assert.Contains(Convert.ToBase64String(row!.Ciphertext), raw, StringComparison.Ordinal); + Assert.Contains("\"Ciphertext\"", raw, StringComparison.Ordinal); + } + + [Fact] + public async Task Export_excludes_tombstones_by_default_and_includes_them_when_asked() + { + LiteralMasterKeyProvider kek = NewKek(); + SecretsSession source = BuildSession(Db("a.db"), kek); + await SealAsync(source, "svc/live", "live-value"); + await SealAsync(source, "svc/dead", "dead-value"); + await source.Store.DeleteAsync(new SecretName("svc/dead"), actor: null, CancellationToken.None); + + var service = new BundleService(); + + int excluded = await service.ExportAsync(source, BundlePath, includeDeleted: false, CancellationToken.None); + Assert.Equal(1, excluded); + SecretBundle withoutDeleted = SecretBundleCodec.Deserialize(await File.ReadAllTextAsync(BundlePath, CancellationToken.None)); + Assert.Equal(["svc/live"], withoutDeleted.Entries.Select(e => e.Name).OrderBy(n => n).ToArray()); + + int included = await service.ExportAsync(source, BundlePath, includeDeleted: true, CancellationToken.None); + Assert.Equal(2, included); + SecretBundle withDeleted = SecretBundleCodec.Deserialize(await File.ReadAllTextAsync(BundlePath, CancellationToken.None)); + Assert.Equal(["svc/dead", "svc/live"], withDeleted.Entries.Select(e => e.Name).OrderBy(n => n).ToArray()); + } + + [Fact] + public async Task Import_conflict_resolved_by_last_writer_wins() + { + LiteralMasterKeyProvider kek = NewKek(); + SecretsSession target = BuildSession(Db("b.db"), kek); + await SealAsync(target, "svc/conf", "existing-value"); + StoredSecret existing = (await target.Store.GetAsync(new SecretName("svc/conf"), CancellationToken.None))!; + + var service = new BundleService(); + + // Incoming OLDER than the existing row -> existing survives. + StoredSecret older = target.Cipher!.Encrypt(new SecretName("svc/conf"), "older-bundle-value", SecretContentType.Text) + with { UpdatedUtc = existing.UpdatedUtc.AddMinutes(-1), Revision = existing.Revision }; + WriteBundle(kek.KekId, older); + BundleImportReport olderReport = + await service.ImportAsync(target, BundlePath, sourceKek: null, conflictOverride: null, CancellationToken.None); + Assert.Equal(0, olderReport.Imported); + Assert.Equal(1, olderReport.SkippedOlder); + Assert.Equal(1, olderReport.Conflicts); + StoredSecret afterOlder = (await target.Store.GetAsync(new SecretName("svc/conf"), CancellationToken.None))!; + Assert.Equal("existing-value", target.Cipher!.Decrypt(afterOlder)); + + // Incoming NEWER than the existing row -> bundle row replaces it. + StoredSecret newer = target.Cipher!.Encrypt(new SecretName("svc/conf"), "newer-bundle-value", SecretContentType.Text) + with { UpdatedUtc = existing.UpdatedUtc.AddMinutes(1), Revision = existing.Revision }; + WriteBundle(kek.KekId, newer); + BundleImportReport newerReport = + await service.ImportAsync(target, BundlePath, sourceKek: null, conflictOverride: null, CancellationToken.None); + Assert.Equal(1, newerReport.Imported); + Assert.Equal(0, newerReport.SkippedOlder); + Assert.Equal(1, newerReport.Conflicts); + StoredSecret afterNewer = (await target.Store.GetAsync(new SecretName("svc/conf"), CancellationToken.None))!; + Assert.Equal("newer-bundle-value", target.Cipher!.Decrypt(afterNewer)); + } + + [Fact] + public async Task Import_conflict_callback_can_force_bundle_row() + { + LiteralMasterKeyProvider kek = NewKek(); + SecretsSession target = BuildSession(Db("b.db"), kek); + await SealAsync(target, "svc/conf", "existing-value"); + StoredSecret existing = (await target.Store.GetAsync(new SecretName("svc/conf"), CancellationToken.None))!; + + // Incoming is OLDER, so LWW would skip it — but the override forces take-incoming. + StoredSecret older = target.Cipher!.Encrypt(new SecretName("svc/conf"), "forced-bundle-value", SecretContentType.Text) + with { UpdatedUtc = existing.UpdatedUtc.AddMinutes(-1), Revision = existing.Revision }; + WriteBundle(kek.KekId, older); + + var service = new BundleService(); + BundleImportReport report = await service.ImportAsync( + target, BundlePath, sourceKek: null, conflictOverride: (_, _) => true, CancellationToken.None); + + Assert.Equal(1, report.Imported); + Assert.Equal(0, report.SkippedOlder); + Assert.Equal(1, report.Conflicts); + StoredSecret after = (await target.Store.GetAsync(new SecretName("svc/conf"), CancellationToken.None))!; + Assert.Equal("forced-bundle-value", target.Cipher!.Decrypt(after)); + } + + [Fact] + public async Task Cross_kek_import_rewraps_when_source_kek_supplied() + { + LiteralMasterKeyProvider kekA = NewKek(); + LiteralMasterKeyProvider kekB = NewKek(); + SecretsSession source = BuildSession(Db("a.db"), kekA); + await SealAsync(source, "svc/cross", "cross-value"); + + var service = new BundleService(); + await service.ExportAsync(source, BundlePath, includeDeleted: false, CancellationToken.None); + + SecretsSession target = BuildSession(Db("b.db"), kekB); + BundleImportReport report = + await service.ImportAsync(target, BundlePath, sourceKek: kekA, conflictOverride: null, CancellationToken.None); + + Assert.Equal(1, report.Imported); + Assert.Equal(0, report.SkippedForeignKek); + StoredSecret? dst = await target.Store.GetAsync(new SecretName("svc/cross"), CancellationToken.None); + Assert.NotNull(dst); + Assert.Equal(kekB.KekId, dst!.KekId); + Assert.Equal("cross-value", target.Cipher!.Decrypt(dst)); + } + + [Fact] + public async Task Cross_kek_import_without_source_kek_skips_rows_and_reports_them() + { + LiteralMasterKeyProvider kekA = NewKek(); + LiteralMasterKeyProvider kekB = NewKek(); + SecretsSession source = BuildSession(Db("a.db"), kekA); + await SealAsync(source, "svc/cross", "cross-value"); + + var service = new BundleService(); + await service.ExportAsync(source, BundlePath, includeDeleted: false, CancellationToken.None); + + SecretsSession target = BuildSession(Db("b.db"), kekB); + BundleImportReport report = + await service.ImportAsync(target, BundlePath, sourceKek: null, conflictOverride: null, CancellationToken.None); + + Assert.Equal(0, report.Imported); + Assert.Equal(1, report.SkippedForeignKek); + StoredSecret? dst = await target.Store.GetAsync(new SecretName("svc/cross"), CancellationToken.None); + Assert.Null(dst); + } +}