Found by the HistorianGateway adoption of 0.6.0, which probed the rejected paths
instead of observing a clean boot — a clean boot being equally consistent with the
validator not being wired at all.
An application that expands ${secret:} references before building its host composes
this subsystem in a throwaway ServiceCollection with no IHostEnvironment in it, then
migrates the store from that container. SecretsOptionsValidator resolves the content
root from IHostEnvironment and skips the under-content-root rule when it is absent, so
pre-host an absolute in-content-root path passed the only rule still applying, the
migrator CREATED the database, and the boot failed a second later once the real host
registered IHostEnvironment and the rule finally fired.
The observable result was a rejected boot that still left an empty store, with -wal and
-shm siblings, at the rejected path — the same artifact that made the MxGateway outage
read as "the database is there, it's just empty", which is precisely what this guard
exists to prevent. A relative path was unaffected because the absolute-path rule fires
with or without a content root; that asymmetry is what exposed it.
The mistake was conflating "no IHostEnvironment registered" with "no content root
exists". New AddZbSecrets overload takes the content root explicitly; the 3-argument
overload is unchanged and still falls back to IHostEnvironment, so this is additive.
SecretsOptionsValidator gains a string? constructor alongside the IHostEnvironment? one.
Reproduced first in an isolated harness before changing anything: a bare host rejects
both bad paths leaving no artifacts, and a bare ServiceCollection with no
IHostEnvironment migrates an in-content-root path to disk. Both are now regression tests,
the second paired with a positive control asserting the old behaviour still holds when no
content root is supplied — so the test is about the explicit content root rather than
about paths in general.
Also documents an upgrade note that applies to 0.6.0: a store seeded under the old
relative default is orphaned rather than migrated, and nothing signals it.
15 KiB
Changelog
All notable changes to the ZB.MOM.WW.Secrets family are recorded here. Format loosely
follows Keep a Changelog; versions are shared across every
package in the family (see Directory.Build.props) even when a given release only touches
one of them.
0.6.1 — 2026-08-11
Fixed
-
The 0.6.0 guard had a hole at exactly the moment the store file is created (found by the HistorianGateway adoption of 0.6.0, which probed the rejected paths rather than observing a clean boot). An application that expands
${secret:}references before building its host composes this subsystem in a throwawayServiceCollectionwith noIHostEnvironmentin it, then migrates the store from that container.SecretsOptionsValidatorresolves the content root fromIHostEnvironmentand skips the under-content-root rule when it is absent — so pre-host an absolute in-content-root path passed the only rule still applying, the migrator created the database, and the boot then failed a second later once the real host registeredIHostEnvironmentand the rule finally fired.The observable result was a rejected boot that still left an empty store, with
-wal/-shmsiblings, sitting at the rejected path — the same artifact that made the outage this guard exists to prevent read as "the database is there, it's just empty". A relative path was unaffected, because the absolute-path rule fires with or without a content root; that asymmetry is what exposed it.Conflating "no
IHostEnvironmentregistered" with "no content root exists" was the mistake. New overloadAddZbSecrets(services, config, sectionPath, contentRootPath)takes the content root explicitly; the 3-argument overload is unchanged and still falls back toIHostEnvironment. Pre-host composition should pass it — e.g.builder.Environment.ContentRootPath. Passingnullremains correct only where there genuinely is no content root, such as the CLI.SecretsOptionsValidatorgains astring?constructor alongside theIHostEnvironment?one.
Upgrade note (applies to 0.6.0 as well)
- A store seeded under the pre-0.6.0 default is orphaned, not migrated, and nothing says so.
The old default was the relative
secrets.db, which resolved against the process working directory; the new default is absolute. An application that relied on the old default silently starts using a different file, and anything previously seeded into the old one is stranded. This is a dev-loop concern for the apps in this family (every deployment sets an explicit absolute path), but check for a stray*-secrets.dbbeside your project before assuming an empty store after upgrade means the store was lost. Deleting the key from configuration, rather than leaving a relative value, is what makes the new default apply.
0.6.0 — 2026-08-11
Added
-
ZB.MOM.WW.Secrets— fail-closed validation of the store path.Secrets:SqlitePathis now validated at startup (ValidateOnStart) bySecretsOptionsValidator, using two rules in the new publicSecretsStorePathRules. Both come from production incidents and neither implies the other:- Must be absolute. A relative value resolves against the process working directory —
for a Windows service that is
C:\Windows\System32, so the store lands outside the installation entirely; in a dev loop it lands in the source tree (observed: a gateway wrote its secrets database intosrc/and tripped that repo's own tree-hygiene test). - Must not be inside the application content root. An absolute path can still point
into the directory every deployment procedure in this family replaces or renames on
upgrade. On 2026-08-09 a gateway's credential store sat inside its
Server\directory; the deploy renamedServertoServer.bak.*, the database left with it, and the process created a fresh empty one at the same path. Every consumer lost its credential, silently, for two days. The existing "absolute path" convention did not help — the path was absolute.
The rules reject rather than relocate: silently moving a credential store presents later as an authentication failure pointing nowhere near the configuration that caused it, whereas a boot error is seen immediately by whoever caused it. Validation is deliberately not exempted in Development — a guard that never runs in a dev loop or in CI rots, and the configuration behind the outage looked entirely reasonable.
The content-root rule is skipped when no
IHostEnvironmentis available (the CLI composes the subsystem without a host); the absolute-path rule still applies. - Must be absolute. A relative value resolves against the process working directory —
for a Windows service that is
Changed
-
BREAKING (configuration, not API):
SecretsOptions.SqlitePathnow defaults to an absolute per-user path —ZB.MOM.WW/secrets.dbunderLocalApplicationData(~/.local/shareon Linux and macOS,%LOCALAPPDATA%on Windows), exposed asSecretsOptions.DefaultSqlitePath. It wassecrets.db, which the new rules reject.A consuming application that configures a relative
Secrets:SqlitePathwill now fail to boot. The fix is to remove the key (accepting the default) or set an absolute path outside the content root. Every deployment of this family already sets an absolute path, so this bites dev loops and unconfigured installs, not production — but verify before upgrading: a host that relied on the old default now looks in a new location, finds nothing, and creates an empty store, which is the same failure this release exists to prevent.Per-user rather than machine-wide is deliberate.
CommonApplicationDatais/usr/shareon Linux and macOS, which a normal user cannot write; as a library default that would fail every consuming application's boot on a developer machine. An application is free to compute its own default (a machine-wide Windows service reasonably prefersCommonApplicationDataalongside its other stores) — the configured value always wins. -
AddZbSecretsregistration of the validator is idempotent.AddZbSecretsis legitimately called more than once in real compositions (a replication package calls it after the application already has), andTryAddEnumerablecannot de-duplicate a factory-built descriptor — it de-duplicates on implementation type, which a factory does not carry, so it throws on the second call rather than skipping. -
ZB.MOM.WW.Secretsnow depends onZB.MOM.WW.Configuration(0.1.0) for the sharedOptionsValidatorBase/ValidationBuildervocabulary. Every consumer already references it.
0.5.1 — 2026-08-07
Fixed
ZB.MOM.WW.Secrets.Replicator.SqlServer— virgin-database concurrent migrator race (present since 0.2.0; found by the ScadaBridge central shared-store live gate). The migrator's doc claimed simultaneous provisioning of a virgin database converges, but its retry filter covered only deadlock victims (1205): Serializable isolation does not serialize theIF NOT EXISTSmetadata probes against another session's in-flightCREATE, so of two nodes booting together against an empty database the loser died withSqlException2714 ("There is already an object named 'zbsecrets'") and aborted its host — observed live on an ordinarydocker compose upof the ScadaBridge central pair. The retry filter now also treats the lost-CREATE/lost-INSERT race outcomes as retryable — 2714 (schema/table already exists), 1913 (index already exists), 2627 (schema_version MERGE-insert PK violation) — since losing such a race means the object now exists, which is the desired end state; the retry re-runs the idempotentIF NOT EXISTSpath and proceeds. Classification scans the wholeSqlException.Errorscollection (a failed DDL batch stacks follow-on errors such as 2759, so the retryable number is not guaranteed first), and the retry cap (5) keeps a genuine conflict — e.g. an unrelated pre-existing object colliding with the schema name — failing closed. 2705 (duplicate column) is deliberately not retried: every column lives inside a singleCREATE TABLEstatement, so no cross-session race can produce it. The error-number predicate is now an internal static pinned by offline tests, and the live suite gains an N-concurrent-migrators virgin-database test.
0.5.0 — 2026-08-07
Added
ZB.MOM.WW.Secrets.Replicator.Grpc— hub endpoint failover for followers (scadaproj#4).SecretsGrpcHubClientOptionsgainsFallbackEndpoints: additional hub addresses tried, in order, when a call to the primaryEndpoint(or an earlier fallback) fails with a transport-level gRPC error. The answering endpoint is sticky — a sweep that failed over does not re-pay the dead endpoint's fullCallDeadlineon every subsequent call — and rotation wraps, so a recovered primary is retried the next time the stuck-to endpoint fails. OnlyRpcExceptiontriggers failover; cancellation and everything else propagate untouched, and when every endpoint fails the lastRpcExceptionis rethrown so the sweep degrades to last-known-good exactly as before. Each failover logs a Warning naming the failed and next endpoint URIs (never any credential material).- Same-store requirement: list only endpoints that serve the SAME store — for ScadaBridge, the central pair over one shared SQL Server store. Endpoints backed by independent stores turn a primary outage into a silent convergence stop (failing over to an emptier hub "succeeds"), which is the exact defect scadaproj#4 recorded.
- Validation fails startup on a blank/relative fallback or one duplicating the primary or another fallback (a duplicated endpoint silently halves the configured failover).
Unchanged
- With
FallbackEndpointsempty (the default), the registration graph is identical to 0.4.x — one keyed channel, oneGrpcSecretsHubClient, no failover wrapper — and the wire behavior of every call is byte-identical. Every other package in the family is behaviorally unchanged in this release.
0.4.1 — 2026-08-07
Fixed
ZB.MOM.WW.Secrets.Replicator.Grpc— the hub now logs its denials. A ScadaBridge live gate found thatSecretsHubAuthInterceptorrefused calls correctly but wrote nothing about it: the only trace of a refusal was a frameworkGrpc.AspNetCore.ServerInformation line, so a follower whose bearer token had been mis-rotated silently stopped converging and the hub showed it only at INF. Every denial is now counted, and aWarningsummarising the window is emitted on the first denial after startup or after 60 quiet seconds — carrying the number of denials since the previous summary plus a breakdown by cause (missing/malformed credential, wrong credential, hub token unconfigured). A single probe is therefore visible immediately, while a sustained attack is bounded to one log line per minute — the endpoint is unauthenticated by construction, so a per-denial warning would be an unbounded log write driven by an unauthenticated caller.
Unchanged
- Wire behavior is byte-identical. The cause breakdown is server-side diagnostics only; every
rejection still returns the same uniform
Unauthenticatedstatus with the same detail, and the credential comparison is still constant-time. No token material, presented or configured, appears in any log line at any level. - The existing warn-once-on-unconfigured-token line is unchanged and still emitted exactly once;
denials in that state also count toward the new summary's
unconfiguredcategory. - Every other package in the family is behaviorally unchanged in this release.
0.4.0 — 2026-08-07
Added
- New package
ZB.MOM.WW.Secrets.Replicator.Grpc— a central-hostedsecrets_hub.v1gRPC hub for the sites-talk-to-central production topology (scadaproj#3; design docdocs/plans/2026-08-07-secrets-production-topology-and-grpc-hub.md).- Pull-only by construction: the proto exposes no write RPC. Updates originate at central only; a site never pushes a secret upstream.
- Fail-closed bearer auth on the hub server: constant-time comparison, uniform denial response (no signal distinguishing "wrong token" from "missing token"), a warn-once log on an unconfigured/empty token rather than a silent open door.
GetSecretsenforces a batch-size cap, rejecting an oversized request withResourceExhaustedinstead of serving it partially or unbounded.- Site-side
GrpcSecretSyncServicesweeps the hub on an interval with per-row parse isolation — one malformed/undecryptable row logs and is skipped, it does not fault the whole sweep. - Keyed
GrpcChannelregistration so multiple hub targets can be wired in one process without channel collisions. - Offline behavior is last-known-good: if the hub is unreachable, the site keeps serving the most recently synced values rather than failing secret resolution.
Unchanged
ZB.MOM.WW.Secrets.Abstractions,ZB.MOM.WW.Secrets,ZB.MOM.WW.Secrets.Ui,ZB.MOM.WW.Secrets.Replicator.SqlServer, andZB.MOM.WW.Secrets.Replicator.AkkaDotNetare behaviorally unchanged in this release — no breaking changes to any existing package.
0.3.0 — 2026-07-19
- Menu-driven Spectre.Console interactive operator console added to the
secretCLI (list / set / reveal / delete, reference audit & seed, KEK doctor, ciphertext-only bundle export/import). Seedocs/operations/interactive-console.md.
0.2.3 — 2026-07-19
ZB.MOM.WW.Secrets.Ui: fixed the delete-confirmation modal being permanently invisible on every Bootstrap host (ConfirmDeleteModalused bareclass="modal"with no shipped CSS; Bootstrap's.modal{display:none}hid it everywhere but mxaccessgw). Fixed via azb-secrets-modal*class rename plus a component-emitted<style>block.
0.2.2 — 2026-07-18
- Fixed the Akka replicator's hosted-process DI deadlock (a circular singleton dependency
hidden behind factory lambdas that MS.DI's
StackGuardturned into a silent cross-thread hang rather than a stack overflow). NewDeferredSecretCacheInvalidatordefers the one cycle-closing edge to first eviction.
0.2.1 — 2026-07-18
- Fixed the Akka replicator being silently inert since 0.2.0 (a
TryAddregistration-order bug published into a no-op sink and never spawned an actor). SQL-Server replication was never affected.
0.2.0 — 2026-07-18
- Added clustered replication (G-7):
ZB.MOM.WW.Secrets.Replicator.SqlServer(shared store, or local-store-with-hub) andZB.MOM.WW.Secrets.Replicator.AkkaDotNet(peer-to-peer over distributed pub/sub).