using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.Secrets.Replicator.AkkaDotNet.Protocol;
///
/// The wire representation of one encrypted secret row.
///
///
///
/// This is a deliberate hand-written mirror of rather than the type
/// itself, and the duplication is the point. It makes the trust boundary structural: the wire
/// contract is a flat set of primitives that a reader can check against "ciphertext only, never the
/// KEK" in one glance, and a field added to in future cannot start
/// crossing the network merely by existing — someone has to add it here on purpose.
///
///
/// All fields are primitives (, arrays, ,
/// ) so the DTO round-trips through any serializer, including Akka's default JSON
/// one if an application never registers . Timestamps are
/// ISO-8601 "O" strings for the same reason the stores use them: last-writer-wins compares
/// them for equality, so the representation has to be exact rather than merely close.
///
///
public sealed record SecretRowDto
{
/// The normalized secret name.
public required string Name { get; init; }
/// Optional human-readable description.
public string? Description { get; init; }
/// The enum name.
public required string ContentType { get; init; }
/// AES-256-GCM ciphertext of the secret plaintext.
public required byte[] Ciphertext { get; init; }
/// Nonce sealing .
public required byte[] Nonce { get; init; }
/// Authentication tag for .
public required byte[] Tag { get; init; }
/// The per-secret data key, wrapped by the KEK. The KEK itself never travels.
public required byte[] WrappedDek { get; init; }
/// Nonce used to wrap .
public required byte[] WrapNonce { get; init; }
/// Authentication tag for .
public required byte[] WrapTag { get; init; }
/// Identifier of the KEK that wrapped the DEK — an identifier, not key material.
public required string KekId { get; init; }
/// Monotonic revision.
public required long Revision { get; init; }
/// Whether the row is a tombstone.
public bool IsDeleted { get; init; }
/// ISO-8601 deletion timestamp, or .
public string? DeletedUtc { get; init; }
/// ISO-8601 creation timestamp.
public required string CreatedUtc { get; init; }
/// ISO-8601 last-update timestamp — half of the last-writer-wins ordering key.
public required string UpdatedUtc { get; init; }
/// Principal that created the row, if known.
public string? CreatedBy { get; init; }
/// Principal that last updated the row, if known.
public string? UpdatedBy { get; init; }
/// Projects a stored row onto the wire contract.
/// The encrypted row.
/// The wire representation.
public static SecretRowDto FromStoredSecret(StoredSecret row)
{
ArgumentNullException.ThrowIfNull(row);
return new SecretRowDto
{
Name = row.Name.Value,
Description = row.Description,
ContentType = row.ContentType.ToString(),
Ciphertext = row.Ciphertext,
Nonce = row.Nonce,
Tag = row.Tag,
WrappedDek = row.WrappedDek,
WrapNonce = row.WrapNonce,
WrapTag = row.WrapTag,
KekId = row.KekId,
Revision = row.Revision,
IsDeleted = row.IsDeleted,
DeletedUtc = row.DeletedUtc?.ToString("O"),
CreatedUtc = row.CreatedUtc.ToString("O"),
UpdatedUtc = row.UpdatedUtc.ToString("O"),
CreatedBy = row.CreatedBy,
UpdatedBy = row.UpdatedBy,
};
}
/// Rebuilds the stored row from the wire contract.
/// The encrypted row.
/// The name or content type is not valid.
public StoredSecret ToStoredSecret() => new()
{
// SecretName re-validates on construction, so a malformed or path-traversing name from a
// peer is rejected here rather than reaching the store.
Name = new SecretName(Name),
Description = Description,
ContentType = ParseContentType(ContentType),
// `required` on a byte[] means "present in the payload", NOT "non-null" — a peer sending an
// explicit JSON null satisfies it and yields a null array, which would then blow up deep in
// the store's parameter binding instead of being rejected at the boundary.
Ciphertext = RequireBlob(Ciphertext, nameof(Ciphertext)),
Nonce = RequireBlob(Nonce, nameof(Nonce)),
Tag = RequireBlob(Tag, nameof(Tag)),
WrappedDek = RequireBlob(WrappedDek, nameof(WrappedDek)),
WrapNonce = RequireBlob(WrapNonce, nameof(WrapNonce)),
WrapTag = RequireBlob(WrapTag, nameof(WrapTag)),
KekId = KekId ?? throw new ArgumentException("Replicated secret row has a null KekId."),
Revision = Revision,
IsDeleted = IsDeleted,
DeletedUtc = DeletedUtc is null ? null : ParseUtc(DeletedUtc),
CreatedUtc = ParseUtc(CreatedUtc),
UpdatedUtc = ParseUtc(UpdatedUtc),
CreatedBy = CreatedBy,
UpdatedBy = UpdatedBy,
};
private static DateTimeOffset ParseUtc(string value) => DateTimeOffset.Parse(
value,
System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.RoundtripKind);
private static byte[] RequireBlob(byte[]? value, string field) =>
value ?? throw new ArgumentException($"Replicated secret row has a null {field}.", field);
// Enum.Parse is wrong for untrusted input on two counts: a numeric string that overflows the
// underlying type throws OverflowException (which the receiving actor's ArgumentException filter
// would not catch, restarting it in a redelivery loop), and any in-range number is accepted even
// if it names no member — persisting an undefined enum value that later readers assume is valid.
private static SecretContentType ParseContentType(string value) =>
Enum.TryParse(value, out SecretContentType parsed) && Enum.IsDefined(parsed)
? parsed
: throw new ArgumentException(
$"Replicated secret row has an unrecognized content type '{value}'.", nameof(value));
}
/// One manifest entry on the wire: enough to decide who is newer, with no ciphertext.
public sealed record SecretManifestEntryDto
{
/// The normalized secret name.
public required string Name { get; init; }
/// Monotonic revision.
public required long Revision { get; init; }
/// ISO-8601 last-update timestamp.
public required string UpdatedUtc { get; init; }
/// Whether the row is a tombstone.
public bool IsDeleted { get; init; }
/// Projects a manifest entry onto the wire contract.
/// The manifest entry.
/// The wire representation.
public static SecretManifestEntryDto FromEntry(SecretManifestEntry entry)
{
ArgumentNullException.ThrowIfNull(entry);
return new SecretManifestEntryDto
{
Name = entry.Name.Value,
Revision = entry.Revision,
UpdatedUtc = entry.UpdatedUtc.ToString("O"),
IsDeleted = entry.IsDeleted,
};
}
/// Rebuilds the manifest entry from the wire contract.
/// The manifest entry.
public SecretManifestEntry ToEntry() => new()
{
Name = new SecretName(Name),
Revision = Revision,
UpdatedUtc = DateTimeOffset.Parse(
UpdatedUtc,
System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.RoundtripKind),
IsDeleted = IsDeleted,
};
}
///
/// A node broadcasting its full inventory so peers can work out, in one round trip, both what they
/// need from it and what it needs from them.
///
/// The sender's manifest.
public sealed record SecretManifestAnnounce(IReadOnlyList Entries);
/// A peer asking for the encrypted rows behind names it found it was missing or behind on.
/// The requested secret names.
public sealed record SecretPullRequest(IReadOnlyList Names);
///
/// Encrypted rows delivered to a peer — the reply to a , the
/// unsolicited push half of an anti-entropy exchange, and the live broadcast of a fresh local write.
///
/// The encrypted rows.
public sealed record SecretRowsMessage(IReadOnlyList Rows);