feat(secrets-cli): ciphertext-only bundle export/import with LWW + cross-KEK rewrap
This commit is contained in:
+263
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user