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.
5.5 KiB
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 library; the proposed public API is in
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.
Target behaviour
-
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."
-
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
nameso a ciphertext row cannot be swapped under another name. KEK rotation re-wraps DEKs only (bodies are never re-encrypted); akek_idrecords which master key wrapped each row. -
Pluggable master key (
IMasterKeyProvider). The KEK is resolved from a config- selected provider: Environment (base64 env var, defaultZB_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. -
Pluggable store (
ISecretStore), SQLite default. A schema-versioned, idempotent migrator (same idiom asSqliteAuthStoreMigrator), schema v1, one row per secret (overwrite-in-place, no value history). The row carriesrevision/updated_utc/is_deletedcolumns now so future cluster sync needs no migration.ListAsyncreturns metadata only — never ciphertext or plaintext. A SQL-Server store is swappable behind the same seam (build when a consumer needs it). -
Two consumption paths.
- App runtime (
ISecretResolver) — decrypts on demand with a short in-memory cache invalidated on rotate/delete. Every resolve writes aZB.MOM.WW.Auditevent (actor + name + outcome — never the value). ${secret:name}config expansion — aSecretReferenceExpanderruns after config load and before options binding/validation, replacing${secret:name}tokens in configuration values. Keeps plaintext out ofappsettings. 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.
- App runtime (
-
Human reveal is separately gated + audited. The UI reveal returns current plaintext behind a dedicated
secrets:revealauthz policy (strictly more privileged thansecrets:manage), audited with the actor. Opt-in per deployment. -
Management UI (
/admin/secrets). A Blazor RCL on theZB.MOM.WW.Themekit the host mounts into its dashboard: metadata-only list, add / rotate (overwrite-in-place) / delete (in-page Theme modal — no JSconfirm/alert), policy-gated audited reveal. -
Fail closed everywhere. GCM tag mismatch →
SecretDecryptionException(tamper / wrong KEK, never returns garbage); unknown secret →SecretNotFoundException/${secret:}startup failure; row wrapped by an unknownkek_id→ fail-closed. Nothing logs plaintext; failures audit asOutcome=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>).