diff --git a/docs/requirements/Component-Transport.md b/docs/requirements/Component-Transport.md index 39272d11..cea9831f 100644 --- a/docs/requirements/Component-Transport.md +++ b/docs/requirements/Component-Transport.md @@ -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:","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: diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/TransportImport.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/TransportImport.razor index f2536614..98906942 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/TransportImport.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/TransportImport.razor @@ -119,6 +119,23 @@
SHA-256
@_session.Manifest.ContentHash
+
Signature
+
+ @switch (_session.SignatureStatus) + { + case BundleSignatureStatus.Verified: + Verified — signed by @_session.SignerName + break; + case BundleSignatureStatus.UntrustedKey: + Signed by an untrusted key + @_session.SignerKeyId + break; + default: + Unsigned + break; + } +
+
Encryption
@if (_session.Manifest.Encryption is null) diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/TransportImport.razor.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/TransportImport.razor.cs index 3789134f..f6f7fe3e 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/TransportImport.razor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/TransportImport.razor.cs @@ -298,6 +298,14 @@ public partial class TransportImport : ComponentBase, IDisposable _session = null; throw; } + catch (BundleSignatureException ex) + { + // ScadaBridge#45: the signature policy refused the bundle (forged/altered + // signature, or an unsigned/untrusted bundle on a cluster that requires + // signing). Checked before the passphrase, so no unlock attempt was spent. + _session = null; + _errorMessage = $"Bundle signature rejected: {ex.Message}"; + } catch (InvalidDataException ex) { _session = null; diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/TransportCommands.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/TransportCommands.cs index 182c1f6e..0a012bfc 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/TransportCommands.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/TransportCommands.cs @@ -72,6 +72,19 @@ public sealed record PreviewBundleResult( /// Connection references the operator must resolve before import. Never null. public IReadOnlyList RequiredConnectionMappings { get; init; } = RequiredConnectionMappings ?? Array.Empty(); + + /// + /// ScadaBridge#45: the bundle's signature status — Unsigned, Verified or + /// UntrustedKey (the BundleSignatureStatus name). Additive; defaults to + /// Unsigned. + /// + public string SignatureStatus { get; init; } = nameof(BundleSignatureStatus.Unsigned); + + /// ScadaBridge#45: the signing key's sha256: fingerprint; null when unsigned. + public string? SignerKeyId { get; init; } + + /// ScadaBridge#45: the trusted key's name when the signature verified; otherwise null. + public string? SignerName { get; init; } } /// diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Transport/BundleSession.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Transport/BundleSession.cs index f525dadf..6c8ae1a8 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Transport/BundleSession.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Transport/BundleSession.cs @@ -23,6 +23,22 @@ public sealed class BundleSession /// session. Empty until the bundle is successfully unlocked. /// public byte[] DecryptedContent { get; init; } = Array.Empty(); + /// + /// ScadaBridge#45: outcome of the detached-signature check made when the bundle was + /// loaded. for every bundle produced before + /// signing existed. + /// + public BundleSignatureStatus SignatureStatus { get; init; } = BundleSignatureStatus.Unsigned; + /// + /// ScadaBridge#45: the sha256: fingerprint of the key that signed the bundle, when + /// it is signed (verified or not); null for an unsigned bundle. + /// + public string? SignerKeyId { get; init; } + /// + /// ScadaBridge#45: the trust-store name of the signing key when the signature verified; + /// null otherwise. + /// + public string? SignerName { get; init; } /// UTC timestamp after which this session is considered expired and must be re-uploaded. public DateTimeOffset ExpiresAt { get; init; } /// diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Transport/BundleSignatureException.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Transport/BundleSignatureException.cs new file mode 100644 index 00000000..44418c43 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Transport/BundleSignatureException.cs @@ -0,0 +1,21 @@ +namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Transport; + +/// +/// ScadaBridge#45: thrown by IBundleImporter.LoadAsync when a bundle fails the +/// signature policy — a signature that does not verify against the trusted key it names, +/// a malformed manifest.sig, or (with Transport:Signing:RequireSignedBundles +/// on) an unsigned bundle or one signed by an untrusted key. +/// +/// Lives in Commons, beside , so the management surface — which +/// references Commons but not Transport — can curate it for the CLI. +/// +/// +public sealed class BundleSignatureException : Exception +{ + /// Initializes a new . + /// Operator-facing reason the bundle was refused. + public BundleSignatureException(string message) + : base(message) + { + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Transport/BundleSignatureStatus.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Transport/BundleSignatureStatus.cs new file mode 100644 index 00000000..eff2eaa3 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Transport/BundleSignatureStatus.cs @@ -0,0 +1,24 @@ +namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Transport; + +/// +/// ScadaBridge#45: the outcome of checking a bundle's optional detached signature +/// (manifest.sig) when it is loaded. A signature that is present, names a trusted +/// key and does NOT verify is never a status — it is a hard +/// , because it means the manifest (and therefore +/// the content it hashes) was altered after signing. +/// +public enum BundleSignatureStatus +{ + /// The bundle carries no signature (every bundle produced before signing existed). + Unsigned = 0, + + /// The signature verified against a key in this cluster's trust store. + Verified = 1, + + /// + /// The bundle is signed, but by a key this cluster does not trust, so the signature + /// could not be checked. Accepted only while Transport:Signing:RequireSignedBundles + /// is off. + /// + UntrustedKey = 2, +} diff --git a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs index d4175567..7a96ed5e 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs @@ -4543,6 +4543,13 @@ public class ManagementActor : ReceiveActor // message; no per-bundle strike was burned. throw new ManagementCommandException(ex.Message); } + catch (BundleSignatureException ex) + { + // ScadaBridge#45: signature policy refusal (forged/altered signature, or an + // unsigned/untrusted bundle where signing is required). Curated so the CLI + // prints the reason. + throw new ManagementCommandException(ex.Message); + } // Register row 43: a preview NEVER applies, so nothing else would ever // release this session — every `transport preview` used to burn one of @@ -4559,7 +4566,13 @@ public class ManagementActor : ReceiveActor // (CLI or UI) sees which references need resolving before applying. return new PreviewBundleResult( preview.Items, adds, mods, ids, blocks, - preview.RequiredSiteMappings, preview.RequiredConnectionMappings); + preview.RequiredSiteMappings, preview.RequiredConnectionMappings) + { + // ScadaBridge#45: who signed the bundle, so a scripted preview can gate on it. + SignatureStatus = session.SignatureStatus.ToString(), + SignerKeyId = session.SignerKeyId, + SignerName = session.SignerName, + }; } finally { @@ -4619,6 +4632,13 @@ public class ManagementActor : ReceiveActor // message; no per-bundle strike was burned. throw new ManagementCommandException(ex.Message); } + catch (BundleSignatureException ex) + { + // ScadaBridge#45: signature policy refusal (forged/altered signature, or an + // unsigned/untrusted bundle where signing is required). Curated so the CLI + // prints the reason. + throw new ManagementCommandException(ex.Message); + } // Register row 43: ApplyAsync releases the session on both its success // and failure paths, but every abort BEFORE it (blockers, unresolved diff --git a/src/ZB.MOM.WW.ScadaBridge.Transport/Export/BundleExporter.cs b/src/ZB.MOM.WW.ScadaBridge.Transport/Export/BundleExporter.cs index b83825f6..8a54e9f6 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Transport/Export/BundleExporter.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Transport/Export/BundleExporter.cs @@ -7,6 +7,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport; using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase; using ZB.MOM.WW.ScadaBridge.Transport.Encryption; using ZB.MOM.WW.ScadaBridge.Transport.Serialization; +using ZB.MOM.WW.ScadaBridge.Transport.Signing; namespace ZB.MOM.WW.ScadaBridge.Transport.Export; @@ -27,6 +28,9 @@ public sealed class BundleExporter : IBundleExporter private readonly IAuditService _auditService; private readonly ScadaBridgeDbContext _dbContext; private readonly IOptions _options; + // ScadaBridge#45: optional — null (or a signer whose key source yields no key) packs + // an unsigned bundle exactly as before signing existed. + private readonly BundleSigner? _signer; /// Initializes the exporter with all pipeline collaborators. /// Resolves template dependency graphs and transitive entities. @@ -37,6 +41,7 @@ public sealed class BundleExporter : IBundleExporter /// Records one audit row per export operation. /// Database context for reading export-time entity state. /// Transport options (e.g., format version). + /// Optional (ScadaBridge#45): signs the manifest when a signing key is configured. public BundleExporter( DependencyResolver resolver, EntitySerializer entitySerializer, @@ -45,7 +50,8 @@ public sealed class BundleExporter : IBundleExporter BundleSerializer bundleSerializer, IAuditService auditService, ScadaBridgeDbContext dbContext, - IOptions options) + IOptions options, + BundleSigner? signer = null) { _resolver = resolver ?? throw new ArgumentNullException(nameof(resolver)); _entitySerializer = entitySerializer ?? throw new ArgumentNullException(nameof(entitySerializer)); @@ -55,6 +61,7 @@ public sealed class BundleExporter : IBundleExporter _auditService = auditService ?? throw new ArgumentNullException(nameof(auditService)); _dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext)); _options = options ?? throw new ArgumentNullException(nameof(options)); + _signer = signer; } /// @@ -178,7 +185,21 @@ public sealed class BundleExporter : IBundleExporter // 5. Pack the zip; with a passphrase, Pack re-encrypts and re-stamps // EncryptionMetadata (fresh salt/iv) + ContentHash. - var zipStream = _bundleSerializer.PackContentBytes(contentBytes, templateManifest, passphrase, _encryptor); + // ScadaBridge#45: when a signing key is configured the final manifest bytes are + // signed into a detached manifest.sig entry; the key id is captured for the audit. + string? signingKeyId = null; + var zipStream = _bundleSerializer.PackContentBytes( + contentBytes, templateManifest, passphrase, _encryptor, + _signer is null ? null : manifestBytes => + { + var sig = _signer.Sign(manifestBytes); + if (sig is not null) + { + signingKeyId = System.Text.Json.JsonDocument.Parse(sig).RootElement + .GetProperty(nameof(BundleSignatureEnvelope.KeyId)).GetString(); + } + return sig; + }); // 6. Audit — entityId is the bundle's overall SHA-256 over the zip bytes // (opaque to caller but uniquely identifies this export). Action @@ -194,6 +215,8 @@ public sealed class BundleExporter : IBundleExporter { SourceEnvironment = sourceEnvironment, Encrypted = passphrase is not null, + Signed = signingKeyId is not null, + SigningKeyId = signingKeyId, ArtifactCount = resolved.ContentManifest.Count, Summary = summary, }, diff --git a/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs b/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs index c932cc44..b046204d 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs @@ -31,6 +31,7 @@ using ZB.MOM.WW.ScadaBridge.TemplateEngine; using ZB.MOM.WW.ScadaBridge.TemplateEngine.Validation; using ZB.MOM.WW.ScadaBridge.Transport.Encryption; using ZB.MOM.WW.ScadaBridge.Transport.Serialization; +using ZB.MOM.WW.ScadaBridge.Transport.Signing; namespace ZB.MOM.WW.ScadaBridge.Transport.Import; @@ -103,6 +104,9 @@ public sealed class BundleImporter : IBundleImporter // what the import wrote. Null on hosts without the central cache-invalidation bus. private readonly ICentralCacheInvalidationPublisher? _cacheInvalidationPublisher; private readonly ILogger? _logger; + // ScadaBridge#45: signature policy. Never null — when no signer is injected the + // importer uses one with no trusted keys that still honours RequireSignedBundles. + private readonly BundleSigner _signer; /// /// Initializes a new with all required dependencies. @@ -130,6 +134,7 @@ public sealed class BundleImporter : IBundleImporter /// Optional: template-graph version watermark, bumped once per apply attempt so the flatten-session caches and the process-wide staleness memo cannot serve pre-import (or rolled-back) readings. Null on hosts that don't register it. /// Optional (Gitea #83): drops this node's external-system / database-connection definition cache after a committed import, so imported definitions take effect immediately rather than after the cache TTL. Null on hosts without the External System Gateway. /// Optional (ScadaBridge#100 item 3): tells the peer central node to drop its caches of what a committed import wrote. Null on hosts without the central cache-invalidation bus. + /// Optional (ScadaBridge#45): the bundle signature policy. Null falls back to a signer with no trusted keys that still honours Signing:RequireSignedBundles. public BundleImporter( BundleSerializer bundleSerializer, ManifestValidator manifestValidator, @@ -153,7 +158,8 @@ public sealed class BundleImporter : IBundleImporter ILogger? logger = null, ITemplateGraphWatermark? watermark = null, IExternalDefinitionCacheInvalidator? definitionCacheInvalidator = null, - ICentralCacheInvalidationPublisher? cacheInvalidationPublisher = null) + ICentralCacheInvalidationPublisher? cacheInvalidationPublisher = null, + BundleSigner? signer = null) { _bundleSerializer = bundleSerializer ?? throw new ArgumentNullException(nameof(bundleSerializer)); _manifestValidator = manifestValidator ?? throw new ArgumentNullException(nameof(manifestValidator)); @@ -178,6 +184,7 @@ public sealed class BundleImporter : IBundleImporter _definitionCacheInvalidator = definitionCacheInvalidator; _cacheInvalidationPublisher = cacheInvalidationPublisher; _logger = logger; + _signer = signer ?? BundleSigner.CreateWithoutKeys(options); } /// @@ -203,7 +210,8 @@ public sealed class BundleImporter : IBundleImporter // buffered inside ReadEnvelopeAsync only, so it becomes unreachable as soon // as the manifest and the content entry have been extracted, rather than // staying live alongside the decrypted copy for the rest of this method. - var (manifest, contentBytes) = await ReadEnvelopeAsync(bundleStream, ct).ConfigureAwait(false); + var (manifest, contentBytes, manifestBytes, signatureBytes) = + await ReadEnvelopeAsync(bundleStream, ct).ConfigureAwait(false); // Validate format version + content-hash + manifest shape. Reject paths // surface as distinct exceptions so the UI can disambiguate the cause. @@ -224,6 +232,14 @@ public sealed class BundleImporter : IBundleImporter throw new InvalidDataException($"Unrecognised manifest validation result: {validation}."); } + // ScadaBridge#45: signature policy. Checked after the content-hash check (so a + // verified signature over the manifest transitively covers the content bytes) and + // BEFORE any passphrase work, so an unsigned or forged bundle on a cluster that + // requires signatures is refused without spending an unlock attempt. A bundle with + // no manifest.sig — every bundle produced before signing existed — is Unsigned and + // loads exactly as before unless RequireSignedBundles is on. + var signature = _signer.Verify(manifestBytes, signatureBytes); + // Decrypt when the manifest carries EncryptionMetadata. // // T-003: lockout enforcement is server-side and keyed by ContentHash so a @@ -335,6 +351,9 @@ public sealed class BundleImporter : IBundleImporter Manifest = manifest, DecryptedContent = sessionContent, ExpiresAt = _timeProvider.GetUtcNow() + ttl, + SignatureStatus = signature.Status, + SignerKeyId = signature.KeyId, + SignerName = signature.SignerName, }; return _sessionStore.Open(session); } @@ -347,7 +366,7 @@ public sealed class BundleImporter : IBundleImporter /// The uploaded bundle stream (need not be seekable). /// Cancellation token. /// The parsed manifest and the content entry's bytes (ciphertext when encrypted). - private async Task<(BundleManifest Manifest, byte[] ContentBytes)> ReadEnvelopeAsync( + private async Task<(BundleManifest Manifest, byte[] ContentBytes, byte[] ManifestBytes, byte[]? SignatureBytes)> ReadEnvelopeAsync( Stream bundleStream, CancellationToken ct) { // Copy to a seekable buffer — manifest + content readers each open a @@ -380,10 +399,13 @@ public sealed class BundleImporter : IBundleImporter ValidateArchiveEnvelope(ms); BundleManifest manifest; + byte[] manifestBytes; try { ms.Position = 0; - manifest = _bundleSerializer.ReadManifest(ms); + // ScadaBridge#45: keep the exact manifest bytes — a bundle signature covers them. + manifestBytes = _bundleSerializer.ReadManifestBytes(ms); + manifest = _bundleSerializer.DeserializeManifest(manifestBytes); } catch (InvalidDataException) { @@ -398,7 +420,9 @@ public sealed class BundleImporter : IBundleImporter ms.Position = 0; var contentBytes = _bundleSerializer.ReadContentBytes(ms, manifest); - return (manifest, contentBytes); + ms.Position = 0; + var signatureBytes = _bundleSerializer.ReadSignatureBytes(ms); + return (manifest, contentBytes, manifestBytes, signatureBytes); } /// @@ -1965,6 +1989,10 @@ public sealed class BundleImporter : IBundleImporter BundleImportId = bundleImportId, session.Manifest.SourceEnvironment, session.Manifest.ContentHash, + // ScadaBridge#45: who signed what was imported — the non-repudiation record. + SignatureStatus = session.SignatureStatus.ToString(), + session.SignerKeyId, + session.SignerName, Summary = new { summary.Added, diff --git a/src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/BundleSerializer.cs b/src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/BundleSerializer.cs index 86a1ba38..17a49953 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/BundleSerializer.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/BundleSerializer.cs @@ -25,6 +25,13 @@ public sealed class BundleSerializer private const string ContentJsonEntryName = "content.json"; private const string ContentEncEntryName = "content.enc"; + /// + /// ScadaBridge#45: optional detached signature over the exact manifest.json + /// bytes. Additive — a pre-signing importer opens entries by name, so it never reads + /// this one, and the default MaxBundleEntryCount (4) already admits a third entry. + /// + public const string SignatureEntryName = "manifest.sig"; + // All bundle content serialization goes through BundleJsonOptions.Default — // see that class for the rationale (WhenWritingNull + unknown-member tolerance). private static readonly JsonSerializerOptions JsonOptions = BundleJsonOptions.Default; @@ -71,12 +78,18 @@ public sealed class BundleSerializer /// The bundle manifest describing the content and encryption metadata. /// Passphrase used for encryption; null produces a plaintext bundle. /// Encryptor required when is supplied. + /// + /// ScadaBridge#45: optional signer over the final manifest.json bytes; a non-null + /// result is written as the entry. null (the default) + /// or a null result packs an unsigned bundle, byte-for-byte as before. + /// /// A seeked-to-start containing the bundle ZIP archive. public Stream PackContentBytes( byte[] contentBytes, BundleManifest manifest, string? passphrase, - BundleSecretEncryptor? encryptor) + BundleSecretEncryptor? encryptor, + Func? signManifest = null) { ArgumentNullException.ThrowIfNull(contentBytes); ArgumentNullException.ThrowIfNull(manifest); @@ -124,11 +137,16 @@ public sealed class BundleSerializer } var manifestBytes = JsonSerializer.SerializeToUtf8Bytes(finalManifest, JsonOptions); + var signatureBytes = signManifest?.Invoke(manifestBytes); var ms = new MemoryStream(); using (var archive = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true)) { WriteEntry(archive, ManifestEntryName, manifestBytes); WriteEntry(archive, payloadEntryName, payload); + if (signatureBytes is not null) + { + WriteEntry(archive, SignatureEntryName, signatureBytes); + } } ms.Position = 0; @@ -154,6 +172,58 @@ public sealed class BundleSerializer return manifest; } + /// + /// ScadaBridge#45: returns the exact bytes of the manifest.json entry — what a + /// bundle signature covers. Deserialise them with . + /// + /// The ZIP stream containing the bundle. + /// The raw manifest bytes. + public byte[] ReadManifestBytes(Stream zipStream) + { + ArgumentNullException.ThrowIfNull(zipStream); + return ReadEntryBytes(zipStream, ManifestEntryName) + ?? throw new InvalidDataException("Bundle is missing manifest.json."); + } + + /// + /// ScadaBridge#45: deserialises manifest bytes obtained from . + /// + /// The raw manifest.json bytes. + /// The deserialized manifest. + public BundleManifest DeserializeManifest(byte[] manifestBytes) + { + ArgumentNullException.ThrowIfNull(manifestBytes); + return JsonSerializer.Deserialize(manifestBytes, JsonOptions) + ?? throw new InvalidDataException("manifest.json deserialized to null."); + } + + /// + /// ScadaBridge#45: returns the bytes of the optional entry, + /// or null for an unsigned bundle (every bundle produced before signing existed). + /// + /// The ZIP stream containing the bundle. + /// The signature entry bytes, or null. + public byte[]? ReadSignatureBytes(Stream zipStream) + { + ArgumentNullException.ThrowIfNull(zipStream); + return ReadEntryBytes(zipStream, SignatureEntryName); + } + + private static byte[]? ReadEntryBytes(Stream zipStream, string entryName) + { + using var archive = new ZipArchive(zipStream, ZipArchiveMode.Read, leaveOpen: true); + var entry = archive.GetEntry(entryName); + if (entry is null) + { + return null; + } + + using var es = entry.Open(); + using var ms = new MemoryStream(); + es.CopyTo(ms); + return ms.ToArray(); + } + /// /// Returns the raw bytes from content.enc if present, otherwise from /// content.json. The caller decrypts + JSON-deserializes via diff --git a/src/ZB.MOM.WW.ScadaBridge.Transport/ServiceCollectionExtensions.cs b/src/ZB.MOM.WW.ScadaBridge.Transport/ServiceCollectionExtensions.cs index 26abf563..1cf0a957 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Transport/ServiceCollectionExtensions.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Transport/ServiceCollectionExtensions.cs @@ -8,6 +8,7 @@ using ZB.MOM.WW.ScadaBridge.Transport.Encryption; using ZB.MOM.WW.ScadaBridge.Transport.Export; using ZB.MOM.WW.ScadaBridge.Transport.Import; using ZB.MOM.WW.ScadaBridge.Transport.Serialization; +using ZB.MOM.WW.ScadaBridge.Transport.Signing; namespace ZB.MOM.WW.ScadaBridge.Transport; @@ -41,6 +42,13 @@ public static class ServiceCollectionExtensions services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + // ScadaBridge#45: bundle signing. The key source and trust store are TryAdd'd so a + // host can plug in its own (HSM / KMS / a shared trust registry) by registering + // before AddTransport(); the defaults read ScadaBridge:Transport:Signing and do + // nothing while it is left at its off-by-default values. + services.TryAddSingleton(); + services.TryAddSingleton(); + services.AddSingleton(); services.AddScoped(); services.AddScoped(); services.AddSingleton(); diff --git a/src/ZB.MOM.WW.ScadaBridge.Transport/Signing/BundleSigner.cs b/src/ZB.MOM.WW.ScadaBridge.Transport/Signing/BundleSigner.cs new file mode 100644 index 00000000..9e90fee6 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.Transport/Signing/BundleSigner.cs @@ -0,0 +1,187 @@ +using System.Security.Cryptography; +using System.Text.Json; +using Microsoft.Extensions.Options; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport; +using ZB.MOM.WW.ScadaBridge.Transport.Serialization; + +namespace ZB.MOM.WW.ScadaBridge.Transport.Signing; + +/// +/// ScadaBridge#45: the contents of the optional manifest.sig zip entry — a detached +/// ECDSA signature over the exact manifest.json bytes. +/// +/// Always . +/// The signing key's sha256: fingerprint. +/// Base64 of the IEEE P1363 (r‖s) signature. +public sealed record BundleSignatureEnvelope(string Algorithm, string KeyId, string SignatureB64); + +/// ScadaBridge#45: the outcome of a successful signature-policy check. +/// Unsigned, verified, or signed by an untrusted key. +/// The signing key's fingerprint; null when unsigned. +/// The trust-store name; set only when verified. +public sealed record BundleSignatureVerification( + BundleSignatureStatus Status, string? KeyId, string? SignerName) +{ + /// The result for a bundle that carries no signature. + public static BundleSignatureVerification Unsigned { get; } = new(BundleSignatureStatus.Unsigned, null, null); +} + +/// +/// ScadaBridge#45: signs exported bundle manifests and enforces the import-side signature +/// policy. What is signed is the exact serialised manifest.json; because the manifest +/// carries the SHA-256 of the content entry, verifying it also authenticates the content — +/// the importer's existing content-hash check binds the two. Signing never touches the +/// manifest itself or the AES-GCM associated data, so it composes with encryption and a +/// pre-signing importer reads a signed bundle unchanged. +/// +public sealed class BundleSigner +{ + private readonly IBundleSigningKeySource _keySource; + private readonly IBundleTrustStore _trustStore; + private readonly IOptions _options; + + /// Initializes the signer. + /// Supplies the export signing key (or none). + /// Resolves a bundle's signing key to a trusted public key. + /// Transport options (Signing:RequireSignedBundles). + public BundleSigner( + IBundleSigningKeySource keySource, + IBundleTrustStore trustStore, + IOptions options) + { + _keySource = keySource ?? throw new ArgumentNullException(nameof(keySource)); + _trustStore = trustStore ?? throw new ArgumentNullException(nameof(trustStore)); + _options = options ?? throw new ArgumentNullException(nameof(options)); + } + + /// + /// A signer with no signing key and an empty trust store that still honours + /// Signing:RequireSignedBundles — the importer's fallback when none is injected. + /// + /// Transport options. + /// The key-less signer. + public static BundleSigner CreateWithoutKeys(IOptions options) => + new(NoKeys.Instance, NoKeys.Instance, options); + + private sealed class NoKeys : IBundleSigningKeySource, IBundleTrustStore + { + public static readonly NoKeys Instance = new(); + + public BundleSigningKey? GetSigningKey() => null; + + public TrustedBundleKey? Find(string keyId) => null; + } + + /// + /// Signs the manifest bytes with the configured key. + /// + /// The exact bytes that will be written as manifest.json. + /// The manifest.sig entry bytes, or null when no signing key is configured. + public byte[]? Sign(byte[] manifestBytes) + { + ArgumentNullException.ThrowIfNull(manifestBytes); + var signingKey = _keySource.GetSigningKey(); + if (signingKey is null) + { + return null; + } + + using (signingKey.Key) + { + var signature = signingKey.Key.SignData(manifestBytes, HashAlgorithmName.SHA256); + var envelope = new BundleSignatureEnvelope( + BundleSigningKeys.Algorithm, signingKey.KeyId, Convert.ToBase64String(signature)); + return JsonSerializer.SerializeToUtf8Bytes(envelope, BundleJsonOptions.Default); + } + } + + /// + /// Applies the import-side signature policy to a loaded bundle. + /// + /// The exact manifest.json bytes read from the bundle. + /// The manifest.sig entry bytes, or null when absent. + /// The verification outcome when the bundle is acceptable. + /// The bundle fails the policy. + public BundleSignatureVerification Verify(byte[] manifestBytes, byte[]? signatureBytes) + { + ArgumentNullException.ThrowIfNull(manifestBytes); + var require = _options.Value.Signing.RequireSignedBundles; + + if (signatureBytes is null) + { + if (require) + { + throw new BundleSignatureException( + "Bundle is not signed, and this cluster requires signed bundles " + + "(ScadaBridge:Transport:Signing:RequireSignedBundles)."); + } + + return BundleSignatureVerification.Unsigned; + } + + var envelope = ParseEnvelope(signatureBytes); + + var trusted = _trustStore.Find(envelope.KeyId); + if (trusted is null) + { + if (require) + { + throw new BundleSignatureException( + $"Bundle is signed by key {envelope.KeyId}, which is not in this cluster's trusted " + + "signing keys (ScadaBridge:Transport:Signing:TrustedKeys)."); + } + + return new BundleSignatureVerification(BundleSignatureStatus.UntrustedKey, envelope.KeyId, null); + } + + using (trusted.Key) + { + byte[] signature; + try + { + signature = Convert.FromBase64String(envelope.SignatureB64); + } + catch (FormatException) + { + throw new BundleSignatureException("Bundle signature is malformed (manifest.sig)."); + } + + if (!trusted.Key.VerifyData(manifestBytes, signature, HashAlgorithmName.SHA256)) + { + throw new BundleSignatureException( + $"Bundle signature does not verify against trusted key '{trusted.Name}' — " + + "the bundle was modified after it was signed."); + } + } + + return new BundleSignatureVerification(BundleSignatureStatus.Verified, envelope.KeyId, trusted.Name); + } + + private static BundleSignatureEnvelope ParseEnvelope(byte[] signatureBytes) + { + BundleSignatureEnvelope? envelope; + try + { + envelope = JsonSerializer.Deserialize(signatureBytes, BundleJsonOptions.Default); + } + catch (JsonException) + { + envelope = null; + } + + if (envelope is null + || string.IsNullOrWhiteSpace(envelope.KeyId) + || string.IsNullOrWhiteSpace(envelope.SignatureB64)) + { + throw new BundleSignatureException("Bundle signature is malformed (manifest.sig)."); + } + + if (!string.Equals(envelope.Algorithm, BundleSigningKeys.Algorithm, StringComparison.Ordinal)) + { + throw new BundleSignatureException( + $"Bundle signature algorithm '{envelope.Algorithm}' is not supported; expected '{BundleSigningKeys.Algorithm}'."); + } + + return envelope; + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.Transport/Signing/BundleSigningKeys.cs b/src/ZB.MOM.WW.ScadaBridge.Transport/Signing/BundleSigningKeys.cs new file mode 100644 index 00000000..06aeef78 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.Transport/Signing/BundleSigningKeys.cs @@ -0,0 +1,116 @@ +using System.Security.Cryptography; + +namespace ZB.MOM.WW.ScadaBridge.Transport.Signing; + +/// +/// ScadaBridge#45: key-material helpers shared by the signer, the default key source, the +/// default trust store and the options validator. Only ECDSA over NIST P-256 is accepted. +/// +public static class BundleSigningKeys +{ + /// The single signature algorithm the bundle format supports. + public const string Algorithm = "ECDSA-P256-SHA256"; + + /// + /// Loads an ECDSA P-256 private key from a PEM block (PRIVATE KEY or + /// EC PRIVATE KEY) or from the base64 of a PKCS#8 DER blob. + /// + /// The configured key text. + /// The loaded key; the caller owns and disposes it. + /// The text is not a P-256 private key. + public static ECDsa LoadPrivateKey(string text) + { + ArgumentException.ThrowIfNullOrWhiteSpace(text); + var key = ECDsa.Create(); + try + { + if (text.Contains("-----BEGIN", StringComparison.Ordinal)) + { + key.ImportFromPem(text); + } + else + { + key.ImportPkcs8PrivateKey(DecodeBase64(text), out _); + } + + RequireP256(key); + // A public-only PEM imports without error; signing would then throw much later. + _ = key.ExportParameters(includePrivateParameters: true); + return key; + } + catch + { + key.Dispose(); + throw; + } + } + + /// + /// Loads an ECDSA P-256 public key from a PUBLIC KEY PEM block or from the base64 + /// of a SubjectPublicKeyInfo DER blob. + /// + /// The configured key text. + /// The loaded key; the caller owns and disposes it. + /// The text is not a P-256 public key. + public static ECDsa LoadPublicKey(string text) + { + ArgumentException.ThrowIfNullOrWhiteSpace(text); + var key = ECDsa.Create(); + try + { + if (text.Contains("-----BEGIN", StringComparison.Ordinal)) + { + key.ImportFromPem(text); + } + else + { + key.ImportSubjectPublicKeyInfo(DecodeBase64(text), out _); + } + + RequireP256(key); + return key; + } + catch + { + key.Dispose(); + throw; + } + } + + /// + /// The key id a bundle names its signer by: sha256: + lowercase hex of the SHA-256 + /// of the key's SubjectPublicKeyInfo DER. Derived, never configured, so a trust-store entry + /// cannot claim an id that does not belong to its key. + /// + /// A public or private P-256 key. + /// The key fingerprint. + public static string ComputeKeyId(ECDsa key) + { + ArgumentNullException.ThrowIfNull(key); + var spki = key.ExportSubjectPublicKeyInfo(); + return "sha256:" + Convert.ToHexString(SHA256.HashData(spki)).ToLowerInvariant(); + } + + private static void RequireP256(ECDsa key) + { + var curve = key.ExportParameters(includePrivateParameters: false).Curve; + if (key.KeySize != 256 || !curve.IsNamed + || !(string.Equals(curve.Oid.Value, ECCurve.NamedCurves.nistP256.Oid.Value, StringComparison.Ordinal) + || string.Equals(curve.Oid.FriendlyName, ECCurve.NamedCurves.nistP256.Oid.FriendlyName, StringComparison.OrdinalIgnoreCase))) + { + throw new CryptographicException("Bundle signing keys must be ECDSA over NIST P-256."); + } + } + + private static byte[] DecodeBase64(string text) + { + try + { + return Convert.FromBase64String(text.Trim()); + } + catch (FormatException ex) + { + throw new CryptographicException("Key is neither a PEM block nor valid base64.", ex); + } + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.Transport/Signing/ConfiguredBundleKeys.cs b/src/ZB.MOM.WW.ScadaBridge.Transport/Signing/ConfiguredBundleKeys.cs new file mode 100644 index 00000000..6a532e7a --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.Transport/Signing/ConfiguredBundleKeys.cs @@ -0,0 +1,77 @@ +using Microsoft.Extensions.Options; + +namespace ZB.MOM.WW.ScadaBridge.Transport.Signing; + +/// +/// ScadaBridge#45: default — the key configured at +/// ScadaBridge:Transport:Signing:PrivateKey, used only while +/// Signing:Enabled is true. Startup validation has already proven the key +/// parses, so a failure here would be a configuration change after boot. +/// +public sealed class ConfiguredBundleSigningKeySource : IBundleSigningKeySource +{ + private readonly IOptions _options; + + /// Initializes the key source over the Transport options. + /// Transport options. + public ConfiguredBundleSigningKeySource(IOptions options) + { + _options = options ?? throw new ArgumentNullException(nameof(options)); + } + + /// + public BundleSigningKey? GetSigningKey() + { + var signing = _options.Value.Signing; + if (!signing.Enabled || string.IsNullOrWhiteSpace(signing.PrivateKey)) + { + return null; + } + + var key = BundleSigningKeys.LoadPrivateKey(signing.PrivateKey); + return new BundleSigningKey(key, BundleSigningKeys.ComputeKeyId(key)); + } +} + +/// +/// ScadaBridge#45: default — the public keys configured at +/// ScadaBridge:Transport:Signing:TrustedKeys, matched by their computed fingerprint. +/// +public sealed class ConfiguredBundleTrustStore : IBundleTrustStore +{ + private readonly IOptions _options; + + /// Initializes the trust store over the Transport options. + /// Transport options. + public ConfiguredBundleTrustStore(IOptions options) + { + _options = options ?? throw new ArgumentNullException(nameof(options)); + } + + /// + public TrustedBundleKey? Find(string keyId) + { + if (string.IsNullOrWhiteSpace(keyId)) + { + return null; + } + + foreach (var entry in _options.Value.Signing.TrustedKeys) + { + if (string.IsNullOrWhiteSpace(entry.PublicKey)) + { + continue; + } + + var key = BundleSigningKeys.LoadPublicKey(entry.PublicKey); + if (string.Equals(BundleSigningKeys.ComputeKeyId(key), keyId, StringComparison.Ordinal)) + { + return new TrustedBundleKey(entry.Name, key); + } + + key.Dispose(); + } + + return null; + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.Transport/Signing/IBundleSigningKeySource.cs b/src/ZB.MOM.WW.ScadaBridge.Transport/Signing/IBundleSigningKeySource.cs new file mode 100644 index 00000000..8b94df2c --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.Transport/Signing/IBundleSigningKeySource.cs @@ -0,0 +1,43 @@ +using System.Security.Cryptography; + +namespace ZB.MOM.WW.ScadaBridge.Transport.Signing; + +/// +/// ScadaBridge#45: supplies the private key that signs exported bundles. Pluggable — the +/// default reads +/// ScadaBridge:Transport:Signing:PrivateKey (a ${secret:} reference in +/// production); a host can register its own implementation (an HSM, a KMS) before +/// AddTransport(), which only TryAdds the default. +/// +public interface IBundleSigningKeySource +{ + /// + /// Returns the signing key, or null when exports should not be signed. The caller + /// disposes the returned key's . + /// + /// The key and its fingerprint, or null. + BundleSigningKey? GetSigningKey(); +} + +/// ScadaBridge#45: a private signing key plus its fingerprint. +/// The ECDSA P-256 private key. +/// The key's sha256: fingerprint. +public sealed record BundleSigningKey(ECDsa Key, string KeyId); + +/// +/// ScadaBridge#45: resolves a bundle's signing-key fingerprint to a trusted public key. +/// Pluggable for the same reason as ; the default +/// reads ScadaBridge:Transport:Signing:TrustedKeys. +/// +public interface IBundleTrustStore +{ + /// Looks up a trusted key by fingerprint. + /// The sha256: fingerprint named by the bundle. + /// The trusted key, or null when the fingerprint is not trusted. The caller disposes it. + TrustedBundleKey? Find(string keyId); +} + +/// ScadaBridge#45: a trusted public key and its operator-facing name. +/// The trust-store name, shown on a verified import. +/// The ECDSA P-256 public key. +public sealed record TrustedBundleKey(string Name, ECDsa Key); diff --git a/src/ZB.MOM.WW.ScadaBridge.Transport/TransportOptions.cs b/src/ZB.MOM.WW.ScadaBridge.Transport/TransportOptions.cs index a94ee16b..5085c49d 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Transport/TransportOptions.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Transport/TransportOptions.cs @@ -61,4 +61,61 @@ public sealed class TransportOptions /// the default placeholder is fine for single-cluster deployments. /// public string SourceEnvironment { get; set; } = "scadabridge"; + + /// + /// ScadaBridge#45: optional detached bundle signing. Off by default — with the defaults + /// a cluster exports unsigned bundles and imports signed and unsigned bundles alike, + /// exactly as before signing existed. + /// + public TransportSigningOptions Signing { get; set; } = new(); +} + +/// +/// ScadaBridge#45: bound from ScadaBridge:Transport:Signing. Signing is a +/// non-repudiation layer on top of (not instead of) the passphrase encryption: the exporting +/// cluster signs the exact manifest.json bytes with an ECDSA P-256 private key, and +/// since the manifest carries the SHA-256 of the content entry, the signature covers the +/// whole bundle. The signature rides in an optional manifest.sig zip entry, so the +/// bundle format version does not change and older importers ignore it. +/// +public sealed class TransportSigningOptions +{ + /// + /// When true, every export is signed with the key from the registered + /// IBundleSigningKeySource (by default ). Default false. + /// + public bool Enabled { get; set; } + + /// + /// The ECDSA P-256 private key used to sign exports — a PKCS#8 / SEC1 PEM block, or the + /// base64 of the PKCS#8 DER. Supply it as a ${secret:NAME} reference so it lives + /// in the encrypted secret store rather than in appsettings. Only read when + /// is true and the default key source is in use. + /// + public string? PrivateKey { get; set; } + + /// + /// When true, the importer refuses a bundle that is unsigned or signed by a key + /// not in . Default false — such bundles load and the + /// signature status is shown to the operator. A signature that is present, names a + /// trusted key and fails to verify is refused regardless of this flag. + /// + public bool RequireSignedBundles { get; set; } + + /// + /// Public keys this cluster trusts to sign bundles. Each entry's key id is the + /// sha256: fingerprint of its SubjectPublicKeyInfo, so no id needs to be + /// configured — a bundle names its signing key by that fingerprint. + /// + public List TrustedKeys { get; set; } = new(); +} + +/// ScadaBridge#45: one entry of . +public sealed class TrustedBundleSigningKey +{ + /// Operator-facing name for the key (e.g. the exporting environment), shown on a verified import. + public string Name { get; set; } = string.Empty; + + /// The ECDSA P-256 public key — a PUBLIC KEY PEM block, or the base64 of the SubjectPublicKeyInfo DER. + public string PublicKey { get; set; } = string.Empty; } diff --git a/src/ZB.MOM.WW.ScadaBridge.Transport/TransportOptionsValidator.cs b/src/ZB.MOM.WW.ScadaBridge.Transport/TransportOptionsValidator.cs index 3e57ec6d..2b4311b8 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Transport/TransportOptionsValidator.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Transport/TransportOptionsValidator.cs @@ -1,4 +1,5 @@ using ZB.MOM.WW.Configuration; +using ZB.MOM.WW.ScadaBridge.Transport.Signing; namespace ZB.MOM.WW.ScadaBridge.Transport; @@ -60,8 +61,62 @@ public sealed class TransportOptionsValidator : OptionsValidatorBase + /// ScadaBridge#45: a signing key or trusted key that does not parse must fail at boot, + /// not on the first export/import — a typo would otherwise silently ship unsigned + /// bundles or reject every signed one. + /// + private static void ValidateSigning(ValidationBuilder builder, TransportSigningOptions signing) + { + if (signing is null) + { + builder.RequireThat(false, "ScadaBridge:Transport:Signing must not be null."); + return; + } + + if (signing.Enabled) + { + builder.RequireThat(!string.IsNullOrWhiteSpace(signing.PrivateKey), + "ScadaBridge:Transport:Signing:PrivateKey is required when Signing:Enabled is true; " + + "supply the ECDSA P-256 private key (PEM or base64 PKCS#8), ideally as a ${secret:} reference."); + if (!string.IsNullOrWhiteSpace(signing.PrivateKey)) + { + builder.RequireThat(TryLoad(() => BundleSigningKeys.LoadPrivateKey(signing.PrivateKey)), + "ScadaBridge:Transport:Signing:PrivateKey is not an ECDSA P-256 private key " + + "(PEM or base64 PKCS#8)."); + } + } + + for (var i = 0; i < signing.TrustedKeys.Count; i++) + { + var entry = signing.TrustedKeys[i]; + builder.RequireThat(!string.IsNullOrWhiteSpace(entry.Name), + $"ScadaBridge:Transport:Signing:TrustedKeys:{i}:Name must be a non-empty name."); + builder.RequireThat(!string.IsNullOrWhiteSpace(entry.PublicKey) + && TryLoad(() => BundleSigningKeys.LoadPublicKey(entry.PublicKey)), + $"ScadaBridge:Transport:Signing:TrustedKeys:{i}:PublicKey is not an ECDSA P-256 public key " + + "(PEM or base64 SubjectPublicKeyInfo)."); + } + } + + private static bool TryLoad(Func load) + { + try + { + using var key = load(); + return true; + } + catch (Exception ex) when (ex is System.Security.Cryptography.CryptographicException + or ArgumentException) + { + return false; + } + } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/Design/TransportImportPageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/Design/TransportImportPageTests.cs index febab7a8..5dc16718 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/Design/TransportImportPageTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/Design/TransportImportPageTests.cs @@ -935,6 +935,74 @@ public class TransportImportPageTests : BunitContext // widening the surface of the production class with test-only accessors. // ───────────────────────────────────────────────────────────────────── + /// + /// ScadaBridge#45: a signature-policy refusal is shown as its own reason and is NOT + /// counted as a wrong passphrase (the importer checks the signature before decrypting). + /// + /// A task that represents the asynchronous operation. + [Fact] + public async Task Signature_rejection_shows_reason_without_counting_a_failed_unlock() + { + _importer.LoadAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Throws(new BundleSignatureException("Bundle is not signed, and this cluster requires signed bundles.")); + + var cut = Render(); + await cut.InvokeAsync(() => + { + SeedAtPassphraseStep(cut.Instance, new byte[] { 0x01, 0x02 }); + SetField(cut.Instance, "_passphrase", "any"); + }); + await cut.InvokeAsync(async () => await InvokeAsyncMethod(cut.Instance, "SubmitPassphraseAsync")); + + Assert.Equal(0, GetField(cut.Instance, "_failedUnlockAttempts")); + var error = GetField(cut.Instance, "_errorMessage"); + Assert.NotNull(error); + Assert.Contains("Bundle signature rejected", error); + Assert.Contains("requires signed bundles", error); + } + + /// ScadaBridge#45: the upload step's manifest summary shows the bundle's signature status. + /// The session's signature status. + /// Text the Signature row must contain. + /// A task that represents the asynchronous operation. + [Theory] + [InlineData(BundleSignatureStatus.Verified, "Verified")] + [InlineData(BundleSignatureStatus.UntrustedKey, "untrusted key")] + [InlineData(BundleSignatureStatus.Unsigned, "Unsigned")] + public async Task Manifest_summary_shows_signature_status(BundleSignatureStatus status, string expected) + { + var cut = Render(); + var session = BuildEncryptedSession(); + session = new BundleSession + { + SessionId = session.SessionId, + Manifest = session.Manifest, + DecryptedContent = session.DecryptedContent, + ExpiresAt = session.ExpiresAt, + SignatureStatus = status, + SignerKeyId = status == BundleSignatureStatus.Unsigned ? null : "sha256:feed", + SignerName = status == BundleSignatureStatus.Verified ? "dev-cluster" : null, + }; + await cut.InvokeAsync(() => + { + SeedAtPassphraseStep(cut.Instance, new byte[] { 0x01 }); + SetField(cut.Instance, "_step", TransportImportPage.ImportWizardStep.Upload); + SetField(cut.Instance, "_session", session); + }); + cut.Render(); + + var row = cut.Find("[data-testid=manifest-signature]"); + Assert.Contains(expected, row.TextContent); + if (status == BundleSignatureStatus.Verified) + { + Assert.Contains("dev-cluster", row.TextContent); + } + if (status == BundleSignatureStatus.UntrustedKey) + { + Assert.Contains("sha256:feed", row.TextContent); + } + } + private static void SetField(object obj, string name, object? value) { var field = obj.GetType().GetField( diff --git a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs index 51409e70..ec677cb8 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs @@ -4669,6 +4669,63 @@ public class ManagementActorTests : TestKit, IDisposable Assert.DoesNotContain("An internal error occurred", response.Error); } + /// ScadaBridge#45: a signature-policy refusal reaches the CLI as the curated reason, on preview and import. + [Fact] + public void BundleCommands_SignatureRejected_SurfaceTheCuratedReason() + { + var (_, importer) = AddBundleSubstitutes(); + importer.LoadAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(_ => + throw new Commons.Types.Transport.BundleSignatureException("Bundle is not signed, and this cluster requires signed bundles.")); + + var payload = Convert.ToBase64String(new byte[] { 0x01, 0x02, 0x03 }); + var actor = CreateActor(); + actor.Tell(Envelope(new PreviewBundleCommand(payload, null), "Administrator")); + var preview = ExpectMsg(TimeSpan.FromSeconds(5)); + actor.Tell(Envelope(new ImportBundleCommand(payload, null, "skip"), "Administrator")); + var import = ExpectMsg(TimeSpan.FromSeconds(5)); + + foreach (var response in new[] { preview, import }) + { + Assert.Equal("COMMAND_FAILED", response.ErrorCode); + Assert.Contains("requires signed bundles", response.Error); + Assert.DoesNotContain("An internal error occurred", response.Error); + } + } + + /// ScadaBridge#45: the preview result carries the bundle's signature status and signer. + [Fact] + public void PreviewBundleCommand_ReportsSignatureStatus() + { + var (_, importer) = AddBundleSubstitutes(); + var sessionId = Guid.NewGuid(); + importer.LoadAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new Commons.Types.Transport.BundleSession + { + SessionId = sessionId, + Manifest = null!, + ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(5), + SignatureStatus = Commons.Types.Transport.BundleSignatureStatus.Verified, + SignerKeyId = "sha256:abc", + SignerName = "dev-cluster", + }); + importer.PreviewAsync(sessionId, Arg.Any()) + .Returns(new Commons.Types.Transport.ImportPreview( + sessionId, Array.Empty())); + + var actor = CreateActor(); + actor.Tell(Envelope(new PreviewBundleCommand(Convert.ToBase64String(new byte[] { 1 }), null), "Administrator")); + + var response = ExpectMsg(TimeSpan.FromSeconds(5)); + using var doc = System.Text.Json.JsonDocument.Parse(response.JsonData); + var root = doc.RootElement; + string Prop(string name) => root.EnumerateObject() + .Single(p => string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase)).Value.GetString()!; + Assert.Equal("Verified", Prop("SignatureStatus")); + Assert.Equal("sha256:abc", Prop("SignerKeyId")); + Assert.Equal("dev-cluster", Prop("SignerName")); + } + // ======================================================================== // Native alarm source CRUD // ======================================================================== diff --git a/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Fixtures/pre-signing-encrypted.scadabundle b/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Fixtures/pre-signing-encrypted.scadabundle new file mode 100644 index 00000000..fd47c915 Binary files /dev/null and b/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Fixtures/pre-signing-encrypted.scadabundle differ diff --git a/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Fixtures/pre-signing-plaintext.scadabundle b/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Fixtures/pre-signing-plaintext.scadabundle new file mode 100644 index 00000000..a58b6a6b Binary files /dev/null and b/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Fixtures/pre-signing-plaintext.scadabundle differ diff --git a/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/LegacyBundleCompatibilityTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/LegacyBundleCompatibilityTests.cs new file mode 100644 index 00000000..27eb1240 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/LegacyBundleCompatibilityTests.cs @@ -0,0 +1,207 @@ +using System.Security.Cryptography; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; +using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services; +using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport; +using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase; +using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories; +using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Services; +using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts; +using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates; +using ZB.MOM.WW.ScadaBridge.Transport.Signing; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates; + +namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests; + +/// +/// ScadaBridge#45 — bundle-format compatibility. The two bundles under Fixtures/ were +/// exported by the Transport code from BEFORE bundle signing existed (origin/main +/// 3ff463b6, real AddTransport() pipeline over in-memory EF, the +/// seed: templates BaseDevice / Pump (composes +/// BaseDevice as base) / Standalone, shared script HelperFn, +/// external system ErpSystem, notification list OnCall; source environment +/// legacy-env). pre-signing-plaintext is unencrypted; pre-signing-encrypted +/// uses the passphrase . +/// +/// Never regenerate these files — their value is that old code wrote them. When the +/// format changes again, add a new fixture beside them. +/// +/// +public sealed class LegacyBundleCompatibilityTests +{ + private const string LegacyPassphrase = "legacy-fixture-passphrase"; + + private static ServiceProvider BuildProvider(IDictionary settings) + { + var services = new ServiceCollection(); + services.AddSingleton(new ConfigurationBuilder().AddInMemoryCollection(settings).Build()); + var dbName = $"LegacyBundleCompat_{Guid.NewGuid()}"; + services.AddDbContext(opts => opts + .UseInMemoryDatabase(dbName) + .ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning))); + services.AddSingleton(new EphemeralDataProtectionProvider()); + services.AddScoped(sp => new ScadaBridgeDbContext( + sp.GetRequiredService>(), + sp.GetRequiredService())); + services.AddSingleton(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddTransport(); + return services.BuildServiceProvider(); + } + + /// Signing configured (key + trusted keys) but not required — the realistic rollout state. + private static Dictionary SigningConfigured() + { + using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256); + return new Dictionary + { + ["ScadaBridge:Transport:Signing:Enabled"] = "true", + ["ScadaBridge:Transport:Signing:PrivateKey"] = key.ExportPkcs8PrivateKeyPem(), + ["ScadaBridge:Transport:Signing:TrustedKeys:0:Name"] = "self", + ["ScadaBridge:Transport:Signing:TrustedKeys:0:PublicKey"] = key.ExportSubjectPublicKeyInfoPem(), + }; + } + + private static byte[] Fixture(string name) => + File.ReadAllBytes(Path.Combine(AppContext.BaseDirectory, "Fixtures", name)); + + public static TheoryData Cases() => new() + { + { "pre-signing-plaintext.scadabundle", null, false }, + { "pre-signing-encrypted.scadabundle", LegacyPassphrase, false }, + { "pre-signing-plaintext.scadabundle", null, true }, + { "pre-signing-encrypted.scadabundle", LegacyPassphrase, true }, + }; + + /// + /// A bundle produced before signing existed loads, previews and applies in full — with + /// signing left at its defaults and with signing configured (not required). + /// + [Theory] + [MemberData(nameof(Cases))] + public async Task Pre_signing_bundle_still_imports(string fixture, string? passphrase, bool signingConfigured) + { + await using var provider = BuildProvider( + signingConfigured ? SigningConfigured() : new Dictionary()); + + BundleSession session; + await using (var scope = provider.CreateAsyncScope()) + { + var importer = scope.ServiceProvider.GetRequiredService(); + session = await importer.LoadAsync(new MemoryStream(Fixture(fixture)), passphrase); + } + Assert.Equal(BundleSignatureStatus.Unsigned, session.SignatureStatus); + Assert.Equal("legacy-env", session.Manifest.SourceEnvironment); + + ImportResult result; + await using (var scope = provider.CreateAsyncScope()) + { + var importer = scope.ServiceProvider.GetRequiredService(); + var preview = await importer.PreviewAsync(session.SessionId); + Assert.Equal(6, preview.Items.Count); + Assert.All(preview.Items, i => Assert.Equal(ConflictKind.New, i.Kind)); + var resolutions = preview.Items + .Select(i => new ImportResolution(i.EntityType, i.Name, ResolutionAction.Add, null)) + .ToList(); + result = await importer.ApplyAsync(session.SessionId, resolutions, user: "bob"); + } + Assert.Equal(6, result.Added); + + await using (var scope = provider.CreateAsyncScope()) + { + var ctx = scope.ServiceProvider.GetRequiredService(); + var baseDevice = await ctx.Templates.Include(t => t.Scripts).SingleAsync(t => t.Name == "BaseDevice"); + Assert.Equal("init", Assert.Single(baseDevice.Scripts).Name); + var pump = await ctx.Templates.Include(t => t.Compositions).SingleAsync(t => t.Name == "Pump"); + Assert.Equal(baseDevice.Id, Assert.Single(pump.Compositions).ComposedTemplateId); + Assert.Equal("return 1;", (await ctx.SharedScripts.SingleAsync(s => s.Name == "HelperFn")).Code); + Assert.True(await ctx.ExternalSystemDefinitions.AnyAsync(e => e.Name == "ErpSystem")); + Assert.True(await ctx.NotificationLists.AnyAsync(n => n.Name == "OnCall")); + var imported = await ctx.AuditLogEntries.SingleAsync(a => a.Action == "BundleImported"); + Assert.Contains("\"SignatureStatus\":\"Unsigned\"", imported.AfterStateJson!, StringComparison.Ordinal); + } + } + + /// + /// With RequireSignedBundles on, the same pre-signing bundle is refused — the flag is + /// the one deliberate way to stop accepting old bundles. + /// + [Fact] + public async Task Pre_signing_bundle_is_refused_when_signing_required() + { + var settings = SigningConfigured(); + settings["ScadaBridge:Transport:Signing:RequireSignedBundles"] = "true"; + await using var provider = BuildProvider(settings); + await using var scope = provider.CreateAsyncScope(); + var importer = scope.ServiceProvider.GetRequiredService(); + + await Assert.ThrowsAsync(() => importer.LoadAsync( + new MemoryStream(Fixture("pre-signing-encrypted.scadabundle")), LegacyPassphrase)); + } + + /// + /// End to end through DI: with signing enabled the exporter writes a signed, encrypted bundle + /// (and audits the key), and an importer that trusts the key loads it as Verified and + /// records the signer on the BundleImported row. + /// + [Fact] + public async Task Signed_export_imports_as_Verified_through_DI() + { + var settings = SigningConfigured(); + settings["ScadaBridge:Transport:Signing:RequireSignedBundles"] = "true"; + await using var provider = BuildProvider(settings); + + await using (var scope = provider.CreateAsyncScope()) + { + var ctx = scope.ServiceProvider.GetRequiredService(); + ctx.SharedScripts.Add(new SharedScript("Signed", "return 2;")); + ctx.Templates.Add(new Template("SignedTemplate")); + await ctx.SaveChangesAsync(); + } + + byte[] bundle; + await using (var scope = provider.CreateAsyncScope()) + { + var ctx = scope.ServiceProvider.GetRequiredService(); + var selection = new ExportSelection( + TemplateIds: await ctx.Templates.Select(t => t.Id).ToListAsync(), + SharedScriptIds: await ctx.SharedScripts.Select(s => s.Id).ToListAsync(), + ExternalSystemIds: Array.Empty(), + DatabaseConnectionIds: Array.Empty(), + NotificationListIds: Array.Empty(), + SmtpConfigurationIds: Array.Empty(), + ApiMethodIds: Array.Empty(), + IncludeDependencies: true); + var stream = await scope.ServiceProvider.GetRequiredService() + .ExportAsync(selection, "alice", "signing-env", "pw-for-signed-bundle"); + using var ms = new MemoryStream(); + await stream.CopyToAsync(ms); + bundle = ms.ToArray(); + + var exported = await ctx.AuditLogEntries.SingleAsync(a => a.Action == "BundleExported"); + Assert.Contains("\"Signed\":true", exported.AfterStateJson!, StringComparison.Ordinal); + } + + await using (var scope = provider.CreateAsyncScope()) + { + var keyId = scope.ServiceProvider.GetRequiredService().GetSigningKey()!.KeyId; + var session = await scope.ServiceProvider.GetRequiredService() + .LoadAsync(new MemoryStream(bundle), "pw-for-signed-bundle"); + Assert.Equal(BundleSignatureStatus.Verified, session.SignatureStatus); + Assert.Equal("self", session.SignerName); + Assert.Equal(keyId, session.SignerKeyId); + scope.ServiceProvider.GetRequiredService().Abandon(session.SessionId); + } + } +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests.csproj b/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests.csproj index dcbdf43c..bdefec79 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests.csproj +++ b/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests.csproj @@ -30,6 +30,12 @@ + + + + + diff --git a/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Signing/BundleSignerTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Signing/BundleSignerTests.cs new file mode 100644 index 00000000..9b0aeb4f --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Signing/BundleSignerTests.cs @@ -0,0 +1,166 @@ +using System.Security.Cryptography; +using System.Text; +using Microsoft.Extensions.Options; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport; +using ZB.MOM.WW.ScadaBridge.Transport.Signing; + +namespace ZB.MOM.WW.ScadaBridge.Transport.Tests.Signing; + +/// +/// ScadaBridge#45: the detached-signature mechanism and the import-side policy over it. +/// +public sealed class BundleSignerTests +{ + internal static (string PrivatePem, string PublicPem, string KeyId) NewKeyPair() + { + using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256); + return (key.ExportPkcs8PrivateKeyPem(), key.ExportSubjectPublicKeyInfoPem(), BundleSigningKeys.ComputeKeyId(key)); + } + + internal static TransportOptions SigningOptions( + string? privatePem, bool require = false, params (string Name, string PublicPem)[] trusted) + { + var opts = new TransportOptions(); + opts.Signing.Enabled = privatePem is not null; + opts.Signing.PrivateKey = privatePem; + opts.Signing.RequireSignedBundles = require; + foreach (var (name, pem) in trusted) + { + opts.Signing.TrustedKeys.Add(new TrustedBundleSigningKey { Name = name, PublicKey = pem }); + } + return opts; + } + + internal static BundleSigner Signer(TransportOptions opts) + { + var o = Options.Create(opts); + return new BundleSigner(new ConfiguredBundleSigningKeySource(o), new ConfiguredBundleTrustStore(o), o); + } + + private static readonly byte[] Manifest = Encoding.UTF8.GetBytes("{\"BundleFormatVersion\":1,\"SourceEnvironment\":\"dev\"}"); + + /// A signature made with a key the importer trusts verifies and names the signer. + [Fact] + public void Sign_then_verify_with_trusted_key_is_Verified() + { + var (priv, pub, keyId) = NewKeyPair(); + var sig = Signer(SigningOptions(priv)).Sign(Manifest); + Assert.NotNull(sig); + + var result = Signer(SigningOptions(null, trusted: ("dev-cluster", pub))).Verify(Manifest, sig); + + Assert.Equal(BundleSignatureStatus.Verified, result.Status); + Assert.Equal(keyId, result.KeyId); + Assert.Equal("dev-cluster", result.SignerName); + } + + /// Signing is off by default: no key, no signature entry. + [Fact] + public void Sign_returns_null_when_signing_disabled() + { + Assert.Null(Signer(new TransportOptions()).Sign(Manifest)); + } + + /// Any change to the signed manifest bytes is a hard rejection, required or not. + [Fact] + public void Verify_rejects_altered_manifest() + { + var (priv, pub, _) = NewKeyPair(); + var sig = Signer(SigningOptions(priv)).Sign(Manifest); + var altered = Encoding.UTF8.GetBytes("{\"BundleFormatVersion\":1,\"SourceEnvironment\":\"prd\"}"); + + var ex = Assert.Throws( + () => Signer(SigningOptions(null, trusted: ("dev-cluster", pub))).Verify(altered, sig)); + Assert.Contains("modified after it was signed", ex.Message); + } + + /// A signature claiming a trusted key id but made with another key does not verify. + [Fact] + public void Verify_rejects_signature_from_other_key_claiming_trusted_id() + { + var (_, pub, trustedId) = NewKeyPair(); + var (otherPriv, _, _) = NewKeyPair(); + using var other = ECDsa.Create(); + other.ImportFromPem(otherPriv); + var forged = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(new BundleSignatureEnvelope( + BundleSigningKeys.Algorithm, trustedId, Convert.ToBase64String(other.SignData(Manifest, HashAlgorithmName.SHA256)))); + + Assert.Throws( + () => Signer(SigningOptions(null, trusted: ("dev-cluster", pub))).Verify(Manifest, forged)); + } + + /// Unsigned bundles load by default (every pre-signing bundle) and are refused when signing is required. + [Fact] + public void Unsigned_is_accepted_unless_required() + { + Assert.Equal(BundleSignatureStatus.Unsigned, Signer(new TransportOptions()).Verify(Manifest, null).Status); + Assert.Throws(() => Signer(SigningOptions(null, require: true)).Verify(Manifest, null)); + } + + /// A signature by a key outside the trust store is reported, and refused when signing is required. + [Fact] + public void Untrusted_key_is_reported_unless_required() + { + var (priv, _, keyId) = NewKeyPair(); + var sig = Signer(SigningOptions(priv)).Sign(Manifest); + + var result = Signer(new TransportOptions()).Verify(Manifest, sig); + Assert.Equal(BundleSignatureStatus.UntrustedKey, result.Status); + Assert.Equal(keyId, result.KeyId); + Assert.Null(result.SignerName); + + Assert.Throws(() => Signer(SigningOptions(null, require: true)).Verify(Manifest, sig)); + } + + /// Malformed signature entries and unknown algorithms are refused. + [Theory] + [InlineData("not json")] + [InlineData("{}")] + [InlineData("{\"Algorithm\":\"RSA-PSS\",\"KeyId\":\"sha256:00\",\"SignatureB64\":\"AA==\"}")] + public void Malformed_signature_entry_is_refused(string entry) + { + Assert.Throws( + () => Signer(new TransportOptions()).Verify(Manifest, Encoding.UTF8.GetBytes(entry))); + } + + /// Keys load from PEM or bare base64 DER and must be P-256. + [Fact] + public void Key_loading_accepts_pem_and_base64_and_rejects_other_curves() + { + using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256); + using var fromB64 = BundleSigningKeys.LoadPrivateKey(Convert.ToBase64String(key.ExportPkcs8PrivateKey())); + using var pubB64 = BundleSigningKeys.LoadPublicKey(Convert.ToBase64String(key.ExportSubjectPublicKeyInfo())); + Assert.Equal(BundleSigningKeys.ComputeKeyId(key), BundleSigningKeys.ComputeKeyId(pubB64)); + + using var p384 = ECDsa.Create(ECCurve.NamedCurves.nistP384); + Assert.Throws(() => BundleSigningKeys.LoadPrivateKey(p384.ExportPkcs8PrivateKeyPem())); + // A public key is not a signing key. + Assert.ThrowsAny(() => BundleSigningKeys.LoadPrivateKey(key.ExportSubjectPublicKeyInfoPem())); + } + + /// Startup validation rejects an enabled-without-key or unparseable configuration and accepts a good one. + [Fact] + public void Options_validator_checks_signing_keys() + { + var validator = new TransportOptionsValidator(); + var (priv, pub, _) = NewKeyPair(); + + var enabledNoKey = new TransportOptions(); + enabledNoKey.Signing.Enabled = true; + var r1 = validator.Validate(Options.DefaultName, enabledNoKey); + Assert.True(r1.Failed); + Assert.Contains("Signing:PrivateKey", r1.FailureMessage); + + var badKey = SigningOptions("-----BEGIN PRIVATE KEY-----\nAAAA\n-----END PRIVATE KEY-----"); + Assert.True(validator.Validate(Options.DefaultName, badKey).Failed); + + var badTrusted = SigningOptions(null, trusted: ("x", "not-a-key")); + var r3 = validator.Validate(Options.DefaultName, badTrusted); + Assert.True(r3.Failed); + Assert.Contains("TrustedKeys:0:PublicKey", r3.FailureMessage); + + var good = SigningOptions(priv, require: true, trusted: ("dev", pub)); + var r4 = validator.Validate(Options.DefaultName, good); + Assert.True(r4.Succeeded, r4.FailureMessage); + } +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Signing/SignedBundleLoadTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Signing/SignedBundleLoadTests.cs new file mode 100644 index 00000000..c6ef97d5 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Signing/SignedBundleLoadTests.cs @@ -0,0 +1,193 @@ +using System.IO.Compression; +using System.Text; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using NSubstitute; +using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; +using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services; +using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport; +using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase; +using ZB.MOM.WW.ScadaBridge.TemplateEngine.Validation; +using ZB.MOM.WW.ScadaBridge.Transport.Encryption; +using ZB.MOM.WW.ScadaBridge.Transport.Import; +using ZB.MOM.WW.ScadaBridge.Transport.Serialization; +using ZB.MOM.WW.ScadaBridge.Transport.Signing; + +namespace ZB.MOM.WW.ScadaBridge.Transport.Tests.Signing; + +/// +/// ScadaBridge#45: enforces the signature policy on a +/// real packed bundle, and a signed bundle stays readable by the pre-signing read path. +/// +public sealed class SignedBundleLoadTests +{ + private const string Passphrase = "correct horse battery"; + + private static BundleContentDto Content() => new( + TemplateFolders: Array.Empty(), + Templates: new[] + { + new TemplateDto("Pump", null, null, "signed", + Array.Empty(), Array.Empty(), + Array.Empty(), Array.Empty()), + }, + SharedScripts: Array.Empty(), + ExternalSystems: Array.Empty(), + DatabaseConnections: Array.Empty(), + NotificationLists: Array.Empty(), + SmtpConfigs: Array.Empty(), + ApiMethods: Array.Empty()); + + private static byte[] Pack(TransportOptions exporterOptions, string? passphrase) + { + var serializer = new BundleSerializer(); + var content = Content(); + var contentBytes = serializer.SerializeContentBytes(content); + var seed = passphrase is null + ? null + : new EncryptionMetadata("AES-256-GCM", "PBKDF2-SHA256", 100_000, string.Empty, string.Empty); + var manifest = new ManifestBuilder().Build("dev", "alice", "1.0.0", seed, + new BundleSummary(1, 0, 0, 0, 0, 0, 0, 0), Array.Empty(), contentBytes); + var signer = BundleSignerTests.Signer(exporterOptions); + using var zip = serializer.PackContentBytes(contentBytes, manifest, passphrase, + new BundleSecretEncryptor(), signer.Sign); + using var ms = new MemoryStream(); + zip.CopyTo(ms); + return ms.ToArray(); + } + + private static BundleImporter Importer(TransportOptions opts) + { + var iOpts = Options.Create(opts); + return new BundleImporter( + bundleSerializer: new BundleSerializer(), + manifestValidator: new ManifestValidator(), + encryptor: new BundleSecretEncryptor(), + entitySerializer: new EntitySerializer(), + sessionStore: new BundleSessionStore(iOpts, TimeProvider.System), + unlockRateLimiter: new BundleUnlockRateLimiter(TimeProvider.System, BundleUnlockRateLimiter.DefaultWindow), + options: iOpts, + timeProvider: TimeProvider.System, + templateRepo: Substitute.For(), + externalRepo: Substitute.For(), + notificationRepo: Substitute.For(), + inboundApiRepo: Substitute.For(), + siteRepo: Substitute.For(), + auditService: Substitute.For(), + correlationContext: Substitute.For(), + dbContext: new ScadaBridgeDbContext(new DbContextOptionsBuilder().Options), + semanticValidator: new SemanticValidator(), + signer: BundleSignerTests.Signer(opts)); + } + + private static byte[] ReplaceEntry(byte[] bundle, string entryName, Func rewrite) + { + using var output = new MemoryStream(); + using (var src = new ZipArchive(new MemoryStream(bundle), ZipArchiveMode.Read)) + using (var dst = new ZipArchive(output, ZipArchiveMode.Create, leaveOpen: true)) + { + foreach (var entry in src.Entries) + { + using var es = entry.Open(); + using var buf = new MemoryStream(); + es.CopyTo(buf); + var bytes = entry.FullName == entryName ? rewrite(buf.ToArray()) : buf.ToArray(); + using var ds = dst.CreateEntry(entry.FullName).Open(); + ds.Write(bytes); + } + } + return output.ToArray(); + } + + /// A signed bundle — plaintext or encrypted — loads as Verified on a cluster that trusts the key. + [Theory] + [InlineData(null)] + [InlineData(Passphrase)] + public async Task Signed_bundle_loads_as_Verified(string? passphrase) + { + var (priv, pub, keyId) = BundleSignerTests.NewKeyPair(); + var bundle = Pack(BundleSignerTests.SigningOptions(priv), passphrase); + var importer = Importer(BundleSignerTests.SigningOptions(null, require: true, trusted: ("dev-cluster", pub))); + + var session = await importer.LoadAsync(new MemoryStream(bundle), passphrase); + + Assert.Equal(BundleSignatureStatus.Verified, session.SignatureStatus); + Assert.Equal(keyId, session.SignerKeyId); + Assert.Equal("dev-cluster", session.SignerName); + Assert.Equal("Pump", Assert.Single(BundleImporter.ReadSessionContent(session).Templates).Name); + } + + /// + /// Re-labelling a signed bundle's manifest (here, its source environment) — which the + /// content hash alone cannot catch for a plaintext bundle — is refused. + /// + [Fact] + public async Task Altered_manifest_of_signed_bundle_is_refused() + { + var (priv, pub, _) = BundleSignerTests.NewKeyPair(); + var bundle = Pack(BundleSignerTests.SigningOptions(priv), passphrase: null); + var tampered = ReplaceEntry(bundle, "manifest.json", bytes => + Encoding.UTF8.GetBytes(Encoding.UTF8.GetString(bytes).Replace("\"dev\"", "\"prod\"", StringComparison.Ordinal))); + var importer = Importer(BundleSignerTests.SigningOptions(null, trusted: ("dev-cluster", pub))); + + await Assert.ThrowsAsync(() => importer.LoadAsync(new MemoryStream(tampered), null)); + } + + /// + /// With signing required, an unsigned encrypted bundle is refused before the passphrase is + /// asked for — the policy check precedes the unlock, so it spends no unlock attempt. + /// + [Fact] + public async Task Required_signing_refuses_unsigned_bundle_before_passphrase() + { + var bundle = Pack(new TransportOptions(), Passphrase); + var importer = Importer(BundleSignerTests.SigningOptions(null, require: true)); + + await Assert.ThrowsAsync(() => importer.LoadAsync(new MemoryStream(bundle), null)); + } + + /// By default an unsigned bundle loads unchanged and reports Unsigned. + [Fact] + public async Task Default_policy_loads_unsigned_bundle() + { + var bundle = Pack(new TransportOptions(), Passphrase); + + var session = await Importer(new TransportOptions()).LoadAsync(new MemoryStream(bundle), Passphrase); + + Assert.Equal(BundleSignatureStatus.Unsigned, session.SignatureStatus); + Assert.Null(session.SignerKeyId); + } + + /// + /// Forward compatibility: a signed, encrypted bundle is still readable through the + /// pre-signing read path (open entries by name, AAD over the deserialised manifest) and + /// stays inside the default zip entry-count cap — so an importer from before #45 imports it. + /// + [Fact] + public void Signed_bundle_is_readable_by_pre_signing_read_path() + { + var (priv, _, _) = BundleSignerTests.NewKeyPair(); + var bundle = Pack(BundleSignerTests.SigningOptions(priv), Passphrase); + + using (var zip = new ZipArchive(new MemoryStream(bundle), ZipArchiveMode.Read)) + { + Assert.Equal(3, zip.Entries.Count); + Assert.True(zip.Entries.Count <= new TransportOptions().MaxBundleEntryCount); + Assert.NotNull(zip.GetEntry(BundleSerializer.SignatureEntryName)); + } + + var serializer = new BundleSerializer(); + var manifest = serializer.ReadManifest(new MemoryStream(bundle)); + var raw = serializer.ReadContentBytes(new MemoryStream(bundle), manifest); + Assert.Equal(ManifestValidationResult.Ok, new ManifestValidator().Validate(manifest, raw)); + var content = serializer.UnpackContent(raw, manifest, Passphrase, new BundleSecretEncryptor()); + Assert.Equal("Pump", Assert.Single(content.Templates).Name); + + // The manifest JSON itself carries no signature field — nothing a pre-signing + // deserialiser or the AES-GCM associated data could trip on. + using var doc = JsonDocument.Parse(serializer.ReadManifestBytes(new MemoryStream(bundle))); + Assert.DoesNotContain(doc.RootElement.EnumerateObject(), p => p.Name.Contains("Sign", StringComparison.OrdinalIgnoreCase)); + } +}