feat(transport): optional detached bundle signing with a pluggable key source (ScadaBridge#45)

Exports can now carry a detached ECDSA P-256 signature over the exact
manifest.json bytes in a new optional manifest.sig zip entry. The manifest
already pins the content hash, so a verified signature covers the whole
bundle, including the plaintext source environment the confirm step relies
on. Off by default (ScadaBridge:Transport:Signing): with the defaults,
exports are unsigned and imports accept signed and unsigned bundles.

- The bundle format does not change: bundleFormatVersion stays 1,
  manifest.json and the AES-GCM associated data are untouched, and older
  importers open entries by name and ignore manifest.sig. Three entries fit
  within the default MaxBundleEntryCount of 4.
- Import policy runs after the content-hash check and before any passphrase
  work. A signature that fails to verify against a trusted key is always
  refused. RequireSignedBundles additionally refuses unsigned bundles and
  bundles signed by an untrusted key. A refusal is BundleSignatureException
  (Commons), shown on the UI upload step and on CLI preview/import.
- The signature status, key id and signer are recorded on BundleSession and
  shown in the import wizard. They are also returned on PreviewBundleResult
  and recorded on the BundleImported audit row. BundleExported records
  Signed/SigningKeyId.
- Key material is pluggable. IBundleSigningKeySource and IBundleTrustStore
  are TryAdd'd; the defaults read Signing:PrivateKey (meant to be a
  ${secret:} reference) and Signing:TrustedKeys. Key ids are SPKI
  fingerprints. Startup validation rejects unparseable or non-P-256 keys.
- Compatibility proof: the checked-in fixtures pre-signing-plaintext and
  pre-signing-encrypted were exported by origin/main 3ff463b6, before this
  change, and are imported end to end with signing off and with signing
  configured.

