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:
@@ -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>`).
|
||||
Reference in New Issue
Block a user