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
This commit is contained in:
Joseph Doherty
2026-07-18 04:08:23 -04:00
parent e46060fada
commit dd0a846b64
55 changed files with 4848 additions and 51 deletions
+1 -1
View File
@@ -5,7 +5,7 @@
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
<Version>0.1.3</Version>
<Version>0.2.0</Version>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<PackageReadmeFile>README.md</PackageReadmeFile>
</PropertyGroup>
+20 -1
View File
@@ -16,8 +16,25 @@
which carries advisory GHSA-2m69-gcr7-jv3q (NU1903). Pin the patched 2.1.12 native lib. -->
<PackageVersion Include="SQLitePCLRaw.lib.e_sqlite3" Version="2.1.12" />
<!-- Shared SQL-Server secret store (ZB.MOM.WW.Secrets.Replication.SqlServer).
6.0.2 matches the version ScadaBridge and ZB.MOM.WW.GalaxyRepository already resolve, so a
consumer taking both gets one SqlClient rather than a downgrade warning. -->
<PackageVersion Include="Microsoft.Data.SqlClient" Version="6.0.2" />
<!-- Akka cluster replication (ZB.MOM.WW.Secrets.Replication.Akka).
1.5.62 matches ScadaBridge's pinned Akka.NET line — the clustered consumer must not be
forced into an Akka version bump just to adopt secret replication. -->
<PackageVersion Include="Akka" Version="1.5.62" />
<PackageVersion Include="Akka.Cluster" Version="1.5.62" />
<PackageVersion Include="Akka.Cluster.Tools" Version="1.5.62" />
<PackageVersion Include="Akka.Remote" Version="1.5.62" />
<PackageVersion Include="Akka.TestKit.Xunit2" Version="1.5.62" />
<!-- Cryptography -->
<PackageVersion Include="System.Security.Cryptography.ProtectedData" Version="9.0.0" />
<!-- 9.0.4, not 9.0.0: Microsoft.Data.SqlClient 6.0.2 pulls System.Configuration.ConfigurationManager
9.0.4, which requires ProtectedData >= 9.0.4. With transitive pinning on, the lower central
version is a hard NU1109 downgrade error rather than a silent resolve. -->
<PackageVersion Include="System.Security.Cryptography.ProtectedData" Version="9.0.4" />
<!-- Extensions -->
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.7" />
@@ -28,6 +45,8 @@
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.7" />
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.7" />
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.7" />
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.7" />
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.7" />
<!-- ASP.NET Core Authentication / Authorization -->
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.7" />
+72 -5
View File
@@ -11,6 +11,8 @@ plaintext back on demand — from application code, from configuration, or from
| `ZB.MOM.WW.Secrets.Abstractions` | Contracts only (`ISecretStore`, `ISecretResolver`, `IMasterKeyProvider`, `ISecretCipher`, `ISecretReplicator`, `ISecretCacheInvalidator`, `ISecretActorAccessor`), value types, exceptions. Dependency-light. |
| `ZB.MOM.WW.Secrets` | Implementation: AES-256-GCM envelope cipher, env/file/DPAPI master-key providers, SQLite store, TTL resolver (audited), `${secret:}` config expander, `AddZbSecrets`. |
| `ZB.MOM.WW.Secrets.Ui` | Blazor RCL on `ZB.MOM.WW.Theme`: list / add / rotate / delete + policy-gated, audited reveal. |
| `ZB.MOM.WW.Secrets.Replicator.SqlServer` | Cluster-wide secrets via SQL Server — as one shared store, or as a hub each node syncs against. |
| `ZB.MOM.WW.Secrets.Replicator.AkkaDotNet` | Peer-to-peer replication over an Akka.NET cluster — partition-tolerant, no shared database. |
A `secret` CLI (`set` / `get` / `list` / `rm` / `rotate` / `rewrap-all`) ships in the repo (not packed).
@@ -78,8 +80,73 @@ SQL-Server store; once per node for per-node SQLite on a shared KEK). See the op
## Clustered deployments
Secrets storage is local SQLite by default. The schema already carries the
`revision` / `updated_utc` / tombstone columns and an `ISecretReplicator` seam for
cluster replication, but the Akka replicator (`ZB.MOM.WW.Secrets.Akka`) is a deferred
follow-on. **When replicating across a node pair, every node must resolve the _same_ master
KEK** (e.g. the same mounted key file) — a row wrapped by an unknown KEK fails closed.
Storage is local SQLite by default, which is correct for a single-process app and wrong the
moment a secret written on one node has to be readable on another. Three topologies are
supported; pick the least complicated one that meets the requirement.
> **Every topology requires the same master KEK on every node.** The database and the wire carry
> ciphertext only, so a node with a different key cannot decrypt what another wrote — it fails
> closed on a `kek_id` mismatch, which looks like corruption but is a deployment mistake. Mount the
> same key file, or supply the same key environment variable, everywhere.
### 1. Shared SQL-Server store — *the default choice*
Every node points at one database. There is exactly one copy of each row, so there is no
replication, no reconciliation, no split-brain, and no clock skew to reason about.
```csharp
builder.Services.AddZbSecretsSqlServerStore(builder.Configuration);
```
```jsonc
"Secrets": {
"MasterKey": { "Source": "File", "FilePath": "/run/secrets/zb-kek" },
"SqlServer": {
"ConnectionString": "Server=sql01;Database=ZbSecrets;...",
"SchemaName": "zbsecrets"
}
}
```
**Trade-off:** availability is coupled to that database. A node cut off from it falls back only to
the resolver's short in-memory cache. Rotation (`rewrap-all`) runs **once**, against the one store.
### 2. SQL-Server hub replication
Each node keeps its local store and stays converged with a shared hub: writes publish immediately,
and a background sweep reconciles **both directions** on an interval (default 30s).
```csharp
builder.Services.AddZbSecretsSqlServerReplication(builder.Configuration);
```
Choose this over (1) when a node must keep **resolving** secrets while cut off from the hub — reads
never leave the node, so a hub outage degrades propagation rather than availability.
### 3. Akka cluster peer-to-peer
No shared database at all. Nodes broadcast writes over distributed pub/sub and repair divergence
with a periodic manifest exchange.
```csharp
builder.Services.AddZbSecretsAkkaReplication(builder.Configuration);
// Recommended — pins the serializer that carries ciphertext:
Config config = AkkaSecretsReplication.SerializationConfig.WithFallback(myAppConfig);
```
Requires the application to register its `ActorSystem` in DI. The local store is SQLite. This is
the right answer for air-gapped or intermittently-connected sites; it is the wrong answer if every
node can always reach a shared database, because you pay real distributed-systems behaviour for it.
### What replication costs you (2 and 3)
- **Eventual consistency.** Rows converge within the sweep interval, not instantly.
- **Last-writer-wins.** Two nodes writing the same secret concurrently: the later `updated_utc`
wins and the other write is discarded. There is no merge and no conflict report.
- **Publishing is best-effort.** A failed broadcast is logged, not thrown — the local write is
already durable and anti-entropy repairs it. Writes never fail because a peer was unreachable.
- **KEK rotation does not replicate.** A re-wrap deliberately leaves the revision untouched so it
stays invisible to last-writer-wins. Run `rewrap-all` against **each** independent store.
See [`docs/operations/clustered-secrets.md`](docs/operations/clustered-secrets.md) for operator
setup, and [`docs/operations/kek-rotation.md`](docs/operations/kek-rotation.md) for rotation.
+4
View File
@@ -2,11 +2,15 @@
<Folder Name="/src/">
<Project Path="src/ZB.MOM.WW.Secrets.Abstractions/ZB.MOM.WW.Secrets.Abstractions.csproj" />
<Project Path="src/ZB.MOM.WW.Secrets/ZB.MOM.WW.Secrets.csproj" />
<Project Path="src/ZB.MOM.WW.Secrets.Replicator.SqlServer/ZB.MOM.WW.Secrets.Replicator.SqlServer.csproj" />
<Project Path="src/ZB.MOM.WW.Secrets.Replicator.AkkaDotNet/ZB.MOM.WW.Secrets.Replicator.AkkaDotNet.csproj" />
<Project Path="src/ZB.MOM.WW.Secrets.Ui/ZB.MOM.WW.Secrets.Ui.csproj" />
<Project Path="src/ZB.MOM.WW.Secrets.Cli/ZB.MOM.WW.Secrets.Cli.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/ZB.MOM.WW.Secrets.Tests/ZB.MOM.WW.Secrets.Tests.csproj" />
<Project Path="tests/ZB.MOM.WW.Secrets.Replicator.SqlServer.Tests/ZB.MOM.WW.Secrets.Replicator.SqlServer.Tests.csproj" />
<Project Path="tests/ZB.MOM.WW.Secrets.Replicator.AkkaDotNet.Tests/ZB.MOM.WW.Secrets.Replicator.AkkaDotNet.Tests.csproj" />
<Project Path="tests/ZB.MOM.WW.Secrets.Ui.Tests/ZB.MOM.WW.Secrets.Ui.Tests.csproj" />
</Folder>
</Solution>
@@ -0,0 +1,210 @@
# Operating clustered secrets
How to run `ZB.MOM.WW.Secrets` across more than one node. Covers the shared SQL-Server store, the
SQL-Server hub, and Akka peer-to-peer replication.
Companion: [`kek-rotation.md`](kek-rotation.md).
---
## 0. The one rule that breaks everything if you get it wrong
**Every node must resolve the same master KEK.**
Rows are ciphertext everywhere — in the database, in a backup, on the wire. Only the KEK decrypts
them, and it never leaves the node. So if node B has a different master key than node A:
- replication still "works" — rows arrive and are stored;
- every resolve on B of a secret written on A **fails closed** with a `kek_id` mismatch.
The symptom looks like data corruption and is not. Before enabling any topology below, confirm the
same key material is present on every node:
```bash
# On each node — compare the digests, never print the key itself.
sha256sum /run/secrets/zb-kek
# or, for the environment source:
printf '%s' "$ZB_SECRETS_MASTER_KEY" | sha256sum
```
---
## 1. Shared SQL-Server store
One database, one copy of each row. Prefer this unless a node must keep resolving secrets while
partitioned from the database.
### Provision
The schema is created automatically by the first node that starts (idempotent, and safe if several
start at once). You only need a database and a login:
```sql
CREATE DATABASE ZbSecrets;
GO
USE ZbSecrets;
CREATE LOGIN zbsecrets WITH PASSWORD = '<strong password>';
CREATE USER zbsecrets FOR LOGIN zbsecrets;
GO
-- The app creates and owns the zbsecrets schema; it needs permission to do so.
GRANT CREATE SCHEMA, CREATE TABLE TO zbsecrets;
ALTER ROLE db_owner ADD MEMBER zbsecrets; -- or grant on the schema once it exists
GO
```
To pre-create the schema instead (so the app can run with narrower rights), run the DDL from
`SqlServerSecretsSchema.CreateSchemaDdl("zbsecrets")` and then grant only
`SELECT, INSERT, UPDATE` on `[zbsecrets].[secret]` and `[zbsecrets].[schema_version]`.
> `DELETE` is deliberately **not** required. Deletes are tombstones — an `UPDATE` — so a compromised
> app credential cannot destroy secret history.
### Configure
```jsonc
"Secrets": {
"MasterKey": { "Source": "File", "FilePath": "/run/secrets/zb-kek" },
"SqlServer": {
"ConnectionString": "Server=sql01;Database=ZbSecrets;User Id=zbsecrets;Password=${secret:sql/zbsecrets-password};TrustServerCertificate=False",
"SchemaName": "zbsecrets",
"CommandTimeout": "00:00:30"
}
}
```
The connection string is itself a credential — deliver it by `${secret:}` reference, an orchestrator
secret, or an environment variable. Never commit it.
### Verify
```bash
secret set smoke/test hello --description "delete me" # on node A
secret get smoke/test # on node B — expect: hello
secret rm smoke/test
```
### Rotation
Run `rewrap-all` **once**, against the shared store, with resolve traffic quiesced.
---
## 2. SQL-Server hub replication
Each node keeps its local SQLite store and syncs against a shared hub.
Same provisioning as topology 1. Configure with `AddZbSecretsSqlServerReplication` and:
```jsonc
"Secrets": {
"SqlitePath": "/var/lib/zb/secrets.db",
"MasterKey": { "Source": "File", "FilePath": "/run/secrets/zb-kek" },
"SqlServer": {
"ConnectionString": "...",
"SyncInterval": "00:00:30",
"SyncOnStartup": true
}
}
```
`SyncInterval` is the convergence bound, not the propagation latency — a live write reaches the hub
in milliseconds; the interval governs how fast a *dropped* write or a rejoining node is repaired.
### Rotation
Run `rewrap-all` against the hub **and** against every node's local SQLite store. A re-wrap does not
bump the revision (by design — otherwise every node's independent re-wrap would churn replication),
so it does **not** propagate.
### Verifying convergence
```
Secret hub sync converged: pulled N row(s) from the hub, pushed M row(s) to it.
```
Logged at Information only when something actually moved. A healthy steady-state cluster logs
nothing. Continuous non-zero pushes on every tick means two nodes are fighting over the same secret
— look for a script writing the same name on a loop.
---
## 3. Akka cluster peer-to-peer
No shared database. Requires the application to register its `ActorSystem` in DI.
```jsonc
"Secrets": {
"SqlitePath": "/var/lib/zb/secrets.db",
"MasterKey": { "Source": "File", "FilePath": "/run/secrets/zb-kek" },
"Replication": {
"AnnounceInterval": "00:00:30",
"ActorName": "zb-secret-replication"
}
}
```
Merge the serializer config when creating the actor system:
```csharp
Config config = AkkaSecretsReplication.SerializationConfig.WithFallback(myAppConfig);
ActorSystem.Create("my-system", config);
```
Optional but recommended: without it the protocol still round-trips under Akka's default JSON
serializer, but the serializer carrying secret ciphertext becomes an inherited default rather than
a deliberate choice.
### Securing the transport
Rows on the wire are ciphertext and the KEK never travels, so a passive eavesdropper learns
nothing but secret *names*, revisions, and timestamps. That is still metadata worth protecting, and
an unauthenticated cluster port is a much larger problem than secret replication — configure
Akka.Remote TLS and cluster authentication as you would for any other cluster traffic.
### Rotation
Run `rewrap-all` on **each node's** local store. Re-wraps do not replicate (see above).
### Announce traffic
One manifest per node per interval: names, revisions, timestamps, tombstone flags — no ciphertext.
Cost grows as O(nodes × secrets), so a large cluster with a large secret set should lengthen
`AnnounceInterval` rather than shorten it.
---
## Choosing between them
| | Shared store | SQL hub | Akka peer-to-peer |
|---|---|---|---|
| Resolves while cut off from the DB | ✗ (cache TTL only) | ✓ | ✓ (no DB at all) |
| Distributed failure modes | none | LWW, sweep lag | LWW, sweep lag |
| Rotation runs | once | hub + each node | each node |
| Needs a shared database | ✓ | ✓ | ✗ |
| Needs an Akka cluster | ✗ | ✗ | ✓ |
Start at the left and move right only when a stated requirement forces it.
---
## Troubleshooting
**`SecretDecryptionException` / `kek_id` mismatch after enabling replication.**
The KEKs differ between nodes. Compare digests as in §0. Do not "fix" it by re-writing the secret on
the failing node — that hides the mismatch until the next secret.
**A secret written on node A never appears on node B.**
Check, in order: (1) are both nodes actually in the cluster / pointed at the same hub database and
schema; (2) has one `AnnounceInterval` / `SyncInterval` elapsed; (3) is there a warning-level log
about a failed publish or a failed sweep. A dropped publish is expected and self-repairing — a
sweep that fails every interval is not.
**A deleted secret came back.**
Deletes are tombstones and propagate as newer rows, so this should not happen through replication.
It does happen if a node's local store was restored from a backup taken before the delete: the
restore reintroduces the row with an old timestamp, loses to LWW, and the tombstone re-wins. If
instead the row is *live* again, someone re-created it — check the audit log for the write.
**The migration refuses to run.** `SecretStoreMigrationException` naming a version newer than
supported means another node is running a newer build against the same store. Upgrade this node;
do not downgrade the store.
@@ -2,7 +2,8 @@ namespace ZB.MOM.WW.Secrets.Abstractions;
/// <summary>
/// Publishes an encrypted row to cluster peers. Core registers a no-op implementation;
/// <c>ZB.MOM.WW.Secrets.Akka</c> (deferred) provides the real cluster implementation.
/// <c>ZB.MOM.WW.Secrets.Replication.Akka</c> (peer-to-peer over the cluster) and
/// <c>ZB.MOM.WW.Secrets.Replication.SqlServer</c> (via a shared hub database) provide the real ones.
/// </summary>
public interface ISecretReplicator
{
@@ -0,0 +1,22 @@
namespace ZB.MOM.WW.Secrets.Abstractions;
/// <summary>
/// Provisions the schema backing an <see cref="ISecretStore"/>. Implementations are idempotent —
/// running a migration repeatedly is safe — and refuse to run against a store whose on-disk schema
/// version is <em>newer</em> than the build supports, rather than silently downgrading it.
/// </summary>
/// <remarks>
/// This seam exists so the startup migration hosted service and the CLI stay store-agnostic: the
/// SQLite store, the shared SQL-Server store, and any future provider are all provisioned through
/// the same call, and swapping the store never touches the migration wiring.
/// </remarks>
public interface ISecretsStoreMigrator
{
/// <summary>Creates or updates the secret-store schema to the version this build supports.</summary>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>A task that completes when the schema is at the supported version.</returns>
/// <exception cref="SecretStoreMigrationException">
/// The store's schema version is newer than this build supports.
/// </exception>
Task MigrateAsync(CancellationToken cancellationToken);
}
@@ -0,0 +1,58 @@
namespace ZB.MOM.WW.Secrets.Abstractions;
/// <summary>
/// The single definition of the last-writer-wins (LWW) ordering used to reconcile a secret row
/// against a peer's copy: newer <see cref="StoredSecret.UpdatedUtc"/> wins, and
/// <see cref="StoredSecret.Revision"/> breaks a timestamp tie.
/// </summary>
/// <remarks>
/// <para>
/// Every store's <see cref="ISecretStore.ApplyReplicatedAsync"/> and every replication transport
/// reconciler routes its "is the incoming row newer?" decision through here. Keeping one definition
/// is a correctness requirement, not a tidiness one: a cluster can mix stores (a node backed by the
/// shared SQL-Server store alongside nodes on local SQLite), and two implementations that disagree
/// about a tie would converge to <em>different</em> rows on different nodes — a silent split-brain
/// that no single-node test can catch.
/// </para>
/// <para>
/// The comparison is deliberately <b>strict</b>: a row that merely ties the local one is not newer
/// and is ignored. That makes replication idempotent — re-delivering a row a node already has is a
/// no-op rather than a write — so at-least-once transports (pub/sub redelivery, an anti-entropy
/// sweep racing a live broadcast) cost nothing and cannot flap a row back and forth.
/// </para>
/// </remarks>
public static class SecretLastWriterWins
{
/// <summary>
/// Returns <see langword="true"/> if a row at (<paramref name="incomingUpdatedUtc"/>,
/// <paramref name="incomingRevision"/>) should overwrite a local row at
/// (<paramref name="localUpdatedUtc"/>, <paramref name="localRevision"/>).
/// </summary>
/// <param name="incomingUpdatedUtc">The incoming row's last-updated timestamp.</param>
/// <param name="incomingRevision">The incoming row's revision.</param>
/// <param name="localUpdatedUtc">The local row's last-updated timestamp.</param>
/// <param name="localRevision">The local row's revision.</param>
/// <returns><see langword="true"/> if the incoming row is strictly newer.</returns>
public static bool IsNewer(
DateTimeOffset incomingUpdatedUtc,
long incomingRevision,
DateTimeOffset localUpdatedUtc,
long localRevision) =>
incomingUpdatedUtc > localUpdatedUtc ||
(incomingUpdatedUtc == localUpdatedUtc && incomingRevision > localRevision);
/// <summary>
/// Returns <see langword="true"/> if <paramref name="incoming"/> should overwrite the local row
/// described by <paramref name="local"/>.
/// </summary>
/// <param name="incoming">The manifest entry received from a peer.</param>
/// <param name="local">The corresponding local manifest entry.</param>
/// <returns><see langword="true"/> if the incoming entry is strictly newer.</returns>
public static bool IsNewer(SecretManifestEntry incoming, SecretManifestEntry local)
{
ArgumentNullException.ThrowIfNull(incoming);
ArgumentNullException.ThrowIfNull(local);
return IsNewer(incoming.UpdatedUtc, incoming.Revision, local.UpdatedUtc, local.Revision);
}
}
@@ -1,12 +1,10 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.Secrets.Abstractions;
using ZB.MOM.WW.Secrets.Cli;
using ZB.MOM.WW.Secrets.DependencyInjection;
using ZB.MOM.WW.Secrets.MasterKey;
using ZB.MOM.WW.Secrets.Sqlite;
// Headless `secret` CLI: set / get / list / rm / rotate over the ZB.MOM.WW secrets store.
// The heavy lifting lives in SecretCommands; Program only builds the host, runs the schema
@@ -20,8 +18,9 @@ builder.Services.AddZbSecrets(builder.Configuration, "Secrets");
using IHost host = builder.Build();
// Ensure the schema exists before any store operation.
await host.Services.GetRequiredService<SqliteSecretsStoreMigrator>()
// Ensure the schema exists before any store operation. Resolved through the ISecretsStoreMigrator
// seam so the CLI provisions whichever store the app configured (local SQLite or shared SQL Server).
await host.Services.GetRequiredService<ISecretsStoreMigrator>()
.MigrateAsync(CancellationToken.None);
var commands = new SecretCommands(
@@ -0,0 +1,33 @@
using Akka.Actor;
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.Secrets.Replicator.AkkaDotNet;
/// <summary>
/// Publishes local secret writes to cluster peers by handing them to this node's
/// <see cref="SecretReplicationActor"/>.
/// </summary>
/// <remarks>
/// The hand-off is a <c>Tell</c>, so <see cref="PublishAsync"/> completes as soon as the row is in
/// the actor's mailbox rather than when peers have applied it. That is the correct contract here:
/// the caller (<c>ReplicatingSecretStore</c>) has already durably written the row locally and treats
/// publishing as best-effort, and blocking a write until every peer acknowledged would make each
/// node's availability depend on all the others. Delivery is guaranteed by anti-entropy, not by
/// this call.
/// </remarks>
/// <param name="replicationActor">This node's replication actor.</param>
public sealed class AkkaSecretReplicator(IActorRef replicationActor) : ISecretReplicator
{
private readonly IActorRef _replicationActor =
replicationActor ?? throw new ArgumentNullException(nameof(replicationActor));
/// <inheritdoc />
public Task PublishAsync(StoredSecret row, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(row);
_replicationActor.Tell(new SecretReplicationActor.PublishLocalWrite(row));
return Task.CompletedTask;
}
}
@@ -0,0 +1,41 @@
using Akka.Configuration;
namespace ZB.MOM.WW.Secrets.Replicator.AkkaDotNet;
/// <summary>
/// Akka configuration this package contributes.
/// </summary>
public static class AkkaSecretsReplication
{
/// <summary>
/// HOCON binding the secret-replication wire protocol to
/// <see cref="SecretReplicationSerializer"/>. Merge it into the application's Akka
/// configuration when creating the <c>ActorSystem</c>:
/// <code>
/// Config config = AkkaSecretsReplication.SerializationConfig
/// .WithFallback(myAppConfig);
/// ActorSystem.Create("my-system", config);
/// </code>
/// </summary>
/// <remarks>
/// Optional — the protocol DTOs round-trip under Akka's default JSON serializer without it — but
/// recommended, because it makes the serializer carrying secret ciphertext an explicit choice
/// rather than an inherited default, and pins the manifest strings that a future protocol
/// version would bump.
/// </remarks>
public static Config SerializationConfig { get; } = ConfigurationFactory.ParseString($$"""
akka.actor {
serializers {
zb-secrets-replication = "{{typeof(SecretReplicationSerializer).AssemblyQualifiedName}}"
}
serialization-bindings {
"ZB.MOM.WW.Secrets.Replicator.AkkaDotNet.Protocol.SecretRowsMessage, ZB.MOM.WW.Secrets.Replicator.AkkaDotNet" = zb-secrets-replication
"ZB.MOM.WW.Secrets.Replicator.AkkaDotNet.Protocol.SecretManifestAnnounce, ZB.MOM.WW.Secrets.Replicator.AkkaDotNet" = zb-secrets-replication
"ZB.MOM.WW.Secrets.Replicator.AkkaDotNet.Protocol.SecretPullRequest, ZB.MOM.WW.Secrets.Replicator.AkkaDotNet" = zb-secrets-replication
}
serialization-identifiers {
"{{typeof(SecretReplicationSerializer).AssemblyQualifiedName}}" = {{SecretReplicationSerializer.SerializerId}}
}
}
""");
}
@@ -0,0 +1,40 @@
namespace ZB.MOM.WW.Secrets.Replicator.AkkaDotNet;
/// <summary>
/// Configuration for peer-to-peer secret replication over the Akka cluster.
/// </summary>
public sealed class AkkaSecretsReplicationOptions
{
/// <summary>
/// How often this node broadcasts its manifest for anti-entropy. Defaults to 30 seconds.
/// </summary>
/// <remarks>
/// This is the convergence bound, not the propagation latency: a live write reaches peers in
/// milliseconds, and this interval only governs how quickly a <em>dropped</em> write or a
/// rejoining node is repaired. The traffic is one manifest per node per interval — names,
/// revisions, and timestamps, no ciphertext — so shortening it is cheap for a small cluster and
/// grows as O(nodes × secrets).
/// </remarks>
public TimeSpan AnnounceInterval { get; set; } = TimeSpan.FromSeconds(30);
/// <summary>Name of the replication actor within the actor system. Defaults to <c>zb-secret-replication</c>.</summary>
public string ActorName { get; set; } = "zb-secret-replication";
/// <summary>
/// Throws if the options are unusable.
/// </summary>
/// <exception cref="InvalidOperationException">The announce interval is not positive, or the actor name is blank.</exception>
public void Validate()
{
if (AnnounceInterval <= TimeSpan.Zero)
{
throw new InvalidOperationException(
$"Secret replication AnnounceInterval must be positive (was {AnnounceInterval}).");
}
if (string.IsNullOrWhiteSpace(ActorName))
{
throw new InvalidOperationException("Secret replication ActorName must not be blank.");
}
}
}
@@ -0,0 +1,124 @@
using Akka.Actor;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.Secrets.Abstractions;
using ZB.MOM.WW.Secrets.Replication;
using ZB.MOM.WW.Secrets.DependencyInjection;
using ZB.MOM.WW.Secrets.Sqlite;
namespace ZB.MOM.WW.Secrets.Replicator.AkkaDotNet.DependencyInjection;
/// <summary>
/// Dependency-injection entry point for peer-to-peer secret replication over an Akka.NET cluster.
/// </summary>
public static class AkkaSecretsServiceCollectionExtensions
{
/// <summary>
/// Registers secret replication over the cluster: each node keeps its own local store, local
/// writes broadcast to peers, and a periodic manifest exchange repairs any divergence in both
/// directions.
/// </summary>
/// <remarks>
/// <para>
/// Choose this transport when a node must keep resolving secrets while <b>partitioned</b> from
/// the rest of the cluster — reads never leave the node, so an isolated site keeps running on its
/// last-known-good state and re-converges when the partition heals. If every node can always
/// reach a shared database, the SQL-Server package's shared-store mode is simpler and has no
/// distributed failure modes at all; prefer it unless partition tolerance is a stated requirement.
/// </para>
/// <para>
/// <b>Requirements.</b> The application must (1) register its <see cref="ActorSystem"/> in DI
/// before the first secret resolve, and (2) give every node the <b>same KEK</b> — rows carry
/// ciphertext only, so a node with a different master key fails closed on a <c>kek_id</c>
/// mismatch. Merging <see cref="AkkaSecretsReplication.SerializationConfig"/> into the actor
/// system's configuration is recommended.
/// </para>
/// <para>
/// <b>KEK rotation does not replicate.</b> A re-wrap deliberately leaves the revision untouched
/// so it stays invisible to last-writer-wins; run <c>rewrap-all</c> against every node's local
/// store.
/// </para>
/// </remarks>
/// <param name="services">The service collection to add to.</param>
/// <param name="config">The application configuration.</param>
/// <param name="secretsSectionPath">Configuration section holding the core secrets options.</param>
/// <param name="replicationSectionPath">Configuration section holding the replication options.</param>
/// <returns>The same <paramref name="services"/> instance, for chaining.</returns>
public static IServiceCollection AddZbSecretsAkkaReplication(
this IServiceCollection services,
IConfiguration config,
string secretsSectionPath = "Secrets",
string replicationSectionPath = "Secrets:Replication")
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(config);
ArgumentException.ThrowIfNullOrWhiteSpace(secretsSectionPath);
ArgumentException.ThrowIfNullOrWhiteSpace(replicationSectionPath);
IConfigurationSection section = config.GetSection(replicationSectionPath);
services.Configure<AkkaSecretsReplicationOptions>(section);
// Validate eagerly so a bad interval fails at startup, not on the first announce tick.
var eager = new AkkaSecretsReplicationOptions();
section.Bind(eager);
eager.Validate();
// The local store + migrator come from AddZbSecrets (SQLite by default).
services.AddZbSecrets(config, secretsSectionPath);
// The actor is created lazily on first use rather than at registration: the ActorSystem is
// typically registered by the application AFTER this call, and a cluster node may also not be
// ready to join at the moment the container is built.
services.TryAddSingleton<SecretReplicationActorProvider>(sp => new SecretReplicationActorProvider(
sp.GetRequiredService<ActorSystem>(),
sp.GetRequiredService<SqliteSecretStore>(),
sp.GetService<ISecretCacheInvalidator>(),
sp.GetRequiredService<IOptions<AkkaSecretsReplicationOptions>>().Value));
services.TryAddSingleton<ISecretReplicator>(sp =>
new AkkaSecretReplicator(sp.GetRequiredService<SecretReplicationActorProvider>().ActorRef));
// Decorate the local store so every write publishes. Resolved from the concrete SQLite store,
// not ISecretStore — asking the container for ISecretStore here would resolve this very
// decorator and recurse forever.
services.AddSingleton<ISecretStore>(sp => new ReplicatingSecretStore(
sp.GetRequiredService<SqliteSecretStore>(),
sp.GetRequiredService<ISecretReplicator>(),
sp.GetRequiredService<ILogger<ReplicatingSecretStore>>()));
return services;
}
}
/// <summary>
/// Creates this node's <see cref="SecretReplicationActor"/> on first access and hands out its ref.
/// </summary>
/// <remarks>
/// The indirection exists because the actor must be created lazily — the application usually
/// registers its <see cref="ActorSystem"/> after <c>AddZbSecretsAkkaReplication</c>, so eagerly
/// resolving one at registration time would fail. Creation is guarded by
/// <see cref="Lazy{T}"/> with <see cref="LazyThreadSafetyMode.ExecutionAndPublication"/> so
/// concurrent first writes cannot spawn two actors on the same node.
/// </remarks>
/// <param name="system">The application's actor system.</param>
/// <param name="store">The node's local secret store — the undecorated one.</param>
/// <param name="cacheInvalidator">Resolver-cache seam.</param>
/// <param name="options">Replication options.</param>
public sealed class SecretReplicationActorProvider(
ActorSystem system,
ISecretStore store,
ISecretCacheInvalidator? cacheInvalidator,
AkkaSecretsReplicationOptions options)
{
private readonly Lazy<IActorRef> _actorRef = new(
() => system.ActorOf(
SecretReplicationActor.Props(store, cacheInvalidator, options.AnnounceInterval),
options.ActorName),
LazyThreadSafetyMode.ExecutionAndPublication);
/// <summary>This node's replication actor, created on first access.</summary>
public IActorRef ActorRef => _actorRef.Value;
}
@@ -0,0 +1,215 @@
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);
@@ -0,0 +1,303 @@
using Akka.Actor;
using Akka.Cluster.Tools.PublishSubscribe;
using Akka.Event;
using ZB.MOM.WW.Secrets.Abstractions;
using ZB.MOM.WW.Secrets.Replication;
using ZB.MOM.WW.Secrets.Replicator.AkkaDotNet.Protocol;
namespace ZB.MOM.WW.Secrets.Replicator.AkkaDotNet;
/// <summary>
/// One per node. Owns this node's participation in secret replication: broadcasts local writes,
/// periodically announces its manifest, and services peers' pulls.
/// </summary>
/// <remarks>
/// <para>
/// The protocol is deliberately small — three messages, no leader, no singleton:
/// </para>
/// <list type="number">
/// <item><description>
/// <b>Live push.</b> A local write publishes a <see cref="SecretRowsMessage"/> to the topic. Peers
/// apply it last-writer-wins. This is the fast path — propagation in milliseconds — and it is
/// allowed to fail.
/// </description></item>
/// <item><description>
/// <b>Anti-entropy.</b> Every node periodically publishes a <see cref="SecretManifestAnnounce"/>.
/// A peer receiving it computes <em>both</em> directions in one pass: it pushes back rows it holds
/// newer, and asks (<see cref="SecretPullRequest"/>) for rows it is missing or behind on. This is
/// the correctness path — it repairs anything the fast path dropped, in either direction.
/// </description></item>
/// <item><description>
/// <b>Pull service.</b> A <see cref="SecretPullRequest"/> is answered with the requested rows.
/// </description></item>
/// </list>
/// <para>
/// There is no coordinator because there does not need to be one: every exchange is idempotent and
/// commutative under last-writer-wins, so nodes converge regardless of message order, duplication,
/// or which subset of peers is currently reachable. A node that was partitioned keeps serving its
/// local store the whole time and re-converges on the first announce after the partition heals —
/// which is the entire reason to choose this transport over the shared SQL-Server store.
/// </para>
/// </remarks>
public sealed class SecretReplicationActor : ReceiveActor, IWithTimers
{
/// <summary>The distributed pub/sub topic secret replication traffic rides on.</summary>
public const string Topic = "zb-mom-ww-secrets";
private readonly ISecretStore _store;
private readonly SecretReplicationReconciler _reconciler;
private readonly TimeSpan _announceInterval;
private readonly IActorRef _mediator;
private readonly ILoggingAdapter _log = Context.GetLogger();
/// <inheritdoc />
public ITimerScheduler Timers { get; set; } = null!;
/// <summary>Creates the replication actor.</summary>
/// <param name="store">The node's local secret store.</param>
/// <param name="cacheInvalidator">Resolver-cache seam, evicted for every applied row.</param>
/// <param name="announceInterval">How often this node announces its manifest.</param>
public SecretReplicationActor(
ISecretStore store,
ISecretCacheInvalidator? cacheInvalidator,
TimeSpan announceInterval)
{
_store = store ?? throw new ArgumentNullException(nameof(store));
_reconciler = new SecretReplicationReconciler(
_store,
cacheInvalidator,
onRowFailed: (name, ex) => _log.Warning(
ex, "Discarding replicated secret row {0} that could not be applied locally.", name.Value));
_announceInterval = announceInterval;
_mediator = DistributedPubSub.Get(Context.System).Mediator;
_mediator.Tell(new Subscribe(Topic, Self));
Receive<PublishLocalWrite>(HandlePublishLocalWrite);
Receive<SecretRowsMessage>(HandleRows);
Receive<SecretManifestAnnounce>(HandleManifestAnnounce);
Receive<SecretPullRequest>(HandlePullRequest);
Receive<AnnounceTick>(_ => HandleAnnounceTick());
Receive<SubscribeAck>(_ => _log.Debug("Subscribed to secret replication topic {0}.", Topic));
Receive<ExchangeFailed>(f =>
_log.Warning(f.Cause, "Secret anti-entropy exchange with a peer failed; retrying next announce."));
Receive<ExchangeDone>(_ => { /* successful exchange, piped back to self — nothing to do. */ });
}
/// <summary>Props for the replication actor.</summary>
/// <param name="store">The node's local secret store.</param>
/// <param name="cacheInvalidator">Resolver-cache seam.</param>
/// <param name="announceInterval">How often this node announces its manifest.</param>
/// <returns>Props creating the actor.</returns>
public static Props Props(
ISecretStore store, ISecretCacheInvalidator? cacheInvalidator, TimeSpan announceInterval) =>
Akka.Actor.Props.Create(() =>
new SecretReplicationActor(store, cacheInvalidator, announceInterval));
/// <inheritdoc />
protected override void PreStart() =>
Timers.StartPeriodicTimer(AnnounceTick.Instance, AnnounceTick.Instance, _announceInterval, _announceInterval);
// A local write, handed over by AkkaSecretReplicator. Published from inside the actor so the
// message carries Self as sender — that is what lets a peer reply directly to this node instead
// of broadcasting its answer to everyone.
private void HandlePublishLocalWrite(PublishLocalWrite message)
{
_mediator.Tell(new Publish(
Topic,
new SecretRowsMessage([SecretRowDto.FromStoredSecret(message.Row)])));
}
private void HandleRows(SecretRowsMessage message)
{
if (IsFromSelf())
{
return;
}
IReadOnlyList<StoredSecret> rows = Materialize(message.Rows);
if (rows.Count == 0)
{
return;
}
// PipeTo nothing: applying is fire-and-forget from the mailbox's point of view. Failures are
// logged, and anti-entropy re-delivers anything that did not land.
_reconciler.ApplyAsync(rows, CancellationToken.None)
.ContinueWith(
task => _log.Warning(
task.Exception,
"Failed to apply {0} replicated secret row(s) from a peer; anti-entropy will retry.",
rows.Count),
CancellationToken.None,
TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously,
TaskScheduler.Default);
}
private void HandleManifestAnnounce(SecretManifestAnnounce message)
{
if (IsFromSelf())
{
return;
}
IActorRef peer = Sender;
IReadOnlyList<SecretManifestEntry> remote = [.. message.Entries.Select(e => e.ToEntry())];
ReconcileWithPeerAsync(peer, remote).PipeTo(Self, failure: ex => new ExchangeFailed(ex));
}
private void HandlePullRequest(SecretPullRequest message)
{
if (IsFromSelf())
{
return;
}
IActorRef peer = Sender;
// Captured before the continuation: Self is context-dependent and there is no guarantee the
// actor context is still current when the continuation runs (Akka analyzer AK1005).
IActorRef self = Self;
IReadOnlyList<SecretName> names = ParseNames(message.Names);
_reconciler.ReadLocalAsync(names, CancellationToken.None)
.ContinueWith(
task =>
{
if (task.IsFaulted)
{
// Logged rather than swallowed: the peer is waiting for a reply it will never
// get, and without this the only symptom is a secret that quietly never
// converges. The peer re-asks on its next announce.
_log.Warning(
task.Exception,
"Failed to read {0} local secret row(s) for a peer's pull request.",
names.Count);
return;
}
if (task.IsCompletedSuccessfully && task.Result.Count > 0)
{
peer.Tell(
new SecretRowsMessage([.. task.Result.Select(SecretRowDto.FromStoredSecret)]),
self);
}
},
CancellationToken.None,
TaskContinuationOptions.ExecuteSynchronously,
TaskScheduler.Default);
}
private void HandleAnnounceTick()
{
_store.GetManifestAsync(CancellationToken.None)
.ContinueWith(
task =>
{
if (task.IsCompletedSuccessfully)
{
_mediator.Tell(new Publish(
Topic,
new SecretManifestAnnounce(
[.. task.Result.Select(SecretManifestEntryDto.FromEntry)])));
}
},
CancellationToken.None,
TaskContinuationOptions.ExecuteSynchronously,
TaskScheduler.Default);
}
// Both halves of an anti-entropy exchange, computed from one manifest: push what the peer is
// behind on, ask for what we are behind on.
private async Task<ExchangeDone> ReconcileWithPeerAsync(
IActorRef peer, IReadOnlyList<SecretManifestEntry> remote)
{
IReadOnlyList<SecretManifestEntry> localManifest =
await _store.GetManifestAsync(CancellationToken.None).ConfigureAwait(false);
IReadOnlyList<SecretName> push =
SecretReplicationReconciler.ComputePushSet(localManifest, remote);
if (push.Count > 0)
{
IReadOnlyList<StoredSecret> rows =
await _reconciler.ReadLocalAsync(push, CancellationToken.None).ConfigureAwait(false);
if (rows.Count > 0)
{
peer.Tell(new SecretRowsMessage([.. rows.Select(SecretRowDto.FromStoredSecret)]), Self);
}
}
IReadOnlyList<SecretName> pull =
SecretReplicationReconciler.ComputePullSet(localManifest, remote);
if (pull.Count > 0)
{
peer.Tell(new SecretPullRequest([.. pull.Select(n => n.Value)]), Self);
}
return ExchangeDone.Instance;
}
// DistributedPubSub delivers a node's own publications back to it. Dropping them here is what
// stops a broadcast from being re-applied locally and, worse, echoed onward.
private bool IsFromSelf() => Sender.Equals(Self);
// A peer's rows are untrusted input: a malformed name or content type must drop that row, not
// fault the actor and stall replication for every other secret.
private IReadOnlyList<StoredSecret> Materialize(IReadOnlyList<SecretRowDto> dtos)
{
var rows = new List<StoredSecret>(dtos.Count);
foreach (SecretRowDto dto in dtos)
{
try
{
rows.Add(dto.ToStoredSecret());
}
catch (Exception ex) when (ex is ArgumentException or FormatException)
{
_log.Warning(ex, "Discarding a malformed replicated secret row from a peer.");
}
}
return rows;
}
private IReadOnlyList<SecretName> ParseNames(IReadOnlyList<string> names)
{
var parsed = new List<SecretName>(names.Count);
foreach (string name in names)
{
try
{
parsed.Add(new SecretName(name));
}
catch (ArgumentException ex)
{
_log.Warning(ex, "Discarding a malformed secret name in a peer's pull request.");
}
}
return parsed;
}
/// <summary>Instruction from <see cref="AkkaSecretReplicator"/> to broadcast a local write.</summary>
/// <param name="Row">The encrypted row to broadcast.</param>
public sealed record PublishLocalWrite(StoredSecret Row);
private sealed record AnnounceTick
{
public static readonly AnnounceTick Instance = new();
}
private sealed record ExchangeFailed(Exception Cause);
private sealed record ExchangeDone
{
public static readonly ExchangeDone Instance = new();
}
}
@@ -0,0 +1,84 @@
using System.Runtime.Serialization;
using System.Text.Json;
using Akka.Actor;
using Akka.Serialization;
using ZB.MOM.WW.Secrets.Replicator.AkkaDotNet.Protocol;
namespace ZB.MOM.WW.Secrets.Replicator.AkkaDotNet;
/// <summary>
/// Explicit System.Text.Json serializer for the secret-replication wire protocol.
/// </summary>
/// <remarks>
/// <para>
/// Registering this is <b>optional but recommended</b>. The protocol DTOs are all primitives, so
/// Akka's default JSON serializer round-trips them correctly if an application never wires this up —
/// but relying on the default means secret ciphertext is serialized by whatever the cluster's
/// fallback happens to be, which is not a decision worth leaving implicit for this payload. Binding
/// it explicitly also pins the manifest strings below as the versioning boundary: a future protocol
/// change adds a manifest, so a mixed-version cluster fails loudly on an unknown manifest instead of
/// silently mis-deserializing a secret.
/// </para>
/// <para>
/// Wire it up by merging <see cref="AkkaSecretsReplication.SerializationConfig"/> into the
/// application's Akka configuration — see that member for the HOCON.
/// </para>
/// </remarks>
/// <param name="system">The actor system this serializer belongs to.</param>
public sealed class SecretReplicationSerializer(ExtendedActorSystem system) : SerializerWithStringManifest(system)
{
/// <summary>Stable serializer id. Must not collide with other serializers in the cluster.</summary>
public const int SerializerId = 7710;
private const string RowsManifest = "zbs-rows-v1";
private const string ManifestAnnounceManifest = "zbs-manifest-v1";
private const string PullRequestManifest = "zbs-pull-v1";
private static readonly JsonSerializerOptions JsonOptions = new()
{
// Explicitly NOT indented and NOT camel-cased: the wire form should be compact and stable,
// not pretty. Property names are the C# names, pinned by the manifest version.
WriteIndented = false,
};
/// <inheritdoc />
public override int Identifier => SerializerId;
/// <inheritdoc />
public override string Manifest(object o) => o switch
{
SecretRowsMessage => RowsManifest,
SecretManifestAnnounce => ManifestAnnounceManifest,
SecretPullRequest => PullRequestManifest,
_ => throw new ArgumentException(
$"{nameof(SecretReplicationSerializer)} cannot serialize {o?.GetType().FullName ?? "null"}.",
nameof(o)),
};
/// <inheritdoc />
public override byte[] ToBinary(object obj) => obj switch
{
SecretRowsMessage rows => JsonSerializer.SerializeToUtf8Bytes(rows, JsonOptions),
SecretManifestAnnounce announce => JsonSerializer.SerializeToUtf8Bytes(announce, JsonOptions),
SecretPullRequest pull => JsonSerializer.SerializeToUtf8Bytes(pull, JsonOptions),
_ => throw new ArgumentException(
$"{nameof(SecretReplicationSerializer)} cannot serialize {obj?.GetType().FullName ?? "null"}.",
nameof(obj)),
};
/// <inheritdoc />
public override object FromBinary(byte[] bytes, string manifest) => manifest switch
{
RowsManifest => Deserialize<SecretRowsMessage>(bytes, manifest),
ManifestAnnounceManifest => Deserialize<SecretManifestAnnounce>(bytes, manifest),
PullRequestManifest => Deserialize<SecretPullRequest>(bytes, manifest),
_ => throw new SerializationException(
$"Unknown secret-replication manifest '{manifest}'. This usually means a peer is running a " +
"newer protocol version than this node."),
};
private static T Deserialize<T>(byte[] bytes, string manifest) =>
JsonSerializer.Deserialize<T>(bytes, JsonOptions)
?? throw new SerializationException(
$"Secret-replication payload with manifest '{manifest}' deserialized to null.");
}
@@ -0,0 +1,41 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>
<PropertyGroup>
<IsPackable>true</IsPackable>
<PackageId>ZB.MOM.WW.Secrets.Replicator.AkkaDotNet</PackageId>
<Authors>ZB.MOM.WW</Authors>
<Description>Peer-to-peer secret replication over an Akka.NET cluster for the ZB.MOM.WW SCADA family — partition-tolerant, ciphertext-only.</Description>
<PackageProjectUrl>https://gitea.dohertylan.com/dohertj2/zb-mom-ww-secrets</PackageProjectUrl>
<RepositoryUrl>https://gitea.dohertylan.com/dohertj2/zb-mom-ww-secrets</RepositoryUrl>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\ZB.MOM.WW.Secrets\ZB.MOM.WW.Secrets.csproj" />
<ProjectReference Include="..\ZB.MOM.WW.Secrets.Abstractions\ZB.MOM.WW.Secrets.Abstractions.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Akka" />
<PackageReference Include="Akka.Cluster" />
<PackageReference Include="Akka.Cluster.Tools" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="ZB.MOM.WW.Secrets.Replicator.AkkaDotNet.Tests" />
</ItemGroup>
</Project>
@@ -0,0 +1,141 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.Secrets.Abstractions;
using ZB.MOM.WW.Secrets.Replication;
using ZB.MOM.WW.Secrets.DependencyInjection;
namespace ZB.MOM.WW.Secrets.Replicator.SqlServer.DependencyInjection;
/// <summary>
/// Dependency-injection entry points for backing a cluster's secrets with SQL Server, in either of
/// the two supported topologies.
/// </summary>
/// <remarks>
/// <para>
/// <b>Both require the same KEK on every node.</b> The database stores ciphertext only, so a node
/// whose master key differs cannot decrypt rows another node wrote — it fails closed on resolve with
/// a <c>kek_id</c> mismatch, which reads like data corruption but is a deployment error. Mount the
/// same key file or supply the same key environment variable everywhere.
/// </para>
/// </remarks>
public static class SqlServerSecretsServiceCollectionExtensions
{
/// <summary>
/// <b>Shared-store mode.</b> Points every node's <see cref="ISecretStore"/> at one shared
/// SQL-Server database. There is exactly one copy of each row, so there is nothing to replicate
/// and nothing to reconcile — no split-brain, no anti-entropy lag, no clock skew.
/// </summary>
/// <remarks>
/// Prefer this unless a node must keep resolving secrets while <em>partitioned</em> from the
/// shared database. The trade is availability: a node cut off from the database falls back only
/// to the resolver's short in-memory cache. If that is unacceptable, use
/// <see cref="AddZbSecretsSqlServerReplication"/> (local store + hub) or the Akka package
/// (peer-to-peer, no shared database at all).
/// </remarks>
/// <param name="services">The service collection to add to.</param>
/// <param name="config">The application configuration.</param>
/// <param name="secretsSectionPath">Configuration section holding the core secrets options.</param>
/// <param name="sqlServerSectionPath">Configuration section holding the SQL-Server options.</param>
/// <returns>The same <paramref name="services"/> instance, for chaining.</returns>
public static IServiceCollection AddZbSecretsSqlServerStore(
this IServiceCollection services,
IConfiguration config,
string secretsSectionPath = "Secrets",
string sqlServerSectionPath = "Secrets:SqlServer")
{
RegisterSqlServerCore(services, config, sqlServerSectionPath);
// Registered BEFORE AddZbSecrets so its TryAdd calls find these already present and leave
// them alone — that is the whole mechanism by which the SQL-Server store displaces SQLite.
services.TryAddSingleton<ISecretStore>(sp => sp.GetRequiredService<SqlServerSecretStore>());
services.TryAddSingleton<ISecretsStoreMigrator>(sp =>
sp.GetRequiredService<SqlServerSecretsStoreMigrator>());
services.AddZbSecrets(config, secretsSectionPath);
return services;
}
/// <summary>
/// <b>Hub-replication mode.</b> Each node keeps its own local store (SQLite by default) and
/// stays converged with a shared SQL-Server hub: local writes publish to the hub immediately, and
/// a background sweep reconciles both directions on an interval.
/// </summary>
/// <remarks>
/// Choose this over <see cref="AddZbSecretsSqlServerStore"/> when a node must keep resolving
/// secrets while cut off from the hub — reads are served from local state, so a hub outage
/// degrades propagation rather than availability. The cost is real distributed-systems
/// behaviour: rows converge eventually (bounded by the sweep interval), conflicting concurrent
/// writes to the same secret resolve last-writer-wins, and KEK rotation must be run against the
/// hub <em>and</em> each node's local store, because a re-wrap deliberately does not replicate.
/// </remarks>
/// <param name="services">The service collection to add to.</param>
/// <param name="config">The application configuration.</param>
/// <param name="secretsSectionPath">Configuration section holding the core secrets options.</param>
/// <param name="sqlServerSectionPath">Configuration section holding the SQL-Server options.</param>
/// <returns>The same <paramref name="services"/> instance, for chaining.</returns>
public static IServiceCollection AddZbSecretsSqlServerReplication(
this IServiceCollection services,
IConfiguration config,
string secretsSectionPath = "Secrets",
string sqlServerSectionPath = "Secrets:SqlServer")
{
RegisterSqlServerCore(services, config, sqlServerSectionPath);
services.TryAddSingleton<ISecretReplicationHub>(sp => sp.GetRequiredService<SqlServerSecretStore>());
services.TryAddSingleton<ISecretReplicator>(sp =>
new SqlServerSecretReplicator(sp.GetRequiredService<ISecretReplicationHub>()));
// The local store + its migrator come from AddZbSecrets (SQLite by default).
services.AddZbSecrets(config, secretsSectionPath);
// Decorate the local store so every write publishes. Resolved from the concrete SQLite store
// rather than ISecretStore, because ISecretStore is what we are replacing — asking the
// container for it here would resolve this very decorator and recurse forever.
services.AddSingleton<ISecretStore>(sp => new ReplicatingSecretStore(
sp.GetRequiredService<Secrets.Sqlite.SqliteSecretStore>(),
sp.GetRequiredService<ISecretReplicator>(),
sp.GetRequiredService<ILogger<ReplicatingSecretStore>>()));
// The hub's schema must exist too — the node's own migrator only provisions the local store.
services.AddHostedService<SqlServerHubMigrationHostedService>();
services.AddHostedService<SqlServerSecretSyncService>();
return services;
}
// Registers the pieces both modes need: validated options, connection factory, hub store, and
// hub migrator.
private static void RegisterSqlServerCore(
IServiceCollection services,
IConfiguration config,
string sqlServerSectionPath)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(config);
ArgumentException.ThrowIfNullOrWhiteSpace(sqlServerSectionPath);
IConfigurationSection section = config.GetSection(sqlServerSectionPath);
services.Configure<SqlServerSecretsOptions>(section);
// Bind and validate NOW as well as through the options pipeline: a missing connection string
// or a bad schema name should fail at startup with a clear message, not on the first resolve.
var eager = new SqlServerSecretsOptions();
section.Bind(eager);
eager.Validate();
services.TryAddSingleton(sp =>
new SecretsSqlServerConnectionFactory(
sp.GetRequiredService<IOptions<SqlServerSecretsOptions>>().Value));
services.TryAddSingleton(sp =>
new SqlServerSecretStore(sp.GetRequiredService<SecretsSqlServerConnectionFactory>()));
services.TryAddSingleton(sp =>
new SqlServerSecretsStoreMigrator(sp.GetRequiredService<SecretsSqlServerConnectionFactory>()));
}
}
@@ -0,0 +1,34 @@
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.Secrets.Replicator.SqlServer;
/// <summary>
/// The subset of store operations hub replication needs from the shared database: compare
/// inventories, fetch rows in bulk, and accept a row verbatim.
/// </summary>
/// <remarks>
/// Narrower than <see cref="ISecretStore"/> on purpose. The hub is never resolved from, never
/// written through <see cref="ISecretStore.UpsertAsync"/> (which would stamp the hub's own revision
/// and bounce the row back), and never rewrapped by a syncing node — so exposing those operations
/// would only create ways to use it wrongly. It also makes the sync sweep testable against an
/// in-process fake instead of requiring a live SQL Server for every convergence case.
/// </remarks>
public interface ISecretReplicationHub
{
/// <summary>Returns the hub's manifest — names, revisions, timestamps, tombstone flags.</summary>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>The hub's manifest entries.</returns>
Task<IReadOnlyList<SecretManifestEntry>> GetManifestAsync(CancellationToken ct);
/// <summary>Fetches several encrypted rows by name; names absent from the hub are omitted.</summary>
/// <param name="names">The names to fetch.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>The matching encrypted rows.</returns>
Task<IReadOnlyList<StoredSecret>> GetManyAsync(IReadOnlyList<SecretName> names, CancellationToken ct);
/// <summary>Applies a row to the hub verbatim, last-writer-wins.</summary>
/// <param name="row">The encrypted row.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>A task that completes when the row has been applied or ignored.</returns>
Task ApplyReplicatedAsync(StoredSecret row, CancellationToken ct);
}
@@ -0,0 +1,57 @@
using Microsoft.Data.SqlClient;
namespace ZB.MOM.WW.Secrets.Replicator.SqlServer;
/// <summary>
/// Opens connections to the shared SQL-Server secret database and carries the settings every
/// command needs (schema name, command timeout).
/// </summary>
/// <param name="options">The validated SQL-Server store options.</param>
public sealed class SecretsSqlServerConnectionFactory(SqlServerSecretsOptions options)
{
private readonly SqlServerSecretsOptions _options =
options ?? throw new ArgumentNullException(nameof(options));
/// <summary>The bracket-quoted, allow-list-validated schema prefix for object names.</summary>
public string SchemaName => _options.SchemaName;
/// <summary>Command timeout, in whole seconds, for every command issued against this store.</summary>
public int CommandTimeoutSeconds => Math.Max(1, (int)_options.CommandTimeout.TotalSeconds);
/// <summary>Opens a connection to the shared secret database.</summary>
/// <param name="cancellationToken">A token to cancel the connect.</param>
/// <returns>An opened connection. The caller owns disposal.</returns>
public async Task<SqlConnection> OpenConnectionAsync(CancellationToken cancellationToken)
{
var connection = new SqlConnection(_options.ConnectionString);
try
{
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
return connection;
}
catch
{
await connection.DisposeAsync().ConfigureAwait(false);
throw;
}
}
/// <summary>
/// Creates a command on <paramref name="connection"/> with the configured timeout applied.
/// </summary>
/// <param name="connection">The open connection.</param>
/// <param name="commandText">The command text.</param>
/// <param name="transaction">The ambient transaction, if any.</param>
/// <returns>The configured command. The caller owns disposal.</returns>
public SqlCommand CreateCommand(
SqlConnection connection, string commandText, SqlTransaction? transaction = null)
{
ArgumentNullException.ThrowIfNull(connection);
SqlCommand command = connection.CreateCommand();
command.CommandText = commandText;
command.CommandTimeout = CommandTimeoutSeconds;
command.Transaction = transaction;
return command;
}
}
@@ -0,0 +1,33 @@
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
namespace ZB.MOM.WW.Secrets.Replicator.SqlServer;
/// <summary>
/// Provisions the shared hub schema at startup in hub-replication mode.
/// </summary>
/// <remarks>
/// Separate from the core migration hosted service on purpose: in hub mode there are <em>two</em>
/// stores to provision — the node's local one (handled by the core service through the
/// <c>ISecretsStoreMigrator</c> seam) and the shared hub, which no node "owns". Every node runs this
/// idempotently at startup, so the hub exists as soon as any node has started, with no separate
/// provisioning step for an operator to forget.
/// </remarks>
/// <param name="migrator">The hub migrator.</param>
/// <param name="options">Core secrets options, for the shared startup-migration switch.</param>
internal sealed class SqlServerHubMigrationHostedService(
SqlServerSecretsStoreMigrator migrator,
IOptions<SecretsOptions> options) : IHostedService
{
/// <inheritdoc />
public async Task StartAsync(CancellationToken cancellationToken)
{
if (options.Value.RunMigrationsOnStartup)
{
await migrator.MigrateAsync(cancellationToken).ConfigureAwait(false);
}
}
/// <inheritdoc />
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
@@ -0,0 +1,26 @@
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.Secrets.Replicator.SqlServer;
/// <summary>
/// Publishes local writes to the shared SQL-Server hub, so a secret written on one node is
/// immediately visible to every other node that syncs against the same hub.
/// </summary>
/// <remarks>
/// The row is written through <see cref="ISecretStore.ApplyReplicatedAsync"/>, not
/// <see cref="ISecretStore.UpsertAsync"/> — the hub must record the row <em>verbatim</em>, keeping
/// the originating node's revision and <c>updated_utc</c>. An upsert would stamp the hub's own
/// revision and timestamp, and the row would then look newer than the writer's own copy, so it
/// would bounce straight back on the next sync.
/// </remarks>
/// <param name="hub">The shared hub store.</param>
public sealed class SqlServerSecretReplicator(ISecretReplicationHub hub) : ISecretReplicator
{
/// <inheritdoc />
public Task PublishAsync(StoredSecret row, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(row);
return hub.ApplyReplicatedAsync(row, ct);
}
}
@@ -0,0 +1,398 @@
using System.Data;
using System.Globalization;
using Microsoft.Data.SqlClient;
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.Secrets.Replicator.SqlServer;
/// <summary>
/// SQL-Server-backed <see cref="ISecretStore"/>. Holds the same envelope-encrypted
/// <see cref="StoredSecret"/> rows as the SQLite store, in a database every node of a cluster can
/// reach — so one write is visible everywhere with no replication at all.
/// </summary>
/// <remarks>
/// <para>
/// The database holds <b>ciphertext only</b>. The KEK stays per-node and out of the database, so a
/// DBA, a backup tape, or a replica cannot decrypt anything. The corollary is a hard operational
/// constraint: every node must resolve the <em>same</em> KEK, because a row whose <c>kek_id</c> does
/// not match the local provider fails closed on resolve.
/// </para>
/// <para>
/// Semantics are deliberately identical to <c>SqliteSecretStore</c> — revision bump on upsert,
/// created stamps preserved on overwrite, verbatim last-writer-wins apply, compare-and-swap rewrap.
/// A cluster can run both stores at once (nodes on local SQLite replicating against a shared hub),
/// so a divergence between the two would be a silent convergence bug.
/// </para>
/// </remarks>
/// <param name="connectionFactory">Factory for connections to the shared secret database.</param>
public sealed class SqlServerSecretStore(SecretsSqlServerConnectionFactory connectionFactory)
: ISecretStore, ISecretReplicationHub
{
// All columns of the secret table, in schema order, for full-row reads.
private const string AllColumns =
"name, description, content_type, ciphertext, nonce, tag, wrapped_dek, wrap_nonce, wrap_tag, " +
"kek_id, revision, is_deleted, deleted_utc, created_utc, updated_utc, created_by, updated_by";
// The safe metadata projection — deliberately excludes every ciphertext / crypto BLOB column.
private const string MetadataColumns =
"name, description, content_type, kek_id, revision, is_deleted, created_utc, updated_utc, created_by, updated_by";
private string Secret => $"[{connectionFactory.SchemaName}].[{SqlServerSecretsSchema.SecretTable}]";
/// <inheritdoc />
public async Task<StoredSecret?> GetAsync(SecretName name, CancellationToken ct)
{
await using SqlConnection connection =
await connectionFactory.OpenConnectionAsync(ct).ConfigureAwait(false);
await using SqlCommand command = connectionFactory.CreateCommand(
connection, $"SELECT {AllColumns} FROM {Secret} WHERE name = @name;");
command.Parameters.AddWithValue("@name", name.Value);
await using SqlDataReader reader = await command.ExecuteReaderAsync(ct).ConfigureAwait(false);
return await reader.ReadAsync(ct).ConfigureAwait(false)
? ReadStoredSecret(reader)
: null;
}
/// <inheritdoc />
public async Task UpsertAsync(StoredSecret row, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(row);
await using SqlConnection connection =
await connectionFactory.OpenConnectionAsync(ct).ConfigureAwait(false);
string now = DateTimeOffset.UtcNow.ToString("O");
// MERGE establishes revision 0 / created==updated==now on insert; on match it overwrites the
// crypto material in place, bumps the revision, refreshes updated_utc/updated_by, and clears
// any tombstone — but deliberately preserves created_utc / created_by (the SQLite store's
// ON CONFLICT semantics, expressed in T-SQL).
//
// HOLDLOCK is not optional: without it MERGE takes only an update lock on the key range and
// two concurrent nodes upserting the same new name can both miss the match and both INSERT,
// producing a primary-key violation instead of one insert + one update.
await using SqlCommand command = connectionFactory.CreateCommand(connection, $"""
MERGE {Secret} WITH (HOLDLOCK) AS target
USING (SELECT @name AS name) AS source
ON target.name = source.name
WHEN MATCHED THEN UPDATE SET
description = @description,
content_type = @content_type,
ciphertext = @ciphertext,
nonce = @nonce,
tag = @tag,
wrapped_dek = @wrapped_dek,
wrap_nonce = @wrap_nonce,
wrap_tag = @wrap_tag,
kek_id = @kek_id,
revision = target.revision + 1,
updated_utc = @now,
updated_by = @updated_by,
is_deleted = 0,
deleted_utc = NULL
WHEN NOT MATCHED THEN INSERT (
name, description, content_type, ciphertext, nonce, tag,
wrapped_dek, wrap_nonce, wrap_tag, kek_id, revision,
is_deleted, deleted_utc, created_utc, updated_utc, created_by, updated_by)
VALUES (
@name, @description, @content_type, @ciphertext, @nonce, @tag,
@wrapped_dek, @wrap_nonce, @wrap_tag, @kek_id, 0,
0, NULL, @now, @now, @created_by, @updated_by);
""");
BindCryptoColumns(command, row);
command.Parameters.AddWithValue("@now", now);
command.Parameters.AddWithValue("@created_by", (object?)row.CreatedBy ?? DBNull.Value);
command.Parameters.AddWithValue("@updated_by", (object?)row.UpdatedBy ?? DBNull.Value);
await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<bool> DeleteAsync(SecretName name, string? actor, CancellationToken ct)
{
await using SqlConnection connection =
await connectionFactory.OpenConnectionAsync(ct).ConfigureAwait(false);
string now = DateTimeOffset.UtcNow.ToString("O");
await using SqlCommand command = connectionFactory.CreateCommand(connection, $"""
UPDATE {Secret} SET
is_deleted = 1,
deleted_utc = @now,
revision = revision + 1,
updated_utc = @now,
updated_by = @actor
WHERE name = @name AND is_deleted = 0;
""");
command.Parameters.AddWithValue("@now", now);
command.Parameters.AddWithValue("@actor", (object?)actor ?? DBNull.Value);
command.Parameters.AddWithValue("@name", name.Value);
int rowsAffected = await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
return rowsAffected > 0;
}
/// <inheritdoc />
public async Task<IReadOnlyList<SecretMetadata>> ListAsync(bool includeDeleted, CancellationToken ct)
{
await using SqlConnection connection =
await connectionFactory.OpenConnectionAsync(ct).ConfigureAwait(false);
await using SqlCommand command = connectionFactory.CreateCommand(
connection,
$"SELECT {MetadataColumns} FROM {Secret} WHERE (@include_deleted = 1 OR is_deleted = 0) ORDER BY name;");
command.Parameters.AddWithValue("@include_deleted", includeDeleted ? 1 : 0);
var results = new List<SecretMetadata>();
await using SqlDataReader reader = await command.ExecuteReaderAsync(ct).ConfigureAwait(false);
while (await reader.ReadAsync(ct).ConfigureAwait(false))
{
results.Add(new SecretMetadata
{
Name = new SecretName(reader.GetString(0)),
Description = reader.IsDBNull(1) ? null : reader.GetString(1),
ContentType = Enum.Parse<SecretContentType>(reader.GetString(2)),
KekId = reader.GetString(3),
Revision = reader.GetInt64(4),
IsDeleted = reader.GetBoolean(5),
CreatedUtc = ParseUtc(reader.GetString(6)),
UpdatedUtc = ParseUtc(reader.GetString(7)),
CreatedBy = reader.IsDBNull(8) ? null : reader.GetString(8),
UpdatedBy = reader.IsDBNull(9) ? null : reader.GetString(9),
});
}
return results;
}
/// <inheritdoc />
public async Task<IReadOnlyList<SecretManifestEntry>> GetManifestAsync(CancellationToken ct)
{
await using SqlConnection connection =
await connectionFactory.OpenConnectionAsync(ct).ConfigureAwait(false);
await using SqlCommand command = connectionFactory.CreateCommand(
connection, $"SELECT name, revision, updated_utc, is_deleted FROM {Secret} ORDER BY name;");
var results = new List<SecretManifestEntry>();
await using SqlDataReader reader = await command.ExecuteReaderAsync(ct).ConfigureAwait(false);
while (await reader.ReadAsync(ct).ConfigureAwait(false))
{
results.Add(new SecretManifestEntry
{
Name = new SecretName(reader.GetString(0)),
Revision = reader.GetInt64(1),
UpdatedUtc = ParseUtc(reader.GetString(2)),
IsDeleted = reader.GetBoolean(3),
});
}
return results;
}
/// <inheritdoc />
public async Task ApplyReplicatedAsync(StoredSecret row, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(row);
await using SqlConnection connection =
await connectionFactory.OpenConnectionAsync(ct).ConfigureAwait(false);
await using SqlTransaction transaction = (SqlTransaction)
await connection.BeginTransactionAsync(IsolationLevel.Serializable, ct).ConfigureAwait(false);
// Read the local (updated_utc, revision) so we can apply last-writer-wins. UPDLOCK/HOLDLOCK
// keeps another node from slipping a newer row in between this read and the write below —
// Serializable alone would not lock a key range that currently has no row.
await using (SqlCommand read = connectionFactory.CreateCommand(
connection,
$"SELECT updated_utc, revision FROM {Secret} WITH (UPDLOCK, HOLDLOCK) WHERE name = @name;",
transaction))
{
read.Parameters.AddWithValue("@name", row.Name.Value);
await using SqlDataReader reader = await read.ExecuteReaderAsync(ct).ConfigureAwait(false);
if (await reader.ReadAsync(ct).ConfigureAwait(false))
{
DateTimeOffset localUpdated = ParseUtc(reader.GetString(0));
long localRevision = reader.GetInt64(1);
// Routed through the shared predicate so every store agrees on ties.
if (!SecretLastWriterWins.IsNewer(row.UpdatedUtc, row.Revision, localUpdated, localRevision))
{
await reader.CloseAsync().ConfigureAwait(false);
await transaction.CommitAsync(ct).ConfigureAwait(false);
return;
}
}
}
// Apply the incoming row VERBATIM — its own revision, timestamps, tombstone flag, and crypto
// material — with no revision bump (this is the replication path, not a local write).
await using (SqlCommand upsert = connectionFactory.CreateCommand(connection, $"""
MERGE {Secret} WITH (HOLDLOCK) AS target
USING (SELECT @name AS name) AS source
ON target.name = source.name
WHEN MATCHED THEN UPDATE SET
description = @description,
content_type = @content_type,
ciphertext = @ciphertext,
nonce = @nonce,
tag = @tag,
wrapped_dek = @wrapped_dek,
wrap_nonce = @wrap_nonce,
wrap_tag = @wrap_tag,
kek_id = @kek_id,
revision = @revision,
is_deleted = @is_deleted,
deleted_utc = @deleted_utc,
created_utc = @created_utc,
updated_utc = @updated_utc,
created_by = @created_by,
updated_by = @updated_by
WHEN NOT MATCHED THEN INSERT (
name, description, content_type, ciphertext, nonce, tag,
wrapped_dek, wrap_nonce, wrap_tag, kek_id, revision,
is_deleted, deleted_utc, created_utc, updated_utc, created_by, updated_by)
VALUES (
@name, @description, @content_type, @ciphertext, @nonce, @tag,
@wrapped_dek, @wrap_nonce, @wrap_tag, @kek_id, @revision,
@is_deleted, @deleted_utc, @created_utc, @updated_utc, @created_by, @updated_by);
""", transaction))
{
BindCryptoColumns(upsert, row);
upsert.Parameters.AddWithValue("@revision", row.Revision);
upsert.Parameters.AddWithValue("@is_deleted", row.IsDeleted ? 1 : 0);
upsert.Parameters.AddWithValue("@deleted_utc", (object?)row.DeletedUtc?.ToString("O") ?? DBNull.Value);
upsert.Parameters.AddWithValue("@created_utc", row.CreatedUtc.ToString("O"));
upsert.Parameters.AddWithValue("@updated_utc", row.UpdatedUtc.ToString("O"));
upsert.Parameters.AddWithValue("@created_by", (object?)row.CreatedBy ?? DBNull.Value);
upsert.Parameters.AddWithValue("@updated_by", (object?)row.UpdatedBy ?? DBNull.Value);
await upsert.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
}
await transaction.CommitAsync(ct).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<bool> ApplyRewrapAsync(
StoredSecret rewrappedRow, byte[] expectedCurrentWrappedDek, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(rewrappedRow);
ArgumentNullException.ThrowIfNull(expectedCurrentWrappedDek);
await using SqlConnection connection =
await connectionFactory.OpenConnectionAsync(ct).ConfigureAwait(false);
// Overwrite ONLY the KEK-wrap envelope + kek_id. Revision, updated_utc/by, the sealed body,
// and the tombstone state are deliberately untouched — a re-wrap is not a logical change, so
// it must not bump the revision or updated timestamp (see ISecretStore.ApplyRewrapAsync).
//
// Compare-and-swap on wrapped_dek: a concurrent set/rotate generates a fresh (random) DEK
// wrap, so this matches 0 rows instead of pairing this stale re-wrap with a newer body —
// which would leave the row permanently undecryptable. The caller re-processes a 0-row miss.
await using SqlCommand command = connectionFactory.CreateCommand(connection, $"""
UPDATE {Secret} SET
wrapped_dek = @wrapped_dek,
wrap_nonce = @wrap_nonce,
wrap_tag = @wrap_tag,
kek_id = @kek_id
WHERE name = @name AND wrapped_dek = @expected_wrapped_dek;
""");
command.Parameters.AddWithValue("@wrapped_dek", rewrappedRow.WrappedDek);
command.Parameters.AddWithValue("@wrap_nonce", rewrappedRow.WrapNonce);
command.Parameters.AddWithValue("@wrap_tag", rewrappedRow.WrapTag);
command.Parameters.AddWithValue("@kek_id", rewrappedRow.KekId);
command.Parameters.AddWithValue("@name", rewrappedRow.Name.Value);
command.Parameters.AddWithValue("@expected_wrapped_dek", expectedCurrentWrappedDek);
int rowsAffected = await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
return rowsAffected > 0;
}
/// <summary>
/// Fetches several rows by name in one round trip — the replication fetch path. Names not
/// present are simply absent from the result.
/// </summary>
/// <param name="names">The names to fetch.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>The matching encrypted rows.</returns>
public async Task<IReadOnlyList<StoredSecret>> GetManyAsync(
IReadOnlyList<SecretName> names, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(names);
if (names.Count == 0)
{
return [];
}
await using SqlConnection connection =
await connectionFactory.OpenConnectionAsync(ct).ConfigureAwait(false);
// One parameter per name — still fully parameterized, no name text is interpolated.
string[] placeholders = [.. names.Select((_, i) => $"@n{i.ToString(CultureInfo.InvariantCulture)}")];
await using SqlCommand command = connectionFactory.CreateCommand(
connection,
$"SELECT {AllColumns} FROM {Secret} WHERE name IN ({string.Join(", ", placeholders)});");
for (int i = 0; i < names.Count; i++)
{
command.Parameters.AddWithValue(placeholders[i], names[i].Value);
}
var results = new List<StoredSecret>(names.Count);
await using SqlDataReader reader = await command.ExecuteReaderAsync(ct).ConfigureAwait(false);
while (await reader.ReadAsync(ct).ConfigureAwait(false))
{
results.Add(ReadStoredSecret(reader));
}
return results;
}
// Binds the identity, description, content-type, KEK id, and all six crypto BLOB columns
// shared by every insert path.
private static void BindCryptoColumns(SqlCommand command, StoredSecret row)
{
command.Parameters.AddWithValue("@name", row.Name.Value);
command.Parameters.AddWithValue("@description", (object?)row.Description ?? DBNull.Value);
command.Parameters.AddWithValue("@content_type", row.ContentType.ToString());
command.Parameters.AddWithValue("@ciphertext", row.Ciphertext);
command.Parameters.AddWithValue("@nonce", row.Nonce);
command.Parameters.AddWithValue("@tag", row.Tag);
command.Parameters.AddWithValue("@wrapped_dek", row.WrappedDek);
command.Parameters.AddWithValue("@wrap_nonce", row.WrapNonce);
command.Parameters.AddWithValue("@wrap_tag", row.WrapTag);
command.Parameters.AddWithValue("@kek_id", row.KekId);
}
private static StoredSecret ReadStoredSecret(SqlDataReader reader) => new()
{
Name = new SecretName(reader.GetString(0)),
Description = reader.IsDBNull(1) ? null : reader.GetString(1),
ContentType = Enum.Parse<SecretContentType>(reader.GetString(2)),
Ciphertext = reader.GetFieldValue<byte[]>(3),
Nonce = reader.GetFieldValue<byte[]>(4),
Tag = reader.GetFieldValue<byte[]>(5),
WrappedDek = reader.GetFieldValue<byte[]>(6),
WrapNonce = reader.GetFieldValue<byte[]>(7),
WrapTag = reader.GetFieldValue<byte[]>(8),
KekId = reader.GetString(9),
Revision = reader.GetInt64(10),
IsDeleted = reader.GetBoolean(11),
DeletedUtc = reader.IsDBNull(12) ? null : ParseUtc(reader.GetString(12)),
CreatedUtc = ParseUtc(reader.GetString(13)),
UpdatedUtc = ParseUtc(reader.GetString(14)),
CreatedBy = reader.IsDBNull(15) ? null : reader.GetString(15),
UpdatedBy = reader.IsDBNull(16) ? null : reader.GetString(16),
};
private static DateTimeOffset ParseUtc(string value) =>
DateTimeOffset.Parse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind);
}
@@ -0,0 +1,158 @@
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.Secrets.Abstractions;
using ZB.MOM.WW.Secrets.Replication;
namespace ZB.MOM.WW.Secrets.Replicator.SqlServer;
/// <summary>
/// Keeps a node's local secret store converged with the shared SQL-Server hub, in hub-replication
/// mode. Runs a bidirectional anti-entropy sweep on an interval: pulls rows the hub holds newer,
/// pushes rows the node holds newer.
/// </summary>
/// <remarks>
/// <para>
/// This is the durability guarantee behind best-effort publishing. Live publishes make a write
/// visible in milliseconds; this sweep is what makes it <em>certain</em>. A node that was offline
/// while a peer wrote catches up on its next sweep, and a write made while the hub was unreachable
/// is pushed on the next sweep instead of being lost.
/// </para>
/// <para>
/// The sweep never throws out of <see cref="ExecuteAsync"/>: a hub outage must degrade the node to
/// "serving its last-known-good local secrets" rather than crash the host. Failures are logged and
/// retried on the next tick.
/// </para>
/// </remarks>
public sealed partial class SqlServerSecretSyncService : BackgroundService
{
private readonly SecretReplicationReconciler _reconciler;
private readonly ISecretStore _local;
private readonly ISecretReplicationHub _hub;
private readonly SqlServerSecretsOptions _options;
private readonly ILogger<SqlServerSecretSyncService> _logger;
private readonly TimeProvider _timeProvider;
/// <summary>Creates the sync service.</summary>
/// <param name="local">The node's local store.</param>
/// <param name="hub">The shared hub store.</param>
/// <param name="cacheInvalidator">Resolver-cache seam, evicted for every applied row.</param>
/// <param name="options">SQL-Server replication options.</param>
/// <param name="logger">Logger for sweep outcomes and failures.</param>
/// <param name="timeProvider">Clock, injectable so tests need not wait a real interval.</param>
public SqlServerSecretSyncService(
ISecretStore local,
ISecretReplicationHub hub,
ISecretCacheInvalidator? cacheInvalidator,
IOptions<SqlServerSecretsOptions> options,
ILogger<SqlServerSecretSyncService> logger,
TimeProvider? timeProvider = null)
{
ArgumentNullException.ThrowIfNull(options);
_local = local ?? throw new ArgumentNullException(nameof(local));
_hub = hub ?? throw new ArgumentNullException(nameof(hub));
_options = options.Value;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
_reconciler = new SecretReplicationReconciler(
_local,
cacheInvalidator,
onRowFailed: (name, ex) => LogRowDiscarded(ex, name.Value));
}
/// <inheritdoc />
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
if (_options.SyncOnStartup)
{
await SweepSafelyAsync(stoppingToken).ConfigureAwait(false);
}
using PeriodicTimer timer = new(_options.SyncInterval, _timeProvider);
while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false))
{
await SweepSafelyAsync(stoppingToken).ConfigureAwait(false);
}
}
/// <summary>
/// Runs one bidirectional reconcile against the hub.
/// </summary>
/// <param name="ct">A token to cancel the sweep.</param>
/// <returns>How many rows were pulled from and pushed to the hub.</returns>
internal async Task<(int Pulled, int Pushed)> SweepAsync(CancellationToken ct)
{
IReadOnlyList<SecretManifestEntry> hubManifest =
await _hub.GetManifestAsync(ct).ConfigureAwait(false);
// Pull first: adopting the hub's newer rows before computing the push set means a row the hub
// already had newer is not then pushed straight back at it.
int pulled = await _reconciler
.ReconcileAsync(hubManifest, (names, token) => _hub.GetManyAsync(names, token), ct)
.ConfigureAwait(false);
IReadOnlyList<SecretManifestEntry> localManifest =
await _local.GetManifestAsync(ct).ConfigureAwait(false);
IReadOnlyList<SecretName> push =
SecretReplicationReconciler.ComputePushSet(localManifest, hubManifest);
int pushed = 0;
if (push.Count > 0)
{
IReadOnlyList<StoredSecret> rows =
await _reconciler.ReadLocalAsync(push, ct).ConfigureAwait(false);
foreach (StoredSecret row in rows)
{
// Verbatim, like the replicator — the hub records the originating node's revision.
await _hub.ApplyReplicatedAsync(row, ct).ConfigureAwait(false);
pushed++;
}
}
return (pulled, pushed);
}
private async Task SweepSafelyAsync(CancellationToken ct)
{
try
{
(int pulled, int pushed) = await SweepAsync(ct).ConfigureAwait(false);
if (pulled > 0 || pushed > 0)
{
LogSweepConverged(pulled, pushed);
}
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
// Host shutdown — not a failure.
}
catch (Exception ex)
{
LogSweepFailed(ex);
}
}
[LoggerMessage(
EventId = 1,
Level = LogLevel.Information,
Message = "Secret hub sync converged: pulled {Pulled} row(s) from the hub, pushed {Pushed} row(s) to it.")]
private partial void LogSweepConverged(int pulled, int pushed);
[LoggerMessage(
EventId = 2,
Level = LogLevel.Warning,
Message = "Secret hub sync failed; the node continues serving its local store and will retry on the next interval.")]
private partial void LogSweepFailed(Exception exception);
[LoggerMessage(
EventId = 3,
Level = LogLevel.Warning,
Message = "Discarding replicated secret row {SecretName} that could not be applied locally; the rest of the batch continues.")]
private partial void LogRowDiscarded(Exception exception, string secretName);
}
@@ -0,0 +1,121 @@
namespace ZB.MOM.WW.Secrets.Replicator.SqlServer;
/// <summary>
/// Configuration for the shared SQL-Server secret store and its hub replication. Bound from a
/// configuration section by the <c>AddZbSecretsSqlServer*</c> extensions.
/// </summary>
public sealed class SqlServerSecretsOptions
{
private string _schemaName = SqlServerSecretsSchema.DefaultSchemaName;
/// <summary>
/// Connection string for the shared secret database. Required. Supply it through a
/// <c>${secret:}</c> / <c>secret:</c> reference like every other connection string in the family
/// — the shared store holds ciphertext only, but its credentials are still credentials.
/// </summary>
public string ConnectionString { get; set; } = string.Empty;
/// <summary>
/// SQL schema the secret tables live in. Defaults to <c>zbsecrets</c> so the secret tables never
/// collide with an application's own <c>dbo</c> objects when they share a database.
/// </summary>
/// <exception cref="ArgumentException">
/// The value is not a plain identifier (letters, digits, and underscore, not starting with a
/// digit, at most 128 characters).
/// </exception>
/// <remarks>
/// Validated on assignment rather than at use: the schema name is interpolated into DDL and
/// query text (T-SQL cannot parameterize an object name), so the allow-list is the injection
/// boundary and it must be impossible to get an unvalidated value into the property at all.
/// </remarks>
public string SchemaName
{
get => _schemaName;
set => _schemaName = ValidateIdentifier(value);
}
/// <summary>
/// How often the sync service reconciles the local store against the hub, in hub-replication
/// mode. Defaults to 30 seconds. Ignored in shared-store mode (there is nothing to reconcile).
/// </summary>
public TimeSpan SyncInterval { get; set; } = TimeSpan.FromSeconds(30);
/// <summary>
/// When <see langword="true"/> (the default), hub-replication mode runs a reconcile at startup
/// before the first interval elapses, so a node that was down while a peer wrote does not serve
/// stale secrets for a whole interval after it comes back.
/// </summary>
public bool SyncOnStartup { get; set; } = true;
/// <summary>
/// Command timeout applied to every store operation. Defaults to 30 seconds — long enough for a
/// contended shared database, short enough that a hung hub surfaces as an error rather than
/// stalling a resolve indefinitely.
/// </summary>
public TimeSpan CommandTimeout { get; set; } = TimeSpan.FromSeconds(30);
/// <summary>
/// Throws if the options are unusable. Called by the DI extensions at registration time so a
/// misconfigured node fails at startup rather than on the first secret resolve.
/// </summary>
/// <exception cref="InvalidOperationException">The connection string is missing, or an interval is not positive.</exception>
public void Validate()
{
if (string.IsNullOrWhiteSpace(ConnectionString))
{
throw new InvalidOperationException(
"SQL-Server secret store requires a ConnectionString (Secrets:SqlServer:ConnectionString).");
}
if (SyncInterval <= TimeSpan.Zero)
{
throw new InvalidOperationException(
$"SQL-Server secret replication SyncInterval must be positive (was {SyncInterval}).");
}
if (CommandTimeout <= TimeSpan.Zero)
{
throw new InvalidOperationException(
$"SQL-Server secret store CommandTimeout must be positive (was {CommandTimeout}).");
}
}
/// <summary>
/// Strict allow-list for a SQL identifier that will be interpolated into DDL or query text.
/// Rejects everything that is not <c>[A-Za-z_][A-Za-z0-9_]{0,127}</c> — no brackets, quotes,
/// dots, or spaces — so no bracket-escape (<c>]]</c>) trick can break out of the quoting applied
/// at the use site. Uses the ASCII-only character tests deliberately, so homoglyph and
/// full-width look-alikes are rejected rather than accepted as "letters".
/// </summary>
/// <param name="value">The candidate identifier.</param>
/// <returns>The same value, once validated.</returns>
/// <exception cref="ArgumentException">The value is not a plain identifier.</exception>
internal static string ValidateIdentifier(string value)
{
ArgumentException.ThrowIfNullOrWhiteSpace(value);
if (value.Length > 128)
{
throw new ArgumentException(
$"SQL schema name must be at most 128 characters (was {value.Length}).", nameof(value));
}
if (!char.IsAsciiLetter(value[0]) && value[0] != '_')
{
throw new ArgumentException(
$"SQL schema name must start with a letter or underscore (was '{value}').", nameof(value));
}
foreach (char c in value)
{
if (!char.IsAsciiLetterOrDigit(c) && c != '_')
{
throw new ArgumentException(
$"SQL schema name may contain only letters, digits, and underscore (was '{value}').",
nameof(value));
}
}
return value;
}
}
@@ -0,0 +1,100 @@
namespace ZB.MOM.WW.Secrets.Replicator.SqlServer;
/// <summary>
/// Schema constants and DDL for the shared SQL-Server secret store. Mirrors
/// <c>ZB.MOM.WW.Secrets.Sqlite.SqliteSecretsSchema</c> column-for-column so a row means exactly the
/// same thing in either store.
/// </summary>
/// <remarks>
/// <para>
/// <b>Timestamps are ISO-8601 <c>nvarchar</c>, not <c>datetimeoffset</c>.</b> This looks wrong for a
/// SQL-Server schema and is deliberate: the same <see cref="ZB.MOM.WW.Secrets.Abstractions.StoredSecret"/>
/// rows replicate between this store and SQLite (which stores <c>"O"</c>-format text), and
/// last-writer-wins compares those timestamps for equality. <c>datetimeoffset</c> rounds to 100ns
/// while .NET <c>DateTimeOffset</c> ticks are also 100ns but the round-trip through the driver is
/// not guaranteed bit-identical across providers — a sub-tick difference would turn a tie into a
/// spurious "newer", and two nodes would then overwrite each other forever. Storing the exact
/// round-trippable text both providers already agree on removes the failure mode entirely.
/// </para>
/// <para>
/// The cost is that <c>ORDER BY updated_utc</c> is a lexicographic sort. That is still chronological
/// for <c>"O"</c>-format UTC strings (fixed-width, zero-padded, always <c>+00:00</c>), which is what
/// the store writes.
/// </para>
/// </remarks>
public static class SqlServerSecretsSchema
{
/// <summary>
/// The schema version this build creates and supports. The migrator refuses a database whose
/// recorded version is <em>newer</em> than this.
/// </summary>
public const int CurrentVersion = 1;
/// <summary>Default schema the secret tables live in.</summary>
public const string DefaultSchemaName = "zbsecrets";
/// <summary>Name of the single-row table tracking the applied schema version.</summary>
public const string SchemaVersionTable = "schema_version";
/// <summary>Name of the table storing envelope-encrypted secret records.</summary>
public const string SecretTable = "secret";
/// <summary>
/// Returns the idempotent DDL that provisions the schema, both tables, and the supporting index
/// in <paramref name="schemaName"/>.
/// </summary>
/// <param name="schemaName">The already-validated SQL schema name to create the objects in.</param>
/// <returns>The DDL batch.</returns>
/// <remarks>
/// <paramref name="schemaName"/> is interpolated, not parameterized — T-SQL has no parameter form
/// for an object name. It is re-validated here against the same strict identifier allow-list
/// <see cref="SqlServerSecretsOptions.SchemaName"/> enforces, and bracket-quoted below. The
/// duplicate check is deliberate: this method is <c>public</c>, so it must defend itself rather
/// than trust that every caller routed through the options type.
/// </remarks>
/// <exception cref="ArgumentException"><paramref name="schemaName"/> is not a plain identifier.</exception>
public static string CreateSchemaDdl(string schemaName)
{
SqlServerSecretsOptions.ValidateIdentifier(schemaName);
return $"""
IF NOT EXISTS (SELECT 1 FROM sys.schemas WHERE name = N'{schemaName}')
EXEC(N'CREATE SCHEMA [{schemaName}]');
IF OBJECT_ID(N'[{schemaName}].[{SchemaVersionTable}]', N'U') IS NULL
CREATE TABLE [{schemaName}].[{SchemaVersionTable}] (
id INT NOT NULL PRIMARY KEY CHECK (id = 1),
version INT NOT NULL,
applied_utc NVARCHAR(33) NOT NULL
);
IF OBJECT_ID(N'[{schemaName}].[{SecretTable}]', N'U') IS NULL
CREATE TABLE [{schemaName}].[{SecretTable}] (
name NVARCHAR(450) NOT NULL PRIMARY KEY,
description NVARCHAR(MAX) NULL,
content_type NVARCHAR(64) NOT NULL,
ciphertext VARBINARY(MAX) NOT NULL,
nonce VARBINARY(MAX) NOT NULL,
tag VARBINARY(MAX) NOT NULL,
wrapped_dek VARBINARY(MAX) NOT NULL,
wrap_nonce VARBINARY(MAX) NOT NULL,
wrap_tag VARBINARY(MAX) NOT NULL,
kek_id NVARCHAR(256) NOT NULL,
revision BIGINT NOT NULL CONSTRAINT DF_secret_revision DEFAULT 0,
is_deleted BIT NOT NULL CONSTRAINT DF_secret_is_deleted DEFAULT 0,
deleted_utc NVARCHAR(33) NULL,
created_utc NVARCHAR(33) NOT NULL,
updated_utc NVARCHAR(33) NOT NULL,
created_by NVARCHAR(256) NULL,
updated_by NVARCHAR(256) NULL
);
IF NOT EXISTS (
SELECT 1 FROM sys.indexes
WHERE name = N'ix_secret_updated_utc'
AND object_id = OBJECT_ID(N'[{schemaName}].[{SecretTable}]'))
CREATE INDEX ix_secret_updated_utc
ON [{schemaName}].[{SecretTable}] (updated_utc);
""";
}
}
@@ -0,0 +1,125 @@
using System.Data;
using System.Globalization;
using Microsoft.Data.SqlClient;
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.Secrets.Replicator.SqlServer;
/// <summary>
/// Creates the shared SQL-Server secret schema and records the applied version. Idempotent: safe to
/// run on every node at every startup, including concurrently. Refuses to run against a database
/// whose recorded version is newer than this build supports.
/// </summary>
/// <param name="connectionFactory">Factory for connections to the shared secret database.</param>
public sealed class SqlServerSecretsStoreMigrator(SecretsSqlServerConnectionFactory connectionFactory)
: ISecretsStoreMigrator
{
// Concurrent Serializable DDL against the system catalogs can pick a deadlock victim (error
// 1205) even though the IF NOT EXISTS guards make the logic itself safe. Every node runs this at
// every startup, so a virgin database being provisioned by N nodes at once is the NORMAL case,
// not a rare race — and an unretried victim aborts that node's whole host at startup. Retrying
// is correct precisely because the migration is idempotent.
private const int MaxDeadlockRetries = 5;
private const int SqlDeadlockVictimErrorNumber = 1205;
/// <inheritdoc />
/// <exception cref="SecretStoreMigrationException">
/// The recorded schema version is newer than <see cref="SqlServerSecretsSchema.CurrentVersion"/>.
/// </exception>
public async Task MigrateAsync(CancellationToken cancellationToken)
{
for (int attempt = 1; ; attempt++)
{
try
{
await MigrateOnceAsync(cancellationToken).ConfigureAwait(false);
return;
}
catch (SqlException ex)
when (ex.Number == SqlDeadlockVictimErrorNumber && attempt < MaxDeadlockRetries)
{
// Staggered back-off so two victims do not retry in lockstep and deadlock again.
await Task.Delay(TimeSpan.FromMilliseconds(100 * attempt), cancellationToken)
.ConfigureAwait(false);
}
}
}
private async Task MigrateOnceAsync(CancellationToken cancellationToken)
{
string schema = connectionFactory.SchemaName;
await using SqlConnection connection =
await connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
// Serializable: unlike the single-writer SQLite store, EVERY node in the cluster runs this
// migration at startup, so concurrent first-boots are the normal case rather than a rare
// race. The isolation level plus the IF NOT EXISTS guards make a simultaneous provision by
// two nodes converge on one schema instead of one of them failing with "object exists".
await using SqlTransaction transaction = (SqlTransaction)
await connection.BeginTransactionAsync(IsolationLevel.Serializable, cancellationToken)
.ConfigureAwait(false);
int existingVersion =
await ReadExistingSchemaVersionAsync(connection, transaction, schema, cancellationToken)
.ConfigureAwait(false);
if (existingVersion > SqlServerSecretsSchema.CurrentVersion)
{
throw new SecretStoreMigrationException(
$"Shared secret database schema version {existingVersion} is newer than supported version " +
$"{SqlServerSecretsSchema.CurrentVersion}. Upgrade this node before it writes to the shared store.");
}
await using (SqlCommand ddl = connectionFactory.CreateCommand(
connection, SqlServerSecretsSchema.CreateSchemaDdl(schema), transaction))
{
await ddl.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
await using (SqlCommand version = connectionFactory.CreateCommand(
connection,
$"""
MERGE [{schema}].[{SqlServerSecretsSchema.SchemaVersionTable}] AS target
USING (SELECT 1 AS id, @version AS version, @applied_utc AS applied_utc) AS source
ON target.id = source.id
WHEN MATCHED THEN
UPDATE SET version = source.version, applied_utc = source.applied_utc
WHEN NOT MATCHED THEN
INSERT (id, version, applied_utc) VALUES (1, source.version, source.applied_utc);
""",
transaction))
{
version.Parameters.AddWithValue("@version", SqlServerSecretsSchema.CurrentVersion);
version.Parameters.AddWithValue("@applied_utc", DateTimeOffset.UtcNow.ToString("O"));
await version.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
}
private static async Task<int> ReadExistingSchemaVersionAsync(
SqlConnection connection,
SqlTransaction transaction,
string schema,
CancellationToken cancellationToken)
{
await using SqlCommand command = connection.CreateCommand();
command.Transaction = transaction;
// OBJECT_ID returns NULL for an absent table, so this reads 0 on a virgin database without
// needing the table to exist first.
command.CommandText = $"""
IF OBJECT_ID(N'[{schema}].[{SqlServerSecretsSchema.SchemaVersionTable}]', N'U') IS NULL
SELECT 0;
ELSE
SELECT ISNULL((SELECT version FROM [{schema}].[{SqlServerSecretsSchema.SchemaVersionTable}] WHERE id = 1), 0);
""";
object? version = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
return version is null || version == DBNull.Value
? 0
: Convert.ToInt32(version, CultureInfo.InvariantCulture);
}
}
@@ -0,0 +1,39 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>
<PropertyGroup>
<IsPackable>true</IsPackable>
<PackageId>ZB.MOM.WW.Secrets.Replicator.SqlServer</PackageId>
<Authors>ZB.MOM.WW</Authors>
<Description>Shared SQL-Server secret store and hub replication for the ZB.MOM.WW SCADA family — cluster-wide, ciphertext-only secrets.</Description>
<PackageProjectUrl>https://gitea.dohertylan.com/dohertj2/zb-mom-ww-secrets</PackageProjectUrl>
<RepositoryUrl>https://gitea.dohertylan.com/dohertj2/zb-mom-ww-secrets</RepositoryUrl>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\ZB.MOM.WW.Secrets\ZB.MOM.WW.Secrets.csproj" />
<ProjectReference Include="..\ZB.MOM.WW.Secrets.Abstractions\ZB.MOM.WW.Secrets.Abstractions.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Data.SqlClient" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="ZB.MOM.WW.Secrets.Replicator.SqlServer.Tests" />
</ItemGroup>
</Project>
@@ -4,8 +4,9 @@ namespace ZB.MOM.WW.Secrets.DependencyInjection;
/// <summary>
/// The single-node default <see cref="ISecretReplicator"/>: publishing a row is a no-op because
/// there are no peers to broadcast to. The deferred <c>ZB.MOM.WW.Secrets.Akka</c> package replaces
/// this registration with a real cluster replicator for clustered deployments.
/// there are no peers to broadcast to. The <c>ZB.MOM.WW.Secrets.Replication.Akka</c> and
/// <c>ZB.MOM.WW.Secrets.Replication.SqlServer</c> packages replace this registration with a real
/// replicator for clustered deployments.
/// </summary>
public sealed class NoOpSecretReplicator : ISecretReplicator
{
@@ -1,16 +1,21 @@
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.Secrets.Sqlite;
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.Secrets.DependencyInjection;
/// <summary>
/// Runs the secrets SQLite schema migration at application startup when
/// Runs the secret-store schema migration at application startup when
/// <see cref="SecretsOptions.RunMigrationsOnStartup"/> is <see langword="true"/>. The migration is
/// idempotent, so repeated restarts are safe.
/// </summary>
/// <remarks>
/// Depends on the <see cref="ISecretsStoreMigrator"/> seam rather than a concrete migrator, so the
/// same hosted service provisions the local SQLite store or the shared SQL-Server store depending
/// only on which migrator the application registered.
/// </remarks>
internal sealed class SecretsMigrationHostedService(
SqliteSecretsStoreMigrator migrator,
ISecretsStoreMigrator migrator,
IOptions<SecretsOptions> options) : IHostedService
{
/// <inheritdoc />
@@ -51,9 +51,15 @@ public static class SecretsServiceCollectionExtensions
services.TryAddSingleton<ISecretCipher>(sp =>
new AesGcmEnvelopeCipher(sp.GetRequiredService<IMasterKeyProvider>()));
services.TryAddSingleton<ISecretStore>(sp =>
// Registered BOTH concretely and behind the interface, forwarding to the one singleton.
// The concrete registration is what lets a replication package resolve the *undecorated*
// local store when it replaces ISecretStore with a decorator — asking the container for
// ISecretStore there would resolve the decorator itself and recurse forever.
services.TryAddSingleton(sp =>
new SqliteSecretStore(sp.GetRequiredService<SecretsSqliteConnectionFactory>()));
services.TryAddSingleton<ISecretStore>(sp => sp.GetRequiredService<SqliteSecretStore>());
services.TryAddSingleton<ISecretReplicator, NoOpSecretReplicator>();
// ONE shared resolver instance backs both the read seam (ISecretResolver) and the
@@ -81,10 +87,16 @@ public static class SecretsServiceCollectionExtensions
services.TryAddSingleton<ISecretCacheInvalidator>(sp => sp.GetRequiredService<DefaultSecretResolver>());
// Migrator: singleton, constructed from the already-registered connection factory. Needed
// before any store operations so the schema exists.
// before any store operations so the schema exists. Registered BOTH concretely (for callers
// that want the SQLite migrator specifically) and behind the ISecretsStoreMigrator seam that
// the hosted service and CLI consume — a replication package that swaps the store registers
// its own ISecretsStoreMigrator BEFORE calling AddZbSecrets, and TryAdd leaves it in place.
services.TryAddSingleton(sp =>
new SqliteSecretsStoreMigrator(sp.GetRequiredService<SecretsSqliteConnectionFactory>()));
services.TryAddSingleton<ISecretsStoreMigrator>(sp =>
sp.GetRequiredService<SqliteSecretsStoreMigrator>());
// Hosted service that runs migrations on startup when SecretsOptions.RunMigrationsOnStartup.
services.AddHostedService<SecretsMigrationHostedService>();
@@ -0,0 +1,115 @@
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.Secrets.Replication;
/// <summary>
/// Decorates a local <see cref="ISecretStore"/> so every successful local write is published to
/// peers through <see cref="ISecretReplicator"/>.
/// </summary>
/// <remarks>
/// <para>
/// This is what turns the <see cref="ISecretReplicator"/> seam from a declaration into behaviour.
/// Decorating the store — rather than calling the replicator from each write site — means the CLI,
/// the Blazor UI, and any future caller all replicate automatically, and no new write path can
/// forget to.
/// </para>
/// <para>
/// <b>Publishing is best-effort.</b> A transport failure is logged and swallowed: the local write is
/// already durable, and refusing it because a peer was unreachable would make every node a single
/// point of failure for every other. Propagation is then guaranteed by the transport's periodic
/// anti-entropy sweep instead, which is why every transport in this library reconciles
/// <em>bidirectionally</em> — a dropped publish is repaired by the next sweep rather than lost.
/// </para>
/// <para>
/// <see cref="ApplyReplicatedAsync"/> deliberately does not publish (that would echo a peer's row
/// straight back and, across three or more nodes, never stop). Neither does
/// <see cref="ApplyRewrapAsync"/>: a re-wrap does not bump the revision, so it is invisible to
/// last-writer-wins by design and a peer would discard it — KEK rotation is run once per independent
/// store instead.
/// </para>
/// </remarks>
/// <param name="inner">The local store doing the actual persistence.</param>
/// <param name="replicator">The transport that publishes rows to peers.</param>
/// <param name="logger">Logger for publish failures.</param>
public sealed class ReplicatingSecretStore(
ISecretStore inner,
ISecretReplicator replicator,
ILogger<ReplicatingSecretStore> logger) : ISecretStore
{
/// <inheritdoc />
public Task<StoredSecret?> GetAsync(SecretName name, CancellationToken ct) =>
inner.GetAsync(name, ct);
/// <inheritdoc />
public Task<IReadOnlyList<SecretMetadata>> ListAsync(bool includeDeleted, CancellationToken ct) =>
inner.ListAsync(includeDeleted, ct);
/// <inheritdoc />
public Task<IReadOnlyList<SecretManifestEntry>> GetManifestAsync(CancellationToken ct) =>
inner.GetManifestAsync(ct);
/// <inheritdoc />
public Task ApplyReplicatedAsync(StoredSecret row, CancellationToken ct) =>
inner.ApplyReplicatedAsync(row, ct);
/// <inheritdoc />
public Task<bool> ApplyRewrapAsync(
StoredSecret rewrappedRow, byte[] expectedCurrentWrappedDek, CancellationToken ct) =>
inner.ApplyRewrapAsync(rewrappedRow, expectedCurrentWrappedDek, ct);
/// <inheritdoc />
public async Task UpsertAsync(StoredSecret row, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(row);
await inner.UpsertAsync(row, ct).ConfigureAwait(false);
await PublishCurrentAsync(row.Name, ct).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<bool> DeleteAsync(SecretName name, string? actor, CancellationToken ct)
{
bool deleted = await inner.DeleteAsync(name, actor, ct).ConfigureAwait(false);
if (deleted)
{
// The tombstone is the thing being replicated — a delete propagates as a newer row, not
// as an absence, so peers converge on "deleted" instead of resurrecting it.
await PublishCurrentAsync(name, ct).ConfigureAwait(false);
}
return deleted;
}
// Re-reads the row the store actually persisted and publishes THAT, never the caller's input:
// the store assigns the revision and updated_utc, and those two fields are exactly what peers
// order by. Publishing the caller's copy would broadcast a row whose ordering keys are wrong.
private async Task PublishCurrentAsync(SecretName name, CancellationToken ct)
{
StoredSecret? persisted = await inner.GetAsync(name, ct).ConfigureAwait(false);
if (persisted is null)
{
return;
}
try
{
await replicator.PublishAsync(persisted, ct).ConfigureAwait(false);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
// Deliberately swallowed — see the class remarks. Anti-entropy repairs it.
logger.LogWarning(
ex,
"Failed to publish secret {SecretName} to peers; the local write succeeded and " +
"anti-entropy will propagate it on the next sweep.",
name.Value);
}
}
}
@@ -0,0 +1,228 @@
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.Secrets.Replication;
/// <summary>
/// Transport-agnostic anti-entropy: compares a peer's manifest against the local store, pulls only
/// the rows the peer holds a strictly newer copy of, and applies them last-writer-wins.
/// </summary>
/// <remarks>
/// <para>
/// This is the half of replication that is pure logic, deliberately separated from <em>how</em> the
/// peer is reached. The SQL-Server package points it at a shared hub database; the Akka package
/// points it at a cluster peer over pub/sub. Both get identical convergence behaviour, and the
/// interesting edge cases (ties, tombstones, a peer that is behind) are testable offline without a
/// database or a cluster.
/// </para>
/// <para>
/// Reconciliation is a pull, never a push: it only ever writes rows the peer says are newer, so
/// running it against a peer that is behind is a no-op rather than a regression. It is also safe to
/// run concurrently with live replication — <see cref="ISecretStore.ApplyReplicatedAsync"/> re-checks
/// the same predicate under its own transaction, so a row that a broadcast delivered first is simply
/// ignored the second time.
/// </para>
/// </remarks>
/// <param name="local">The local store to bring up to date.</param>
/// <param name="cacheInvalidator">
/// Optional resolver-cache seam. When supplied, every applied name is evicted so a node picks up a
/// peer's write within the reconcile interval instead of serving a stale plaintext for the rest of
/// the cache TTL.
/// </param>
/// <param name="onRowFailed">
/// Optional callback invoked when a single replicated row could not be applied. The row is skipped
/// and the rest of the batch proceeds; without a callback the failure is silent, so transports
/// should supply one that logs.
/// </param>
public sealed class SecretReplicationReconciler(
ISecretStore local,
ISecretCacheInvalidator? cacheInvalidator = null,
Action<SecretName, Exception>? onRowFailed = null)
{
/// <summary>
/// Returns the names for which <paramref name="remote"/> holds a strictly newer row than
/// <paramref name="localManifest"/> — including names absent locally, and including tombstones
/// (a delete propagates as a newer row, not as an absence).
/// </summary>
/// <param name="localManifest">The local store's manifest.</param>
/// <param name="remote">The peer's manifest.</param>
/// <returns>The names to pull from the peer, in manifest order.</returns>
public static IReadOnlyList<SecretName> ComputePullSet(
IReadOnlyList<SecretManifestEntry> localManifest,
IReadOnlyList<SecretManifestEntry> remote)
{
ArgumentNullException.ThrowIfNull(localManifest);
ArgumentNullException.ThrowIfNull(remote);
Dictionary<string, SecretManifestEntry> localByName =
localManifest.ToDictionary(entry => entry.Name.Value, StringComparer.Ordinal);
var pull = new List<SecretName>();
foreach (SecretManifestEntry remoteEntry in remote)
{
// Absent locally, or the peer's copy wins LWW. A name we hold and the peer does not is
// deliberately NOT handled here: this side pulls, the peer's own reconcile pushes.
if (!localByName.TryGetValue(remoteEntry.Name.Value, out SecretManifestEntry? localEntry) ||
SecretLastWriterWins.IsNewer(remoteEntry, localEntry))
{
pull.Add(remoteEntry.Name);
}
}
return pull;
}
/// <summary>
/// Returns the names the local store holds a strictly newer row for than <paramref name="remote"/>
/// — the mirror of <see cref="ComputePullSet"/>, used to push local writes a peer is missing.
/// </summary>
/// <remarks>
/// Every transport here reconciles in <b>both</b> directions, and this is why: publishing a write
/// is best-effort (see <c>ReplicatingSecretStore</c>), so without a push sweep a write made while
/// a peer was unreachable would sit on one node forever — the peer never learns the name exists,
/// so its own pull can never ask for it.
/// </remarks>
/// <param name="localManifest">The local store's manifest.</param>
/// <param name="remote">The peer's manifest.</param>
/// <returns>The names to push to the peer.</returns>
public static IReadOnlyList<SecretName> ComputePushSet(
IReadOnlyList<SecretManifestEntry> localManifest,
IReadOnlyList<SecretManifestEntry> remote) =>
// Exactly the pull computation with the two sides swapped: "what does the peer need from me"
// is "what would I need, if I were the peer".
ComputePullSet(remote, localManifest);
/// <summary>
/// Reconciles the local store against <paramref name="remoteManifest"/>, fetching and applying
/// every row the peer holds a newer copy of.
/// </summary>
/// <param name="remoteManifest">The peer's manifest.</param>
/// <param name="fetchAsync">
/// Fetches the encrypted rows for the requested names from the peer. May return fewer rows than
/// requested (a row deleted between manifest and fetch); extra rows are applied normally.
/// </param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>The number of rows applied locally.</returns>
public async Task<int> ReconcileAsync(
IReadOnlyList<SecretManifestEntry> remoteManifest,
Func<IReadOnlyList<SecretName>, CancellationToken, Task<IReadOnlyList<StoredSecret>>> fetchAsync,
CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(remoteManifest);
ArgumentNullException.ThrowIfNull(fetchAsync);
IReadOnlyList<SecretManifestEntry> localManifest =
await local.GetManifestAsync(ct).ConfigureAwait(false);
IReadOnlyList<SecretName> pull = ComputePullSet(localManifest, remoteManifest);
if (pull.Count == 0)
{
return 0;
}
int applied = 0;
// Chunked, not fetched in one shot. A cold-starting node's pull set is the peer's ENTIRE
// inventory, and an unbounded fetch breaks on real backends — SQL Server caps a command at
// 2100 parameters, so a hub with more than that many secrets would fail the same way every
// interval and the node would never receive a single row. Chunking also bounds the size of
// one replication message on the wire.
foreach (IReadOnlyList<SecretName> batch in Chunk(pull, FetchBatchSize))
{
IReadOnlyList<StoredSecret> rows = await fetchAsync(batch, ct).ConfigureAwait(false);
applied += await ApplyAsync(rows, ct).ConfigureAwait(false);
}
return applied;
}
/// <summary>
/// Names fetched per round trip. Well under SQL Server's 2100-parameter command limit, and small
/// enough that one replication message stays a sane size.
/// </summary>
internal const int FetchBatchSize = 500;
private static IEnumerable<IReadOnlyList<SecretName>> Chunk(
IReadOnlyList<SecretName> names, int size)
{
for (int offset = 0; offset < names.Count; offset += size)
{
yield return [.. names.Skip(offset).Take(size)];
}
}
/// <summary>
/// Reads the local rows for <paramref name="names"/>, skipping any that vanished between the
/// manifest and the read. Used by transports to assemble a push batch.
/// </summary>
/// <param name="names">The names to read.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>The encrypted rows that still exist locally.</returns>
public async Task<IReadOnlyList<StoredSecret>> ReadLocalAsync(
IReadOnlyList<SecretName> names, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(names);
var rows = new List<StoredSecret>(names.Count);
foreach (SecretName name in names)
{
StoredSecret? row = await local.GetAsync(name, ct).ConfigureAwait(false);
if (row is not null)
{
rows.Add(row);
}
}
return rows;
}
/// <summary>
/// Applies encrypted rows received from a peer, last-writer-wins, evicting the resolver cache for
/// each. Used both by <see cref="ReconcileAsync"/> and by transports that receive live broadcasts.
/// </summary>
/// <param name="rows">The encrypted rows to apply.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>The number of rows passed to the store.</returns>
public async Task<int> ApplyAsync(IReadOnlyList<StoredSecret> rows, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(rows);
int applied = 0;
foreach (StoredSecret row in rows)
{
try
{
await local.ApplyReplicatedAsync(row, ct).ConfigureAwait(false);
applied++;
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
// Isolated per row, because a batch is not a transaction and must not be all-or-
// nothing. One row a peer cannot supply cleanly (a hostile or buggy peer, a
// constraint violation) would otherwise abandon every row after it — and since the
// pull set is recomputed identically each sweep, in the same manifest order, the
// SAME row would poison the SAME batch forever and every name behind it would never
// converge. Drop the row, keep the batch.
onRowFailed?.Invoke(row.Name, ex);
continue;
}
finally
{
// Evicted unconditionally: the store may have ignored the row as stale, but an
// eviction only costs the next resolve a re-read, whereas skipping one when the row
// DID land serves a stale plaintext until the TTL expires.
cacheInvalidator?.Invalidate(row.Name);
}
}
return applied;
}
}
@@ -200,12 +200,9 @@ public sealed class SqliteSecretStore(SecretsSqliteConnectionFactory connectionF
DateTimeOffset localUpdated = ParseUtc(reader.GetString(0));
long localRevision = reader.GetInt64(1);
// Incoming wins only if strictly newer by (updated_utc, then revision).
bool incomingIsNewer =
row.UpdatedUtc > localUpdated ||
(row.UpdatedUtc == localUpdated && row.Revision > localRevision);
if (!incomingIsNewer)
// Incoming wins only if strictly newer by (updated_utc, then revision). Routed through
// the shared predicate so every store agrees on ties — see SecretLastWriterWins.
if (!SecretLastWriterWins.IsNewer(row.UpdatedUtc, row.Revision, localUpdated, localRevision))
{
await transaction.CommitAsync(ct).ConfigureAwait(false);
return;
@@ -10,6 +10,7 @@ namespace ZB.MOM.WW.Secrets.Sqlite;
/// newer than this build supports.
/// </summary>
public sealed class SqliteSecretsStoreMigrator(SecretsSqliteConnectionFactory connectionFactory)
: ISecretsStoreMigrator
{
/// <summary>Applies the schema migration to the secret store.</summary>
/// <param name="cancellationToken">Cancellation token.</param>
@@ -13,6 +13,7 @@
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Configuration" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="ZB.MOM.WW.Audit" />
</ItemGroup>
@@ -0,0 +1,81 @@
using ZB.MOM.WW.Secrets.Abstractions;
using ZB.MOM.WW.Secrets.Replicator.AkkaDotNet.Protocol;
namespace ZB.MOM.WW.Secrets.Replicator.AkkaDotNet.Tests;
/// <summary>
/// The DTO is the trust boundary for anything a peer sends. Every rejection here must surface as an
/// <see cref="ArgumentException"/>, because that is the exception type the receiving actor filters
/// on — anything else escapes its handler, restarts the actor, and (since the row is redelivered)
/// loops.
/// </summary>
public sealed class HostileWireInputTests
{
private static SecretRowDto Valid() => new()
{
Name = "app/ok",
ContentType = "Text",
Ciphertext = [1],
Nonce = [2],
Tag = [3],
WrappedDek = [4],
WrapNonce = [5],
WrapTag = [6],
KekId = "sha256:x",
Revision = 0,
CreatedUtc = "2026-01-01T00:00:00.0000000+00:00",
UpdatedUtc = "2026-01-01T00:00:00.0000000+00:00",
};
[Fact]
public void A_valid_row_materializes()
{
StoredSecret row = Valid().ToStoredSecret();
Assert.Equal("app/ok", row.Name.Value);
Assert.Equal(SecretContentType.Text, row.ContentType);
}
[Theory]
[InlineData("../../etc/passwd")]
[InlineData("/rooted")]
[InlineData("has space")]
[InlineData("")]
public void A_malformed_name_is_rejected(string name)
{
Assert.Throws<ArgumentException>(() => (Valid() with { Name = name }).ToStoredSecret());
}
[Theory]
// An in-range number names no member — Enum.Parse would happily produce (SecretContentType)4096
// and the store would persist it for later readers that assume a defined value.
[InlineData("4096")]
// Overflows the underlying type: Enum.Parse throws OverflowException, which is NOT an
// ArgumentException, so it would escape the actor's filter and restart it.
[InlineData("99999999999999999999")]
[InlineData("NotAContentType")]
[InlineData("")]
public void An_unrecognized_content_type_is_rejected_as_an_ArgumentException(string contentType)
{
Assert.Throws<ArgumentException>(
() => (Valid() with { ContentType = contentType }).ToStoredSecret());
}
[Fact]
public void A_null_crypto_blob_is_rejected_at_the_boundary()
{
// `required byte[]` means "present in the payload", not "non-null" — an explicit JSON null
// satisfies it. Caught here, or it blows up deep in the store's parameter binding instead.
Assert.Throws<ArgumentException>(
() => (Valid() with { Ciphertext = null! }).ToStoredSecret());
Assert.Throws<ArgumentException>(
() => (Valid() with { WrappedDek = null! }).ToStoredSecret());
}
[Fact]
public void A_malformed_timestamp_is_rejected()
{
Assert.ThrowsAny<FormatException>(
() => (Valid() with { UpdatedUtc = "not-a-date" }).ToStoredSecret());
}
}
@@ -0,0 +1,160 @@
using Akka.Actor;
using Akka.Serialization;
using Akka.TestKit.Xunit2;
using ZB.MOM.WW.Secrets.Abstractions;
using ZB.MOM.WW.Secrets.Replicator.AkkaDotNet.Protocol;
namespace ZB.MOM.WW.Secrets.Replicator.AkkaDotNet.Tests;
/// <summary>
/// Wire-contract tests. Secret ciphertext crosses the network as these bytes, so the round-trip is
/// asserted field-by-field rather than by equality alone — a silently dropped crypto field would
/// produce a row that stores fine and fails to decrypt much later, on another node.
/// </summary>
public sealed class SecretReplicationSerializerTests : TestKit
{
public SecretReplicationSerializerTests()
: base(AkkaSecretsReplication.SerializationConfig)
{
}
private static StoredSecret Row(string name) => new()
{
Name = new SecretName(name),
Description = "a description",
ContentType = SecretContentType.ConnectionString,
Ciphertext = [1, 2, 3, 250],
Nonce = [4, 5, 6],
Tag = [7, 8, 9],
WrappedDek = [10, 11, 12],
WrapNonce = [13, 14, 15],
WrapTag = [16, 17, 18],
KekId = "sha256:abc",
Revision = 42,
IsDeleted = true,
DeletedUtc = new DateTimeOffset(2026, 3, 2, 1, 0, 0, TimeSpan.Zero),
CreatedUtc = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero),
UpdatedUtc = new DateTimeOffset(2026, 2, 1, 0, 0, 0, TimeSpan.Zero),
CreatedBy = "alice",
UpdatedBy = "bob",
};
private object RoundTrip(object message)
{
Serializer serializer = Sys.Serialization.FindSerializerFor(message);
Assert.IsType<SecretReplicationSerializer>(serializer);
byte[] bytes = serializer.ToBinary(message);
string manifest = ((SerializerWithStringManifest)serializer).Manifest(message);
return ((SerializerWithStringManifest)serializer).FromBinary(bytes, manifest);
}
[Fact]
public void SecretRowsMessage_round_trips_every_field()
{
StoredSecret original = Row("app/db-conn");
var message = new SecretRowsMessage([SecretRowDto.FromStoredSecret(original)]);
var restored = (SecretRowsMessage)RoundTrip(message);
StoredSecret result = restored.Rows.Single().ToStoredSecret();
Assert.Equal(original.Name.Value, result.Name.Value);
Assert.Equal(original.Description, result.Description);
Assert.Equal(original.ContentType, result.ContentType);
Assert.Equal(original.Ciphertext, result.Ciphertext);
Assert.Equal(original.Nonce, result.Nonce);
Assert.Equal(original.Tag, result.Tag);
Assert.Equal(original.WrappedDek, result.WrappedDek);
Assert.Equal(original.WrapNonce, result.WrapNonce);
Assert.Equal(original.WrapTag, result.WrapTag);
Assert.Equal(original.KekId, result.KekId);
Assert.Equal(original.Revision, result.Revision);
Assert.Equal(original.IsDeleted, result.IsDeleted);
Assert.Equal(original.DeletedUtc, result.DeletedUtc);
Assert.Equal(original.CreatedUtc, result.CreatedUtc);
Assert.Equal(original.UpdatedUtc, result.UpdatedUtc);
Assert.Equal(original.CreatedBy, result.CreatedBy);
Assert.Equal(original.UpdatedBy, result.UpdatedBy);
}
[Fact]
public void Timestamps_survive_the_wire_exactly()
{
// Last-writer-wins compares UpdatedUtc for EQUALITY to break revision ties. A serializer that
// rounded to the millisecond would turn a tie into a spurious "newer" and two nodes would
// then overwrite each other forever, so tick-exactness is a correctness requirement.
StoredSecret original = Row("app/precise") with
{
UpdatedUtc = new DateTimeOffset(2026, 2, 1, 12, 34, 56, TimeSpan.Zero).AddTicks(1234567),
};
var restored = (SecretRowsMessage)RoundTrip(
new SecretRowsMessage([SecretRowDto.FromStoredSecret(original)]));
Assert.Equal(original.UpdatedUtc, restored.Rows.Single().ToStoredSecret().UpdatedUtc);
Assert.Equal(original.UpdatedUtc.Ticks, restored.Rows.Single().ToStoredSecret().UpdatedUtc.Ticks);
}
[Fact]
public void ManifestAnnounce_round_trips()
{
var message = new SecretManifestAnnounce(
[
SecretManifestEntryDto.FromEntry(new SecretManifestEntry
{
Name = new SecretName("a/one"),
Revision = 3,
UpdatedUtc = new DateTimeOffset(2026, 5, 5, 5, 5, 5, TimeSpan.Zero),
IsDeleted = true,
}),
]);
var restored = (SecretManifestAnnounce)RoundTrip(message);
SecretManifestEntry entry = restored.Entries.Single().ToEntry();
Assert.Equal("a/one", entry.Name.Value);
Assert.Equal(3, entry.Revision);
Assert.True(entry.IsDeleted);
Assert.Equal(new DateTimeOffset(2026, 5, 5, 5, 5, 5, TimeSpan.Zero), entry.UpdatedUtc);
}
[Fact]
public void PullRequest_round_trips()
{
var restored = (SecretPullRequest)RoundTrip(new SecretPullRequest(["a/one", "b/two"]));
Assert.Equal(["a/one", "b/two"], restored.Names);
}
[Fact]
public void An_unknown_manifest_fails_loudly_rather_than_guessing()
{
// A mixed-version cluster must fail on an unrecognized payload, not mis-deserialize a secret.
var serializer = (SerializerWithStringManifest)Sys.Serialization
.FindSerializerFor(new SecretPullRequest([]));
Assert.Throws<System.Runtime.Serialization.SerializationException>(
() => serializer.FromBinary([1, 2, 3], "zbs-from-the-future-v9"));
}
[Fact]
public void The_wire_contract_carries_no_field_that_could_hold_key_material()
{
// Structural guard on the trust boundary: the DTO's surface is fixed and reviewed. If someone
// adds a property to SecretRowDto, this test fails and forces a deliberate look at whether
// the new field is safe to put on the wire.
string[] properties = [.. typeof(SecretRowDto)
.GetProperties()
.Select(p => p.Name)
.Order(StringComparer.Ordinal)];
Assert.Equal(
[
"Ciphertext", "ContentType", "CreatedBy", "CreatedUtc", "DeletedUtc", "Description",
"IsDeleted", "KekId", "Name", "Nonce", "Revision", "Tag", "UpdatedBy", "UpdatedUtc",
"WrapNonce", "WrapTag", "WrappedDek",
],
properties);
}
}
@@ -0,0 +1,354 @@
using Akka.Actor;
using Akka.Cluster;
using Akka.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.Secrets.Abstractions;
using ZB.MOM.WW.Secrets.Replication;
using ZB.MOM.WW.Secrets.Sqlite;
namespace ZB.MOM.WW.Secrets.Replicator.AkkaDotNet.Tests;
/// <summary>
/// Two real cluster nodes, two real SQLite stores, real remoting over loopback — the gate the G-7
/// design called for, run in-process so it belongs to the ordinary offline suite instead of needing
/// a deployed rig.
/// </summary>
/// <remarks>
/// Nothing here is faked: each node runs its own <see cref="ActorSystem"/> with Akka.Remote on its
/// own port, they form a cluster, and rows travel through the actual serializer and the actual
/// distributed pub/sub mediator. A mock-based test of this behaviour would prove almost nothing —
/// the failure modes worth catching (self-echo, sender loss across the mediator, tombstones that do
/// not propagate, a partition that never re-converges) only appear when messages really cross nodes.
/// </remarks>
public sealed class TwoNodeClusterReplicationTests : IAsyncLifetime
{
private readonly List<string> _dbPaths = [];
private ActorSystem _systemA = null!;
private ActorSystem _systemB = null!;
private SqliteSecretStore _storeA = null!;
private SqliteSecretStore _storeB = null!;
private ISecretStore _writableA = null!;
private IActorRef _replicatorB = null!;
// Short so anti-entropy assertions do not dominate the suite's runtime.
private static readonly TimeSpan AnnounceInterval = TimeSpan.FromMilliseconds(300);
private static readonly TimeSpan Patience = TimeSpan.FromSeconds(20);
public async Task InitializeAsync()
{
_storeA = CreateStore();
_storeB = CreateStore();
// Port 0 lets the OS assign free ports, so parallel test runs cannot collide.
_systemA = ActorSystem.Create("zb-secrets-cluster", ClusterConfig(port: 0));
int portA = ClusterPort(_systemA);
// Node A seeds itself; node B joins it.
_systemA.Dispose();
_systemA = ActorSystem.Create("zb-secrets-cluster", ClusterConfig(portA, seedPort: portA));
_systemB = ActorSystem.Create("zb-secrets-cluster", ClusterConfig(port: 0, seedPort: portA));
await AwaitClusterUpAsync(_systemA, expectedMembers: 2);
await AwaitClusterUpAsync(_systemB, expectedMembers: 2);
IActorRef replicatorA = _systemA.ActorOf(
SecretReplicationActor.Props(_storeA, null, AnnounceInterval), "zb-secret-replication");
_replicatorB = _systemB.ActorOf(
SecretReplicationActor.Props(_storeB, null, AnnounceInterval), "zb-secret-replication");
_writableA = new ReplicatingSecretStore(
_storeA, new AkkaSecretReplicator(replicatorA), NullLogger<ReplicatingSecretStore>.Instance);
// The mediator gossips subscriptions between nodes; publishing before both sides are
// registered would silently drop the message.
await AwaitBothSubscribedAsync();
}
private SqliteSecretStore CreateStore()
{
string path = Path.Combine(Path.GetTempPath(), $"zb-akka-{Guid.NewGuid():N}.db");
_dbPaths.Add(path);
var factory = new SecretsSqliteConnectionFactory(path);
new SqliteSecretsStoreMigrator(factory).MigrateAsync(CancellationToken.None).GetAwaiter().GetResult();
return new SqliteSecretStore(factory);
}
private static Config ClusterConfig(int port, int? seedPort = null)
{
string seeds = seedPort is null
? "[]"
: $"[\"akka.tcp://zb-secrets-cluster@127.0.0.1:{seedPort}\"]";
return ConfigurationFactory.ParseString($$"""
akka {
loglevel = WARNING
actor.provider = cluster
remote.dot-netty.tcp {
hostname = "127.0.0.1"
public-hostname = "127.0.0.1"
port = {{port}}
}
cluster {
seed-nodes = {{seeds}}
downing-provider-class = "Akka.Cluster.SBR.SplitBrainResolverProvider"
}
}
""").WithFallback(AkkaSecretsReplication.SerializationConfig);
}
private static int ClusterPort(ActorSystem system) =>
Cluster.Get(system).SelfAddress.Port
?? throw new InvalidOperationException("Cluster address has no port.");
private static async Task AwaitClusterUpAsync(ActorSystem system, int expectedMembers)
{
Cluster cluster = Cluster.Get(system);
DateTime deadline = DateTime.UtcNow + Patience;
while (DateTime.UtcNow < deadline)
{
if (cluster.State.Members.Count(m => m.Status == MemberStatus.Up) >= expectedMembers)
{
return;
}
await Task.Delay(100);
}
throw new TimeoutException(
$"Cluster did not reach {expectedMembers} Up members within {Patience}.");
}
// Writes a throwaway secret on A and waits for it to land on B. That round trip is the only
// reliable signal that pub/sub subscriptions have gossiped across both nodes.
private async Task AwaitBothSubscribedAsync()
{
var probe = new SecretName("warmup/probe");
DateTime deadline = DateTime.UtcNow + Patience;
while (DateTime.UtcNow < deadline)
{
await _writableA.UpsertAsync(Row("warmup/probe", 0x01), CancellationToken.None);
if (await WaitForAsync(_storeB, probe, _ => true, TimeSpan.FromMilliseconds(500)))
{
await _storeA.DeleteAsync(probe, "warmup", CancellationToken.None);
await _storeB.DeleteAsync(probe, "warmup", CancellationToken.None);
return;
}
}
throw new TimeoutException("Nodes did not establish pub/sub subscriptions in time.");
}
private static StoredSecret Row(string name, byte marker) => new()
{
Name = new SecretName(name),
ContentType = SecretContentType.Text,
Ciphertext = [marker],
Nonce = [1],
Tag = [2],
WrappedDek = [3],
WrapNonce = [4],
WrapTag = [5],
KekId = "sha256:shared-kek",
Revision = 0,
CreatedUtc = DateTimeOffset.UtcNow,
UpdatedUtc = DateTimeOffset.UtcNow,
};
private static async Task<bool> WaitForAsync(
ISecretStore store, SecretName name, Func<StoredSecret, bool> predicate, TimeSpan? timeout = null)
{
DateTime deadline = DateTime.UtcNow + (timeout ?? Patience);
while (DateTime.UtcNow < deadline)
{
StoredSecret? row = await store.GetAsync(name, CancellationToken.None);
if (row is not null && predicate(row))
{
return true;
}
await Task.Delay(50);
}
return false;
}
[Fact]
public async Task A_secret_written_on_node_A_becomes_resolvable_on_node_B()
{
var name = new SecretName("db/password");
await _writableA.UpsertAsync(Row("db/password", 0xAA), CancellationToken.None);
Assert.True(
await WaitForAsync(_storeB, name, r => r.Ciphertext[0] == 0xAA),
"Node B never received the secret written on node A.");
}
[Fact]
public async Task The_ciphertext_arrives_byte_identical()
{
var name = new SecretName("app/exact");
await _writableA.UpsertAsync(Row("app/exact", 0x7F), CancellationToken.None);
Assert.True(await WaitForAsync(_storeB, name, _ => true));
StoredSecret onA = (await _storeA.GetAsync(name, CancellationToken.None))!;
StoredSecret onB = (await _storeB.GetAsync(name, CancellationToken.None))!;
Assert.Equal(onA.Ciphertext, onB.Ciphertext);
Assert.Equal(onA.WrappedDek, onB.WrappedDek);
Assert.Equal(onA.KekId, onB.KekId);
// Verbatim: the peer keeps the originating node's revision and timestamp, or the row would
// look newer on B than on A and bounce back.
Assert.Equal(onA.Revision, onB.Revision);
Assert.Equal(onA.UpdatedUtc, onB.UpdatedUtc);
}
[Fact]
public async Task A_delete_on_node_A_tombstones_the_row_on_node_B()
{
var name = new SecretName("app/doomed");
await _writableA.UpsertAsync(Row("app/doomed", 0xBB), CancellationToken.None);
Assert.True(await WaitForAsync(_storeB, name, _ => true));
await _writableA.DeleteAsync(name, "operator", CancellationToken.None);
Assert.True(
await WaitForAsync(_storeB, name, r => r.IsDeleted),
"The delete never propagated to node B.");
}
[Fact]
public async Task An_update_overwrites_the_earlier_value_on_the_peer()
{
var name = new SecretName("app/rotating");
await _writableA.UpsertAsync(Row("app/rotating", 0x01), CancellationToken.None);
Assert.True(await WaitForAsync(_storeB, name, r => r.Ciphertext[0] == 0x01));
await _writableA.UpsertAsync(Row("app/rotating", 0x02), CancellationToken.None);
Assert.True(
await WaitForAsync(_storeB, name, r => r.Ciphertext[0] == 0x02 && r.Revision == 1),
"Node B did not converge on the updated value.");
}
[Fact]
public async Task Anti_entropy_repairs_a_write_that_never_got_broadcast()
{
// Simulates a dropped publish — the write goes straight to node A's store, bypassing the
// replicator entirely, so the ONLY route to node B is the periodic manifest exchange. This is
// the case that makes best-effort publishing safe.
var name = new SecretName("app/missed-broadcast");
await _storeA.UpsertAsync(Row("app/missed-broadcast", 0xCC), CancellationToken.None);
Assert.True(
await WaitForAsync(_storeB, name, r => r.Ciphertext[0] == 0xCC),
"Anti-entropy failed to repair a write that was never broadcast.");
}
[Fact]
public async Task Anti_entropy_pulls_in_the_other_direction_too()
{
// The mirror case: node B holds a row node A has never heard of. A cannot request a name it
// does not know exists, so this only converges if the exchange pushes as well as pulls.
var name = new SecretName("app/born-on-b");
await _storeB.UpsertAsync(Row("app/born-on-b", 0xDD), CancellationToken.None);
Assert.True(
await WaitForAsync(_storeA, name, r => r.Ciphertext[0] == 0xDD),
"Node A never learned about a secret that originated on node B.");
}
[Fact]
public async Task A_node_that_joins_late_catches_up_on_everything()
{
// Partition-heal / cold-start: the whole reason to choose this transport over a shared store.
await _writableA.UpsertAsync(Row("bulk/one", 0x11), CancellationToken.None);
await _writableA.UpsertAsync(Row("bulk/two", 0x22), CancellationToken.None);
await _writableA.UpsertAsync(Row("bulk/three", 0x33), CancellationToken.None);
SqliteSecretStore lateStore = CreateStore();
_systemB.ActorOf(
SecretReplicationActor.Props(lateStore, null, AnnounceInterval), "late-joiner");
Assert.True(await WaitForAsync(lateStore, new SecretName("bulk/one"), r => r.Ciphertext[0] == 0x11));
Assert.True(await WaitForAsync(lateStore, new SecretName("bulk/two"), r => r.Ciphertext[0] == 0x22));
Assert.True(await WaitForAsync(lateStore, new SecretName("bulk/three"), r => r.Ciphertext[0] == 0x33));
}
[Fact]
public async Task A_broadcast_round_trip_does_not_drift_the_originating_nodes_revision()
{
// Note on what this does and does NOT prove: the revision would also stay put if the self-echo
// filter were removed, because re-applying an identical row ties on (updated_utc, revision)
// and last-writer-wins rejects a tie. So this covers the drift symptom via defence in depth,
// not the filter itself. The filter is what stops the echo being forwarded onward, which
// needs three nodes to observe — worth adding if a third node ever joins this rig.
var name = new SecretName("app/no-echo");
await _writableA.UpsertAsync(Row("app/no-echo", 0xEE), CancellationToken.None);
Assert.True(await WaitForAsync(_storeB, name, _ => true));
await Task.Delay(AnnounceInterval * 4);
StoredSecret onA = (await _storeA.GetAsync(name, CancellationToken.None))!;
Assert.Equal(0, onA.Revision);
}
[Fact]
public async Task A_malformed_row_from_a_peer_is_discarded_without_stalling_replication()
{
// Peer input is untrusted: a path-traversing name must be dropped, and — critically — the
// actor must keep serving everything else afterwards.
_replicatorB.Tell(new Protocol.SecretRowsMessage(
[
new Protocol.SecretRowDto
{
Name = "../../etc/passwd",
ContentType = "Text",
Ciphertext = [1], Nonce = [1], Tag = [1],
WrappedDek = [1], WrapNonce = [1], WrapTag = [1],
KekId = "sha256:x",
Revision = 0,
CreatedUtc = DateTimeOffset.UtcNow.ToString("O"),
UpdatedUtc = DateTimeOffset.UtcNow.ToString("O"),
},
]));
var name = new SecretName("app/after-malformed");
await _writableA.UpsertAsync(Row("app/after-malformed", 0x5A), CancellationToken.None);
Assert.True(
await WaitForAsync(_storeB, name, r => r.Ciphertext[0] == 0x5A),
"Replication stalled after a malformed row was received.");
}
public async Task DisposeAsync()
{
await CoordinatedShutdown.Get(_systemA).Run(CoordinatedShutdown.ClrExitReason.Instance);
await CoordinatedShutdown.Get(_systemB).Run(CoordinatedShutdown.ClrExitReason.Instance);
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
foreach (string db in _dbPaths)
{
foreach (string path in new[] { db, db + "-wal", db + "-shm" })
{
try
{
if (File.Exists(path))
{
File.Delete(path);
}
}
catch (IOException)
{
// Best-effort temp cleanup.
}
}
}
}
}
@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
<PackageReference Include="Akka.TestKit.Xunit2" />
<PackageReference Include="Akka.Remote" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\ZB.MOM.WW.Secrets.Replicator.AkkaDotNet\ZB.MOM.WW.Secrets.Replicator.AkkaDotNet.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,111 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using ZB.MOM.WW.Secrets.Abstractions;
using ZB.MOM.WW.Secrets.Replication;
using ZB.MOM.WW.Secrets.Replicator.SqlServer.DependencyInjection;
using ZB.MOM.WW.Secrets.Sqlite;
namespace ZB.MOM.WW.Secrets.Replicator.SqlServer.Tests.DependencyInjection;
/// <summary>
/// Container-resolution tests for both SQL-Server topologies.
/// </summary>
/// <remarks>
/// These exist because a code review caught that neither mode could resolve at all: the decorators
/// ask for the concrete <see cref="SqliteSecretStore"/>, which the core package only registered
/// behind <see cref="ISecretStore"/>. Every unit test passed and both modes were dead on arrival,
/// because nothing built a provider. Resolving the graph is the assertion that matters.
/// </remarks>
public sealed class AddZbSecretsSqlServerTests
{
private static IConfiguration Config(string sqlitePath) =>
new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
{
["Secrets:SqlitePath"] = sqlitePath,
["Secrets:RunMigrationsOnStartup"] = "false",
["Secrets:MasterKey:Source"] = "Environment",
["Secrets:MasterKey:EnvVarName"] = "ZB_SECRETS_TEST_KEY",
["Secrets:SqlServer:ConnectionString"] = "Server=unused;Database=x;",
}).Build();
private static string TempDb() => Path.Combine(Path.GetTempPath(), $"zb-di-{Guid.NewGuid():N}.db");
[Fact]
public void SharedStore_mode_resolves_the_SqlServer_store_as_ISecretStore()
{
var services = new ServiceCollection();
services.AddLogging();
services.AddZbSecretsSqlServerStore(Config(TempDb()));
using ServiceProvider provider = services.BuildServiceProvider();
// The SQL-Server store displaces SQLite — no decorator, because there is nothing to replicate.
Assert.IsType<SqlServerSecretStore>(provider.GetRequiredService<ISecretStore>());
Assert.IsType<SqlServerSecretsStoreMigrator>(provider.GetRequiredService<ISecretsStoreMigrator>());
}
[Fact]
public void HubReplication_mode_resolves_a_decorated_local_store()
{
var services = new ServiceCollection();
services.AddLogging();
services.AddZbSecretsSqlServerReplication(Config(TempDb()));
using ServiceProvider provider = services.BuildServiceProvider();
Assert.IsType<ReplicatingSecretStore>(provider.GetRequiredService<ISecretStore>());
Assert.IsType<SqlServerSecretReplicator>(provider.GetRequiredService<ISecretReplicator>());
// The local store is still provisioned by the core SQLite migrator, not the hub's.
Assert.IsType<SqliteSecretsStoreMigrator>(provider.GetRequiredService<ISecretsStoreMigrator>());
}
[Fact]
public void HubReplication_mode_registers_both_the_hub_migration_and_the_sync_service()
{
var services = new ServiceCollection();
services.AddLogging();
services.AddZbSecretsSqlServerReplication(Config(TempDb()));
using ServiceProvider provider = services.BuildServiceProvider();
IHostedService[] hosted = [.. provider.GetServices<IHostedService>()];
// The hub schema is nobody's "own" store, so every node must provision it at startup too.
Assert.Contains(hosted, h => h is SqlServerSecretSyncService);
Assert.Single(hosted, h => h.GetType().Name == "SqlServerHubMigrationHostedService");
}
[Fact]
public void The_decorator_wraps_the_undecorated_local_store_rather_than_itself()
{
var services = new ServiceCollection();
services.AddLogging();
services.AddZbSecretsSqlServerReplication(Config(TempDb()));
using ServiceProvider provider = services.BuildServiceProvider();
// Guards against the self-referential registration that would stack-overflow on first use.
var decorated = (ReplicatingSecretStore)provider.GetRequiredService<ISecretStore>();
Assert.NotNull(decorated);
Assert.NotSame(decorated, provider.GetRequiredService<SqliteSecretStore>());
}
[Fact]
public void A_missing_connection_string_fails_at_registration_not_at_first_resolve()
{
IConfiguration config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Secrets:SqlitePath"] = TempDb(),
})
.Build();
var services = new ServiceCollection();
services.AddLogging();
InvalidOperationException ex = Assert.Throws<InvalidOperationException>(
() => services.AddZbSecretsSqlServerStore(config));
Assert.Contains("ConnectionString", ex.Message, StringComparison.Ordinal);
}
}
@@ -0,0 +1,51 @@
using ZB.MOM.WW.Secrets.Abstractions;
using ZB.MOM.WW.Secrets.Replicator.SqlServer;
using ZB.MOM.WW.Secrets.Sqlite;
namespace ZB.MOM.WW.Secrets.Replicator.SqlServer.Tests.Fakes;
/// <summary>
/// An <see cref="ISecretReplicationHub"/> backed by a real SQLite store, standing in for the shared
/// SQL-Server hub in offline convergence tests.
/// </summary>
/// <remarks>
/// Deliberately backed by a real store rather than a dictionary: the behaviour under test is
/// last-writer-wins convergence, which lives in <c>ApplyReplicatedAsync</c>. A hand-rolled fake would
/// be re-implementing the very logic the tests exist to check, and would happily agree with a broken
/// reconciler. The live SQL-Server tests then confirm the T-SQL store behaves the same way.
/// </remarks>
/// <param name="store">The SQLite store standing in for the hub database.</param>
public sealed class SqliteBackedHub(SqliteSecretStore store) : ISecretReplicationHub
{
/// <summary>Number of times the sweep asked this hub for rows — asserts the fetch was skipped.</summary>
public int GetManyCallCount { get; private set; }
/// <inheritdoc />
public Task<IReadOnlyList<SecretManifestEntry>> GetManifestAsync(CancellationToken ct) =>
store.GetManifestAsync(ct);
/// <inheritdoc />
public async Task<IReadOnlyList<StoredSecret>> GetManyAsync(
IReadOnlyList<SecretName> names, CancellationToken ct)
{
GetManyCallCount++;
var rows = new List<StoredSecret>(names.Count);
foreach (SecretName name in names)
{
StoredSecret? row = await store.GetAsync(name, ct);
if (row is not null)
{
rows.Add(row);
}
}
return rows;
}
/// <inheritdoc />
public Task ApplyReplicatedAsync(StoredSecret row, CancellationToken ct) =>
store.ApplyReplicatedAsync(row, ct);
}
@@ -0,0 +1,68 @@
using Microsoft.Data.SqlClient;
namespace ZB.MOM.WW.Secrets.Replicator.SqlServer.Tests.Live;
/// <summary>
/// Shared connection details for the env-gated live SQL-Server suite.
/// </summary>
/// <remarks>
/// Gated on <c>SECRETS_SQLSERVER_CONNSTR</c> and skips cleanly when it is unset, matching the family
/// live-test idiom — the offline suite must stay runnable on any machine. Set it to a database the
/// test is allowed to create and drop a schema in, for example:
/// <c>Server=10.100.0.35,31433;Database=ZbSecretsTest;User Id=sa;Password=...;TrustServerCertificate=True</c>.
/// </remarks>
public static class LiveSqlServer
{
/// <summary>Environment variable holding the live connection string.</summary>
public const string ConnectionStringVariable = "SECRETS_SQLSERVER_CONNSTR";
/// <summary>The configured connection string, or <see langword="null"/> when the suite is not enabled.</summary>
public static string? ConnectionString =>
Environment.GetEnvironmentVariable(ConnectionStringVariable);
/// <summary>Whether the live suite should run.</summary>
public static bool IsEnabled => !string.IsNullOrWhiteSpace(ConnectionString);
/// <summary>Skip reason shown when the suite is not enabled.</summary>
public const string SkipReason =
"Live SQL-Server suite disabled; set SECRETS_SQLSERVER_CONNSTR to enable.";
/// <summary>
/// Builds options against a throwaway schema so parallel runs and repeat runs cannot collide,
/// and so a failed run leaves no trace in a shared database.
/// </summary>
/// <param name="schemaName">The unique schema name for this test.</param>
/// <returns>Options targeting that schema.</returns>
public static SqlServerSecretsOptions OptionsFor(string schemaName) => new()
{
ConnectionString = ConnectionString!,
SchemaName = schemaName,
};
/// <summary>Generates a unique, allow-list-legal schema name for one test class.</summary>
/// <returns>A fresh schema name.</returns>
public static string NewSchemaName() => $"zbtest_{Guid.NewGuid():N}"[..32];
/// <summary>Drops the throwaway schema and its tables.</summary>
/// <param name="schemaName">The schema to drop.</param>
/// <returns>A task that completes when the schema is gone.</returns>
public static async Task DropSchemaAsync(string schemaName)
{
if (!IsEnabled)
{
return;
}
await using var connection = new SqlConnection(ConnectionString);
await connection.OpenAsync();
await using SqlCommand command = connection.CreateCommand();
command.CommandText = $"""
IF OBJECT_ID(N'[{schemaName}].[secret]', N'U') IS NOT NULL DROP TABLE [{schemaName}].[secret];
IF OBJECT_ID(N'[{schemaName}].[schema_version]', N'U') IS NOT NULL DROP TABLE [{schemaName}].[schema_version];
IF EXISTS (SELECT 1 FROM sys.schemas WHERE name = N'{schemaName}') EXEC(N'DROP SCHEMA [{schemaName}]');
""";
await command.ExecuteNonQueryAsync();
}
}
@@ -0,0 +1,365 @@
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.Secrets.Replicator.SqlServer.Tests.Live;
/// <summary>
/// The SQLite store's test suite, ported case-for-case onto the SQL-Server store.
/// </summary>
/// <remarks>
/// Ported rather than newly written on purpose. The two stores must be behaviourally identical —
/// a cluster can hold both at once — so the strongest available check is that the assertions
/// written against SQLite pass unchanged here. Anywhere the T-SQL diverges in observable behaviour,
/// one of these fails.
/// </remarks>
[Collection("live-sqlserver")]
public sealed class SqlServerSecretStoreLiveTests : IAsyncLifetime
{
private readonly string _schema = LiveSqlServer.NewSchemaName();
private SqlServerSecretStore _store = null!;
public async Task InitializeAsync()
{
if (!LiveSqlServer.IsEnabled)
{
return;
}
var factory = new SecretsSqlServerConnectionFactory(LiveSqlServer.OptionsFor(_schema));
await new SqlServerSecretsStoreMigrator(factory).MigrateAsync(CancellationToken.None);
_store = new SqlServerSecretStore(factory);
}
public Task DisposeAsync() => LiveSqlServer.DropSchemaAsync(_schema);
private static StoredSecret MakeSecret(
string name,
long revision = 0,
byte[]? ciphertext = null,
DateTimeOffset? updatedUtc = null,
DateTimeOffset? createdUtc = null,
bool isDeleted = false,
DateTimeOffset? deletedUtc = null,
string? createdBy = "alice",
string? updatedBy = "alice") => new()
{
Name = new SecretName(name),
Description = "desc",
ContentType = SecretContentType.ConnectionString,
Ciphertext = ciphertext ?? [1, 2, 3],
Nonce = [4, 5, 6],
Tag = [7, 8, 9],
WrappedDek = [10, 11, 12],
WrapNonce = [13, 14, 15],
WrapTag = [16, 17, 18],
KekId = "kek-1",
Revision = revision,
IsDeleted = isDeleted,
DeletedUtc = deletedUtc,
CreatedUtc = createdUtc ?? DateTimeOffset.UtcNow,
UpdatedUtc = updatedUtc ?? DateTimeOffset.UtcNow,
CreatedBy = createdBy,
UpdatedBy = updatedBy,
};
[SkippableFact]
public async Task Upsert_Then_Get_RoundTrips()
{
Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
await _store.UpsertAsync(MakeSecret("app/db-conn"), CancellationToken.None);
StoredSecret? got = await _store.GetAsync(new SecretName("app/db-conn"), CancellationToken.None);
Assert.NotNull(got);
Assert.Equal("app/db-conn", got!.Name.Value);
Assert.Equal("desc", got.Description);
Assert.Equal(SecretContentType.ConnectionString, got.ContentType);
Assert.Equal(new byte[] { 1, 2, 3 }, got.Ciphertext);
Assert.Equal(new byte[] { 4, 5, 6 }, got.Nonce);
Assert.Equal(new byte[] { 7, 8, 9 }, got.Tag);
Assert.Equal(new byte[] { 10, 11, 12 }, got.WrappedDek);
Assert.Equal(new byte[] { 13, 14, 15 }, got.WrapNonce);
Assert.Equal(new byte[] { 16, 17, 18 }, got.WrapTag);
Assert.Equal("kek-1", got.KekId);
Assert.Equal(0, got.Revision);
Assert.False(got.IsDeleted);
Assert.Null(got.DeletedUtc);
Assert.Equal("alice", got.CreatedBy);
Assert.Equal("alice", got.UpdatedBy);
}
[SkippableFact]
public async Task Get_ReturnsNull_WhenAbsent()
{
Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
Assert.Null(await _store.GetAsync(new SecretName("nope"), CancellationToken.None));
}
[SkippableFact]
public async Task Upsert_Existing_OverwritesInPlace_BumpsRevision()
{
Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
await _store.UpsertAsync(
MakeSecret("app/rotating", ciphertext: [1, 1, 1], createdBy: "alice", updatedBy: "alice"),
CancellationToken.None);
StoredSecret afterFirst =
(await _store.GetAsync(new SecretName("app/rotating"), CancellationToken.None))!;
Assert.Equal(0, afterFirst.Revision);
DateTimeOffset originalCreatedUtc = afterFirst.CreatedUtc;
await _store.UpsertAsync(
MakeSecret("app/rotating", ciphertext: [9, 9, 9], createdBy: "bob", updatedBy: "bob"),
CancellationToken.None);
StoredSecret afterSecond =
(await _store.GetAsync(new SecretName("app/rotating"), CancellationToken.None))!;
Assert.Equal(1, afterSecond.Revision);
Assert.Equal(new byte[] { 9, 9, 9 }, afterSecond.Ciphertext);
Assert.Equal(originalCreatedUtc, afterSecond.CreatedUtc);
Assert.Equal("alice", afterSecond.CreatedBy);
Assert.Equal("bob", afterSecond.UpdatedBy);
Assert.False(afterSecond.IsDeleted);
}
[SkippableFact]
public async Task List_ExcludesTombstoned_ByDefault()
{
Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
await _store.UpsertAsync(MakeSecret("keep"), CancellationToken.None);
await _store.UpsertAsync(MakeSecret("gone"), CancellationToken.None);
await _store.DeleteAsync(new SecretName("gone"), "carol", CancellationToken.None);
IReadOnlyList<SecretMetadata> visible =
await _store.ListAsync(includeDeleted: false, CancellationToken.None);
Assert.DoesNotContain(visible, m => m.Name.Value == "gone");
Assert.Contains(visible, m => m.Name.Value == "keep");
IReadOnlyList<SecretMetadata> all =
await _store.ListAsync(includeDeleted: true, CancellationToken.None);
Assert.Contains(all, m => m.Name.Value == "gone");
Assert.Contains(all, m => m.Name.Value == "keep");
}
[SkippableFact]
public async Task Delete_SetsTombstone_BumpsRevision_ReturnsFalseWhenAbsent()
{
Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
await _store.UpsertAsync(MakeSecret("app/secret"), CancellationToken.None);
Assert.True(await _store.DeleteAsync(new SecretName("app/secret"), "carol", CancellationToken.None));
StoredSecret tombstoned =
(await _store.GetAsync(new SecretName("app/secret"), CancellationToken.None))!;
Assert.True(tombstoned.IsDeleted);
Assert.NotNull(tombstoned.DeletedUtc);
Assert.Equal(1, tombstoned.Revision);
Assert.Equal("carol", tombstoned.UpdatedBy);
Assert.False(await _store.DeleteAsync(new SecretName("app/secret"), "carol", CancellationToken.None));
Assert.False(await _store.DeleteAsync(new SecretName("never-existed"), "carol", CancellationToken.None));
}
[SkippableFact]
public async Task GetManifest_ReturnsAllRows()
{
Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
await _store.UpsertAsync(MakeSecret("a"), CancellationToken.None);
await _store.UpsertAsync(MakeSecret("b"), CancellationToken.None);
await _store.DeleteAsync(new SecretName("b"), "carol", CancellationToken.None);
IReadOnlyList<SecretManifestEntry> manifest = await _store.GetManifestAsync(CancellationToken.None);
Assert.Equal(2, manifest.Count);
SecretManifestEntry a = manifest.Single(e => e.Name.Value == "a");
SecretManifestEntry b = manifest.Single(e => e.Name.Value == "b");
Assert.False(a.IsDeleted);
Assert.Equal(0, a.Revision);
Assert.True(b.IsDeleted);
Assert.Equal(1, b.Revision);
}
[SkippableFact]
public async Task ApplyReplicated_AppliesNewer_IgnoresStale()
{
Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
DateTimeOffset t1 = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
DateTimeOffset t2 = new(2026, 1, 2, 0, 0, 0, TimeSpan.Zero);
DateTimeOffset t3 = new(2026, 1, 3, 0, 0, 0, TimeSpan.Zero);
await _store.ApplyReplicatedAsync(
MakeSecret("repl", revision: 5, ciphertext: [5, 5, 5], updatedUtc: t2, createdUtc: t1),
CancellationToken.None);
Assert.Equal(5, (await _store.GetAsync(new SecretName("repl"), CancellationToken.None))!.Revision);
await _store.ApplyReplicatedAsync(
MakeSecret("repl", revision: 6, ciphertext: [6, 6, 6], updatedUtc: t3, createdUtc: t1),
CancellationToken.None);
StoredSecret afterNewer = (await _store.GetAsync(new SecretName("repl"), CancellationToken.None))!;
Assert.Equal(6, afterNewer.Revision);
Assert.Equal(new byte[] { 6, 6, 6 }, afterNewer.Ciphertext);
Assert.Equal(t3, afterNewer.UpdatedUtc);
await _store.ApplyReplicatedAsync(
MakeSecret("repl", revision: 4, ciphertext: [4, 4, 4], updatedUtc: t1, createdUtc: t1),
CancellationToken.None);
StoredSecret afterStale = (await _store.GetAsync(new SecretName("repl"), CancellationToken.None))!;
Assert.Equal(6, afterStale.Revision);
Assert.Equal(new byte[] { 6, 6, 6 }, afterStale.Ciphertext);
}
[SkippableFact]
public async Task ApplyRewrap_ChangesOnlyWrapEnvelopeAndKekId_PreservingRevisionBodyAndTimestamps()
{
Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
await _store.UpsertAsync(
MakeSecret("app/rewrap", ciphertext: [1, 2, 3], createdUtc: new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero)),
CancellationToken.None);
await _store.UpsertAsync(MakeSecret("app/rewrap", ciphertext: [1, 2, 3]), CancellationToken.None);
StoredSecret before = (await _store.GetAsync(new SecretName("app/rewrap"), CancellationToken.None))!;
Assert.Equal(1, before.Revision);
bool applied = await _store.ApplyRewrapAsync(
before with
{
WrappedDek = [90, 91, 92],
WrapNonce = [93, 94, 95],
WrapTag = [96, 97, 98],
KekId = "kek-2",
},
before.WrappedDek,
CancellationToken.None);
Assert.True(applied);
StoredSecret after = (await _store.GetAsync(new SecretName("app/rewrap"), CancellationToken.None))!;
Assert.Equal(new byte[] { 90, 91, 92 }, after.WrappedDek);
Assert.Equal(new byte[] { 93, 94, 95 }, after.WrapNonce);
Assert.Equal(new byte[] { 96, 97, 98 }, after.WrapTag);
Assert.Equal("kek-2", after.KekId);
Assert.Equal(before.Revision, after.Revision);
Assert.Equal(before.UpdatedUtc, after.UpdatedUtc);
Assert.Equal(before.CreatedUtc, after.CreatedUtc);
Assert.Equal(before.UpdatedBy, after.UpdatedBy);
Assert.Equal(new byte[] { 1, 2, 3 }, after.Ciphertext);
Assert.Equal(before.Nonce, after.Nonce);
Assert.Equal(before.Tag, after.Tag);
}
[SkippableFact]
public async Task ApplyRewrap_RewrapsTombstonedRows()
{
Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
await _store.UpsertAsync(MakeSecret("app/dead"), CancellationToken.None);
await _store.DeleteAsync(new SecretName("app/dead"), "carol", CancellationToken.None);
StoredSecret tomb = (await _store.GetAsync(new SecretName("app/dead"), CancellationToken.None))!;
Assert.True(tomb.IsDeleted);
Assert.True(await _store.ApplyRewrapAsync(
tomb with { WrappedDek = [1], WrapNonce = [2], WrapTag = [3], KekId = "kek-2" },
tomb.WrappedDek,
CancellationToken.None));
StoredSecret after = (await _store.GetAsync(new SecretName("app/dead"), CancellationToken.None))!;
Assert.Equal("kek-2", after.KekId);
Assert.True(after.IsDeleted);
Assert.Equal(tomb.Revision, after.Revision);
}
[SkippableFact]
public async Task ApplyRewrap_ReturnsFalse_WhenRowAbsent()
{
Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
StoredSecret ghost = MakeSecret("never-existed") with { KekId = "kek-2" };
Assert.False(await _store.ApplyRewrapAsync(ghost, ghost.WrappedDek, CancellationToken.None));
}
[SkippableFact]
public async Task ApplyRewrap_ReturnsFalse_WhenCurrentWrapChanged_UnderConcurrentWrite()
{
Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
await _store.UpsertAsync(MakeSecret("app/cas", ciphertext: [1, 2, 3]), CancellationToken.None);
StoredSecret before = (await _store.GetAsync(new SecretName("app/cas"), CancellationToken.None))!;
await _store.UpsertAsync(
MakeSecret("app/cas", ciphertext: [9, 9, 9]) with { WrappedDek = [77, 78, 79] },
CancellationToken.None);
bool applied = await _store.ApplyRewrapAsync(
before with { WrappedDek = [1], WrapNonce = [2], WrapTag = [3], KekId = "kek-2" },
before.WrappedDek,
CancellationToken.None);
Assert.False(applied);
StoredSecret after = (await _store.GetAsync(new SecretName("app/cas"), CancellationToken.None))!;
Assert.Equal(new byte[] { 77, 78, 79 }, after.WrappedDek);
Assert.Equal("kek-1", after.KekId);
}
[SkippableFact]
public async Task GetMany_returns_only_the_requested_rows()
{
Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
await _store.UpsertAsync(MakeSecret("a/one"), CancellationToken.None);
await _store.UpsertAsync(MakeSecret("b/two"), CancellationToken.None);
await _store.UpsertAsync(MakeSecret("c/three"), CancellationToken.None);
IReadOnlyList<StoredSecret> rows = await _store.GetManyAsync(
[new SecretName("a/one"), new SecretName("c/three"), new SecretName("absent/name")],
CancellationToken.None);
Assert.Equal(2, rows.Count);
Assert.Contains(rows, r => r.Name.Value == "a/one");
Assert.Contains(rows, r => r.Name.Value == "c/three");
}
[SkippableFact]
public async Task GetMany_short_circuits_on_an_empty_request()
{
Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
// Guards the IN () clause: an empty list would otherwise produce invalid T-SQL.
Assert.Empty(await _store.GetManyAsync([], CancellationToken.None));
}
[SkippableFact]
public async Task Migrator_is_idempotent()
{
Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
var factory = new SecretsSqlServerConnectionFactory(LiveSqlServer.OptionsFor(_schema));
var migrator = new SqlServerSecretsStoreMigrator(factory);
// Every node runs this at every startup — re-running must be a no-op, not an error.
await migrator.MigrateAsync(CancellationToken.None);
await migrator.MigrateAsync(CancellationToken.None);
await _store.UpsertAsync(MakeSecret("survives/migration"), CancellationToken.None);
await migrator.MigrateAsync(CancellationToken.None);
Assert.NotNull(await _store.GetAsync(new SecretName("survives/migration"), CancellationToken.None));
}
}
/// <summary>
/// Serializes the live suite: the tests share one database server and each provisions its own
/// schema, so running them in parallel would multiply connection load for no benefit.
/// </summary>
[CollectionDefinition("live-sqlserver", DisableParallelization = true)]
public sealed class LiveSqlServerCollection;
@@ -0,0 +1,205 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.Secrets.Abstractions;
using ZB.MOM.WW.Secrets.Replicator.SqlServer.Tests.Fakes;
using ZB.MOM.WW.Secrets.Sqlite;
namespace ZB.MOM.WW.Secrets.Replicator.SqlServer.Tests;
/// <summary>
/// Convergence behaviour of the bidirectional hub sweep, exercised against real stores on both
/// sides so the assertions are about actual persisted state rather than mock interactions.
/// </summary>
public sealed class SqlServerSecretSyncServiceTests : IDisposable
{
private readonly List<string> _dbPaths = [];
private readonly SqliteSecretStore _local;
private readonly SqliteSecretStore _hubStore;
private readonly SqliteBackedHub _hub;
public SqlServerSecretSyncServiceTests()
{
_local = CreateStore();
_hubStore = CreateStore();
_hub = new SqliteBackedHub(_hubStore);
}
private SqliteSecretStore CreateStore()
{
string path = Path.Combine(Path.GetTempPath(), $"zb-sync-{Guid.NewGuid():N}.db");
_dbPaths.Add(path);
var factory = new SecretsSqliteConnectionFactory(path);
new SqliteSecretsStoreMigrator(factory).MigrateAsync(CancellationToken.None).GetAwaiter().GetResult();
return new SqliteSecretStore(factory);
}
private SqlServerSecretSyncService CreateService() => new(
_local,
_hub,
cacheInvalidator: null,
Options.Create(new SqlServerSecretsOptions { ConnectionString = "Server=unused;" }),
NullLogger<SqlServerSecretSyncService>.Instance);
private static StoredSecret Row(string name, byte marker) => new()
{
Name = new SecretName(name),
ContentType = SecretContentType.Text,
Ciphertext = [marker],
Nonce = [1],
Tag = [2],
WrappedDek = [3],
WrapNonce = [4],
WrapTag = [5],
KekId = "sha256:test",
Revision = 0,
CreatedUtc = DateTimeOffset.UtcNow,
UpdatedUtc = DateTimeOffset.UtcNow,
};
[Fact]
public async Task Sweep_pulls_a_secret_the_node_has_never_seen()
{
await _hubStore.UpsertAsync(Row("db/password", 0xAA), CancellationToken.None);
(int pulled, int pushed) = await CreateService().SweepAsync(CancellationToken.None);
Assert.Equal(1, pulled);
Assert.Equal(0, pushed);
StoredSecret? local = await _local.GetAsync(new SecretName("db/password"), CancellationToken.None);
Assert.NotNull(local);
Assert.Equal(0xAA, local!.Ciphertext[0]);
}
[Fact]
public async Task Sweep_pushes_a_local_write_the_hub_never_received()
{
// The case that makes best-effort publishing safe: the publish failed (hub was down), so the
// hub has never heard of this name and can never ask for it. Only a push sweep repairs this.
await _local.UpsertAsync(Row("api/key", 0xBB), CancellationToken.None);
(int pulled, int pushed) = await CreateService().SweepAsync(CancellationToken.None);
Assert.Equal(0, pulled);
Assert.Equal(1, pushed);
StoredSecret? hub = await _hubStore.GetAsync(new SecretName("api/key"), CancellationToken.None);
Assert.NotNull(hub);
Assert.Equal(0xBB, hub!.Ciphertext[0]);
}
[Fact]
public async Task Sweep_preserves_the_originating_revision_when_pushing()
{
// Pushed verbatim, not upserted: if the hub stamped its own revision the row would look newer
// than the writer's copy and bounce straight back on the next sweep.
await _local.UpsertAsync(Row("api/key", 0xBB), CancellationToken.None);
await _local.UpsertAsync(Row("api/key", 0xCC), CancellationToken.None);
StoredSecret localRow = (await _local.GetAsync(new SecretName("api/key"), CancellationToken.None))!;
Assert.Equal(1, localRow.Revision);
await CreateService().SweepAsync(CancellationToken.None);
StoredSecret hubRow = (await _hubStore.GetAsync(new SecretName("api/key"), CancellationToken.None))!;
Assert.Equal(localRow.Revision, hubRow.Revision);
Assert.Equal(localRow.UpdatedUtc, hubRow.UpdatedUtc);
}
[Fact]
public async Task Sweep_converges_both_directions_in_one_pass()
{
await _local.UpsertAsync(Row("only/local", 0x11), CancellationToken.None);
await _hubStore.UpsertAsync(Row("only/hub", 0x22), CancellationToken.None);
(int pulled, int pushed) = await CreateService().SweepAsync(CancellationToken.None);
Assert.Equal(1, pulled);
Assert.Equal(1, pushed);
Assert.NotNull(await _local.GetAsync(new SecretName("only/hub"), CancellationToken.None));
Assert.NotNull(await _hubStore.GetAsync(new SecretName("only/local"), CancellationToken.None));
}
[Fact]
public async Task Sweep_is_idempotent_so_a_converged_pair_stays_quiet()
{
await _local.UpsertAsync(Row("api/key", 0xBB), CancellationToken.None);
SqlServerSecretSyncService service = CreateService();
await service.SweepAsync(CancellationToken.None);
(int pulled, int pushed) = await service.SweepAsync(CancellationToken.None);
// A steady-state cluster must not write on every tick — that would churn the hub forever.
Assert.Equal(0, pulled);
Assert.Equal(0, pushed);
}
[Fact]
public async Task Sweep_propagates_a_delete_as_a_tombstone()
{
await _local.UpsertAsync(Row("api/key", 0xBB), CancellationToken.None);
await CreateService().SweepAsync(CancellationToken.None);
await _local.DeleteAsync(new SecretName("api/key"), "tester", CancellationToken.None);
await CreateService().SweepAsync(CancellationToken.None);
StoredSecret hubRow = (await _hubStore.GetAsync(new SecretName("api/key"), CancellationToken.None))!;
Assert.True(hubRow.IsDeleted);
}
[Fact]
public async Task Sweep_does_not_resurrect_a_secret_deleted_on_the_hub()
{
await _local.UpsertAsync(Row("api/key", 0xBB), CancellationToken.None);
await CreateService().SweepAsync(CancellationToken.None);
await _hubStore.DeleteAsync(new SecretName("api/key"), "admin", CancellationToken.None);
await CreateService().SweepAsync(CancellationToken.None);
StoredSecret localRow = (await _local.GetAsync(new SecretName("api/key"), CancellationToken.None))!;
Assert.True(localRow.IsDeleted);
}
[Fact]
public async Task Sweep_skips_the_hub_fetch_when_nothing_is_newer()
{
await CreateService().SweepAsync(CancellationToken.None);
Assert.Equal(0, _hub.GetManyCallCount);
}
[Fact]
public async Task Sweep_lets_the_newer_side_win_a_conflicting_write()
{
await _local.UpsertAsync(Row("api/key", 0x11), CancellationToken.None);
await CreateService().SweepAsync(CancellationToken.None);
// Both sides then write independently; the hub's write is later.
await _local.UpsertAsync(Row("api/key", 0x22), CancellationToken.None);
await Task.Delay(10);
await _hubStore.UpsertAsync(Row("api/key", 0x33), CancellationToken.None);
await CreateService().SweepAsync(CancellationToken.None);
StoredSecret localRow = (await _local.GetAsync(new SecretName("api/key"), CancellationToken.None))!;
Assert.Equal(0x33, localRow.Ciphertext[0]);
}
public void Dispose()
{
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
foreach (string db in _dbPaths)
{
foreach (string path in new[] { db, db + "-wal", db + "-shm" })
{
if (File.Exists(path))
{
File.Delete(path);
}
}
}
}
}
@@ -0,0 +1,96 @@
namespace ZB.MOM.WW.Secrets.Replicator.SqlServer.Tests;
/// <summary>
/// The schema-name allow-list is the injection boundary for the one value that cannot be
/// parameterized (a T-SQL object name), so it gets adversarial coverage rather than a happy path.
/// </summary>
public sealed class SqlServerSecretsOptionsTests
{
[Theory]
[InlineData("zbsecrets")]
[InlineData("ZbSecrets")]
[InlineData("_private")]
[InlineData("schema_1")]
public void SchemaName_accepts_plain_identifiers(string value)
{
var options = new SqlServerSecretsOptions { SchemaName = value };
Assert.Equal(value, options.SchemaName);
}
[Theory]
// The bracket-escape trick that would otherwise break out of the [quoting] at the use site.
[InlineData("evil]]; DROP TABLE secret; --")]
[InlineData("dbo.secret")]
[InlineData("with space")]
[InlineData("quote'name")]
[InlineData("semi;colon")]
[InlineData("1leading_digit")]
[InlineData("")]
[InlineData(" ")]
public void SchemaName_rejects_anything_that_is_not_a_plain_identifier(string value)
{
Assert.Throws<ArgumentException>(() => new SqlServerSecretsOptions { SchemaName = value });
}
[Fact]
public void SchemaName_rejects_an_over_long_identifier()
{
Assert.Throws<ArgumentException>(() =>
new SqlServerSecretsOptions { SchemaName = new string('a', 129) });
}
[Fact]
public void Validate_rejects_a_missing_connection_string()
{
InvalidOperationException ex =
Assert.Throws<InvalidOperationException>(() => new SqlServerSecretsOptions().Validate());
Assert.Contains("ConnectionString", ex.Message, StringComparison.Ordinal);
}
[Fact]
public void Validate_rejects_a_non_positive_sync_interval()
{
var options = new SqlServerSecretsOptions
{
ConnectionString = "Server=x;",
SyncInterval = TimeSpan.Zero,
};
Assert.Throws<InvalidOperationException>(options.Validate);
}
[Fact]
public void Validate_rejects_a_non_positive_command_timeout()
{
var options = new SqlServerSecretsOptions
{
ConnectionString = "Server=x;",
CommandTimeout = TimeSpan.FromSeconds(-1),
};
Assert.Throws<InvalidOperationException>(options.Validate);
}
[Fact]
public void Validate_accepts_a_fully_configured_instance()
{
var options = new SqlServerSecretsOptions { ConnectionString = "Server=x;Database=y;" };
options.Validate();
}
[Fact]
public void CreateSchemaDdl_quotes_the_schema_name_everywhere_it_appears()
{
string ddl = SqlServerSecretsSchema.CreateSchemaDdl("zbsecrets");
Assert.Contains("[zbsecrets].[secret]", ddl, StringComparison.Ordinal);
Assert.Contains("[zbsecrets].[schema_version]", ddl, StringComparison.Ordinal);
// Every DDL statement is guarded, so re-running the migration on a provisioned database is
// a no-op rather than an error.
Assert.Contains("IF NOT EXISTS", ddl, StringComparison.Ordinal);
Assert.Contains("IS NULL", ddl, StringComparison.Ordinal);
}
}
@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
<PackageReference Include="Xunit.SkippableFact" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Microsoft.Extensions.Configuration" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Logging" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\ZB.MOM.WW.Secrets.Replicator.SqlServer\ZB.MOM.WW.Secrets.Replicator.SqlServer.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,15 @@
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.Secrets.Tests.Fakes;
/// <summary>Records every eviction so tests can assert the resolver cache was cleared.</summary>
public sealed class RecordingCacheInvalidator : ISecretCacheInvalidator
{
private readonly List<SecretName> _invalidated = [];
/// <summary>The names evicted, in call order.</summary>
public IReadOnlyList<SecretName> Invalidated => _invalidated;
/// <inheritdoc />
public void Invalidate(SecretName name) => _invalidated.Add(name);
}
@@ -0,0 +1,265 @@
using ZB.MOM.WW.Secrets.Abstractions;
using ZB.MOM.WW.Secrets.Replication;
using ZB.MOM.WW.Secrets.Sqlite;
using ZB.MOM.WW.Secrets.Tests.Fakes;
namespace ZB.MOM.WW.Secrets.Tests.Replication;
/// <summary>
/// Convergence rules for the transport-agnostic reconciler. These are the cases both replication
/// packages inherit, so they are pinned here once against a real SQLite store rather than a fake —
/// a reconciler that agrees with a mock but not with the store would be worthless.
/// </summary>
public sealed class SecretReplicationReconcilerTests : IDisposable
{
private readonly string _dbPath = Path.Combine(Path.GetTempPath(), $"zb-recon-{Guid.NewGuid():N}.db");
private readonly SecretsSqliteConnectionFactory _factory;
private readonly SqliteSecretStore _store;
public SecretReplicationReconcilerTests()
{
_factory = new SecretsSqliteConnectionFactory(_dbPath);
new SqliteSecretsStoreMigrator(_factory).MigrateAsync(CancellationToken.None).GetAwaiter().GetResult();
_store = new SqliteSecretStore(_factory);
}
private static readonly DateTimeOffset T0 = new(2026, 7, 18, 12, 0, 0, TimeSpan.Zero);
private static SecretManifestEntry Entry(string name, long revision, DateTimeOffset updated, bool deleted = false) =>
new() { Name = new SecretName(name), Revision = revision, UpdatedUtc = updated, IsDeleted = deleted };
private static StoredSecret Row(string name, long revision, DateTimeOffset updated, bool deleted = false) => new()
{
Name = new SecretName(name),
ContentType = SecretContentType.Text,
Ciphertext = [1, 2, 3],
Nonce = [4],
Tag = [5],
WrappedDek = [6],
WrapNonce = [7],
WrapTag = [8],
KekId = "sha256:test",
Revision = revision,
IsDeleted = deleted,
DeletedUtc = deleted ? updated : null,
CreatedUtc = T0,
UpdatedUtc = updated,
};
[Fact]
public void ComputePullSet_pulls_names_absent_locally()
{
IReadOnlyList<SecretName> pull = SecretReplicationReconciler.ComputePullSet(
localManifest: [],
remote: [Entry("db/password", 0, T0)]);
Assert.Equal(["db/password"], pull.Select(n => n.Value));
}
[Fact]
public void ComputePullSet_pulls_a_newer_remote_row()
{
IReadOnlyList<SecretName> pull = SecretReplicationReconciler.ComputePullSet(
localManifest: [Entry("api/key", 1, T0)],
remote: [Entry("api/key", 2, T0.AddSeconds(1))]);
Assert.Equal(["api/key"], pull.Select(n => n.Value));
}
[Fact]
public void ComputePullSet_breaks_a_timestamp_tie_on_revision()
{
IReadOnlyList<SecretName> pull = SecretReplicationReconciler.ComputePullSet(
localManifest: [Entry("api/key", 1, T0)],
remote: [Entry("api/key", 2, T0)]);
Assert.Equal(["api/key"], pull.Select(n => n.Value));
}
[Fact]
public void ComputePullSet_ignores_an_identical_row_so_redelivery_is_free()
{
// At-least-once transports redeliver. A tie must not be "newer", or two nodes would
// ping-pong the same row forever.
IReadOnlyList<SecretName> pull = SecretReplicationReconciler.ComputePullSet(
localManifest: [Entry("api/key", 2, T0)],
remote: [Entry("api/key", 2, T0)]);
Assert.Empty(pull);
}
[Fact]
public void ComputePullSet_ignores_a_peer_that_is_behind()
{
IReadOnlyList<SecretName> pull = SecretReplicationReconciler.ComputePullSet(
localManifest: [Entry("api/key", 5, T0.AddMinutes(1))],
remote: [Entry("api/key", 2, T0)]);
Assert.Empty(pull);
}
[Fact]
public void ComputePullSet_pulls_a_tombstone_so_deletes_propagate()
{
// A delete is a newer row, not an absence — otherwise a deleted secret would be resurrected
// by the next anti-entropy sweep from a node that still has it.
IReadOnlyList<SecretName> pull = SecretReplicationReconciler.ComputePullSet(
localManifest: [Entry("api/key", 1, T0)],
remote: [Entry("api/key", 2, T0.AddSeconds(1), deleted: true)]);
Assert.Equal(["api/key"], pull.Select(n => n.Value));
}
[Fact]
public void ComputePullSet_does_not_pull_a_name_only_the_local_side_has()
{
// The local side only ever pulls. Pushing local-only names is the peer's own reconcile.
IReadOnlyList<SecretName> pull = SecretReplicationReconciler.ComputePullSet(
localManifest: [Entry("local/only", 0, T0)],
remote: []);
Assert.Empty(pull);
}
[Fact]
public async Task ReconcileAsync_applies_only_the_rows_the_peer_holds_newer()
{
await _store.UpsertAsync(Row("shared/a", 0, T0), CancellationToken.None);
StoredSecret localA = (await _store.GetAsync(new SecretName("shared/a"), CancellationToken.None))!;
var reconciler = new SecretReplicationReconciler(_store);
List<SecretName>? requested = null;
int applied = await reconciler.ReconcileAsync(
remoteManifest:
[
// Behind the local copy — must not be fetched.
Entry("shared/a", localA.Revision, localA.UpdatedUtc.AddMinutes(-1)),
// Absent locally — must be fetched.
Entry("shared/b", 3, T0.AddHours(1)),
],
fetchAsync: (names, _) =>
{
requested = [.. names];
return Task.FromResult<IReadOnlyList<StoredSecret>>([Row("shared/b", 3, T0.AddHours(1))]);
},
CancellationToken.None);
Assert.Equal(1, applied);
Assert.Equal(["shared/b"], requested!.Select(n => n.Value));
StoredSecret? b = await _store.GetAsync(new SecretName("shared/b"), CancellationToken.None);
Assert.NotNull(b);
// Applied VERBATIM — the peer's revision, not a locally bumped one.
Assert.Equal(3, b!.Revision);
}
[Fact]
public async Task ReconcileAsync_skips_the_fetch_entirely_when_nothing_is_newer()
{
var reconciler = new SecretReplicationReconciler(_store);
bool fetched = false;
int applied = await reconciler.ReconcileAsync(
remoteManifest: [],
fetchAsync: (_, _) =>
{
fetched = true;
return Task.FromResult<IReadOnlyList<StoredSecret>>([]);
},
CancellationToken.None);
Assert.Equal(0, applied);
Assert.False(fetched);
}
[Fact]
public async Task ApplyAsync_evicts_the_resolver_cache_for_every_row()
{
var invalidator = new RecordingCacheInvalidator();
var reconciler = new SecretReplicationReconciler(_store, invalidator);
await reconciler.ApplyAsync([Row("a/one", 1, T0), Row("b/two", 1, T0)], CancellationToken.None);
Assert.Equal(["a/one", "b/two"], invalidator.Invalidated.Select(n => n.Value));
}
[Fact]
public async Task ApplyAsync_evicts_even_when_the_store_rejected_the_row_as_stale()
{
// Serving a stale plaintext for the rest of the TTL is worse than a redundant re-read.
await _store.UpsertAsync(Row("api/key", 0, T0), CancellationToken.None);
var invalidator = new RecordingCacheInvalidator();
var reconciler = new SecretReplicationReconciler(_store, invalidator);
await reconciler.ApplyAsync([Row("api/key", 0, T0.AddYears(-1))], CancellationToken.None);
Assert.Equal(["api/key"], invalidator.Invalidated.Select(n => n.Value));
}
[Fact]
public async Task ApplyAsync_skips_a_poison_row_and_keeps_applying_the_rest()
{
// A batch is not a transaction. Without per-row isolation, one row a peer cannot supply
// cleanly abandons every row after it — and since the pull set is recomputed identically
// each sweep, in the same manifest order, the same row poisons the same batch forever.
var failures = new List<SecretName>();
var reconciler = new SecretReplicationReconciler(
_store, cacheInvalidator: null, onRowFailed: (name, _) => failures.Add(name));
// A null ciphertext cannot be persisted (NOT NULL column) — stands in for any bad row.
StoredSecret poison = Row("bad/row", 1, T0) with { Ciphertext = null! };
int applied = await reconciler.ApplyAsync(
[Row("good/first", 1, T0), poison, Row("good/second", 1, T0)],
CancellationToken.None);
Assert.Equal(2, applied);
Assert.Equal(["bad/row"], failures.Select(n => n.Value));
Assert.NotNull(await _store.GetAsync(new SecretName("good/first"), CancellationToken.None));
Assert.NotNull(await _store.GetAsync(new SecretName("good/second"), CancellationToken.None));
}
[Fact]
public async Task ReconcileAsync_fetches_in_bounded_batches()
{
// An unbounded fetch breaks on real backends: SQL Server caps a command at 2100 parameters,
// so a cold-starting node pulling a large hub's entire inventory would fail identically
// every interval and never receive a single row.
const int total = SecretReplicationReconciler.FetchBatchSize + 20;
List<SecretManifestEntry> remote = [.. Enumerable.Range(0, total)
.Select(i => Entry($"bulk/{i}", 1, T0))];
var batchSizes = new List<int>();
var reconciler = new SecretReplicationReconciler(_store);
int applied = await reconciler.ReconcileAsync(
remote,
fetchAsync: (names, _) =>
{
batchSizes.Add(names.Count);
return Task.FromResult<IReadOnlyList<StoredSecret>>(
[.. names.Select(n => Row(n.Value, 1, T0))]);
},
CancellationToken.None);
Assert.Equal(total, applied);
Assert.Equal(2, batchSizes.Count);
Assert.All(batchSizes, size => Assert.True(size <= SecretReplicationReconciler.FetchBatchSize));
}
public void Dispose()
{
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
foreach (string path in new[] { _dbPath, _dbPath + "-wal", _dbPath + "-shm" })
{
if (File.Exists(path))
{
File.Delete(path);
}
}
}
}