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.
16 KiB
Secrets — GAPS (adoption backlog)
The delta between each project's current-state and the
SPEC, as prioritized work. The library itself is built + published +
reference-consumer-proven; everything here is per-app adoption of an existing lib (not lib
construction).
Execution status (2026-07-16)
- mxaccessgw — G-4 + G-5 + G-6 DONE. Executed via the plan below (8 tasks, subagent-driven,
classification-driven reviews), merged + pushed to
origin/main(mxaccessgw @e088dfa). Offline: 0-warning build, full suite 781 pass / 44 pre-existing worker-COM (0 non-worker), behavioral claim-type authz test, no Data-Protection disturbance. Box-verified on windev (real .NET 10.0.8): fail-closed on unseeded${secret:}(SecretNotFoundException, refuses to start) + CLI seed/getdecrypt round-trip + zero plaintext in the SQLite store at rest. (/admin/secretsinteractive reveal not re-proven on this box — same shared.Uicomponent already live-proven via HistorianGateway; box gateway runs LDAP-disabled/anonymous so a real LDAP-via-secret login wasn't exercised there.) Two review-caught fixes folded in: the LDAP password change's test/doc blast radius (validator-helper + host-test-bootstrap + doc truth), and the live-LDAP integration suite now honors theMxGateway__Ldap__ServiceAccountPasswordenv override. G-4/G-5/G-6 below are the remaining OtOpcUa + ScadaBridge scope. - ScadaBridge — G-3 + G-4 + G-5 + G-6 DONE. Executed via the plan below (10 tasks, subagent-driven,
classification-driven reviews), FF-merged + pushed to
origin/main(ScadaBridge @128f1596). Offline: 0-warning build, all suites green (Host 279, DCL 235 incl. 5 G-3 resolver tests, Security 181, CentralUI 925, InboundAPI 269, SiteRuntime 522/523 [1 pre-existing ScriptActor timing-flake, passes isolated + branch touched no SiteRuntime src], Commons/ConfigDb/ManagementService/Communication/ClusterInfra all green). G-3 LIVE-PROVEN vs the REAL production MxGateway gateway (wonder-app-vd03.zmr.zimmer.com:5120): a throwaway harness drove the realMxGatewayDataConnectionthrough the DI factory +AddZbSecretswith asecret:-refApiKey→ dummy keyUnauthenticated(proves the resolved value is transmitted, not fail-closed first) → real read-scoped key →Connected+ browsed the real Galaxy root (10 nodes). Temp key created via the gateway dashboard, then revoked + deleted + functionally confirmed dead. Notable deltas from the plan: T3 registersAddZbSecretson both the Central branch (UI) andSiteServiceRegistration(DCL adapter) — the plan assumed one container; T6 claim-type resolved MATCH (ZbClaimTypes.Role == ClaimTypes.Role, adversarially verified) →AddSecretsAuthorization()used directly; T8 (G-5) shipped as a docs-only production-posture runbook (docs/operations/2026-07-16-secrets-clustered-master-key.md) because hardcodingSource=File+/sharedpaths would break the Central-boot Host.Tests + local dev (the pre-host expander migrates the store unconditionally every Central boot). One review-flagged non-blocking follow-up: a pre-existing xUnit env-mutation CI-flake that T4 amplifies (single[Collection]/ disable parallelization for Host.Tests). - OtOpcUa — G-2 + G-4 + G-5 + G-6 DONE (the LAST app — all three now adopted). Executed 2026-07-16 via the plan below (10 tasks, 2 slices, subagent-driven, classification-driven reviews), FF-merged + pushed to
origin/master(lmxopcua @872cf7e3,ec6598ce..872cf7e3). Clean build (0 errors). The full offline suite caught one real regression —AddOtOpcUaDriverFactories' registration now resolvesISecretResolver(Galaxy/OpcUaClient secret: refs), so twoResilienceInvokerFactoryRegistrationTeststhat built a minimal container threwNo service for ISecretResolver; fixed test-only by registering a stub resolver (production always has it via the unconditionalAddZbSecrets, andGetRequiredServicecorrectly fails-fast for a genuinely misconfigured host). Touched-area suites green (Galaxy 338, OpcUaClient 142, AdminUI 662); remaining non-passes are unrelated (flaky Akka/Roslyn timing tests pass in isolation; the environmentalAbCip_Green_AgainstSimsim-fixture test — zero AbCip code touched). Three deliberate deviations from the plan's literal text (verified corrections, same class as the CPM miss the plan itself caught): (1) G-4 does NOT commit a${secret:}token into the default appsettings — the expander is fail-closed AND section-agnostic and the only committed secret-shaped value isServerHistorian:ApiKey=""(empty, disabled-by-default), so a committed token would break every dev/CI/TwoNodeClusterHarnessboot for zero at-rest benefit; instead documented the token-delivery convention (docs/security.md) + updated the ServerHistorian comments, and proved fail-closed live (unseeded →SecretNotFoundExceptionat pre-host expansion; seeded → resolves). (2) G-5 shipped docs-only (docs/operations/2026-07-16-secrets-clustered-master-key.md) — hardcodingSource=File+shared paths into the committed role-overlay appsettings (also consumed by dev + integration) would break those boots; base staysSource=Environment. Mirrors the ScadaBridge G-5 resolution. (3) Task 8 resolves in the factory/driver only, NOT the probe — the v3OpcUaClientDriverProbeis unauthenticatedGetEndpoints-only and never readsPassword/UserCertificatePassword, so probe resolution would be dead code. Layer-B resolution mirrors Galaxy: the sync driver-registry factory means resolution happens lazily at the driver's async session-open (GalaxyDriver.BuildClientOptionsAsync/OpcUaClientDriver.InitializeAsync), threaded via ctor +DriverFactoryBootstrap(real resolver from DI;NullSecretResolvernull-object backs test/parse paths only, fail-closed onsecret:).OpcUaClientDriverOptionswas convertedsealed class→sealed recordfor thewith-expression (no positional params → identical JSON; verified no reference-equality/dict-key/ToString-log usages → no leak/behavior change). AdminUI G-2c is structurally leak-proof:DriverConfigpersists as an opaque JSON string viaRawTreeService, which has NO resolver dependency. Live wonder gate PROVEN 2026-07-16 (VPN up): a throwaway harness drove OtOpcUa's realGalaxyDriverBrowser(resolves thesecret:ApiKeySecretRefviaISecretResolver→GalaxySecretRef.ResolveApiKeyAsync→ connects) against the REAL production MxGatewaywonder-app-vd03:5120. Dummysecret:ref →MxGatewayAuthenticationException(proves the resolved value is transmitted, not fail-closed-first); realmetadata:readkey → CONNECTED + browsed the real Galaxy root (10 nodes: ControlSystem/EUR/KPI_CNC/NAM/_TestArea/MESReceiver…). Temp key minted via the gateway dashboard, then revoked (functionally proven — the same key that browsed 10 nodes then returned Unauthenticated) + deleted (row gone; no prod key touched; the dashboard key-list is stale-cached so revoke/delete were verified FUNCTIONALLY, not by the list UI). Harness + scratch stores destroyed. All of G-2/G-4/G-5/G-6 now DONE + live-proven across all three apps + mxaccessgw. Plan re-verified 2026-07-16 againstorigin/master@ec6598ce(the merged v3.0 dual-namespace rewrite, PR #472). The v3.0 rewrite did NOT touch the driver-secret flow, so the approach held; the plan was corrected for: OtOpcUa is on Central Package Management (was mis-stated as inline-versioned),OpcUaClientDriverOptionslives in…Driver.OpcUaClient.Contracts+GalaxyDriverBrowserin…Driver.Galaxy.Browser, andDriverHostActor.csline shifts (1704/1756/1790 → 1766/1818/1852). Program.cs / Security / AdminUI anchors survived essentially unchanged. - G-8 (KEK rotation) — BUILT; G-7 (clustered replication) — DESIGNED + PLANNED (2026-07-17).
G-8 shipped into
ZB.MOM.WW.Secrets(lib0.1.3): theRewrapDEK primitive, CAS-guardedApplyRewrapAsync,KekRotationService.RewrapAllAsync, thesecret rewrap-allCLI verb, and an operator runbook — full suite green (82+15, 0 regressions) + end-to-end CLI smoke, adversarial crypto review PASS with a review-caught TOCTOU closed via compare-and-swap. G-7's SQL-store-vs-Akka fork is resolved (build the shared SQL-ServerISecretStore; Akka replicator deferred phase-2) with a design doc + executable plan +.tasks.json, no code built. See the G-7/G-8 backlog entries below. Not yet committed/pushed (awaiting go-ahead).
Design + implementation plans (2026-07-16)
G-2 … G-6 are now planned. Shared design + three per-repo executable plans (task-metadata'd,
code-verified anchors, co-located .tasks.json):
docs/plans/2026-07-16-secrets-adoption-design.md— shared design: library API, the two resolution layers (pre-host${secret:}config expander vs runtimeISecretResolver), wiring template, master-key/clustering fork.docs/plans/2026-07-16-secrets-adoption-otopcua.md— G-2 + G-4 + G-5 + G-6 (10 tasks, 2 slices; Layer-A config + Layer-B driver secrets).docs/plans/2026-07-16-secrets-adoption-scadabridge.md— G-3 + G-4 + G-5 + G-6 (10 tasks; incl. claim-type verification + committed-plaintext cleanup).docs/plans/2026-07-16-secrets-adoption-mxaccessgw.md— G-4 + G-5 + G-6 (8 tasks; single-box, no Layer B).
G-8 (KEK rotation) is now BUILT; G-7 (clustered replication) is now DESIGNED + PLANNED (2026-07-17, below).
Cross-cutting observations
- No app has
${secret:}resolution today. All three assemble config with the stockAddJsonFile+AddEnvironmentVariableschain and read secrets verbatim. Two already lean on the pattern the expander formalizes: ScadaBridge's non-functional${SCADABRIDGE_*}placeholders (whole-key env override) and OtOpcUa's driver-levelGalaxySecretRef(env:/file:/dev:/literal). These are the natural first swaps. - The common high-value gap is plaintext credentials in appsettings/env: LDAP service-account passwords (all three), SQL connection-string passwords (OtOpcUa ConfigDb, ScadaBridge ConfigDb + MachineDataDb), JWT/HS256 signing keys (OtOpcUa, ScadaBridge), and API-key peppers (all three, runtime-supplied but plaintext).
- Secrets already stored in a DB in plaintext are the sharpest edge: OtOpcUa's OpcUaClient
Password/UserCertificatePasswordand thedev:/literal Galaxy API key live cleartext in the central config DB; ScadaBridge's MxGateway per-endpointApiKeylives plaintext in the ConfigDbDataConnectionsJSON. These leak at rest to anyone with DB read. - Existing at-rest crypto is Data Protection, and only ScadaBridge applies it to secrets
(4 encrypted ConfigDb columns via
EncryptedStringConverter). OtOpcUa/mxaccessgw use Data Protection only for cookies/tokens. None use DPAPI orProtectKeysWith*; key rings are unencrypted-at-rest.ZB.MOM.WW.Secretsadds envelope encryption with a KEK held outside the store — a stronger boundary than "the DB is the only protection." - Peppered-HMAC API-key stores stay bespoke (mxaccessgw + ScadaBridge): they are hashed, not reversibly stored — already correct. Secrets only takes over the pepper value supply.
Backlog (prioritized)
G-1 — HistorianGateway reference consumer ✅ DONE
Adopted + live-proven 2026-07-16 (historian password via ${secret:}, authenticated read
against the real wonder historian). This is the wiring template for the three apps.
G-2 — OtOpcUa: cleartext-in-DB driver secrets (highest value)
Add a secret: arm to GalaxySecretRef (its own comment anticipates this) and to the
OpcUaClient driver options, resolving through ISecretResolver — retire the dev:/literal
and plaintext Password/UserCertificatePassword DB paths.
G-3 — ScadaBridge: MxGateway ApiKey plaintext-in-ConfigDb
Resolve via ISecretResolver or extend the existing EncryptedStringConverter to that JSON
column.
G-4 — Pre-host ${secret:} for plaintext config secrets (all three)
LDAP passwords, SQL connstr passwords, JWT signing keys, deploy API key (OtOpcUa),
peppers — swap to ${secret:…} tokens, expander running before each app's existing
validator (ConfigPreflight / Ldap+OpcUa validators / GatewayOptionsValidator).
Delete committed dev plaintext and the loose *_login.txt files (ScadaBridge).
G-5 — Master-key provider per deployment
Env (ZB_SECRETS_MASTER_KEY) for containers; DPAPI for the Windows boxes; File (shared
mounted key) for clustered pairs (hard requirement — same KEK on every node).
G-6 — Mount /admin/secrets UI (all three)
Add the RCL page to each dashboard (OtOpcUa AdminUI, MxGateway Server, ScadaBridge CentralUI)
and map each app's admin role onto secrets:manage / secrets:reveal.
G-7 — Clustered replication (ScadaBridge, OtOpcUa) — DESIGNED + PLANNED (2026-07-17)
The SPEC's "shared SQL-Server store vs Akka replicator" fork is resolved: build Option A —
a shared SQL-Server ISecretStore (both clustered apps already run shared SQL + a shared
Data-Protection key ring, so it delivers cluster-wide secrets with zero distributed-systems failure
modes); the Akka replicator (ZB.MOM.WW.Secrets.Akka, LWW/anti-entropy/tombstones) is a deferred
phase-2 to build only on a stated partition-tolerance requirement. Both require a shared KEK; the
ISecretReplicator seam stays in place so Option B remains a drop-in later. Design + executable
plan (+ .tasks.json): docs/plans/2026-07-17-secrets-g7-clustered-replication-design.md
docs/plans/2026-07-17-secrets-g7-sqlserver-store.md. No code built yet — plan is ready to execute.
G-8 — KEK-rotation RewrapAll + runbook ✅ BUILT (2026-07-17, lib 0.1.3)
The RewrapAll(oldKek, newKek) admin primitive + operator runbook are built + fully tested in
ZB.MOM.WW.Secrets. Because the envelope wraps a per-secret DEK under the KEK, rotation re-wraps
DEKs only — bodies are never re-encrypted, revision/timestamps are preserved (invisible to cluster
LWW). Shipped: ISecretCipher.Rewrap(row, oldKek, newKek) (fail-closed on wrong old KEK; DEK zeroed
on all paths; also catches malformed-length wraps → SecretDecryptionException),
ISecretStore.ApplyRewrapAsync(rewrappedRow, expectedCurrentWrappedDek) (updates only the 4 wrap
columns + kek_id, compare-and-swap on the current wrapped DEK so a concurrent write can't
corrupt a row — review-caught TOCTOU), KekRotationService.RewrapAllAsync + RewrapReport
(enumerates all rows incl. tombstones, idempotent/resumable skip-already-current, bounded CAS-retry,
fail-closed on unknown/identical KEK), the secret rewrap-all CLI verb (key material only via
env-var-name/file-path — never a literal), README section, and the operator runbook
ZB.MOM.WW.Secrets/docs/operations/kek-rotation.md.
Verified: full offline suite green (82 core + 15 UI, 0 regressions) + an end-to-end CLI smoke
(migrate → decrypts under new KEK, fail-closed under old → idempotent re-run → identical-KEK guard).
Version bumped 0.1.2→0.1.3 (additive interface members); republish to the Gitea feed is a
separate opt-in step (the apps don't need rewrap — it's a library/CLI admin primitive). Reviewed
by an adversarial crypto/correctness pass (all 7 categories PASS); the one real finding (concurrent-
write TOCTOU) is fixed by the CAS above, plus two low/nit fixes folded in.
Out of scope (this component)
- Akka remoting authentication/TLS (ScadaBridge plain TCP remoting) — a separate hardening concern, not secret storage.
- SQL-Server
ISecretStoreprovider construction (seam supports it; build with G-7).