Files
scadaproj/docs/plans/2026-07-15-secrets-manager-design.md
T
Joseph Doherty 9df4f9bb84 docs(plans): ZB.MOM.WW.Secrets encrypted secrets manager design
Envelope-encryption (AES-256-GCM) secrets lib + Theme RCL UI + CLI for the
ZB.MOM.WW.* family. Pluggable IMasterKeyProvider (env/file/DPAPI), SQLite behind
an ISecretStore seam, ISecretResolver + ${secret:} config expander, HistorianGateway
as reference consumer. Cluster-sync seam + schema now, Akka replicator deferred.
2026-07-15 16:14:10 -04:00

14 KiB
Raw Blame History

ZB.MOM.WW.Secrets — reusable encrypted secrets manager (design)

Date: 2026-07-15 Status: Design approved (brainstorming), ready for implementation planning Author: Joseph Doherty + Claude

1. Purpose

A new shared library in the ZB.MOM.WW.* family that stores secrets encrypted at rest and returns plaintext on demand, plus a Blazor UI to manage them. It normalizes the ad-hoc secret handling scattered across the family today: ScadaBridge's Data-Protection-encrypted connection strings, peppers/passwords riding in environment variables, and LDAP passwords sitting in appsettings.

Canonical use case: "add a SQL login password, then get the value later, but keep it encrypted for storage."

Lives as plain files under scadaproj/ (like every other ZB.MOM.WW.* lib) — it is not a nested git repo. (CLAUDE.md mis-states the nested-repo convention; do not git init it.)

2. Decisions (from brainstorming)

Axis Decision
Crypto AES-256-GCM envelope encryption — per-secret DEK wrapped by a master KEK
Master key Pluggable IMasterKeyProvider seam with built-in env / file / DPAPI providers
Storage SQLite default behind an ISecretStore seam (SQL Server swappable later)
UI ZB.MOM.WW.Secrets.Ui RCL on the Theme kit, embedded in each app's dashboard
Consumption Both — ISecretResolver for app runtime and audited human reveal; ${secret:name} config expander
Versioning Overwrite-in-place — no value history (tombstones for deletes only, see §8)
Scope Build lib + UI + CLI, wire HistorianGateway as the reference consumer
Cluster sync Seam + schema now, Akka replicator deferred until a clustered app adopts

