Files
ScadaBridge/docs/operations/2026-07-16-secrets-clustered-master-key.md
T
Joseph Doherty 4df3a55824 docs: truth sweep — retire stale registers, reconcile ledgers with shipped state
- deferred.md: DELETED (git rm) — stale 2026-07-10 duplicate of the canonical
  deferred-work register; this completes archreview R2-08 T11 (the file was
  tracked, not untracked as the task assumed)
- ScadaBridge-docs-issues.md, ScadaBridge-docs-fixed.md: DELETED (git rm) —
  generated 2026-07-10 CommentChecker reports, already consumed; completes
  R2-08 T13 (also tracked, not untracked)
- stillpending.md: prepended historical-snapshot banner (2026-06-15 audit;
  Tier-1 table is not current open work)
- docs/plans/phase-8-checklist.md: replaced the unevidenced 'Complete / All
  passing' stub with the honest state per register row 25 (WP-4 target-scale
  load test never run)
- archreview/plans/00-MASTER-TRACKER.md: R2-01 T2 live failover drill
  annotated RESOLVED 2026-08-01 (PLAN-R2-01 T4 + docker/failover-drill.sh +
  SbrFailoverTests); R2-08 T11/T13 recorded completed by this sweep
- docs/plans/2026-07-22-clusterclient-to-grpc-plan.md: P3 deferred-RPCs note
  updated (all four live-proven 2026-08-01, 1c99d6fa); ClusterClientSiteAuditClient
  naming follow-up marked DONE (63c16d69)
- docs/plans/2026-05-28-opcua-tag-browser.md.tasks.json: Task 19 flipped to
  completed (manual smoke PASS 2026-08-01, 6dc5d94c)
- archreview/plans/PLAN-R2-0[1-8]*.tasks.json: all-pending manifests reconciled
  with the authoritative tracker (round 2 merged @ 1930f19b) — flipped to
  completed except R2-08 T1/T2 which remain pending needs-user
- docs/operations/2026-07-16-secrets-clustered-master-key.md: correction banner
  (SQL-hub replication shipped 8e12f994; KEK-rotation + clustered-secrets
  runbooks ship with ZB.MOM.WW.Secrets)
- docs/plans/2026-07-19-localdb-phase2-live-gate.md: external-system-delete
  observation annotated RESOLVED (2d03f2d5 reconciles deletions incl.
  external_systems)

Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
2026-08-07 01:56:33 -04:00

169 lines
8.2 KiB
Markdown

