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:
@@ -143,6 +143,7 @@ each project's **code-verified current state**, and the **gaps** between. See
|
||||
| Config + validation (options / startup validation) | Adopted (lib `0.1.0`; all 3 apps, local) | Shared `ZB.MOM.WW.Configuration` lib | [`components/configuration/`](components/configuration/) | [`ZB.MOM.WW.Configuration/`](ZB.MOM.WW.Configuration/) |
|
||||
| Audit (event model + writer seam) | Adopted (lib `0.1.0`; all 3 apps, merged to **local default** main/master + **pushed to origin** (gitea)) | Shared `ZB.MOM.WW.Audit` lib | [`components/audit/`](components/audit/) | [`ZB.MOM.WW.Audit/`](ZB.MOM.WW.Audit/) |
|
||||
| Galaxy Repository (object-hierarchy SQL browse + gRPC service) | Built (lib `0.1.0`, **published to the Gitea feed**; consumed by HistorianGateway as a feed `PackageReference`) | Shared `ZB.MOM.WW.GalaxyRepository` lib | _(design in histsdk + design doc 2026-06-23)_ | [`ZB.MOM.WW.GalaxyRepository/`](ZB.MOM.WW.GalaxyRepository/) |
|
||||
| Secrets (encrypted store + `${secret:}` resolution) | Built (libs `0.1.2`, **published to the Gitea feed**; **HistorianGateway adopted + live-proven** 2026-07-16; 3 apps not yet adopted) | Shared `ZB.MOM.WW.Secrets` lib (3 packages + CLI) | [`components/secrets/`](components/secrets/) | [`ZB.MOM.WW.Secrets/`](ZB.MOM.WW.Secrets/) |
|
||||
|
||||
The auth component is fully populated: a normalized [`spec`](components/auth/spec/SPEC.md), a
|
||||
proposed [`shared-contract`](components/auth/shared-contract/ZB.MOM.WW.Auth.md), three
|
||||
|
||||
@@ -23,6 +23,7 @@ specs and analyses that *drive* changes made in the individual repos.
|
||||
| Observability (metrics / traces / logs) | Draft | OtOpcUa, MxAccessGateway, ScadaBridge | Shared `ZB.MOM.WW.Telemetry` lib (2 packages) | [`observability/`](observability/) |
|
||||
| Config + validation (options / startup validation) | Draft | OtOpcUa, MxAccessGateway, ScadaBridge | Shared `ZB.MOM.WW.Configuration` lib (1 package) | [`configuration/`](configuration/) |
|
||||
| Audit (event model + writer seam) | Draft | OtOpcUa, MxAccessGateway, ScadaBridge | Path to shared code (`ZB.MOM.WW.Audit`) | [`audit/`](audit/) |
|
||||
| Secrets (encrypted store + `${secret:}` resolution) | Built + reference-consumer adopted | OtOpcUa, MxAccessGateway, ScadaBridge | Shared `ZB.MOM.WW.Secrets` lib (3 packages + CLI) | [`secrets/`](secrets/) |
|
||||
|
||||
> Add a row when you start normalizing a new component. Status: `Draft` → `Reviewed` → `Adopting` → `Converged`.
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
# Secrets — GAPS (adoption backlog)
|
||||
|
||||
The delta between each project's [`current-state`](current-state/) and the
|
||||
[`SPEC`](spec/SPEC.md), as prioritized work. The library itself is **built + published +
|
||||
reference-consumer-proven**; everything here is per-app adoption of an existing lib (not lib
|
||||
construction).
|
||||
|
||||
## Cross-cutting observations
|
||||
|
||||
- **No app has `${secret:}` resolution today.** All three assemble config with the stock
|
||||
`AddJsonFile`+`AddEnvironmentVariables` chain and read secrets verbatim. Two already lean
|
||||
on the *pattern* the expander formalizes: ScadaBridge's non-functional `${SCADABRIDGE_*}`
|
||||
placeholders (whole-key env override) and OtOpcUa's driver-level `GalaxySecretRef`
|
||||
(`env:`/`file:`/`dev:`/literal). These are the natural first swaps.
|
||||
- **The common high-value gap is plaintext credentials in appsettings/env:** LDAP
|
||||
service-account passwords (all three), SQL connection-string passwords (OtOpcUa ConfigDb,
|
||||
ScadaBridge ConfigDb + MachineDataDb), JWT/HS256 signing keys (OtOpcUa, ScadaBridge), and
|
||||
API-key peppers (all three, runtime-supplied but plaintext).
|
||||
- **Secrets already stored in a DB in plaintext are the sharpest edge:** OtOpcUa's OpcUaClient
|
||||
`Password`/`UserCertificatePassword` and the `dev:`/literal Galaxy API key live cleartext in
|
||||
the central config DB; ScadaBridge's MxGateway per-endpoint `ApiKey` lives plaintext in the
|
||||
ConfigDb `DataConnections` JSON. These leak at rest to anyone with DB read.
|
||||
- **Existing at-rest crypto is Data Protection, and only ScadaBridge applies it to secrets**
|
||||
(4 encrypted ConfigDb columns via `EncryptedStringConverter`). OtOpcUa/mxaccessgw use Data
|
||||
Protection only for cookies/tokens. None use DPAPI or `ProtectKeysWith*`; key rings are
|
||||
unencrypted-at-rest. `ZB.MOM.WW.Secrets` adds envelope encryption with a KEK held **outside**
|
||||
the store — a stronger boundary than "the DB is the only protection."
|
||||
- **Peppered-HMAC API-key stores stay bespoke** (mxaccessgw + ScadaBridge): they are hashed,
|
||||
not reversibly stored — already correct. Secrets only takes over the *pepper value* supply.
|
||||
|
||||
## Backlog (prioritized)
|
||||
|
||||
### G-1 — HistorianGateway reference consumer ✅ DONE
|
||||
Adopted + live-proven 2026-07-16 (historian password via `${secret:}`, authenticated read
|
||||
against the real wonder historian). This is the wiring template for the three apps.
|
||||
|
||||
### G-2 — OtOpcUa: cleartext-in-DB driver secrets (highest value)
|
||||
Add a `secret:` arm to `GalaxySecretRef` (its own comment anticipates this) and to the
|
||||
OpcUaClient driver options, resolving through `ISecretResolver` — retire the `dev:`/literal
|
||||
and plaintext `Password`/`UserCertificatePassword` DB paths.
|
||||
|
||||
### G-3 — ScadaBridge: MxGateway `ApiKey` plaintext-in-ConfigDb
|
||||
Resolve via `ISecretResolver` or extend the existing `EncryptedStringConverter` to that JSON
|
||||
column.
|
||||
|
||||
### G-4 — Pre-host `${secret:}` for plaintext config secrets (all three)
|
||||
LDAP passwords, SQL connstr passwords, JWT signing keys, deploy API key (OtOpcUa),
|
||||
peppers — swap to `${secret:…}` tokens, expander running **before** each app's existing
|
||||
validator (`ConfigPreflight` / `Ldap`+`OpcUa` validators / `GatewayOptionsValidator`).
|
||||
Delete committed dev plaintext and the loose `*_login.txt` files (ScadaBridge).
|
||||
|
||||
### G-5 — Master-key provider per deployment
|
||||
Env (`ZB_SECRETS_MASTER_KEY`) for containers; DPAPI for the Windows boxes; **File (shared
|
||||
mounted key)** for clustered pairs (hard requirement — same KEK on every node).
|
||||
|
||||
### G-6 — Mount `/admin/secrets` UI (all three)
|
||||
Add the RCL page to each dashboard (OtOpcUa AdminUI, MxGateway Server, ScadaBridge CentralUI)
|
||||
and map each app's admin role onto `secrets:manage` / `secrets:reveal`.
|
||||
|
||||
### G-7 — Clustered replication (ScadaBridge, OtOpcUa) — build `ZB.MOM.WW.Secrets.Akka`
|
||||
Deferred until G-2…G-6 land for a clustered app. Options per SPEC: shared SQL-Server
|
||||
`ISecretStore` (simplest — mirrors the shared Data-Protection key ring) **or** the Akka
|
||||
replicator (LWW, anti-entropy resync, tombstones). Requires a shared KEK.
|
||||
|
||||
### G-8 — KEK-rotation runbook + `RewrapAll`
|
||||
The `RewrapAll(oldKek, newKek)` admin primitive + an operator runbook. Deferred (design-only
|
||||
in this cut).
|
||||
|
||||
## Out of scope (this component)
|
||||
|
||||
- Akka remoting authentication/TLS (ScadaBridge plain TCP remoting) — a separate hardening
|
||||
concern, not secret storage.
|
||||
- SQL-Server `ISecretStore` provider construction (seam supports it; build with G-7).
|
||||
@@ -0,0 +1,38 @@
|
||||
# Secrets (encrypted secret store + `${secret:}` resolution)
|
||||
|
||||
Normalizes how the family stores and consumes secrets — SQL/login passwords, API-key HMAC
|
||||
peppers, LDAP bind passwords, connection strings, TLS material — which are handled ad-hoc
|
||||
and inconsistently across the three apps today (Data-Protection-encrypted connection
|
||||
strings in ScadaBridge; peppers/passwords in environment variables; LDAP passwords in
|
||||
`appsettings`).
|
||||
|
||||
The goal is the shared **`ZB.MOM.WW.Secrets`** library: AES-256-GCM envelope encryption at
|
||||
rest, a pluggable master-key provider and store, an audited `ISecretResolver` +
|
||||
`${secret:name}` config expander for app runtime, and a Blazor `/admin/secrets` management
|
||||
UI. The library is **built, published (0.1.2), and live-proven** via its reference consumer;
|
||||
per-app adoption is the tracked follow-on.
|
||||
|
||||
- **Target:** [`spec/SPEC.md`](spec/SPEC.md)
|
||||
- **Realized API:** [`shared-contract/ZB.MOM.WW.Secrets.md`](shared-contract/ZB.MOM.WW.Secrets.md)
|
||||
- **Implementation:** [`../../ZB.MOM.WW.Secrets/`](../../ZB.MOM.WW.Secrets/) · design [`docs/plans/2026-07-15-secrets-manager-design.md`](../../docs/plans/2026-07-15-secrets-manager-design.md)
|
||||
- **Adoption backlog:** [`GAPS.md`](GAPS.md)
|
||||
|
||||
## Status
|
||||
|
||||
| | State |
|
||||
|---|---|
|
||||
| Library | **Built + published** — `ZB.MOM.WW.Secrets{,.Abstractions,.Ui}` 0.1.2 on the `dohertj2-gitea` feed; `.Cli` in-repo (not packed); `.Akka` replicator **deferred** (design only) |
|
||||
| Reference consumer | **HistorianGateway — adopted + live-proven** (2026-07-16): historian password sourced via `${secret:}`, authenticated read against the real wonder historian |
|
||||
| Three sister apps | **Not yet adopted** — see per-app current-state + GAPS |
|
||||
|
||||
## Per-project current state
|
||||
|
||||
| Project | Today (baseline) | Doc |
|
||||
|---|---|---|
|
||||
| OtOpcUa | _(code-verified baseline)_ | [`current-state/otopcua/CURRENT-STATE.md`](current-state/otopcua/CURRENT-STATE.md) |
|
||||
| MxAccessGateway | _(code-verified baseline)_ | [`current-state/mxaccessgw/CURRENT-STATE.md`](current-state/mxaccessgw/CURRENT-STATE.md) |
|
||||
| ScadaBridge | _(code-verified baseline)_ | [`current-state/scadabridge/CURRENT-STATE.md`](current-state/scadabridge/CURRENT-STATE.md) |
|
||||
|
||||
> Not applicable as a fourth adopter row but the exemplar: **HistorianGateway** already
|
||||
> consumes the lib — its wiring is the template the three apps follow (see the
|
||||
> shared-contract "Consumer wiring" section).
|
||||
@@ -0,0 +1,46 @@
|
||||
# MxAccessGateway — secrets current state
|
||||
|
||||
Descriptive baseline (code-verified) of how `mxaccessgw` handles secrets **today**, before
|
||||
adopting `ZB.MOM.WW.Secrets`. Paths are relative to `~/Desktop/MxAccessGateway`.
|
||||
|
||||
## Where each secret lives today
|
||||
|
||||
| Secret | Location today | At-rest protection |
|
||||
|---|---|---|
|
||||
| gRPC inbound API keys | Peppered HMAC-SHA256 hashes in SQLite `gateway-auth.db` (`MxGateway:Authentication:SqlitePath`, `…Server/appsettings.json:17`) via `ZB.MOM.WW.Auth.ApiKeys` (`…/Security/Authentication/AuthStoreServiceCollectionExtensions.cs:70`) | Hashed (not reversible); plaintext key never stored |
|
||||
| API-key **pepper** | Read by config-key **name** `MxGateway:ApiKeyPepper`; value supplied out-of-band (env `MxGateway__ApiKeyPepper`), intentionally **not** in appsettings (`AuthStoreServiceCollectionExtensions.cs:36,54-55`) | Runtime-supplied; no dedicated env reader (default config binding); flagged open item SEC-10 |
|
||||
| LDAP dashboard bind password | **Plaintext** `appsettings.json:29` `ServiceAccountPassword: "serviceaccount123"`; also a hardcoded default in `Configuration/LdapOptions.cs:61` | None |
|
||||
| Galaxy-repository SQL connection string | **Plaintext** `appsettings.json:76` (currently `Integrated Security=True`, but the field is a raw connstr that would carry `Password=` in plaintext) | None (redacted for **display** only, `Dashboard/DashboardConnectionStringDisplay.cs:10-24`) |
|
||||
| TLS PFX | Generated/persisted with a **null password**, ACL-protected on disk (`Security/Tls/SelfSignedCertificateProvider.cs:153,193`) | Filesystem ACL, no password |
|
||||
| MXAccess/Wonderware creds | **Not stored** — pass-through runtime gRPC request payload forwarded to COM (`Worker/MxAccess/MxAccessCommandExecutor.cs:318-325`) | N/A |
|
||||
|
||||
## Encryption-at-rest today
|
||||
|
||||
- **None** for the secrets above. The only Data Protection use is short-lived SignalR hub
|
||||
bearer tokens (`Dashboard/HubTokenService.cs:43-46`) — and Data Protection is **never
|
||||
explicitly configured** (no `AddDataProtection`/`PersistKeysTo`/`ProtectKeysWith`/
|
||||
`SetApplicationName` anywhere), so the key ring falls back to the ASP.NET default. Not
|
||||
applied to any credential.
|
||||
|
||||
## Config expansion today
|
||||
|
||||
- **None.** Stock `WebApplication.CreateBuilder` chain (`GatewayApplication.cs:60`); no
|
||||
`${...}` expansion, no custom config post-processing before options binding. Secrets are
|
||||
read verbatim from resolved config values.
|
||||
|
||||
## Adoption plan (toward SPEC)
|
||||
|
||||
1. **Highest value first:** move the plaintext **LDAP `ServiceAccountPassword`** and the
|
||||
**Galaxy connection-string password** behind `${secret:ldap/mxgateway/bind}` /
|
||||
`${secret:sql/mxgateway/galaxy}` — pre-host `SecretReferenceExpander` before the existing
|
||||
`GatewayOptionsValidator` runs. Delete the hardcoded plaintext defaults in `LdapOptions`.
|
||||
2. **Pepper:** the pepper is already runtime-supplied by design — optionally source it via
|
||||
`${secret:apikey-pepper/mxgateway}` to close SEC-10's "no dedicated env reader" gap while
|
||||
keeping it out of appsettings.
|
||||
3. **Master key:** env provider (`ZB_SECRETS_MASTER_KEY`) for the Windows/NSSM deployment,
|
||||
or DPAPI provider (Windows box) if a machine-bound key is preferred.
|
||||
4. **UI:** mount `/admin/secrets` in the existing Blazor dashboard; map the gateway's
|
||||
`Administrator` role onto `secrets:manage`/`secrets:reveal`.
|
||||
5. **Keep bespoke:** the peppered-HMAC API-key store (already best-practice — hashed, not
|
||||
encrypted-reversible) and the ACL-protected passwordless TLS PFX stay as-is; MXAccess
|
||||
pass-through creds are out of scope (never stored).
|
||||
@@ -0,0 +1,54 @@
|
||||
# OtOpcUa — secrets current state
|
||||
|
||||
Descriptive baseline (code-verified) of how OtOpcUa handles secrets **today**, before
|
||||
adopting `ZB.MOM.WW.Secrets`. Paths are relative to `~/Desktop/OtOpcUa`.
|
||||
|
||||
## Where each secret lives today
|
||||
|
||||
| Secret | Location today | At-rest protection |
|
||||
|---|---|---|
|
||||
| ConfigDb (SQL Server) connstr password | Connection string `ConfigDb` / env `OTOPCUA_CONFIG_CONNECTION` (`…Configuration/ServiceCollectionExtensions.cs:10,21-25`; `DesignTimeDbContextFactory.cs:16,25-31` — "Do not add a plaintext password as a fallback"); not in committed appsettings | Whatever config source supplies it (plaintext) |
|
||||
| LDAP service-account password | `Security:Ldap:ServiceAccountPassword` (`Security/Ldap/LdapOptions.cs:22,60,63`, bound `Security/ServiceCollectionExtensions.cs:41`); not in committed appsettings | None |
|
||||
| Deploy API key | `Security:DeployApiKey` compared via `FixedTimeEquals` (`AdminUI/Api/DeployApiEndpoints.cs:30-54`); disabled when unset | Plaintext config value |
|
||||
| HistorianGateway client API key | Env `ServerHistorian__ApiKey` (`Runtime/Historian/ServerHistorianOptions.cs:41-46`; appsettings ships `""` + "NEVER commit") | None (env at rest) |
|
||||
| Galaxy/MxAccess gateway API key | `GalaxyDriverOptions.ApiKeySecretRef` resolved by `GalaxySecretRef.cs:43-88` into 4 forms: `env:` / `file:` / `dev:` / literal; stored in the driver `DriverConfig` JSON in the **central config DB** | `env:`/`file:` indirect out; `dev:`/literal are **cleartext in the DB** (self-documented `GalaxySecretRef.cs:86`) |
|
||||
| OPC UA client-driver session + PFX passwords | `OpcUaClientDriverOptions.Password` / `UserCertificatePassword` in the driver `DriverConfig` JSON in the config DB (`Driver.OpcUaClient/OpcUaClientDriver.cs:382,495`) | **Plaintext** in the config DB |
|
||||
| JWT signing key | `Security:Jwt:SigningKey` (HS256, `Jwt/JwtOptions.cs:5-9`); not in committed appsettings | Plaintext config value |
|
||||
|
||||
End-user login/OPC-UA passwords flow in-memory only (never persisted); the desktop client
|
||||
deliberately re-prompts each launch.
|
||||
|
||||
## Encryption-at-rest today
|
||||
|
||||
- The only mechanism is **ASP.NET Core Data Protection** for the auth cookie/JWT payload —
|
||||
**not** for any credential above: `Security/ServiceCollectionExtensions.cs:73-75`
|
||||
(`AddDataProtection().PersistKeysToDbContext<OtOpcUaConfigDbContext>().SetApplicationName("OtOpcUa")`),
|
||||
key ring in the ConfigDb `DataProtectionKeys` table (`OtOpcUaConfigDbContext.cs:70-73`).
|
||||
No DPAPI, no `ProtectKeysWith*` — the key ring itself is unencrypted-at-rest in SQL.
|
||||
- **Log redaction** (not at-rest): `Configuration/LocalCache/ResilientConfigReader.cs:76-89`
|
||||
scrubs `Password|Pwd|User Id|AccessToken|Api-Key` fragments before logging.
|
||||
|
||||
## Config expansion today
|
||||
|
||||
- **None** as a general mechanism (no `${...}` expander; stock
|
||||
`AddJsonFile`+`AddEnvironmentVariables`+`AddCommandLine`, `Host/Program.cs:59-73`). The one
|
||||
partial exception is the driver-level **`GalaxySecretRef`** (`env:`/`file:`/`dev:`/literal
|
||||
indirection, `GalaxySecretRef.cs:23`) which already anticipates "a future PR can swap any
|
||||
of these arms for a DPAPI-backed lookup" — a natural hook for `${secret:}`.
|
||||
|
||||
## Adoption plan (toward SPEC)
|
||||
|
||||
1. **Highest value first:** the driver `DriverConfig` secrets that sit **cleartext in the
|
||||
config DB** — the OpcUaClient `Password`/`UserCertificatePassword` and the `dev:`/literal
|
||||
Galaxy API-key arm. Add a `secret:` arm to `GalaxySecretRef` (and the OpcUaClient options)
|
||||
resolving through `ISecretResolver`, replacing the cleartext-in-DB path.
|
||||
2. **Pre-host `${secret:}`** for `Security:Jwt:SigningKey`, `Security:Ldap:ServiceAccountPassword`,
|
||||
`Security:DeployApiKey`, and the `ConfigDb` connstr password — expander runs before the
|
||||
existing `Ldap`/`OpcUa` options validators.
|
||||
3. **Master key:** env provider (`ZB_SECRETS_MASTER_KEY`) per node. **Clustered:** OtOpcUa is
|
||||
Akka-clustered, so every node must resolve the **same KEK** — use `FileMasterKeyProvider`
|
||||
with a shared mounted key, and when the `ZB.MOM.WW.Secrets.Akka` replicator is built, adopt
|
||||
it (or point `ISecretStore` at the shared ConfigDb SQL store). See SPEC "Clustered pairs".
|
||||
4. **UI:** mount `/admin/secrets` in the AdminUI; map OtOpcUa's admin role onto the two policies.
|
||||
5. **Keep bespoke:** the Data-Protection cookie/JWT key ring, in-memory end-user passwords,
|
||||
and the file-system OPC UA PKI Directory stores.
|
||||
@@ -0,0 +1,61 @@
|
||||
# ScadaBridge — secrets current state
|
||||
|
||||
Descriptive baseline (code-verified) of how ScadaBridge handles secrets **today**, before
|
||||
adopting `ZB.MOM.WW.Secrets`. Paths are relative to `~/Desktop/ScadaBridge`.
|
||||
|
||||
## Where each secret lives today
|
||||
|
||||
| Secret | Location today | At-rest protection |
|
||||
|---|---|---|
|
||||
| MSSQL ConfigDb password | `ScadaBridge:Database:ConfigurationDb` connstr (`Host/Program.cs:221-223`); committed dev plaintext `docker/central-node-a/appsettings.Central.json:21` | **Plaintext** |
|
||||
| Central `dbo` MachineDataDb password | `ScadaBridge:Database:MachineDataDb` (`StartupValidator.cs:63-65`; `appsettings.Central.json:22`) | **Plaintext** |
|
||||
| Site SQLite | `SiteDbPath` / StoreAndForward `SqliteDbPath` (`appsettings.Site.json:26,34`) — plain file paths | None (no SQLCipher) |
|
||||
| LDAP service-account password | `ScadaBridge:Security:Ldap:ServiceAccountPassword` via `ZB.MOM.WW.Auth.Ldap` (`Program.cs:140-142`); committed `serviceaccount123` `appsettings.Central.json:32` (+ loose `ldap_login.txt` etc. at repo root) | **Plaintext** |
|
||||
| Inbound `/api` keys | Hashed (peppered-HMAC) in SQLite `data/inbound-api-keys.sqlite` via `ZB.MOM.WW.Auth.ApiKeys` (`Program.cs:184-217`); legacy SQL-Server key store **retired** (`20260602092753_RetireInboundApiKeyStore`) | Hashed (not reversible) |
|
||||
| API-key **pepper** | `ScadaBridge:InboundApi:ApiKeyPepper` (`InboundAPI/InboundApiOptions.cs:23-36`); committed dev `dev-only-insecure-pepper-…` `docker/docker-compose.yml:18,45` (both central nodes share) | **Plaintext** env |
|
||||
| JWT signing key | `ScadaBridge:Security:JwtSigningKey` (HS256, `Security/SecurityOptions.cs:34`); `appsettings.Central.json:38` | **Plaintext** |
|
||||
| `DatabaseConnectionDefinition.ConnectionString` | ConfigDb column | **Encrypted** (Data Protection — see below) |
|
||||
| MxGateway per-endpoint `ApiKey` | Inside `DataConnection.PrimaryConfiguration`/`BackupConfiguration` JSON in ConfigDb; column mapped **without** the encrypting converter (`ConfigurationDatabase/Configurations/SiteConfiguration.cs:52-56`) | **Plaintext in ConfigDb** (redacted for display/audit only, `ConfigSecretScrubber.cs:18-19`) |
|
||||
| Akka remoting | HOCON `AkkaHostedService.cs:250-308` — hostname/port only | **No secure-cookie / no TLS / no shared secret** (plain TCP) |
|
||||
|
||||
## Encryption-at-rest today (the one existing analog)
|
||||
|
||||
- **`EncryptedStringConverter`** (ASP.NET Core Data Protection EF value converter,
|
||||
`ConfigurationDatabase/EncryptedStringConverter.cs:20-52`, purpose
|
||||
`"…ConfigurationDatabase.EncryptedColumn"`) encrypts **four** ConfigDb columns
|
||||
(`ScadaBridgeDbContext.cs:319-348`): `SmtpConfiguration.Credentials`,
|
||||
`SmsConfiguration.AuthToken`, `ExternalSystemDefinition.AuthConfiguration`,
|
||||
`DatabaseConnectionDefinition.ConnectionString`.
|
||||
- **Key ring** lives **in ConfigDb** (`IDataProtectionKeyContext` + `DataProtectionKeys`,
|
||||
`AddDataProtection().PersistKeysToDbContext<ScadaBridgeDbContext>()`) — shared across
|
||||
central nodes. **No `SetApplicationName`** (discriminator = content-root path) and **no
|
||||
`ProtectKeysWith*`** — the key-ring keys are stored **unencrypted** in the DB; the DB is
|
||||
the only protection boundary.
|
||||
|
||||
## Config expansion today
|
||||
|
||||
- **None functional.** Stock `AddJsonFile`+`AddEnvironmentVariables`+`AddCommandLine`
|
||||
(`Program.cs:38-43`). The `${SCADABRIDGE_*}` tokens in `appsettings.Central.json:23,33,35`
|
||||
are **literal, non-functional placeholders** — the real values arrive by **whole-key env
|
||||
override** (`ScadaBridge__Security__Ldap__ServiceAccountPassword`, etc.), documented in the
|
||||
`_secrets` note (`appsettings.Central.json:21`). `StartupValidator` (`ConfigPreflight` from
|
||||
the shared `ZB.MOM.WW.Configuration` package) validates presence/shape only — no resolution.
|
||||
|
||||
## Adoption plan (toward SPEC)
|
||||
|
||||
1. **Formalize the existing placeholders:** the non-functional `${SCADABRIDGE_*}` +
|
||||
whole-key-override convention is exactly the pattern `${secret:}` replaces — swap the
|
||||
plaintext ConfigDb/MachineDataDb passwords, LDAP password, JWT signing key, and pepper to
|
||||
`${secret:…}` tokens resolved pre-host, **before** `StartupValidator`/`ConfigPreflight`
|
||||
runs. Delete the committed dev plaintext + the loose `*_login.txt` files.
|
||||
2. **Close the MxGateway `ApiKey` plaintext-in-ConfigDb gap** — either extend
|
||||
`EncryptedStringConverter` to that JSON column or resolve it via `ISecretResolver`.
|
||||
3. **Clustered sync:** ScadaBridge is Akka-clustered (central pair + site nodes). Two SPEC-
|
||||
sanctioned options: (a) point `ISecretStore` at the **shared ConfigDb (SQL-Server store)**
|
||||
— one source of truth, mirrors how the Data-Protection key ring is already shared; or
|
||||
(b) adopt the `ZB.MOM.WW.Secrets.Akka` replicator (built on adoption) with a shared KEK
|
||||
(`FileMasterKeyProvider`, same mounted key on every node — hard requirement).
|
||||
4. **UI:** mount `/admin/secrets` in CentralUI; map ScadaBridge's admin role onto the policies.
|
||||
5. **Keep bespoke:** the existing Data-Protection column encryption can coexist (or migrate);
|
||||
the peppered-HMAC inbound-key SQLite store stays (already hashed). Akka remoting
|
||||
auth/TLS is a separate hardening concern, not this component.
|
||||
@@ -0,0 +1,113 @@
|
||||
# Shared contract — `ZB.MOM.WW.Secrets`
|
||||
|
||||
The public API of the shared secrets library. Unlike most `shared-contract/` docs (which
|
||||
propose an API to extract), this component's library is **already built and published** —
|
||||
this records the **realized** surface (v0.1.2 on the `dohertj2-gitea` NuGet feed). Source
|
||||
lives in-repo at [`ZB.MOM.WW.Secrets/`](../../../ZB.MOM.WW.Secrets/).
|
||||
|
||||
## Packages (mirrors `ZB.MOM.WW.Auth`'s shape)
|
||||
|
||||
| Package | Role | Depends on |
|
||||
|---|---|---|
|
||||
| `ZB.MOM.WW.Secrets.Abstractions` | Contracts only — interfaces, records, enums, exceptions | BCL + `DI.Abstractions` |
|
||||
| `ZB.MOM.WW.Secrets` | Impl — cipher, master-key providers, SQLite store + migrator, resolver, `${secret:}` expander, `AddZbSecrets` | Abstractions, `Microsoft.Data.Sqlite`, `ZB.MOM.WW.Audit` |
|
||||
| `ZB.MOM.WW.Secrets.Ui` | Blazor RCL on `ZB.MOM.WW.Theme` — `/admin/secrets` (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` | Secrets |
|
||||
| `ZB.MOM.WW.Secrets.Akka` *(deferred — not built)* | Cluster replication of encrypted rows | Secrets, Akka.Cluster |
|
||||
|
||||
## Abstractions (contracts)
|
||||
|
||||
Interfaces: `IMasterKeyProvider`, `ISecretCipher`, `ISecretStore`, `ISecretResolver`,
|
||||
`ISecretReplicator`, `ISecretCacheInvalidator`, `ISecretActorAccessor`.
|
||||
|
||||
```csharp
|
||||
public interface IMasterKeyProvider // 32-byte KEK; KekId identifies the active key
|
||||
{
|
||||
ReadOnlyMemory<byte> GetMasterKey();
|
||||
string KekId { get; }
|
||||
}
|
||||
|
||||
public interface ISecretStore // pluggable storage (SQLite default)
|
||||
{
|
||||
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
|
||||
Task<IReadOnlyList<SecretManifestEntry>> GetManifestAsync(CancellationToken ct); // (name → revision)
|
||||
Task ApplyReplicatedAsync(StoredSecret row, CancellationToken ct); // LWW upsert (cluster)
|
||||
}
|
||||
|
||||
public interface ISecretResolver // app-runtime read (cached, audited)
|
||||
{
|
||||
Task<string?> GetAsync(SecretName name, CancellationToken ct);
|
||||
}
|
||||
|
||||
public interface ISecretCacheInvalidator { /* invalidate on rotate/delete */ }
|
||||
public interface ISecretReplicator { /* no-op default; Akka impl deferred */ }
|
||||
public interface ISecretActorAccessor { /* supplies the audit actor from the host */ }
|
||||
```
|
||||
|
||||
Types: `SecretName` (validated `readonly record struct` — rejects `..` path-traversal,
|
||||
rooted/`//` segments, empty; fail-fast on `default`), `SecretContentType` enum
|
||||
(`Text` | `ConnectionString` | `Json` | `BinaryBase64`), `StoredSecret` (the ciphertext
|
||||
row), `SecretMetadata` (**no** `byte[]` members — safe to list/log), `SecretManifestEntry`.
|
||||
|
||||
Exceptions (all fail-closed): `MasterKeyUnavailableException`, `SecretDecryptionException`
|
||||
(GCM tag mismatch / tamper / wrong KEK), `SecretNotFoundException`,
|
||||
`SecretStoreMigrationException`.
|
||||
|
||||
## Implementation (core)
|
||||
|
||||
- **`AesGcmEnvelopeCipher`** (`…Crypto`) — per-write DEK, AES-256-GCM, AAD name-binding,
|
||||
DEK-wrap under the KEK, `CryptographicOperations.ZeroMemory` on DEK material.
|
||||
- **Master-key providers** (`…MasterKey`) — `Environment` / `File` / `Dpapi` selected by
|
||||
`MasterKeyProviderFactory` from `MasterKeyOptions`; 32-byte validated; fail-closed.
|
||||
- **`SqliteSecretStore`** + `SqliteSecretsStoreMigrator` + `SecretsSqliteConnectionFactory`
|
||||
(`…Sqlite`) — schema v1, WAL + `busy_timeout`, idempotent migration.
|
||||
- **`DefaultSecretResolver`** — TTL cache; audits every resolve (Success/Failure) via
|
||||
`ZB.MOM.WW.Audit`; **fail-loud** on decrypt failure (audit Failure, then rethrow).
|
||||
- **`SecretReferenceExpander`** (`…Configuration`) — `${secret:name}` expansion pre-host,
|
||||
fail-closed, skips `_`-prefixed comment keys.
|
||||
- **`AddZbSecrets(config, sectionPath)`** (`…DependencyInjection`) — one call wires the
|
||||
provider + cipher + store + resolver (also exposed as `ISecretCacheInvalidator`) + no-op
|
||||
replicator + a startup migration hosted service. Binds `SecretsOptions`
|
||||
(`SqlitePath`, `MasterKey`, `RunMigrationsOnStartup`, `ResolveCacheTtl`).
|
||||
|
||||
## UI + authz
|
||||
|
||||
- `ZB.MOM.WW.Secrets.Ui` — `SecretsPage` (`@page "/admin/secrets"`) mounted via the host's
|
||||
`AddAdditionalAssemblies(SecretsPage.Assembly)`; `SecretsList`, `SecretEditor`,
|
||||
`ConfirmDeleteModal` (in-page Theme modal), `RevealButton`.
|
||||
- `SecretsAuthorization.AddSecretsAuthorization()` adds two named policies:
|
||||
**`secrets:manage`** (`ManageRole`/`RevealRole`/`Administrator`) for CRUD and
|
||||
**`secrets:reveal`** (`RevealRole`/`Administrator`) for plaintext reveal. Role names are
|
||||
compared case-sensitively (Ordinal) by ASP.NET — the admin role is
|
||||
`nameof(CanonicalRole.Administrator)` to match the family's canonical vocabulary.
|
||||
|
||||
## Configuration shape
|
||||
|
||||
```json
|
||||
"Secrets": {
|
||||
"SqlitePath": "app-secrets.db",
|
||||
"MasterKey": { "Source": "Environment", "EnvVarName": "ZB_SECRETS_MASTER_KEY" },
|
||||
"RunMigrationsOnStartup": true,
|
||||
"ResolveCacheTtl": "00:00:30"
|
||||
}
|
||||
```
|
||||
|
||||
`MasterKey.Source` ∈ `Environment` | `File` | `Dpapi`. The master key itself is **never**
|
||||
committed — env var (containers), mounted key file (clustered pairs), or DPAPI (Windows).
|
||||
|
||||
## Consumer wiring (reference: HistorianGateway)
|
||||
|
||||
1. Pre-host: a throwaway `ServiceCollection().AddZbSecrets(...)` provider →
|
||||
`SqliteSecretsStoreMigrator.MigrateAsync` → `SecretReferenceExpander.ExpandConfigurationAsync`
|
||||
over `builder.Configuration`, **before** options bind/validation (scoped
|
||||
`#pragma warning disable ASP0000` for the deliberate throwaway container).
|
||||
2. Runtime: `builder.Services.AddZbSecrets(...)` for the app's `ISecretResolver`.
|
||||
3. UI: `Configure<AuthorizationOptions>(o => o.AddSecretsAuthorization())` +
|
||||
`AddAdditionalAssemblies(SecretsPage.Assembly)`.
|
||||
4. A config value becomes `"${secret:sql/<app>/<name>}"`; the value is seeded via the CLI or
|
||||
the `/admin/secrets` UI. **Live-proven** end-to-end 2026-07-16: a resolved `${secret:}`
|
||||
password authenticated a real read against the wonder historian (wrong secret →
|
||||
`HistorianAuthException`; correct → real samples).
|
||||
@@ -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