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.
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/SqliteSecretsStoreMigratorsemantics:schema_versionsingle-row table +secrettable,CurrentVersion = 1, idempotentIF NOT EXISTSDDL, refuse a newer on-disk version. Column set identical to SQLite; usevarbinary(max)for the 6 crypto BLOBs,nvarcharfor text, and store timestamps as ISO-8601 ("O") text soStoredSecretround-trips byte-identically to the SQLite path (avoidsdatetimeoffsetprecision drift in tests). - Add
Microsoft.Data.SqlClienttoDirectory.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 theSqliteSecretStoreTestscases 1:1, incl. the G-8ApplyRewrap*cases)
Notes:
- Implement all 7 members:
GetAsync,UpsertAsync(revision bump —MERGEor insert/ON CONFLICT-equivalent viaUPDATE+INSERTunder a transaction),DeleteAsync(tombstone + revision bump),ListAsync(metadata projection — never the crypto columns),GetManifestAsync,ApplyReplicatedAsync(LWW underIsolationLevel.Serializable, applied verbatim, no revision bump),ApplyRewrapAsync(UPDATE the 4 wrap columns +kek_idonly; 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(addSecretsStoreKind Store=Sqlitedefault; addstring? SqlServerConnectionString) - Create:
src/ZB.MOM.WW.Secrets/SecretsStoreKind.cs(enumSqlite | SqlServer) - Modify:
src/ZB.MOM.WW.Secrets/DependencyInjection/SecretsServiceCollectionExtensions.cs(branch theISecretStore+ migrator registration onStore; keep SQLite the default so HistorianGateway/mxaccessgw are unaffected) - Modify:
src/ZB.MOM.WW.Secrets/DependencyInjection/SecretsMigrationHostedService.csif 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 eachStorevalue)
Notes:
- Introduce a tiny
ISecretsStoreMigratorseam (both migrators implement it) so the hosted service + CLI stay store-agnostic, OR switch onStoreat registration. Prefer the seam — cleaner. - The connection string should itself be deliverable via
${secret:}/ asecret: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: documentSecrets:Store = SqlServer+ shared-KEK requirement + "runrewrap-allonce 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 : ISecretReplicator—PublishAsyncbroadcasts the encryptedStoredSecretto peers (a cluster-aware router / distributed pub-sub topic).- An anti-entropy actor (cluster singleton or per-node) that periodically exchanges
GetManifestAsyncdigests and pulls newer/missing rows viaApplyReplicatedAsync(LWW); tombstones propagate deletes; a retention window bounds tombstone GC. StoredSecretserialization (ciphertext only — never the KEK) + anISecretActorAccessorwiring.- 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-allper node.