e347286f28
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.
88 lines
5.5 KiB
Markdown
88 lines
5.5 KiB
Markdown
# 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>`).
|