# Secrets — normalized target (SPEC)

The **one target** for how the `ZB.MOM.WW.*` family stores and consumes secrets
(SQL/login passwords, API-key HMAC peppers, LDAP bind passwords, connection strings,
TLS/cert material). Every app should converge on this. Realized as the shared
[`ZB.MOM.WW.Secrets`](../../../ZB.MOM.WW.Secrets/) library; the proposed public API is in
[`shared-contract/ZB.MOM.WW.Secrets.md`](../shared-contract/ZB.MOM.WW.Secrets.md).

This normalizes today's ad-hoc handling: ScadaBridge's Data-Protection-encrypted
connection strings, peppers/passwords riding in environment variables, and LDAP
passwords sitting in `appsettings`. See the design doc
[`docs/plans/2026-07-15-secrets-manager-design.md`](../../../docs/plans/2026-07-15-secrets-manager-design.md).

## Target behaviour

1. **Encrypted at rest, plaintext on demand.** A secret is stored encrypted and returned
   as plaintext only through an audited resolve. The canonical use case is *"add a SQL
   login password, get the value later, but keep it encrypted for storage."*

2. **AES-256-GCM envelope encryption.** Every write generates a fresh per-secret **DEK**;
   plaintext → AES-256-GCM under the DEK. The DEK is then **wrapped** by a master **KEK**
   (also AES-256-GCM). The KEK is **never** persisted to the store. The secret body is
   AAD-bound to its `name` so a ciphertext row cannot be swapped under another name. KEK
   rotation re-wraps DEKs only (bodies are never re-encrypted); a `kek_id` records which
   master key wrapped each row.

3. **Pluggable master key (`IMasterKeyProvider`).** The KEK is resolved from a config-
   selected provider: **Environment** (base64 env var, default `ZB_SECRETS_MASTER_KEY`),
   **File** (mountable Docker/K8s secret — the shared-KEK path for clustered pairs), or
   **DPAPI** (Windows-only, OS-guarded). A missing/invalid key fails closed at construction
   (`MasterKeyUnavailableException`) — the app does not boot half-blind.

4. **Pluggable store (`ISecretStore`), SQLite default.** A schema-versioned, idempotent
   migrator (same idiom as `SqliteAuthStoreMigrator`), schema **v1**, one row per secret
   (**overwrite-in-place, no value history**). The row carries `revision` / `updated_utc` /
   `is_deleted` columns **now** so future cluster sync needs no migration. `ListAsync`
   returns **metadata only — never ciphertext or plaintext.** A SQL-Server store is
   swappable behind the same seam (build when a consumer needs it).

5. **Two consumption paths.**
   - **App runtime (`ISecretResolver`)** — decrypts on demand with a short in-memory cache
     invalidated on rotate/delete. **Every resolve writes a `ZB.MOM.WW.Audit` event**
     (actor + name + outcome — **never the value**).
   - **`${secret:name}` config expansion** — a `SecretReferenceExpander` runs **after**
     config load and **before** options binding/validation, replacing `${secret:name}`
     tokens in configuration values. Keeps plaintext out of `appsettings`. **Fail-closed**:
     a referenced-but-missing secret stops startup naming the token. Documentation/comment
     keys (leaf segment starting with `_`, e.g. `_comment`) are skipped so an example token
     in a comment never resolves.

6. **Human reveal is separately gated + audited.** The UI reveal returns current plaintext
   behind a dedicated `secrets:reveal` authz policy (strictly more privileged than
   `secrets:manage`), audited with the actor. Opt-in per deployment.

7. **Management UI (`/admin/secrets`).** A Blazor RCL on the `ZB.MOM.WW.Theme` kit the host
   mounts into its dashboard: metadata-only list, add / rotate (overwrite-in-place) /
   delete (in-page Theme modal — **no** JS `confirm`/`alert`), policy-gated audited reveal.

8. **Fail closed everywhere.** GCM tag mismatch → `SecretDecryptionException` (tamper /
   wrong KEK, never returns garbage); unknown secret → `SecretNotFoundException` /
   `${secret:}` startup failure; row wrapped by an unknown `kek_id` → fail-closed. Nothing
   logs plaintext; failures audit as `Outcome=Failure`.

## Clustered pairs (design now, replicator deferred)

Applies to the Akka-clustered apps (**ScadaBridge**, **OtOpcUa**) — not the single-process
HistorianGateway. Because each row is ciphertext + a KEK-wrapped DEK, whole rows can ship
over the cluster wire without exposing plaintext or the KEK. The seam (`ISecretReplicator`,
no-op default) + schema columns (`revision`/`updated_utc`/`is_deleted`) + store methods
(`GetManifestAsync`/`ApplyReplicatedAsync`, LWW) exist now; the `ZB.MOM.WW.Secrets.Akka`
replicator is built when a clustered app adopts. **Hard constraint:** every node in a pair
MUST resolve the **same master KEK** (shared env var or shared mounted key file) — a
replicated row whose `kek_id` doesn't match the local provider fails closed on resolve.
The simpler best-practice alternative available via the same seam: point `ISecretStore` at a
**shared SQL-Server store** (one source of truth; still ciphertext-only since the KEK stays
per-node).

## What stays per-project (not normalized)

- The **authz vocabulary** mapping onto `secrets:manage` / `secrets:reveal` (each app maps
  its own roles/groups — OPC-UA permissions / gRPC scopes / cluster roles).
- **Which** secrets each app moves behind the store first (its highest-value credential),
  and the pace of migrating the rest off plaintext env/appsettings.
- The **master-key provider** each deployment picks (env in containers, file for clustered
  pairs, DPAPI on a Windows box).
- App-specific content-type conventions and secret-name namespaces (`sql/<app>/...`,
  `ldap/<app>/...`, `apikey-pepper/<app>`).
