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