Files
scadaproj/docs/plans/2026-07-17-secrets-g7-sqlserver-store.md
T
Joseph Doherty d82d3451e7 feat(secrets): build G-8 KEK rotation (RewrapAll); design+plan G-7 clustered replication
G-8 (KEK rotation) — built in ZB.MOM.WW.Secrets, lib 0.1.2->0.1.3:
- ISecretCipher.Rewrap(row, oldKek, newKek): re-wraps the per-secret DEK only
  (bodies never re-encrypted; revision/timestamps preserved -> invisible to
  cluster LWW). Fail-closed on wrong old-KEK id, wrong key bytes, and malformed
  wraps; DEK zeroed on all paths.
- ISecretStore.ApplyRewrapAsync(rewrappedRow, expectedCurrentWrappedDek):
  updates only the 4 wrap columns + kek_id, compare-and-swap on the current
  wrapped DEK so a concurrent set/rotate cannot corrupt a row (closes a
  review-caught TOCTOU).
- KekRotationService.RewrapAllAsync + RewrapReport: enumerate all rows incl.
  tombstones, idempotent/resumable skip-already-current, bounded CAS-retry,
  fail-closed on unknown/identical KEK.
- `secret rewrap-all` CLI verb: key material only via env-var name / file path,
  JSON counts report; README section + operator runbook.

Verified: full offline suite green (82 core + 15 UI, 0 regressions) + end-to-end
CLI smoke + adversarial crypto review (all 7 categories PASS; TOCTOU fixed).

G-7 (clustered replication) — designed + planned, no code:
- Fork resolved to build Option A (shared SQL-Server ISecretStore); Akka
  replicator ZB.MOM.WW.Secrets.Akka is a deferred phase-2. Design doc +
  executable plan + .tasks.json under docs/plans/2026-07-17-secrets-g7-*.

Tracking: components/secrets/GAPS.md + CLAUDE.md secrets row updated.
2026-07-17 02:55:43 -04:00

6.6 KiB

G-7 (Option A) — Shared SQL-Server ISecretStore Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans (or subagent-driven-development) to implement this plan task-by-task.

Goal: Add a shared SQL-Server ISecretStore provider to ZB.MOM.WW.Secrets so the Akka-clustered apps (ScadaBridge, OtOpcUa) can back their secrets with one shared, ciphertext-only database — every node sees the same secrets, no replication code.

Architecture: Mirror the existing SqliteSecretStore + SqliteSecretsStoreMigrator in T-SQL behind the unchanged ISecretStore seam. Select the store in AddZbSecrets from a new SecretsOptions.Store enum (SQLite stays the default). The KEK stays per-node/out-of-DB, so the DB holds only ciphertext. See the design + fork rationale in 2026-07-17-secrets-g7-clustered-replication-design.md.

Tech Stack: .NET 10, Microsoft.Data.SqlClient, xUnit + Shouldly, env-gated live SQL tests (LocalDB or a mcr.microsoft.com/mssql/server container), matching the app live-test idiom.

Precondition: G-8 is merged — ISecretStore.ApplyRewrapAsync exists and the SQL-Server store must implement it. rewrap-all then runs once against the shared store.


Task 1: SQL-Server schema + migrator

Classification: standard Estimated implement time: ~5 min Parallelizable with: none (Task 2 depends on it)

Files:

  • Create: src/ZB.MOM.WW.Secrets/SqlServer/SqlServerSecretsSchema.cs
  • Create: src/ZB.MOM.WW.Secrets/SqlServer/SqlServerSecretsStoreMigrator.cs
  • Create: src/ZB.MOM.WW.Secrets/SqlServer/SecretsSqlServerConnectionFactory.cs
  • Test: tests/ZB.MOM.WW.Secrets.Tests/SqlServer/SqlServerSecretsStoreMigratorTests.cs (env-gated)

Notes:

  • Mirror SqliteSecretsSchema / SqliteSecretsStoreMigrator semantics: schema_version single-row table + secret table, CurrentVersion = 1, idempotent IF NOT EXISTS DDL, refuse a newer on-disk version. Column set identical to SQLite; use varbinary(max) for the 6 crypto BLOBs, nvarchar for text, and store timestamps as ISO-8601 ("O") text so StoredSecret round-trips byte-identically to the SQLite path (avoids datetimeoffset precision drift in tests).
  • Add Microsoft.Data.SqlClient to Directory.Packages.props + the core csproj.
  • Gate live tests on an env var (e.g. SECRETS_SQLSERVER_CONNSTR); skip cleanly when unset.

