using System.Text.Json;
using System.Text.Json.Serialization;
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.Secrets.Cli.Interactive;
///
/// Ciphertext-only export format for moving secret rows between deployment stores (cloning a
/// deployment, staging a recovery). A bundle carries only the encrypted
/// representation — never plaintext — so it is safe at rest and useless
/// to anyone without the source KEK. Every array rides as a base64 string and
/// every timestamp as an ISO-8601 , so a parsed bundle round-trips
/// byte-identical to what was exported.
///
public sealed record SecretBundle
{
/// The only bundle format version this build can read or write.
public const int CurrentFormatVersion = 1;
/// The on-disk bundle format version (currently 1).
public int FormatVersion { get; init; } = CurrentFormatVersion;
/// When the bundle was exported (UTC).
public DateTimeOffset ExportedUtc { get; init; }
///
/// The 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).
///
public string SourceKekId { get; init; } = "";
/// The exported rows, each mirroring a with base64 crypto fields.
public IReadOnlyList Entries { get; init; } = [];
}
///
/// One exported row — a faithful, ciphertext-only mirror of a . 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.
///
/// The normalized secret name ().
/// Optional human-readable description.
/// The name.
/// Base64 of the AES-256-GCM ciphertext.
/// Base64 of the body nonce.
/// Base64 of the body authentication tag.
/// Base64 of the KEK-wrapped data-encryption key.
/// Base64 of the DEK-wrap nonce.
/// Base64 of the DEK-wrap authentication tag.
/// Identifier of the KEK that wrapped the DEK.
/// The row's monotonic revision.
/// Whether the row is a tombstone.
/// When the row was tombstoned, if it is deleted.
/// When the row was first created (UTC).
/// When the row was last updated (UTC).
/// Principal that created the row, if known.
/// Principal that last updated the row, if known.
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);
///
/// Serializes and parses documents and converts between a
/// row and its mirror. The conversion is
/// lossless in both directions: ((row)) reproduces every
/// field of byte-identically.
///
public static class SecretBundleCodec
{
private static readonly JsonSerializerOptions Options = new()
{
WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
};
/// Serializes to indented JSON.
/// The bundle to serialize.
/// The indented JSON document.
public static string Serialize(SecretBundle bundle)
{
ArgumentNullException.ThrowIfNull(bundle);
return JsonSerializer.Serialize(bundle, Options);
}
/// Parses a from its JSON representation.
/// The JSON document produced by .
/// The parsed bundle.
/// The JSON does not represent a bundle.
public static SecretBundle Deserialize(string json)
{
ArgumentException.ThrowIfNullOrWhiteSpace(json);
return JsonSerializer.Deserialize(json, Options)
?? throw new InvalidOperationException("Bundle JSON deserialized to null.");
}
/// Projects a stored row into its ciphertext-only bundle entry.
/// The stored row to export.
/// The bundle entry mirroring .
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);
}
/// Rebuilds a stored row from a bundle entry.
/// The bundle entry to rehydrate.
/// The the entry was projected from.
public static StoredSecret ToRow(BundleEntry entry)
{
ArgumentNullException.ThrowIfNull(entry);
return new StoredSecret
{
Name = new SecretName(entry.Name),
Description = entry.Description,
ContentType = Enum.Parse(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,
};
}
}