# 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//...`, `ldap//...`, `apikey-pepper/`).