# Secrets: Clustered Master-Key Posture (Central Pair)
> **Update 2026-08-07 (truth sweep):** two claims below are stale.
> (1) "there is no built-in cross-node replication today" — cross-node replication
> has since shipped: ScadaBridge adopted opt-in **SQL-Server hub replication** for
> the host secret store (commit `8e12f994`, "feat(secrets): opt-in SQL-Server hub
> replication for the host secret store"; `ZB.MOM.WW.Secrets` 0.2.x Replicator
> packages), covered by the shared library's clustered-secrets runbook
> (`scadaproj/ZB.MOM.WW.Secrets/docs/operations/clustered-secrets.md`).
> (2) the G-8 KEK-rotation runbook is no longer "not yet built" — it ships with the
> shared library at `scadaproj/ZB.MOM.WW.Secrets/docs/operations/kek-rotation.md`
> (lib 0.1.3, `Rewrap`/`rewrap-all`). The interim shared-volume posture below
> remains valid but is no longer the only option.
## Purpose
`ZB.MOM.WW.Secrets` resolves `${secret:...}` tokens in `appsettings.*.json` via a
pre-host expander that runs at every Central-role boot, before `StartupValidator`
(Host-003). It reads rows from an envelope-encrypted SQLite store
(`Secrets:SqlitePath`) unwrapped with a key-encryption key (KEK) sourced per
`Secrets:MasterKey:Source`.
ScadaBridge's central role is an Akka.NET-clustered **pair** (`central-a` /
`central-b`, see [failover-procedures.md](./failover-procedures.md)) — either node
can be active, and both boot independently. This runbook covers what a production
deployment of the central pair (and any site node that is ever given a `${secret:}`
token) needs so that secret resolution behaves identically no matter which node is
running. It does **not** change any code; it is an operations/deployment posture,
delivered out-of-band from the committed config.
## The two hard requirements
For the pre-host expander to resolve the same plaintext secret on every node:
1. **Identical KEK on every node.** All central nodes must unwrap the store with
the exact same master key. A per-node KEK (e.g. two different DPAPI-protected
keys, one per Windows box) would make each node decrypt the *other* node's
ciphertext rows to garbage.
2. **Identical store rows on every node.** All central nodes must read the same
SQLite database (same file, or a replicated/shared copy with the same rows) —
not two independently-seeded stores that happen to use the same KEK.
`ZB.MOM.WW.Secrets` ships a SQLite-only `ISecretStore` with a `NoOpSecretReplicator`
— there is no built-in cross-node replication today. Meeting both requirements in
production is a deployment concern, covered below.
## Recommended interim posture (G-5)
Until real replication exists (G-7, below), the recommended production posture is:
- **`Secrets:MasterKey:Source = File`**, with `FilePath` pointing at a **read-only
key file that is identical on every central node** — a base64-encoded 32-byte
key, generated out-of-band (e.g. `openssl rand -base64 32`), distributed to each
node's filesystem/secret-mount by the deployment tooling, and **never committed**
to the repo. Treat it with the same discipline as any other production secret
(restrictive file ACLs, no logging, rotated via the KEK-rotation runbook — G-8,
not yet built).
- **`Secrets:SqlitePath`** pointing at a **single shared or replicated volume**
that both central nodes mount, so every node's `SqliteSecretsStoreMigrator` opens
and reads the same rows.
Writes to the store are rare and human-driven — an operator using the
`/admin/secrets` UI (G-6) or the `ZB.MOM.WW.Secrets` CLI on one node — while reads
happen on every boot and on the `ResolveCacheTtl` refresh cycle on both nodes. The
access pattern is read-mostly / effectively single-writer, which is what makes a
shared SQLite volume viable as an interim posture (see caveat below).
## How it's delivered (do NOT commit these values)
The File-KEK + shared-store posture is supplied per-node at deployment time —
**never** by editing the committed `appsettings.json` or
`appsettings.Central.json`. Two acceptable delivery mechanisms:
**Option A — environment variable overrides** (Windows Service / NSSM env block,
container `env_file`, etc.), applied identically on `central-a` and `central-b`:
```
# production deployment — do not commit to the dev appsettings
Secrets__MasterKey__Source=File
Secrets__MasterKey__FilePath=/run/secrets/scadabridge-master.key
Secrets__SqlitePath=/shared/secrets/scadabridge-secrets.db
```
**Option B — a production-only config layer** that is *not* the committed dev
base (e.g. an untracked `appsettings.Production.json` deployed alongside the
binaries, or an orchestrator-injected config mount):
```jsonc
// production deployment — do not commit to the dev appsettings
{
"Secrets": {
"MasterKey": {
"Source": "File",
"FilePath": "/run/secrets/scadabridge-master.key"
},
"SqlitePath": "/shared/secrets/scadabridge-secrets.db"
}
}
```
Either way, the file/path referenced must exist and be identical on every central
node **before** that node boots — the expander runs unconditionally and will throw
(`SecretNotFoundException` / migration failure) if the store or key is missing.
## Caveat: SQLite over a shared volume is not real replication
SQLite's file-locking model does not tolerate concurrent multi-writer access well
over network filesystems (SMB/NFS locking is unreliable, and even on a clustered
block volume only one writer should be active at a time). The interim posture
above is acceptable because:
- Reads dominate (every boot + cache-refresh cycle on both nodes).
- Writes are rare, human-initiated, and effectively single-writer in practice
(an operator runs the CLI/UI against one node at a time).
It is **not** a substitute for real replication, and it is not safe if both nodes
attempt concurrent writes. Do not build automation that writes secrets from both
central nodes simultaneously.
## Data Protection is independent — do not touch it here
ScadaBridge's cookie/session and hub-token protection already has its own
clustered-key story: `AddDataProtection().PersistKeysToDbContext<ScadaBridgeDbContext>()`
(see `docs/components/Security.md`), which shares the Data Protection key ring
across both central nodes via the existing MS SQL `ConfigurationDb`. That mechanism
is unrelated to `ZB.MOM.WW.Secrets`' envelope encryption (KEK + SQLite store) and
must **not** be reconfigured as part of secrets-adoption work — doing so risks
invalidating active sessions/cookies for an unrelated reason.
## The G-7 hand-off
The posture above is an interim, ops-only workaround. The long-term shape,
tracked as **G-7** in `scadaproj/components/secrets/GAPS.md`, is one of:
- A ConfigDb-backed `ISecretStore` — mirroring the pattern ScadaBridge already
uses for the Data Protection key ring (`PersistKeysToDbContext`), giving both
central nodes a single MS SQL-backed source of truth for secret rows instead of
a shared SQLite file; or
- The `ZB.MOM.WW.Secrets.Akka` replicator (LWW + anti-entropy resync +
tombstones) referenced in the library's SPEC.
Both require new library code that does not exist yet. This runbook's posture is
the bridge until one of those lands.
## Dev/test/default posture (unchanged)
The committed default in `appsettings.json` is:
```json
"Secrets": {
"SqlitePath": "scadabridge-secrets.db",
"MasterKey": { "Source": "Environment", "EnvVarName": "ZB_SECRETS_MASTER_KEY" },
"RunMigrationsOnStartup": true,
"ResolveCacheTtl": "00:00:30"
}
```
This is dev-safe: `Source=Environment` needs no filesystem key, and the SQLite
path is relative to the working directory, so local dev and the
`WebApplicationFactory<Program>` `Host.Tests` boot cleanly with no external mount.
Local dev and docker-compose environments supply concrete secret values via the
**whole-key environment override** (e.g. `ScadaBridge__Database__ConfigurationDb`),
which bypasses `${secret:...}` resolution entirely, so the expander is effectively
a no-op there. The File-KEK + shared-volume posture in this runbook applies only
to real clustered production deployments of the central pair — it must never be
baked into the committed dev base, because the expander runs unconditionally at
every Central boot and would break dev/CI if pointed at a nonexistent `/shared`
mount.