docs(components): normalize Secrets component + index the shared lib

Add components/secrets/ (SPEC, realized shared-contract, code-verified
current-state for OtOpcUa/mxaccessgw/ScadaBridge, GAPS adoption backlog) and
register the Secrets row in CLAUDE.md + components/README.md. The
ZB.MOM.WW.Secrets lib is built + published 0.1.2 + reference-consumer-proven
(HistorianGateway, live vs the wonder historian); per-app adoption is the
tracked follow-on.
This commit is contained in:
Joseph Doherty
2026-07-16 04:49:57 -04:00
parent de77bc30e3
commit e347286f28
9 changed files with 474 additions and 0 deletions
+87
View File
@@ -0,0 +1,87 @@
# 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>`).