Files
scadaproj/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Replicator.AkkaDotNet/Protocol/SecretReplicationMessages.cs
T
Joseph Doherty dd0a846b64 feat(secrets): cluster replication via SQL Server and Akka.NET (G-7, 0.2.0)
Secrets were per-node SQLite, so a secret written on one node was invisible to
the rest of a cluster. G-7's design resolved the "shared SQL store vs Akka
replicator" fork to build only the former; both are built here so the choice is
a deployment decision (availability vs partition tolerance) rather than a
library limitation.

Two new packages — ZB.MOM.WW.Secrets.Replicator.SqlServer (shared store, plus a
local-store-with-hub mode) and .Replicator.AkkaDotNet (peer-to-peer over
distributed pub/sub). Core gains ISecretsStoreMigrator, one shared
SecretLastWriterWins predicate so no two stores can disagree on a tie, the
transport-agnostic reconciler, and ReplicatingSecretStore — which closes a real
gap: nothing had ever called ISecretReplicator.PublishAsync, so the seam was
inert and local writes would not have propagated at all.

Verified 182 pass / 1 skip / 0 warnings, including 15 live tests against a real
SQL Server 2022 (the SQLite suite ported case-for-case, so any behavioural
divergence between the stores fails) and a 9-test in-process 2-node Akka
cluster over real remoting. A post-build review caught six defects, all fixed
and now covered: both replication modes could not resolve from the container
(no test had built one), an unbounded fetch that broke past SQL Server's
2100-parameter cap, a poison row that aborted the rest of its batch forever,
Enum.Parse on peer input that could restart the actor in a loop, null crypto
blobs crossing the trust boundary, and a silently dropped pull-read failure.

Packed at 0.2.0 and vulnerability-scanned clean; not yet published to the feed.

Claude-Session: https://claude.ai/code/session_01BL2Vu1ESDQ9SCN4gVKkdts
2026-07-18 04:08:23 -04:00

216 lines
9.4 KiB
C#

using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.Secrets.Replicator.AkkaDotNet.Protocol;
/// <summary>
/// The wire representation of one encrypted secret row.
/// </summary>
/// <remarks>
/// <para>
/// This is a deliberate hand-written mirror of <see cref="StoredSecret"/> rather than the type
/// itself, and the duplication is the point. It makes the trust boundary <b>structural</b>: 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 <see cref="StoredSecret"/> in future cannot start
/// crossing the network merely by existing — someone has to add it here on purpose.
/// </para>
/// <para>
/// All fields are primitives (<see cref="string"/>, <see cref="byte"/> arrays, <see cref="long"/>,
/// <see cref="bool"/>) so the DTO round-trips through any serializer, including Akka's default JSON
/// one if an application never registers <see cref="SecretReplicationSerializer"/>. Timestamps are
/// ISO-8601 <c>"O"</c> 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.
/// </para>
/// </remarks>
public sealed record SecretRowDto
{
/// <summary>The normalized secret name.</summary>
public required string Name { get; init; }
/// <summary>Optional human-readable description.</summary>
public string? Description { get; init; }
/// <summary>The <see cref="SecretContentType"/> enum name.</summary>
public required string ContentType { get; init; }
/// <summary>AES-256-GCM ciphertext of the secret plaintext.</summary>
public required byte[] Ciphertext { get; init; }
/// <summary>Nonce sealing <see cref="Ciphertext"/>.</summary>
public required byte[] Nonce { get; init; }
/// <summary>Authentication tag for <see cref="Ciphertext"/>.</summary>
public required byte[] Tag { get; init; }
/// <summary>The per-secret data key, wrapped by the KEK. The KEK itself never travels.</summary>
public required byte[] WrappedDek { get; init; }
/// <summary>Nonce used to wrap <see cref="WrappedDek"/>.</summary>
public required byte[] WrapNonce { get; init; }
/// <summary>Authentication tag for <see cref="WrappedDek"/>.</summary>
public required byte[] WrapTag { get; init; }
/// <summary>Identifier of the KEK that wrapped the DEK — an identifier, not key material.</summary>
public required string KekId { get; init; }
/// <summary>Monotonic revision.</summary>
public required long Revision { get; init; }
/// <summary>Whether the row is a tombstone.</summary>
public bool IsDeleted { get; init; }
/// <summary>ISO-8601 deletion timestamp, or <see langword="null"/>.</summary>
public string? DeletedUtc { get; init; }
/// <summary>ISO-8601 creation timestamp.</summary>
public required string CreatedUtc { get; init; }
/// <summary>ISO-8601 last-update timestamp — half of the last-writer-wins ordering key.</summary>
public required string UpdatedUtc { get; init; }
/// <summary>Principal that created the row, if known.</summary>
public string? CreatedBy { get; init; }
/// <summary>Principal that last updated the row, if known.</summary>
public string? UpdatedBy { get; init; }
/// <summary>Projects a stored row onto the wire contract.</summary>
/// <param name="row">The encrypted row.</param>
/// <returns>The wire representation.</returns>
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,
};
}
/// <summary>Rebuilds the stored row from the wire contract.</summary>
/// <returns>The encrypted row.</returns>
/// <exception cref="ArgumentException">The name or content type is not valid.</exception>
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));
}
/// <summary>One manifest entry on the wire: enough to decide who is newer, with no ciphertext.</summary>
public sealed record SecretManifestEntryDto
{
/// <summary>The normalized secret name.</summary>
public required string Name { get; init; }
/// <summary>Monotonic revision.</summary>
public required long Revision { get; init; }
/// <summary>ISO-8601 last-update timestamp.</summary>
public required string UpdatedUtc { get; init; }
/// <summary>Whether the row is a tombstone.</summary>
public bool IsDeleted { get; init; }
/// <summary>Projects a manifest entry onto the wire contract.</summary>
/// <param name="entry">The manifest entry.</param>
/// <returns>The wire representation.</returns>
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,
};
}
/// <summary>Rebuilds the manifest entry from the wire contract.</summary>
/// <returns>The manifest entry.</returns>
public SecretManifestEntry ToEntry() => new()
{
Name = new SecretName(Name),
Revision = Revision,
UpdatedUtc = DateTimeOffset.Parse(
UpdatedUtc,
System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.RoundtripKind),
IsDeleted = IsDeleted,
};
}
/// <summary>
/// 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.
/// </summary>
/// <param name="Entries">The sender's manifest.</param>
public sealed record SecretManifestAnnounce(IReadOnlyList<SecretManifestEntryDto> Entries);
/// <summary>A peer asking for the encrypted rows behind names it found it was missing or behind on.</summary>
/// <param name="Names">The requested secret names.</param>
public sealed record SecretPullRequest(IReadOnlyList<string> Names);
/// <summary>
/// Encrypted rows delivered to a peer — the reply to a <see cref="SecretPullRequest"/>, the
/// unsolicited push half of an anti-entropy exchange, and the live broadcast of a fresh local write.
/// </summary>
/// <param name="Rows">The encrypted rows.</param>
public sealed record SecretRowsMessage(IReadOnlyList<SecretRowDto> Rows);