165 lines
7.5 KiB
C#
165 lines
7.5 KiB
C#
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 only bundle format version this build can read or write.</summary>
|
|
public const int CurrentFormatVersion = 1;
|
|
|
|
/// <summary>The on-disk bundle format version (currently <c>1</c>).</summary>
|
|
public int FormatVersion { get; init; } = CurrentFormatVersion;
|
|
|
|
/// <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,
|
|
};
|
|
}
|
|
}
|