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