The design leaves key-per-env vs per-org, trust distribution and rotation,
and the production default for RequireSignedBundles open. These are listed
in Component-Transport.md.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
Joseph Doherty
2026-11-09 17:31:55 -05:00
parent 1ae4c7a4cb
commit 205a06a0f1
26 changed files with 1528 additions and 11 deletions
+39 -2
View File
@@ -25,6 +25,7 @@ As of M8 (T18), Transport is no longer limited to central-only configuration: it
- Serialize and deserialize all transportable entity types to/from bundle DTOs, carving secret fields into an isolated `SecretsBlock`. A template travels with **all** of its child collections — `TemplateAttribute`, `TemplateAlarm`, `TemplateScript`, `TemplateComposition`, and `TemplateNativeAlarmSource` (including `IsInherited` placeholder rows, which the collision detector depends on) — each field-faithful on Add and reconciled add/update/delete on Overwrite. Field completeness is verified by a reflection round-trip guard over every writable property (Add + Overwrite), and includes the fields earlier revisions dropped: the derived-lock flag `LockedInDerived` on attributes/alarms/scripts, script cadence/timeout (`MinTimeBetweenRuns` / `ExecutionTimeoutSeconds`), and — for instances — the real `AreaName` (the exporter previously emitted `null`, half-shipping the Area-by-name feature). Carrying the native alarm sources is what keeps an imported `InstanceNativeAlarmSourceOverride` from dangling against a source the target template would otherwise lack.
- Encrypt content with AES-256-GCM + PBKDF2-SHA256 (600 000 iterations) when a passphrase is supplied; leave content plaintext with a UI warning and an `UnencryptedBundleExport` audit event when none is given.
- Validate `manifest.json` on upload: format version gating, SHA-256 content hash verification.
- **Optionally sign** exported bundles (ECDSA P-256 detached signature in `manifest.sig`) and enforce an import-side signature policy against a trust store — off by default (ScadaBridge#45; see "Bundle Signing").
- Manage in-memory `BundleSession` objects: 30-minute TTL, 3-strike passphrase lockout per session.
- Compute a per-artifact diff between bundle contents and the target environment, classifying each artifact as Identical, Modified, New, a Blocker (import-stopping), or a Warning (advisory, non-blocking).
- Apply user-supplied conflict resolutions (Add, Overwrite, Skip, Rename) in a single EF transaction, running multi-pass semantic validation before committing: a **script trust gate** (`ScriptTrustValidator.FindViolations` over every non-Skip executable C# surface a bundle carries — template / shared / ApiMethod script bodies, template script **and** alarm **Expression-trigger** bodies, **and instance alarm-override trigger expressions** (`InstanceAlarmOverrideDto.TriggerConfigurationOverride`); bundle import is the fifth script-trust call site, so forbidden-API scripts are rejected at import review, not deferred to runtime — all these surfaces hard-block uniformly, since the trust gate's semantic verdict is authoritative with no false-positive channel, and the advisory-vs-hard severity split is a property of the name-resolution heuristic only), then a minimal name-resolution scan over the merged target (ApiMethod findings fail fast on unresolved SharedScript / ExternalSystem identifiers; template-script findings are advisory warnings), then the full `SemanticValidator` from `ZB.MOM.WW.ScadaBridge.TemplateEngine` over each imported template's per-template `FlattenedConfiguration`.
@@ -42,10 +43,11 @@ As of M8 (T18), Transport is no longer limited to central-only configuration: it
bundle.scadabundle
├── manifest.json # required, NOT encrypted
├── content.json # plaintext artifact data (when no passphrase)
└── content.enc # encrypted artifact data (when passphrase set)
├── content.enc # encrypted artifact data (when passphrase set)
└── manifest.sig # OPTIONAL detached signature over manifest.json (ScadaBridge#45)
```
Exactly one of `content.json` or `content.enc` is present. Script bodies (template
Exactly one of `content.json` or `content.enc` is present. `manifest.sig` is present only when the exporting cluster has signing enabled; a pre-#45 importer opens entries by name and never reads it, and three entries sit inside the default `MaxBundleEntryCount` (4). Script bodies (template
scripts, shared scripts, ApiMethod scripts) travel **inline** inside the `content`
blob, not as separate `scripts/` files — a well-formed bundle has exactly two zip
entries (`manifest.json` + one content blob). A separate `scripts/` directory is
@@ -325,6 +327,7 @@ As of M8 the import result enumerates affected instances for real (closing the p
| Upload | Not a zip / missing `manifest.json` | Step 1 error: "Not a valid ScadaBridge bundle" |
| Upload | `bundleFormatVersion` newer than supported | Step 1 error: "Bundle was created by ScadaBridge v{x}; upgrade this cluster" |
| Upload | Content hash mismatch | Step 1 error: "Bundle integrity check failed — file may be corrupt" |
| Upload | Signature fails the policy (ScadaBridge#45) — altered/forged signature, malformed `manifest.sig`, or unsigned/untrusted with `RequireSignedBundles` on | Step 1 error: "Bundle signature rejected: …" (checked before the passphrase; no unlock attempt spent) |
| Unlock | Wrong passphrase | Step 2 error; 3rd wrong attempt invalidates session, audit `BundleImportUnlockFailed` |
| Map | Source site/connection neither auto-matched nor mapped/created | Listed as a blocker row in the Map step (UI) / Diff step; cannot Apply until mapped or set to Create-new. CLI aborts before apply unless `--create-missing-sites` / `--create-missing-connections` is given |
| Map | `CreateNew` selected but the bundle does not carry that site/connection's config | Blocker — Create-new requires the full config to be present in the bundle payload |
@@ -352,6 +355,40 @@ Imports are all-or-nothing per bundle. A bundle either applies fully or not at a
- **Defense in depth on authorization:** `RequireDesign` (export) and `RequireAdmin` (import) are enforced both on the Razor page and inside `ZB.MOM.WW.ScadaBridge.Transport` service entrypoints. The UI is not the only gate.
- **Bundles are not retained server-side** after download (export) or after `ApplyAsync` commits (import).
### Bundle Signing (ScadaBridge#45)
Signing adds **non-repudiation** on top of the passphrase encryption — the passphrase proves only that someone who knew it built the bundle; a signature proves *which cluster* did. It is **off by default** and purely additive: `bundleFormatVersion` stays `1`, `manifest.json` gains no field, and the AES-GCM associated data (`BundleManifestAad`) is untouched.
- **What is signed.** The exporter serialises the final `manifest.json`, signs those exact bytes with ECDSA P-256 / SHA-256 (IEEE P1363 signature), and writes `manifest.sig` = `{"Algorithm":"ECDSA-P256-SHA256","KeyId":"sha256:<hex>","SignatureB64":"..."}`. The manifest carries `contentHash`, and the importer already rejects a content entry that does not match it, so a verified signature authenticates the whole bundle — including the plaintext `sourceEnvironment` the confirm step asks the operator to retype, which for an **unencrypted** bundle nothing else protects. Signing raw bytes (detached) avoids any JSON canonicalisation, so a future additive manifest field cannot break verification on an older cluster.
- **Key id.** `sha256:` + hex SHA-256 of the key's SubjectPublicKeyInfo DER — derived from the key, never configured, so a trust-store entry cannot claim another key's id.
- **Import policy** (`BundleImporter.LoadAsync`, after the content-hash check and **before** any passphrase work, so a refusal spends no unlock attempt):
| Bundle | `RequireSignedBundles=false` (default) | `RequireSignedBundles=true` |
|---|---|---|
| No `manifest.sig` (every pre-#45 bundle) | loads, status **Unsigned** | refused |
| Signed by a key not in `TrustedKeys` | loads, status **UntrustedKey** (fingerprint shown) | refused |
| Signed by a trusted key, verifies | loads, status **Verified** (+ trust-store name) | loads, **Verified** |
| Signed by a trusted key, does not verify / malformed `manifest.sig` / unknown algorithm | **refused** | **refused** |
A refusal is `BundleSignatureException` (Commons, curated on the Central UI upload step and on the CLI `bundle preview` / `bundle import`). The status rides on `BundleSession` (`SignatureStatus` / `SignerKeyId` / `SignerName`), is shown in the import wizard's manifest summary, is returned on `PreviewBundleResult`, and is recorded on the `BundleImported` audit row; `BundleExported` records `Signed` + `SigningKeyId`.
- **Configuration** (`ScadaBridge:Transport:Signing`, startup-validated — a key that does not parse fails boot):
```json
"Signing": {
"Enabled": false, // sign exports
"PrivateKey": "${secret:SB-BUNDLE-SIGNING-KEY}", // P-256, PKCS#8/SEC1 PEM or base64 PKCS#8 DER
"RequireSignedBundles": false, // refuse unsigned / untrusted bundles on import
"TrustedKeys": [ { "Name": "dev-cluster-a", "PublicKey": "-----BEGIN PUBLIC KEY-----..." } ]
}
```
Generate a key pair with `openssl ecparam -name prime256v1 -genkey -noout | openssl pkcs8 -topk8 -nocrypt -out bundle-signing.pem` and `openssl ec -in bundle-signing.pem -pubout`. The private key belongs in the encrypted secret store, referenced as `${secret:}` (expanded pre-host like every other config secret); both central nodes of a cluster share it, since either may run the export.
- **Pluggable key material.** `IBundleSigningKeySource` (export key) and `IBundleTrustStore` (fingerprint → trusted public key) are `TryAdd`-registered; a host can register its own (HSM/KMS signing, a shared trust registry) before `AddTransport()`.
- **Open decisions** (not settled by the original design, recorded here rather than guessed in code): (1) whether each environment gets its own signing key or one key per organisation; (2) how trust is distributed and rotated beyond hand-edited `TrustedKeys` (a trust bundle in the config DB editable from the Admin UI? key expiry/revocation?); (3) whether `RequireSignedBundles` should become the default at the production boundary once every exporting cluster signs; (4) whether the default key source should read the Secrets store directly (by secret name) instead of through `${secret:}` config expansion.
- **Compatibility proof.** `Transport.IntegrationTests/Fixtures/pre-signing-*.scadabundle` were exported by the pre-#45 code and are imported end to end by `LegacyBundleCompatibilityTests` (plaintext and encrypted, signing off and on); `SignedBundleLoadTests.Signed_bundle_is_readable_by_pre_signing_read_path` shows the reverse direction.
Cluster-to-cluster pull and differential bundles (the other two items deferred with signing) are **not implemented**; their design questions are recorded in `docs/plans/2026-11-09-transport-pull-and-differential-addendum.md`.
## Configuration Audit Trail
Import flows through the same audited repository methods the UI and CLI use, so every artifact mutated by `ApplyAsync` emits the existing per-entity `AuditLogEntry` row: