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);