Task 2: SqlServerSecretStore : ISecretStore

Classification: high-risk Estimated implement time: ~5 min Parallelizable with: none (depends on Task 1)

Files:

  • Create: src/ZB.MOM.WW.Secrets/SqlServer/SqlServerSecretStore.cs
  • Test: tests/ZB.MOM.WW.Secrets.Tests/SqlServer/SqlServerSecretStoreTests.cs (env-gated; port the SqliteSecretStoreTests cases 1:1, incl. the G-8 ApplyRewrap* cases)

Notes:

  • Implement all 7 members: GetAsync, UpsertAsync (revision bump — MERGE or insert/ON CONFLICT-equivalent via UPDATE+INSERT under a transaction), DeleteAsync (tombstone + revision bump), ListAsync (metadata projection — never the crypto columns), GetManifestAsync, ApplyReplicatedAsync (LWW under IsolationLevel.Serializable, applied verbatim, no revision bump), ApplyRewrapAsync (UPDATE the 4 wrap columns + kek_id only; no revision/updated_utc change).
  • Fully parameterized commands; same "created_utc/created_by preserved on overwrite" semantics as SQLite. This is high-risk because the LWW + rewrap semantics MUST match SQLite exactly (a cluster reconciles across both if a node ever migrates).

Task 3: DI store-selection wiring

Classification: standard Estimated implement time: ~4 min Parallelizable with: none (depends on Task 2)

Files:

  • Modify: src/ZB.MOM.WW.Secrets/SecretsOptions.cs (add SecretsStoreKind Store = Sqlite default; add string? SqlServerConnectionString)
  • Create: src/ZB.MOM.WW.Secrets/SecretsStoreKind.cs (enum Sqlite | SqlServer)
  • Modify: src/ZB.MOM.WW.Secrets/DependencyInjection/SecretsServiceCollectionExtensions.cs (branch the ISecretStore + migrator registration on Store; keep SQLite the default so HistorianGateway/mxaccessgw are unaffected)
  • Modify: src/ZB.MOM.WW.Secrets/DependencyInjection/SecretsMigrationHostedService.cs if it hard-refs the SQLite migrator type (abstract behind a shared migrator seam or a switch)
  • Test: tests/ZB.MOM.WW.Secrets.Tests/DependencyInjection/AddZbSecretsTests.cs (assert the correct store type is resolved for each Store value)

Notes:

  • Introduce a tiny ISecretsStoreMigrator seam (both migrators implement it) so the hosted service + CLI stay store-agnostic, OR switch on Store at registration. Prefer the seam — cleaner.
  • The connection string should itself be deliverable via ${secret:} / a secret: ref (consumers already do this for other connstrings).

Task 4: Docs + adoption notes

Classification: small Estimated implement time: ~3 min Parallelizable with: Task 3

Files:

  • Modify: README.md (Clustered deployments: document Secrets:Store = SqlServer + shared-KEK requirement + "run rewrap-all once against the shared store")
  • Create: docs/operations/shared-sql-store.md (operator setup: provision the DB, grant, set the connstr via a secret ref, confirm the same KEK on every node)
  • Modify: components/secrets/GAPS.md (G-7 → "Option A built"; note Option B deferred)

Notes: No app changes here — adoption in ScadaBridge/OtOpcUa is a follow-on (flip Secrets:Store, supply the connstr + shared KEK). Capture that as a checklist, not code.


Deferred phase-2 (NOT built here): Option B — ZB.MOM.WW.Secrets.Akka

Build only when an app declares it must resolve secrets while partitioned from the shared store. Shape (for when it's warranted):

  • New package ZB.MOM.WW.Secrets.Akka (net10; refs Akka.Cluster + Abstractions).
  • AkkaSecretReplicator : ISecretReplicatorPublishAsync broadcasts the encrypted StoredSecret to peers (a cluster-aware router / distributed pub-sub topic).
  • An anti-entropy actor (cluster singleton or per-node) that periodically exchanges GetManifestAsync digests and pulls newer/missing rows via ApplyReplicatedAsync (LWW); tombstones propagate deletes; a retention window bounds tombstone GC.
  • StoredSecret serialization (ciphertext only — never the KEK) + an ISecretActorAccessor wiring.
  • Validation: a live 2-node ScadaBridge/OtOpcUa rig proving write-on-A → resolve-on-B, delete propagation, and partition-heal resync — a G-2-class live gate.
  • KEK constraint identical to Option A: same master KEK on every node; rewrap-all per node.