dd0a846b64
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
216 lines
9.4 KiB
C#
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);
|