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,
};
}
}