feat(secrets-cli): ciphertext-only bundle export/import with LWW + cross-KEK rewrap

This commit is contained in:
Joseph Doherty
2026-07-19 09:14:43 -04:00
parent f0f2b23d03
commit 6255409f16
3 changed files with 613 additions and 0 deletions
@@ -0,0 +1,189 @@
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.Secrets.Cli.Interactive;
/// <summary>
/// Tally of an <see cref="BundleService.ImportAsync"/> run.
/// </summary>
/// <param name="Imported">Rows written (new inserts plus conflict winners).</param>
/// <param name="SkippedOlder">Rows a last-writer-wins (or override) decision rejected as not newer.</param>
/// <param name="SkippedForeignKek">
/// Rows wrapped under a KEK other than the target's, with no matching source KEK supplied to re-wrap them.
/// </param>
/// <param name="Conflicts">Rows whose name already existed in the target store (won or lost).</param>
public sealed record BundleImportReport(int Imported, int SkippedOlder, int SkippedForeignKek, int Conflicts);
/// <summary>
/// Exports and imports <see cref="SecretBundle"/> 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 <b>full</b> (non-degraded) session: import needs the target cipher to
/// re-wrap, and export is a data-movement operation that must not run half-configured.
/// </summary>
public sealed class BundleService
{
private readonly TimeProvider _timeProvider;
/// <summary>Creates the service.</summary>
/// <param name="timeProvider">Clock used to stamp <see cref="SecretBundle.ExportedUtc"/>; defaults to <see cref="TimeProvider.System"/>.</param>
public BundleService(TimeProvider? timeProvider = null) =>
_timeProvider = timeProvider ?? TimeProvider.System;
/// <summary>
/// Exports every secret in <paramref name="session"/>'s store to a ciphertext-only bundle at
/// <paramref name="path"/>, written atomically (temp file then move). Tombstones are excluded unless
/// <paramref name="includeDeleted"/> is set.
/// </summary>
/// <param name="session">The full session to export from.</param>
/// <param name="path">Destination bundle path.</param>
/// <param name="includeDeleted">When <see langword="true"/>, tombstoned rows are exported too.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>The number of rows written to the bundle.</returns>
/// <exception cref="InvalidOperationException">The session is degraded (no KEK).</exception>
public async Task<int> 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<SecretMetadata> metadata =
await session.Store.ListAsync(includeDeleted, ct).ConfigureAwait(false);
var entries = new List<BundleEntry>(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;
}
/// <summary>
/// Imports the bundle at <paramref name="path"/> into <paramref name="session"/>'s store.
/// </summary>
/// <remarks>
/// Per row: a row wrapped under a foreign KEK is re-wrapped under the target KEK when
/// <paramref name="sourceKek"/> matches its <see cref="StoredSecret.KekId"/>, otherwise it is
/// skipped (<see cref="BundleImportReport.SkippedForeignKek"/>). A row whose name does not yet
/// exist is imported. A row whose name already exists is a conflict resolved by
/// <paramref name="conflictOverride"/> when supplied, else by <see cref="SecretLastWriterWins"/>;
/// the winner is written and the loser skipped (<see cref="BundleImportReport.SkippedOlder"/>).
/// No plaintext is ever handled — the bundle is ciphertext only.
/// </remarks>
/// <param name="session">The full session to import into.</param>
/// <param name="path">Source bundle path.</param>
/// <param name="sourceKek">The KEK the bundle was exported under, to re-wrap foreign-KEK rows; may be <see langword="null"/>.</param>
/// <param name="conflictOverride">
/// Optional per-row decision for an existing row: given (existing, incoming), return
/// <see langword="true"/> to take the incoming row. When <see langword="null"/>, last-writer-wins decides.
/// </param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>A tally of the import.</returns>
/// <exception cref="InvalidOperationException">The session is degraded (no KEK/cipher).</exception>
public async Task<BundleImportReport> ImportAsync(
SecretsSession session,
string path,
IMasterKeyProvider? sourceKek,
Func<StoredSecret, StoredSecret, bool>? 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 */ }
}
}
}
}
@@ -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;
/// <summary>
/// Ciphertext-only export format for moving secret rows between deployment stores (cloning a
/// deployment, staging a recovery). A bundle carries <b>only</b> the encrypted
/// <see cref="StoredSecret"/> representation — never plaintext — so it is safe at rest and useless
/// to anyone without the source KEK. Every <see cref="byte"/> array rides as a base64 string and
/// every timestamp as an ISO-8601 <see cref="DateTimeOffset"/>, so a parsed bundle round-trips
/// byte-identical to what was exported.
/// </summary>
public sealed record SecretBundle
{
/// <summary>The on-disk bundle format version (currently <c>1</c>).</summary>
public int FormatVersion { get; init; } = 1;
/// <summary>When the bundle was exported (UTC).</summary>
public DateTimeOffset ExportedUtc { get; init; }
/// <summary>
/// The <see cref="IMasterKeyProvider.KekId"/> 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).
/// </summary>
public string SourceKekId { get; init; } = "";
/// <summary>The exported rows, each mirroring a <see cref="StoredSecret"/> with base64 crypto fields.</summary>
public IReadOnlyList<BundleEntry> Entries { get; init; } = [];
}
/// <summary>
/// One exported row — a faithful, ciphertext-only mirror of a <see cref="StoredSecret"/>. 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.
/// </summary>
/// <param name="Name">The normalized secret name (<see cref="SecretName.Value"/>).</param>
/// <param name="Description">Optional human-readable description.</param>
/// <param name="ContentType">The <see cref="SecretContentType"/> name.</param>
/// <param name="Ciphertext">Base64 of the AES-256-GCM ciphertext.</param>
/// <param name="Nonce">Base64 of the body nonce.</param>
/// <param name="Tag">Base64 of the body authentication tag.</param>
/// <param name="WrappedDek">Base64 of the KEK-wrapped data-encryption key.</param>
/// <param name="WrapNonce">Base64 of the DEK-wrap nonce.</param>
/// <param name="WrapTag">Base64 of the DEK-wrap authentication tag.</param>
/// <param name="KekId">Identifier of the KEK that wrapped the DEK.</param>
/// <param name="Revision">The row's monotonic revision.</param>
/// <param name="IsDeleted">Whether the row is a tombstone.</param>
/// <param name="DeletedUtc">When the row was tombstoned, if it is deleted.</param>
/// <param name="CreatedUtc">When the row was first created (UTC).</param>
/// <param name="UpdatedUtc">When the row was last updated (UTC).</param>
/// <param name="CreatedBy">Principal that created the row, if known.</param>
/// <param name="UpdatedBy">Principal that last updated the row, if known.</param>
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);
/// <summary>
/// Serializes and parses <see cref="SecretBundle"/> documents and converts between a
/// <see cref="StoredSecret"/> row and its <see cref="BundleEntry"/> mirror. The conversion is
/// lossless in both directions: <see cref="ToRow"/>(<see cref="FromRow"/>(row)) reproduces every
/// field of <paramref name="row"/> byte-identically.
/// </summary>
public static class SecretBundleCodec
{
private static readonly JsonSerializerOptions Options = new()
{
WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
};
/// <summary>Serializes <paramref name="bundle"/> to indented JSON.</summary>
/// <param name="bundle">The bundle to serialize.</param>
/// <returns>The indented JSON document.</returns>
public static string Serialize(SecretBundle bundle)
{
ArgumentNullException.ThrowIfNull(bundle);
return JsonSerializer.Serialize(bundle, Options);
}
/// <summary>Parses a <see cref="SecretBundle"/> from its JSON representation.</summary>
/// <param name="json">The JSON document produced by <see cref="Serialize"/>.</param>
/// <returns>The parsed bundle.</returns>
/// <exception cref="InvalidOperationException">The JSON does not represent a bundle.</exception>
public static SecretBundle Deserialize(string json)
{
ArgumentException.ThrowIfNullOrWhiteSpace(json);
return JsonSerializer.Deserialize<SecretBundle>(json, Options)
?? throw new InvalidOperationException("Bundle JSON deserialized to null.");
}
/// <summary>Projects a stored row into its ciphertext-only bundle entry.</summary>
/// <param name="row">The stored row to export.</param>
/// <returns>The bundle entry mirroring <paramref name="row"/>.</returns>
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);
}
/// <summary>Rebuilds a stored row from a bundle entry.</summary>
/// <param name="entry">The bundle entry to rehydrate.</param>
/// <returns>The <see cref="StoredSecret"/> the entry was projected from.</returns>
public static StoredSecret ToRow(BundleEntry entry)
{
ArgumentNullException.ThrowIfNull(entry);
return new StoredSecret
{
Name = new SecretName(entry.Name),
Description = entry.Description,
ContentType = Enum.Parse<SecretContentType>(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,
};
}
}
@@ -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;
/// <summary>
/// Exercises <see cref="BundleService"/> 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).
/// </summary>
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);
}
}