Packing Secrets 0.1.3 surfaced NU1903: Microsoft.Data.Sqlite 10.0.7 pulls SQLitePCLRaw.lib.e_sqlite3 2.1.11, which carries high-severity advisory GHSA-2m69-gcr7-jv3q. Auth.ApiKeys had the same exposure and was already shipped at 0.1.4 to three consumers, so both libs are fixed rather than only the one being published. Fixed the way ZB.MOM.WW.LocalDb already had it: CentralPackageTransitivePinningEnabled plus a pin to the patched 2.1.12. Bumping Microsoft.Data.Sqlite does not help -- even 10.0.10 still resolves 2.1.11 -- so the pin is the actual fix. The pin reaches consumers: both nuspecs now declare SQLitePCLRaw.lib.e_sqlite3 >= 2.1.12 as a direct dependency, verified by restoring the published packages into a scratch project, which resolves 2.1.12 and scans clean. Auth goes 0.1.4 -> 0.1.5 with no API change (0.1.4 plus the pin). Suites re-run after the native-lib swap with identical counts, so no behavioral regression: Secrets 97 pass/1 skip, Auth 215 pass/1 skip (both skips are Windows-only DPAPI and opt-in LDAP). Consumers are NOT yet bumped and remain on vulnerable versions: mxaccessgw 0.1.4, HistorianGateway 0.1.4, ScadaBridge 0.1.3. Secrets consumers all sit at 0.1.2 and also lack KEK rotation. Claude-Session: https://claude.ai/code/session_01BL2Vu1ESDQ9SCN4gVKkdts
ZB.MOM.WW.Secrets
A reusable, envelope-encrypted secrets manager for the ZB.MOM.WW.* SCADA family:
store secrets (SQL passwords, API tokens, connection strings) encrypted at rest and get the
plaintext back on demand — from application code, from configuration, or from an operator UI.
Packages
| Package | Purpose |
|---|---|
ZB.MOM.WW.Secrets.Abstractions |
Contracts only (ISecretStore, ISecretResolver, IMasterKeyProvider, ISecretCipher, ISecretReplicator, ISecretCacheInvalidator, ISecretActorAccessor), value types, exceptions. Dependency-light. |
ZB.MOM.WW.Secrets |
Implementation: AES-256-GCM envelope cipher, env/file/DPAPI master-key providers, SQLite store, TTL resolver (audited), ${secret:} config expander, AddZbSecrets. |
ZB.MOM.WW.Secrets.Ui |
Blazor RCL on ZB.MOM.WW.Theme: list / add / rotate / delete + policy-gated, audited reveal. |
A secret CLI (set / get / list / rm / rotate / rewrap-all) ships in the repo (not packed).
How it protects secrets
Each value is encrypted with a fresh data key (DEK) using AES-256-GCM; the DEK is then
wrapped by a master KEK that never touches the database. The AAD binds the ciphertext
to the secret's name and the wrapped DEK to the KEK id, so rows can't be swapped and a
tampered/mis-keyed row fails closed (SecretDecryptionException) rather than returning a
wrong value. The master KEK is resolved through a pluggable IMasterKeyProvider
(environment variable, key file, or Windows DPAPI).
Wiring it up
builder.Services.AddZbSecrets(builder.Configuration, "Secrets");
// After config load, BEFORE options validation — expand ${secret:...} references:
var expander = new SecretReferenceExpander(app.Services.GetRequiredService<ISecretResolver>());
await expander.ExpandConfigurationAsync((IConfigurationRoot)builder.Configuration, ct);
// appsettings.json
"Secrets": {
"SqlitePath": "secrets.db",
"MasterKey": { "Source": "Environment", "EnvVarName": "ZB_SECRETS_MASTER_KEY" },
"RunMigrationsOnStartup": true,
"ResolveCacheTtl": "00:00:30"
},
// Keep plaintext out of config — reference a stored secret instead:
"ConnectionStrings": {
"Historian": "Server=...;User Id=sa;Password=${secret:sql/historian-password}"
}
The master key is 32 bytes, provided base64 in ZB_SECRETS_MASTER_KEY (Environment source).
A missing/invalid key fails closed at startup (MasterKeyUnavailableException).
Rotating the master KEK
Because each row is a body sealed under a per-secret DEK that is wrapped by the KEK, rotating
the master key only re-wraps DEKs — bodies are never re-encrypted and no value history changes.
The secret rewrap-all CLI verb (backed by KekRotationService) migrates every row from the
old KEK to the new one; it is idempotent and safe to re-run:
# New KEK defaults to the configured Secrets:MasterKey; supply the OLD key by env-var name or path.
ZB_SECRETS_MASTER_KEY=<new-base64> ZB_SECRETS_OLD_KEY=<old-base64> \
secret rewrap-all --old-key-env ZB_SECRETS_OLD_KEY
# → {"action":"rewrap-all","total":N,"rewrapped":N,"alreadyCurrent":0}
Run it with resolve traffic quiesced and once per independent store (once for a shared
SQL-Server store; once per node for per-node SQLite on a shared KEK). See the operator runbook:
docs/operations/kek-rotation.md.
Runtime + human access
- App code: inject
ISecretResolverand callGetAsync(name, ct). Every resolve is audited viaZB.MOM.WW.Audit(name + outcome only — the value is never logged). - Operators: mount the
ZB.MOM.WW.Secrets.Ui/admin/secretspage. The list shows metadata only; revealing a value requires thesecrets:revealauthorization policy and is audited. Add/rotate/delete requiresecrets:manage.
Clustered deployments
Secrets storage is local SQLite by default. The schema already carries the
revision / updated_utc / tombstone columns and an ISecretReplicator seam for
cluster replication, but the Akka replicator (ZB.MOM.WW.Secrets.Akka) is a deferred
follow-on. When replicating across a node pair, every node must resolve the same master
KEK (e.g. the same mounted key file) — a row wrapped by an unknown KEK fails closed.