# 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 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/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 2–8)

**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 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` — 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 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 (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 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:**
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 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` — 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 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_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).