3. Package decomposition (mirrors ZB.MOM.WW.Auth's shape)

Package Role Depends on
ZB.MOM.WW.Secrets.Abstractions Contracts only: ISecretStore, ISecretResolver, IMasterKeyProvider, ISecretCipher, ISecretReplicator, records / enums / exceptions BCL + DI.Abstractions
ZB.MOM.WW.Secrets Impl: AesGcmEnvelopeCipher, SqliteSecretStore + migrator, DefaultSecretResolver, master-key providers, ${secret:…} config expander, AddZbSecrets(…) Abstractions, Microsoft.Data.Sqlite, ZB.MOM.WW.Audit
ZB.MOM.WW.Secrets.Ui Blazor RCL on ZB.MOM.WW.Theme: list / add / rotate / delete / gated reveal Abstractions, ZB.MOM.WW.Theme, Auth.AspNetCore
ZB.MOM.WW.Secrets.Cli (tool, not packed) secret set / get / list / rm / rotate, like the apikey CLI Secrets
ZB.MOM.WW.Secrets.Akka (deferred) Cluster replication of encrypted rows — design only in this cut, built when a clustered app adopts Secrets, Akka.Cluster

4. Crypto — envelope encryption

  • Master KEK (32 bytes, AES-256) resolved from IMasterKeyProvider; never persisted to the secrets DB.
  • Every write generates a fresh per-write DEK (32 bytes). Plaintext → AES-256-GCM under the DEK → (ciphertext, nonce, tag). The DEK is then wrapped by the KEK (AES-256-GCM) → (wrapped_dek, wrap_nonce, wrap_tag).
  • AAD binding: the secret body is authenticated against name (the AAD) so a ciphertext row cannot be swapped under a different secret name.
  • KEK rotation is cheap: re-wrap each DEK, never re-encrypt bodies. A kek_id column records which master key wrapped each row; an admin op RewrapAll(oldKek, newKek) walks the table. A row whose kek_id is unknown to the current provider fails closed on resolve.
  • All primitives from System.Security.Cryptography.AesGcm and RandomNumberGenerator (BCL, cross-platform, no native deps). 12-byte random nonces, 16-byte tags.

5. Master-key providers (IMasterKeyProvider seam)

public interface IMasterKeyProvider
{
    // 32-byte AES-256 key material; null/throw => fail closed at construction.
    ReadOnlyMemory<byte> GetMasterKey();
    string KekId { get; } // stable identifier of the currently-active KEK
}

Built-in implementations, selected by config (Secrets:MasterKey:Source):

  • EnvironmentMasterKeyProvider — base64 32 bytes from an env var (default ZB_SECRETS_MASTER_KEY). Container/CI friendly; matches how peppers already ride in env.
  • FileMasterKeyProvider — reads a key file (path from config), mountable as a Docker/K8s secret. This is the provider used to satisfy the shared-KEK requirement across a clustered pair (same file mounted on both nodes).
  • DpapiMasterKeyProvider — Windows DPAPI-protected key. Windows-only, guarded behind an OS check; skipped on macOS/Docker Linux.

Mirrors the family's existing IApiKeyPepperProvider / IGroupRoleMapper<TRole> seam pattern. Fail-closed: a missing/invalid key throws MasterKeyUnavailableException at construction so the app won't boot half-blind (same posture as ApiKeyVerifier on a missing pepper).

6. Storage (ISecretStore seam, SQLite default)

public interface ISecretStore
{
    Task<StoredSecret?> GetAsync(SecretName name, CancellationToken ct);
    Task UpsertAsync(StoredSecret row, CancellationToken ct);       // overwrite-in-place
    Task<bool> DeleteAsync(SecretName name, CancellationToken ct);  // soft delete (tombstone)
    Task<IReadOnlyList<SecretMetadata>> ListAsync(CancellationToken ct); // metadata only, no ciphertext
    // Replication support (see §8):
    Task<IReadOnlyList<SecretManifestEntry>> GetManifestAsync(CancellationToken ct);
    Task ApplyReplicatedAsync(StoredSecret row, CancellationToken ct);   // LWW upsert
}

SqliteSecretStore with a schema-versioned, idempotent migrator (exactly like SqliteAuthStoreMigrator / SqliteAuthSchema), schema v1.

Table secret (one row per secret — overwrite-in-place, no value-history table):

Column Notes
name TEXT PK, canonical namespaced key e.g. sql/historiangw/historian-password
description TEXT
content_type TEXT — text | connection-string | json | binary-base64
ciphertext, nonce, tag BLOB — AES-GCM under the DEK
wrapped_dek, wrap_nonce, wrap_tag BLOB — DEK wrapped by the KEK
kek_id TEXT — which master key wrapped this row
revision INTEGER — per-row monotonic counter (bumped every write); conflict resolution
is_deleted, deleted_utc tombstone (soft delete) — propagates deletes across a cluster
created_utc, updated_utc, created_by, updated_by audit columns

schema_version table/pragma drives the idempotent migrator.

The revision / updated_utc / is_deleted columns exist now even though the Akka replicator is deferred — they're the cheap future-proofing so cluster sync needs no schema migration later (§8). They are not value-history: values are still overwritten in place; only a delete-marker survives.

7. Consumption

7a. App runtime — ISecretResolver

public interface ISecretResolver
{
    Task<string?> GetAsync(SecretName name, CancellationToken ct);
    Task<bool> TryGetAsync(SecretName name, out string value, CancellationToken ct);
}

DefaultSecretResolver decrypts via the cipher + KEK, with a small in-memory cache invalidated on rotate/delete. Every resolve writes a ZB.MOM.WW.Audit event (actor + secret name + outcome — never the value).

7b. Config integration — ${secret:name} expander

A SecretReferenceExpander runs after configuration load and before options validation, expanding ${secret:name} tokens in bound config:

"ConnectionStrings:Historian": "Server=wonder-sql-vd03,32565;...;Password=${secret:sql/historiangw/historian-password}"

Keeps plaintext out of appsettings. Fail-closed: a referenced-but-missing secret stops startup with a clear error naming the token.

7c. Human reveal

The UI "reveal" action returns the current plaintext, gated behind a dedicated secrets:reveal authz policy (strictly more privileged than secrets:manage), and writes an audit event with the actor. Reveal is opt-in per deployment.

8. Clustered pairs — syncing local secrets (design; impl deferred)

Applies to the Akka-clustered apps (ScadaBridge, OtOpcUa) — not the reference consumer HistorianGateway (single-process sidecar). With per-node local SQLite, a secret added/rotated on node A leaves node B stale.

Enabler: because each row is ciphertext + a KEK-wrapped DEK, whole rows can be shipped over the cluster wire without exposing plaintext or the KEK — the transport need not be trusted with secrets.

Hard operational constraint: every node in a pair MUST resolve the same master KEK (same env var / same mounted key file). A replicated row whose kek_id doesn't match the local provider fails closed on resolve ("row wrapped by unknown KEK") rather than returning garbage. Documented as a deployment requirement for clustered adopters; FileMasterKeyProvider with a shared mounted key is the intended path.

Mechanism — optional ZB.MOM.WW.Secrets.Akka package (core stays Akka-free, exactly like ZB.MOM.WW.Health.Akka), driving an ISecretReplicator seam defined in Abstractions (no-op default in core):

  • On write (any node): bump revision + updated_utc, broadcast a SecretReplicationEnvelope (the encrypted row only) over Akka DistributedPubSub; peers ApplyReplicatedAsync.
  • Conflict resolution: last-writer-wins by (updated_utc, revision, node-id tiebreak) — deterministic on every node. No singleton writer bottleneck.
  • Anti-entropy resync on node join / rejoin / cold start: exchange a compact (name → revision) manifest with a peer, pull rows where the peer is ahead, push where local is ahead. Heals partitions and freshly-recreated nodes.
  • Deletes = tombstones. A hard delete would be resurrected from a peer on next resync, so deletes are soft (is_deleted + deleted_utc), replicated, and reaped after a TTL.

Simpler alternative available via the seam: a clustered app may instead point ISecretStore at a shared SQL Server store — one source of truth, no replication (the shared DB still holds only ciphertext, since the KEK stays per-node). This is the usual best-practice answer for clustered state; app-level replication is for when a shared datastore isn't available.

First-cut scope: ship the revision / updated_utc / is_deleted columns + ISecretReplicator no-op seam + manifest/apply store methods now; build the real ZB.MOM.WW.Secrets.Akka replicator when a clustered app adopts (it's dead code for HistorianGateway).

9. UI (RCL on Theme)

Routable /admin/secrets page the host mounts into its dashboard, styled with Theme tokens / side-rail. Features:

  • List — name, description, content-type, kek_id, created/updated_utc/_by. Metadata only; never the value.
  • Add — name + description + content-type + value (masked input).
  • Rotate — enter a new value → overwrite-in-place (bumps revision).
  • Delete — in-page Theme modal confirm (no JS confirm/alert — browser-dialog hazard).
  • Reveal — opt-in, secrets:reveal-gated, audited.

Two permissions wired through Auth.AspNetCore claims: secrets:manage (CRUD) vs secrets:reveal (see plaintext).

10. Error handling — fail closed everywhere

  • Missing/invalid master key → MasterKeyUnavailableException at construction (app won't boot half-blind).
  • GCM tag mismatch on decrypt → SecretDecryptionException (tamper / wrong KEK) — never returns garbage.
  • Unknown secret on resolve → SecretNotFoundException / TryGet false; ${secret:} expansion fails startup naming the token.
  • Row wrapped by an unknown kek_id → fail-closed SecretDecryptionException.
  • Nothing logs plaintext; failures audit as Outcome=Failure.

11. Testing

  • Crypto/unit (xUnit): cipher round-trip; tamper detection (flip a byte → throws); AAD name-binding (swap rows → throws); KEK re-wrap; provider selection (env/file, DPAPI Windows-guarded); resolver cache invalidation on rotate/delete; ${secret:} expansion + fail-closed.
  • Store: SQLite migrator idempotency; schema v1 CRUD; soft-delete tombstone; manifest / ApplyReplicated LWW semantics.
  • UI (bUnit): list renders metadata-only (asserts value never appears in markup); reveal gated by policy; add / rotate / delete flows.
  • All offline / cross-platform (macOS / Windows / Docker) — AesGcm + Sqlite are portable; DPAPI provider guarded behind an OS check.

12. Reference consumer — HistorianGateway

Wire HistorianGateway to source its two SQL passwords (historian wonder-sql-vd03 + Galaxy SQL) via ${secret:…}, seeded through the UI/CLI — proving both the runtime-resolve path and the UI end-to-end in one place. Retains a plain-text fallback path only for local dev where no master key is provisioned (fail-closed otherwise).

13. Out of scope (this cut)

  • The ZB.MOM.WW.Secrets.Akka cluster replicator implementation (design only; §8).
  • SQL Server ISecretStore provider (seam supports it; build when a consumer needs it).
  • Full components/secrets/ normalization (spec / current-state ×3 / GAPS) and all-app adoption — a tracked follow-on, like Health/Telemetry were "Built" before "Adopted".
  • The secure-by-default / KEK-rotation runbook automation beyond the RewrapAll primitive.

14. Component-normalization follow-on

After the lib + reference consumer land, add components/secrets/ (spec, shared-contract, current-state per app documenting today's ad-hoc handling, GAPS backlog) and adopt across ScadaBridge / OtOpcUa / mxaccessgw — at which point the ZB.MOM.WW.Secrets.Akka replicator gets built for the clustered adopters.