Files
scadaproj/docs/plans/2026-07-16-secrets-adoption-mxaccessgw.md
T
Joseph Doherty 62a96e5f22 docs(secrets): reconcile 0.2.1 adoption - what was proven vs merely built
- 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.
2026-07-18 12:21:31 -04:00

348 lines
28 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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**; the `0.1.2` pins 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.md`](2026-07-18-secrets-0.2.0-upgrade-and-clustering.md)
> Task 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.0` carries a high-severity
> transitive `SQLitePCLRaw` advisory fix that `0.1.2` lacks.
> **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`](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`](../../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.
```bash
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 13. Task 5 (optional Galaxy/pepper) depends on 4. Task 6 (UI mount) depends only on 13 and is **parallelizable with 4/5**. Task 7 (master-key operator config) is docs/appsettings only, parallelizable with 46. 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/reconfigure `AddDataProtection`** anywhere 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 28)
**Files:**
- Modify: `~/Desktop/MxAccessGateway/nuget.config:24` (add two `<package pattern>` rows after the last existing ZB pattern; the file has explicit `packageSourceMapping` at `:10-26`, source key `dohertj2-gitea` at `:6`, **no** `ZB.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 three `PackageReference`s)
**Steps:**
1. In `nuget.config`, under the `dohertj2-gitea` `<packageSource>` mapping block (after the existing `ZB.MOM.WW.GalaxyRepository` pattern at line 24), add:
```xml
<package pattern="ZB.MOM.WW.Secrets" />
<package pattern="ZB.MOM.WW.Secrets.*" />
```
(The second pattern covers `.Abstractions` and `.Ui`.)
2. In `ZB.MOM.WW.MxGateway.Server.csproj`, add to the existing `ZB.MOM.WW.*` `PackageReference` `ItemGroup`:
```xml
<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" />
```
(`.Ui` transitively pulls `ZB.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.)
3. Restore + build to prove the feed resolves the three packages:
```bash
dotnet restore src/MxGateway.sln
dotnet build src/MxGateway.sln -warnaserror
```
**Expect:** clean restore (packages pulled from `dohertj2-gitea`), 0 warnings, 0 errors. If restore 404s, re-check the two nuget.config patterns and that `$GITEA_TOKEN`/feed creds are present.
4. 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 38)
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` — method `CreateBuilder(string[] args)` at `:58`; builder-creation chain `:60-64`; `ConfigureSelfSignedTls(builder)` at `:67`. **Insert the expander block between `:64` and `:67`** (after the builder is created, before TLS reads config), so even TLS/Kestrel values can carry tokens.
- Test: `~/Desktop/MxAccessGateway/src/MxGateway.Tests/…` (add `PreHostSecretExpansionTests`, 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:**
1. **Write test first.** Add `PreHostSecretExpansionTests` that constructs a config with a `${secret:test/value}` token and a seeded fake/temp SQLite store, runs the same `SecretReferenceExpander` flow, and asserts the token is rewritten to the seeded plaintext; and a second test asserting an unseeded `${secret:missing}` throws `SecretNotFoundException`. (If the test project cannot easily reach a private `CreateBuilder`, test the `SecretReferenceExpander`/`AddZbSecrets` flow directly against a temp `SqlitePath` — the same code the host runs.)
```bash
dotnet test src/MxGateway.Tests/MxGateway.Tests.csproj --filter "FullyQualifiedName~PreHostSecretExpansion"
```
**Expect:** fails to compile / red (expander not yet wired).
2. 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`, plus `Microsoft.Extensions.DependencyInjection` / `Microsoft.Extensions.Configuration` if not already present.
3. Insert the expander block between line 64 and line 67 (the design §3a template, verbatim). Note `CreateBuilder` is synchronous today — make it `async` (return `Task<…>` / `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 of `CreateBuilder` before choosing; if it is already in an async context, prefer real `await`.
```csharp
#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();
}
```
(Use `await using` + `await` if you made the method async — either is fine; keep the `#pragma`.)
4. Add the `Secrets` block to `src/ZB.MOM.WW.MxGateway.Server/appsettings.json` (design §1 shape):
```json
"Secrets": {
"SqlitePath": "mxgateway-secrets.db",
"MasterKey": { "Source": "Environment", "EnvVarName": "ZB_SECRETS_MASTER_KEY" },
"RunMigrationsOnStartup": true,
"ResolveCacheTtl": "00:00:30"
}
```
5. Build + run the two tests:
```bash
dotnet build src/MxGateway.sln -warnaserror
dotnet test src/MxGateway.Tests/MxGateway.Tests.csproj --filter "FullyQualifiedName~PreHostSecretExpansion"
```
**Expect:** 0 warnings; both tests green (rewrite works, missing-ref throws).
6. 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 48)
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 (alongside `AddGatewayConfiguration` at `:71` / `AddGatewayDashboard` at `:98`).
**Steps:**
1. Add, on the real host's `builder.Services` (after `AddGatewayConfiguration`, before/near `AddGatewayDashboard`):
```csharp
builder.Services.AddZbSecrets(builder.Configuration, "Secrets");
```
2. Build:
```bash
dotnet build src/MxGateway.sln -warnaserror
```
**Expect:** 0 warnings, 0 errors.
3. 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 13)
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:**
1. Change `appsettings.json:29` to:
```json
"ServiceAccountPassword": "${secret:ldap/mxgateway/bind}",
```
2. Blank the hardcoded default at `LdapOptions.cs:61`:
```csharp
public string ServiceAccountPassword { get; init; } = string.Empty;
```
(Non-blank enforcement now lives entirely in the validator + fail-closed expansion; the leaked literal is gone.)
3. **Seed the secret** into the local store via the `secret` CLI (`ZB.MOM.WW.Secrets.Cli`) before booting — the dev LDAP bind password is the current dev value (`serviceaccount123` for the shared GLAuth, or the box's real value). Set `ZB_SECRETS_MASTER_KEY` (base64 32 bytes) and point the CLI at the same `SqlitePath` the app uses (`mxgateway-secrets.db`):
```bash
export 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'
```
(Confirm the exact CLI verb/flags from the `ZB.MOM.WW.Secrets.Cli` help — design §1 references a `set`/seed verb; the store path and `ZB_SECRETS_MASTER_KEY` must match what the app resolves with.)
4. **Boot smoke** — start the gateway with the key present and the secret seeded:
```bash
dotnet run --project src/ZB.MOM.WW.MxGateway.Server/ZB.MOM.WW.MxGateway.Server.csproj
```
**Expect:** clean startup — `GatewayOptionsValidator` + `AddZbLdapAuth` `ValidateOnStart` both pass (password non-blank because the token expanded to the seeded value). Dashboard reachable; LDAP login works against the shared GLAuth.
5. **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.
6. Confirm no plaintext `serviceaccount123` remains in the repo:
```bash
grep -rn "serviceaccount123" ~/Desktop/MxAccessGateway/src || echo "clean"
```
**Expect:** `clean` (both the appsettings literal and the LdapOptions default are gone).
7. 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, section `MxGateway:Galaxy`, bound by `AddZbGalaxyRepository(...,"MxGateway:Galaxy")` at `GatewayApplication.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:**
1. **Galaxy connstr:** the current value is `Integrated Security=True` (no password today), but it is a raw connstr that would carry `Password=` 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. set `appsettings.json:76` to `"${secret:sql/mxgateway/galaxy}"` and seed `sql/mxgateway/galaxy` with 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 before `AddZbGalaxyRepository` (`:103`), so no ordering work is needed.
2. **API-key pepper (SEC-10):** to close the "no dedicated env reader" gap while keeping the pepper out of appsettings, add under `MxGateway`:
```json
"ApiKeyPepper": "${secret:apikey-pepper/mxgateway}",
```
then seed `apikey-pepper/mxgateway`. Because `AddZbApiKeyAuth` resolves the pepper by config-key name (`:70`) after the expander runs, the token resolves transparently. **Caveat:** if the box already supplies `MxGateway__ApiKeyPepper` via 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.
3. Seed whichever secrets you introduced (as in Task 4 step 3), then boot-smoke + negative-control the same way.
4. Build + confirm clean:
```bash
dotnet build src/MxGateway.sln -warnaserror
```
5. 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 13)
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` — the `endpoints.MapRazorComponents<App>().AddInteractiveServerRenderMode().RequireAuthorization(ViewerPolicy)` chain (inside `MapGatewayDashboard` at `:56`, invoked from `GatewayApplication.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">` (no `AdditionalAssemblies` today). Add the RCL assembly.
- Modify: `~/Desktop/MxAccessGateway/src/ZB.MOM.WW.MxGateway.Server/GatewayApplication.cs` — register `AddSecretsAuthorization()` near `:98` (after `AddGatewayDashboard`).
**Steps:**
1. **Register the authorization policies.** In `GatewayApplication.cs` after `AddGatewayDashboard` (`:98`), add:
```csharp
builder.Services.Configure<AuthorizationOptions>(o => o.AddSecretsAuthorization());
```
(Additive — composes with the existing `services.AddAuthorization(...)` block in `DashboardServiceCollectionExtensions.cs:144-159`. Add `using ZB.MOM.WW.Secrets.Ui;`.) Both approaches in the brief are valid; the `Configure<AuthorizationOptions>` form keeps the change out of the dashboard extension.
2. **Endpoint side** — `DashboardEndpointRouteBuilderExtensions.cs:133-135`:
```csharp
endpoints.MapRazorComponents<App>()
.AddInteractiveServerRenderMode()
.AddAdditionalAssemblies(typeof(ZB.MOM.WW.Secrets.Ui.SecretsPage).Assembly)
.RequireAuthorization(ViewerPolicy);
```
(Preserve the existing `.RequireAuthorization(ViewerPolicy)` — the page itself carries `[Authorize(Policy = ManagePolicy)]`, so viewers reaching the assembly still get 403 on the page.)
3. **Router side** — `Routes.razor:1`:
```razor
<Router AppAssembly="@typeof(Routes).Assembly"
AdditionalAssemblies="new[]{ typeof(ZB.MOM.WW.Secrets.Ui.SecretsPage).Assembly }">
```
(Both registrations are required — endpoint-side for render-mode discovery, Router-side for client-side routing — or `/admin/secrets` 404s. Design §3d.)
4. Build:
```bash
dotnet build src/MxGateway.sln -warnaserror
```
**Expect:** 0 warnings, 0 errors.
5. **Browser verify** (defer full check to Task 8, quick smoke here): run the gateway, navigate to `/admin/secrets` unauthenticated → expect redirect to `/login`; log in as an `Administrator` (e.g. `multi-role`) → expect the secrets metadata list renders.
6. 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 13)
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_KEY` was already set in appsettings in Task 2 step 4.)
**Steps:**
1. Add an operator note to `docs/GatewayConfiguration.md` documenting:
- The gateway now resolves `${secret:}` tokens at startup from an encrypted local SQLite store (`Secrets:SqlitePath`, default `mxgateway-secrets.db` under 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 as `MxGateway__Ldap__ServiceAccountPassword`. Provide the `nssm set <svc> AppEnvironmentExtra ZB_SECRETS_MASTER_KEY=<base64>` pattern.
- How to seed a secret on the box with the `secret` CLI (name → value), pointing at the same `SqlitePath` and with the same `ZB_SECRETS_MASTER_KEY` present.
- 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 `RewrapAll` is G-8, out of scope — see Deferred.)
2. No build needed (docs only). Confirm the appsettings `Secrets` block from Task 2 already names `ZB_SECRETS_MASTER_KEY`.
3. 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:**
1. **Full build + test, 0 warnings:**
```bash
dotnet build src/MxGateway.sln -warnaserror
dotnet test src/MxGateway.Tests/MxGateway.Tests.csproj
```
**Expect:** 0 warnings; all tests green (incl. the new `PreHostSecretExpansion` tests).
2. **Boot with real config** (secret seeded, `ZB_SECRETS_MASTER_KEY` set): gateway starts clean; LDAP login works (proves the expanded LDAP password reaches the real `AddZbLdapAuth` bind, not just the shadow validator).
3. **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's `ZB.MOM.WW.Audit` writer) **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/secrets` returns 403 (policy denies non-admins).
4. **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:43` shares the framework-default DP key ring, and nothing in this plan touched DP.
5. **Repo-clean grep:** `grep -rn "serviceaccount123" src` → nothing; no other plaintext credential introduced.
6. If all green, this is the merge point: FF-merge `feat/adopt-zb-secrets` → `main`, push to Gitea.
```bash
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 including `PreHostSecretExpansion` (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_KEY` via the service env, seed `ldap/mxgateway/bind` (and any optional Task-5 secrets) with the `secret` CLI, 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 to `origin` (mxaccessgw), as auth/theme/audit were rolled out.
## Deferred / out of scope
Per [`components/secrets/GAPS.md`](../../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** — `RewrapAll` KEK-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).