- All 4 apps on 0.2.1 (local branches, unpushed). - Records the corrected security story: the version bump closed NO advisory (all four repos already resolved patched 2.1.12); the real live vulnerability was in ScadaBridge, masked by a NuGetAuditSuppress, and was fixed by separate work. - Records the upstream 0.2.0 inert-Akka-replicator defect, its root cause (DI extensions with no container-building test - third instance of that class), and the 0.2.1 fix. - Marks clustered topology WIRED-but-default-OFF and explicitly NOT live-validated; Task 9 remains open and the topology is not 'adopted' until it passes.
28 KiB
MxAccessGateway — Secrets Adoption (G-4, G-5, G-6) Implementation Plan
✅ EXECUTED 2026-07-16 — merged to
origin/main@e088dfa, box-verified fail-closed and encrypt-at-rest. This document is the historical record; the0.1.2pins are what was landed then, not what to use now.Follow-on (not yet executed): a version bump only —
2026-07-18-secrets-0.2.0-upgrade-and-clustering.mdTask 2. This app is a single Windows/NSSM box with no Akka clustering, so no replication package and no topology apply. The bump still matters:0.2.0carries a high-severity transitiveSQLitePCLRawadvisory fix that0.1.2lacks.
For Claude: REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task. Shared context: docs/plans/2026-07-16-secrets-adoption-design.md.
Goal: Adopt the already-built, already-published ZB.MOM.WW.Secrets library (0.1.2, Gitea feed, reference-consumer-proven in HistorianGateway) into MxAccessGateway — retiring the plaintext LDAP bind password (and any password-bearing config) at rest behind pre-host ${secret:} expansion, adding a runtime ISecretResolver, and mounting the /admin/secrets Blazor UI. Covers gaps G-4 (pre-host config-secret expansion), G-5 (master-key provider), and G-6 (mount UI). There is no G-2/G-3 for this app — it has no database-resident driver/connection secrets.
Architecture: No library construction. Every task is per-app wiring of an existing NuGet package, following the HistorianGateway template verbatim (see design §3). All wiring lands in the single x64 gateway project ZB.MOM.WW.MxGateway.Server; the x86 .Worker project needs no secrets (it has no IConfiguration at all). MxAccessGateway is a single Windows/NSSM box, no Akka/clustering — so G-5 takes the simplest posture (Environment master-key provider + local SQLite store), and there is no shared-KEK / cross-node-replication concern (that fork in design §5 applies only to the two clustered apps).
Tech Stack: .NET 10, C#, ZB.MOM.WW.Secrets{,.Abstractions,.Ui} @ 0.1.2 from the dohertj2-gitea NuGet feed, AES-256-GCM envelope crypto, SQLite store, ASP.NET Core authorization + Blazor RCL. Build via dotnet build src/MxGateway.sln; tests via dotnet test src/MxGateway.Tests/MxGateway.Tests.csproj.
Shared design: docs/plans/2026-07-16-secrets-adoption-design.md — API surface §1, two resolution layers §2 (this app is Layer A only), wiring template §3, G-4 §4, G-5 §5 (mxaccessgw sub-section), G-6 §6, cross-cutting gotchas §8. Baseline: components/secrets/current-state/mxaccessgw/CURRENT-STATE.md.
Branch & sequencing
Work on branch feat/adopt-zb-secrets off origin/main in ~/Desktop/MxAccessGateway, mirroring how auth / theme / audit were adopted across the family: commit each task + review, then fast-forward-merge into main and push to Gitea (origin = mxaccessgw). Keep the feat/* branch locally as history.
cd ~/Desktop/MxAccessGateway
git fetch origin && git switch -c feat/adopt-zb-secrets origin/main
Task ordering / parallelism. Tasks 1→2→3 are a strict chain (packages → pre-host expander → runtime registration) because the expander code will not compile until the packages restore, and the runtime registration shares usings/insertion neighbourhood with the expander. Task 4 (switch LDAP password + seed) depends on 1–3. Task 5 (optional Galaxy/pepper) depends on 4. Task 6 (UI mount) depends only on 1–3 and is parallelizable with 4/5. Task 7 (master-key operator config) is docs/appsettings only, parallelizable with 4–6. Task 8 is the final verification gate.
Gotcha carried from design §8 / the recon: Data Protection is never explicitly configured in this app — the only DP consumer is
Dashboard/HubTokenService.cs:43(SignalR hub tokens on the framework-default key ring). Secrets envelope crypto is independent of Data Protection. Do NOT add/reconfigureAddDataProtectionanywhere in this work, or you risk rotating the hub-token key ring and breaking live SignalR sessions. Task 8 explicitly re-verifies SignalR is unaffected.
Task 1 — Package references + nuget.config packageSourceMapping
Classification: small Estimated implement time: ~4 min Parallelizable with: none (gates 2–8)
Files:
- Modify:
~/Desktop/MxAccessGateway/nuget.config:24(add two<package pattern>rows after the last existing ZB pattern; the file has explicitpackageSourceMappingat:10-26, source keydohertj2-giteaat:6, noZB.MOM.WW.*wildcard — restore fails without the new patterns) - Modify:
~/Desktop/MxAccessGateway/src/ZB.MOM.WW.MxGateway.Server/ZB.MOM.WW.MxGateway.Server.csproj(add threePackageReferences)
Steps:
- In
nuget.config, under thedohertj2-gitea<packageSource>mapping block (after the existingZB.MOM.WW.GalaxyRepositorypattern at line 24), add:(The second pattern covers<package pattern="ZB.MOM.WW.Secrets" /> <package pattern="ZB.MOM.WW.Secrets.*" />.Abstractionsand.Ui.) - In
ZB.MOM.WW.MxGateway.Server.csproj, add to the existingZB.MOM.WW.*PackageReferenceItemGroup:(<PackageReference Include="ZB.MOM.WW.Secrets" Version="0.1.2" /> <PackageReference Include="ZB.MOM.WW.Secrets.Abstractions" Version="0.1.2" /> <PackageReference Include="ZB.MOM.WW.Secrets.Ui" Version="0.1.2" />.Uitransitively pullsZB.MOM.WW.Theme+ZB.MOM.WW.Auth.AspNetCore+ZB.MOM.WW.Audit, all of which this app already references — no version conflict expected; verify in step 3.) - Restore + build to prove the feed resolves the three packages:
Expect: clean restore (packages pulled from
dotnet restore src/MxGateway.sln dotnet build src/MxGateway.sln -warnaserrordohertj2-gitea), 0 warnings, 0 errors. If restore 404s, re-check the two nuget.config patterns and that$GITEA_TOKEN/feed creds are present. - Commit:
git add -A && git commit -m "build(secrets): add ZB.MOM.WW.Secrets 0.1.2 package refs + nuget source mapping".
Task 2 — Pre-host ${secret:} expander in GatewayApplication.CreateBuilder (G-4 mechanism)
Classification: standard Estimated implement time: ~5 min Parallelizable with: none (depends on 1; gates 3–8)
Wire the throwaway-provider pre-host expansion (design §3a) so every downstream config consumer sees resolved values. This is a pure mechanism task — no config value is switched to a token yet (that is Task 4), so the app must still boot unchanged (the expander is a no-op when no ${secret:} tokens are present).
Files:
- Modify:
~/Desktop/MxAccessGateway/src/ZB.MOM.WW.MxGateway.Server/GatewayApplication.cs— methodCreateBuilder(string[] args)at:58; builder-creation chain:60-64;ConfigureSelfSignedTls(builder)at:67. Insert the expander block between:64and:67(after the builder is created, before TLS reads config), so even TLS/Kestrel values can carry tokens. - Test:
~/Desktop/MxAccessGateway/src/MxGateway.Tests/…(addPreHostSecretExpansionTests, see step 1)
Why this insertion point (recon-verified): the expander must mutate builder.Configuration before all of: ConfigureSelfSignedTls (:67), AddGatewayConfiguration(builder.Configuration) (:71, which runs AddValidatedOptions<GatewayOptions,GatewayOptionsValidator> with ValidateOnStart), AddZbLdapAuth (DashboardServiceCollectionExtensions.cs:38, binds MxGateway:Ldap directly with its own ValidateOnStart), AddZbGalaxyRepository(...,"MxGateway:Galaxy") (:103), and AddZbApiKeyAuth (AuthStoreServiceCollectionExtensions.cs:70). Because CreateBuilder runs first in the pipeline, placing it right after builder creation satisfies all of these.
Steps:
- Write test first. Add
PreHostSecretExpansionTeststhat constructs a config with a${secret:test/value}token and a seeded fake/temp SQLite store, runs the sameSecretReferenceExpanderflow, and asserts the token is rewritten to the seeded plaintext; and a second test asserting an unseeded${secret:missing}throwsSecretNotFoundException. (If the test project cannot easily reach a privateCreateBuilder, test theSecretReferenceExpander/AddZbSecretsflow directly against a tempSqlitePath— the same code the host runs.)Expect: fails to compile / red (expander not yet wired).dotnet test src/MxGateway.Tests/MxGateway.Tests.csproj --filter "FullyQualifiedName~PreHostSecretExpansion" - Add usings to
GatewayApplication.cs:ZB.MOM.WW.Secrets.Abstractions,ZB.MOM.WW.Secrets.Configuration,ZB.MOM.WW.Secrets.DependencyInjection,ZB.MOM.WW.Secrets.Sqlite, plusMicrosoft.Extensions.DependencyInjection/Microsoft.Extensions.Configurationif not already present. - Insert the expander block between line 64 and line 67 (the design §3a template, verbatim). Note
CreateBuilderis synchronous today — make itasync(returnTask<…>/ValueTask) or run the expansion synchronously via.GetAwaiter().GetResult(). Preferred: keep the method signature stable if the caller chain is non-trivial by calling.GetAwaiter().GetResult()on the two awaits (pre-host bootstrap, single-shot, no sync-context deadlock risk in a console/host startup). Confirm the actual caller ofCreateBuilderbefore choosing; if it is already in an async context, prefer realawait.(Use#pragma warning disable ASP0000 // deliberate throwaway container, disposed here, shares no singletons using (var secretsProvider = new ServiceCollection() .AddZbSecrets(builder.Configuration, "Secrets") .BuildServiceProvider()) #pragma warning restore ASP0000 { secretsProvider.GetRequiredService<SqliteSecretsStoreMigrator>() .MigrateAsync(default).GetAwaiter().GetResult(); var resolver = secretsProvider.GetRequiredService<ISecretResolver>(); new SecretReferenceExpander(resolver) .ExpandConfigurationAsync((IConfigurationRoot)builder.Configuration, default) .GetAwaiter().GetResult(); }await using+awaitif you made the method async — either is fine; keep the#pragma.) - Add the
Secretsblock tosrc/ZB.MOM.WW.MxGateway.Server/appsettings.json(design §1 shape):"Secrets": { "SqlitePath": "mxgateway-secrets.db", "MasterKey": { "Source": "Environment", "EnvVarName": "ZB_SECRETS_MASTER_KEY" }, "RunMigrationsOnStartup": true, "ResolveCacheTtl": "00:00:30" } - Build + run the two tests:
Expect: 0 warnings; both tests green (rewrite works, missing-ref throws).
dotnet build src/MxGateway.sln -warnaserror dotnet test src/MxGateway.Tests/MxGateway.Tests.csproj --filter "FullyQualifiedName~PreHostSecretExpansion" - Commit:
git commit -am "feat(secrets): pre-host \${secret:} config expansion in GatewayApplication.CreateBuilder (G-4 mechanism)".
Task 3 — Runtime AddZbSecrets registration on the host container
Classification: trivial Estimated implement time: ~2 min Parallelizable with: none (depends on 2; gates 4–8)
The pre-host throwaway provider (Task 2) only expands config once. The runtime resolver + SecretsMigrationHostedService are needed by the UI (Task 6) and keep the store schema current (design §3b, gotcha §8.3).
Files:
- Modify:
~/Desktop/MxAccessGateway/src/ZB.MOM.WW.MxGateway.Server/GatewayApplication.cs— service-registration region (alongsideAddGatewayConfigurationat:71/AddGatewayDashboardat:98).
Steps:
- Add, on the real host's
builder.Services(afterAddGatewayConfiguration, before/nearAddGatewayDashboard):builder.Services.AddZbSecrets(builder.Configuration, "Secrets"); - Build:
Expect: 0 warnings, 0 errors.
dotnet build src/MxGateway.sln -warnaserror - Commit:
git commit -am "feat(secrets): register runtime AddZbSecrets on the gateway host".
Task 4 — Switch LDAP ServiceAccountPassword to ${secret:} + seed + remove hardcoded defaults (G-4)
Classification: standard Estimated implement time: ~5 min Parallelizable with: Task 6, Task 7 (depends on 1–3)
Retire the plaintext LDAP bind password. It appears in two places (both must be addressed, or the blanked appsettings falls back to a leaked default):
appsettings.json:29→"ServiceAccountPassword": "serviceaccount123"Configuration/LdapOptions.cs:61→public string ServiceAccountPassword { get; init; } = "serviceaccount123";
Expanding MxGateway:Ldap:ServiceAccountPassword covers both the shadow GatewayOptionsValidator (requires non-blank, GatewayOptionsValidator.cs:134-137) and the real runtime AddZbLdapAuth bind (DashboardServiceCollectionExtensions.cs:38, own ValidateOnStart), because the expander mutates builder.Configuration before both.
Files:
- Modify:
~/Desktop/MxAccessGateway/src/ZB.MOM.WW.MxGateway.Server/appsettings.json:29(token instead of plaintext) - Modify:
~/Desktop/MxAccessGateway/src/ZB.MOM.WW.MxGateway.Server/Configuration/LdapOptions.cs:61(blank the hardcoded default)
Steps:
- Change
appsettings.json:29to:"ServiceAccountPassword": "${secret:ldap/mxgateway/bind}", - Blank the hardcoded default at
LdapOptions.cs:61:(Non-blank enforcement now lives entirely in the validator + fail-closed expansion; the leaked literal is gone.)public string ServiceAccountPassword { get; init; } = string.Empty; - Seed the secret into the local store via the
secretCLI (ZB.MOM.WW.Secrets.Cli) before booting — the dev LDAP bind password is the current dev value (serviceaccount123for the shared GLAuth, or the box's real value). SetZB_SECRETS_MASTER_KEY(base64 32 bytes) and point the CLI at the sameSqlitePaththe app uses (mxgateway-secrets.db):(Confirm the exact CLI verb/flags from theexport ZB_SECRETS_MASTER_KEY="$(openssl rand -base64 32)" # dev only; box uses the operator-delivered key dotnet run --project <path-to>/ZB.MOM.WW.Secrets.Cli -- set \ --sqlite ./src/ZB.MOM.WW.MxGateway.Server/mxgateway-secrets.db \ --name ldap/mxgateway/bind --value 'serviceaccount123'ZB.MOM.WW.Secrets.Clihelp — design §1 references aset/seed verb; the store path andZB_SECRETS_MASTER_KEYmust match what the app resolves with.) - Boot smoke — start the gateway with the key present and the secret seeded:
Expect: clean startup —
dotnet run --project src/ZB.MOM.WW.MxGateway.Server/ZB.MOM.WW.MxGateway.Server.csprojGatewayOptionsValidator+AddZbLdapAuthValidateOnStartboth pass (password non-blank because the token expanded to the seeded value). Dashboard reachable; LDAP login works against the shared GLAuth. - Negative control (fail-closed proof, design §10 / §8.1): stop the app, remove/rename the seeded secret (or unset
ZB_SECRETS_MASTER_KEY), boot again. Expect:SecretNotFoundException(missing secret) or a decrypt failure (missing key) at pre-host expansion → the app refuses to start. Re-seed / restore the key to recover. - Confirm no plaintext
serviceaccount123remains in the repo:Expect:grep -rn "serviceaccount123" ~/Desktop/MxAccessGateway/src || echo "clean"clean(both the appsettings literal and the LdapOptions default are gone). - Commit:
git commit -am "feat(secrets): source LDAP bind password via \${secret:ldap/mxgateway/bind}; drop plaintext defaults (G-4)".
Task 5 — (Optional) Galaxy connstr + API-key pepper to ${secret:}
Classification: small Estimated implement time: ~4 min Parallelizable with: Task 6, Task 7 (depends on 4)
Two lower-priority Layer-A targets. Do these only if the operator wants them under management now; each is independently revertible.
Files:
- Modify:
~/Desktop/MxAccessGateway/src/ZB.MOM.WW.MxGateway.Server/appsettings.json:76(Galaxy connstr, sectionMxGateway:Galaxy, bound byAddZbGalaxyRepository(...,"MxGateway:Galaxy")atGatewayApplication.cs:103) - (Pepper) no appsettings change — the pepper is env-supplied today via config-key name
MxGateway:ApiKeyPepper(AuthStoreServiceCollectionExtensions.cs:36, resolved:70); optionally add an appsettings token so it flows through the expander.
Steps:
- Galaxy connstr: the current value is
Integrated Security=True(no password today), but it is a raw connstr that would carryPassword=if the box switches to SQL auth. If the box uses SQL auth, move the whole connstr (or just the password) behind a token, e.g. setappsettings.json:76to"${secret:sql/mxgateway/galaxy}"and seedsql/mxgateway/galaxywith the full connection string. If the box stays on Integrated Security, skip — leave it as-is and note it in the operator doc. The expander already runs beforeAddZbGalaxyRepository(:103), so no ordering work is needed. - API-key pepper (SEC-10): to close the "no dedicated env reader" gap while keeping the pepper out of appsettings, add under
MxGateway:then seed"ApiKeyPepper": "${secret:apikey-pepper/mxgateway}",apikey-pepper/mxgateway. BecauseAddZbApiKeyAuthresolves the pepper by config-key name (:70) after the expander runs, the token resolves transparently. Caveat: if the box already suppliesMxGateway__ApiKeyPeppervia env, that env value overrides appsettings — decide one source and document it (env-supplied stays valid and needs no change; the token path is the alternative). Do not set both to different values. - Seed whichever secrets you introduced (as in Task 4 step 3), then boot-smoke + negative-control the same way.
- Build + confirm clean:
dotnet build src/MxGateway.sln -warnaserror - Commit:
git commit -am "feat(secrets): (optional) Galaxy connstr + api-key pepper via \${secret:} (G-4)".
Task 6 — AddSecretsAuthorization + mount /admin/secrets in Router & endpoint (G-6)
Classification: standard Estimated implement time: ~5 min Parallelizable with: Task 4, Task 5, Task 7 (depends on 1–3)
Mount the RCL page (design §3d/§6). The library's SecretsPage route is /admin/secrets under policy secrets:manage. Because the library AdminRole == "Administrator" matches DashboardRoles.Admin (DashboardRoles.cs:14) verbatim, and the dashboard already gates on role claims (no claim-type mismatch here, unlike ScadaBridge), Administrators satisfy secrets:manage/secrets:reveal automatically and Viewer (DashboardRoles.cs:19) does not.
Files:
- Modify:
~/Desktop/MxAccessGateway/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardEndpointRouteBuilderExtensions.cs:133-135— theendpoints.MapRazorComponents<App>().AddInteractiveServerRenderMode().RequireAuthorization(ViewerPolicy)chain (insideMapGatewayDashboardat:56, invoked fromGatewayApplication.cs:225). Add.AddAdditionalAssemblies(typeof(ZB.MOM.WW.Secrets.Ui.SecretsPage).Assembly). - Modify:
~/Desktop/MxAccessGateway/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Routes.razor:1—<Router AppAssembly="@typeof(Routes).Assembly">(noAdditionalAssembliestoday). Add the RCL assembly. - Modify:
~/Desktop/MxAccessGateway/src/ZB.MOM.WW.MxGateway.Server/GatewayApplication.cs— registerAddSecretsAuthorization()near:98(afterAddGatewayDashboard).
Steps:
- Register the authorization policies. In
GatewayApplication.csafterAddGatewayDashboard(:98), add:(Additive — composes with the existingbuilder.Services.Configure<AuthorizationOptions>(o => o.AddSecretsAuthorization());services.AddAuthorization(...)block inDashboardServiceCollectionExtensions.cs:144-159. Addusing ZB.MOM.WW.Secrets.Ui;.) Both approaches in the brief are valid; theConfigure<AuthorizationOptions>form keeps the change out of the dashboard extension. - Endpoint side —
DashboardEndpointRouteBuilderExtensions.cs:133-135:(Preserve the existingendpoints.MapRazorComponents<App>() .AddInteractiveServerRenderMode() .AddAdditionalAssemblies(typeof(ZB.MOM.WW.Secrets.Ui.SecretsPage).Assembly) .RequireAuthorization(ViewerPolicy);.RequireAuthorization(ViewerPolicy)— the page itself carries[Authorize(Policy = ManagePolicy)], so viewers reaching the assembly still get 403 on the page.) - Router side —
Routes.razor:1:(Both registrations are required — endpoint-side for render-mode discovery, Router-side for client-side routing — or<Router AppAssembly="@typeof(Routes).Assembly" AdditionalAssemblies="new[]{ typeof(ZB.MOM.WW.Secrets.Ui.SecretsPage).Assembly }">/admin/secrets404s. Design §3d.) - Build:
Expect: 0 warnings, 0 errors.
dotnet build src/MxGateway.sln -warnaserror - Browser verify (defer full check to Task 8, quick smoke here): run the gateway, navigate to
/admin/secretsunauthenticated → expect redirect to/login; log in as anAdministrator(e.g.multi-role) → expect the secrets metadata list renders. - Commit:
git commit -am "feat(secrets): mount /admin/secrets + AddSecretsAuthorization in the dashboard (G-6)".
Task 7 — Master-key provider config for the NSSM box (G-5) + operator note
Classification: small Estimated implement time: ~4 min Parallelizable with: Task 4, Task 5, Task 6 (depends on 1–3)
MxAccessGateway is a single Windows/NSSM box, no clustering (recon grep clean — no Akka), so G-5 is the simple posture from design §5 (mxaccessgw sub-section): Environment master-key provider, delivering ZB_SECRETS_MASTER_KEY via the NSSM service environment exactly how MxGateway__Ldap__ServiceAccountPassword is already delivered (docs/GatewayConfiguration.md:248). The local SQLite store (default SqlitePath) is fine — single node, no shared-KEK requirement. DPAPI is a documented machine-bound alternative (stronger at-rest binding, but ties the store to one machine and complicates DR restore).
Files:
- Modify:
~/Desktop/MxAccessGateway/docs/GatewayConfiguration.md(add a "Secrets master key" operator section near the existing service-account-password env note at:248) - (No code change —
Secrets.MasterKey.Source=Environment+EnvVarName=ZB_SECRETS_MASTER_KEYwas already set in appsettings in Task 2 step 4.)
Steps:
- Add an operator note to
docs/GatewayConfiguration.mddocumenting:- The gateway now resolves
${secret:}tokens at startup from an encrypted local SQLite store (Secrets:SqlitePath, defaultmxgateway-secrets.dbunder the app dir /C:\ProgramData\MxGateway\...). - The master key is delivered out-of-band via the NSSM service env var
ZB_SECRETS_MASTER_KEY(base64 32 bytes) — never committed, same discipline asMxGateway__Ldap__ServiceAccountPassword. Provide thenssm set <svc> AppEnvironmentExtra ZB_SECRETS_MASTER_KEY=<base64>pattern. - How to seed a secret on the box with the
secretCLI (name → value), pointing at the sameSqlitePathand with the sameZB_SECRETS_MASTER_KEYpresent. - Alternative:
MasterKey.Source=Dpapi(FilePath= machine-bound key file) if a machine-bound key is preferred — note the DR-restore caveat. - Rotation / DR note: losing the KEK makes the store unrecoverable; back up the KEK in the org secret vault. (Full KEK rotation
RewrapAllis G-8, out of scope — see Deferred.)
- The gateway now resolves
- No build needed (docs only). Confirm the appsettings
Secretsblock from Task 2 already namesZB_SECRETS_MASTER_KEY. - Commit:
git commit -am "docs(secrets): G-5 master-key operator note (Environment provider via NSSM env)".
Task 8 — Verification gate: 0-warning build, UI reveal+audit, SignalR unaffected
Classification: standard Estimated implement time: ~5 min Parallelizable with: none (final gate; depends on all)
Files:
- Test/verify only — no source change (fix-forward into the relevant earlier task if something fails).
Steps:
- Full build + test, 0 warnings:
Expect: 0 warnings; all tests green (incl. the new
dotnet build src/MxGateway.sln -warnaserror dotnet test src/MxGateway.Tests/MxGateway.Tests.csprojPreHostSecretExpansiontests). - Boot with real config (secret seeded,
ZB_SECRETS_MASTER_KEYset): gateway starts clean; LDAP login works (proves the expanded LDAP password reaches the realAddZbLdapAuthbind, not just the shadow validator). - UI reveal + audit browser check (design §10):
- Unauthenticated
GET /admin/secrets→ redirect to/login(policy enforced). - Log in as
Administrator→ metadata-only secret list renders; reveal a secret → plaintext shown in the UI; confirm an audit row was written (via the app'sZB.MOM.WW.Auditwriter) carrying no plaintext (the value must not appear in the audit event — HistorianGateway proved this; re-confirm here). - Log in as a
Viewer-only user →/admin/secretsreturns 403 (policy denies non-admins).
- Unauthenticated
- SignalR / HubTokenService regression check (design §8.5 / recon surprise): confirm the dashboard's SignalR-backed live surfaces still work after this change (hub connect succeeds, live tiles update). This proves Data Protection was not disturbed —
HubTokenService.cs:43shares the framework-default DP key ring, and nothing in this plan touched DP. - Repo-clean grep:
grep -rn "serviceaccount123" src→ nothing; no other plaintext credential introduced. - If all green, this is the merge point: FF-merge
feat/adopt-zb-secrets→main, push to Gitea.git switch main && git merge --ff-only feat/adopt-zb-secrets && git push origin main
Testing & rollout
- Offline / CI:
dotnet build src/MxGateway.sln -warnaserror+dotnet test src/MxGateway.Tests/MxGateway.Tests.csproj— 0 warnings, all green includingPreHostSecretExpansion(rewrite works; missing ref fails closed). - Boot smoke + negative control (Task 4/8): seeded secret + KEK → clean boot & working LDAP login; unseed the secret or drop the KEK →
SecretNotFoundException/decrypt-fail at startup (fail-closed proof, mirrors HistorianGateway). - UI (Task 8):
/admin/secrets— login redirect when anonymous, admin sees metadata + reveal + a plaintext-free audit row, viewer gets 403. - SignalR (Task 8): dashboard live surfaces unaffected → confirms DP key ring untouched.
- Box rollout: on the NSSM box, set
ZB_SECRETS_MASTER_KEYvia the service env, seedldap/mxgateway/bind(and any optional Task-5 secrets) with thesecretCLI, deploy the new binaries. Rollback path: reverting a${secret:}token back to the env-var/plaintext override still boots (the env value still binds), so the cutover is reversible per-secret. - Follow the family pattern: commit + review each task, FF-merge
feat/adopt-zb-secrets→main, push toorigin(mxaccessgw), as auth/theme/audit were rolled out.
Deferred / out of scope
Per components/secrets/GAPS.md:
- G-7 — Akka cluster secret replicator / shared-SQL
ISecretStore. N/A for this app (single box, no clustering) beyond the general library gap; no work here. - G-8 —
RewrapAllKEK-rotation tooling. Not attempted; the operator note (Task 7) documents KEK backup + the unrecoverable-if-lost caveat only. - G-2 / G-3 — Layer-B database-resident driver/connection secrets. This app has none (MXAccess/Wonderware creds are runtime pass-through, never stored — CURRENT-STATE §"MXAccess creds"). No Layer-B work.
- Kept bespoke as-is (CURRENT-STATE §"Adoption plan" item 5): the peppered-HMAC API-key store (hashed, not reversibly encrypted — already best-practice) and the ACL-protected, null-password TLS PFX. Not migrated.
- Data Protection is deliberately left on the framework default (SignalR hub tokens only) — not reconfigured (design §8.5).