Files
scadaproj/components/secrets/spec/SPEC.md
T
Joseph Doherty e347286f28 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.
2026-07-16 04:49:57 -04:00

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

  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>).