From 559e02f5c80ba61d26c9a2a86155f8fb092085e5 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 9 Nov 2026 22:50:18 -0500 Subject: [PATCH] feat(transport): production-boundary signing advisory, secret-key proof, runbook (#110) Owner decisions on #110: one signing key per environment; TrustedKeys stay hand-edited; Signing:ProductionBoundary (new, default off) logs a startup warning and reports a Degraded bundle-signing-policy /health/ready entry while RequireSignedBundles is off; the private key is read via ${secret:} (proved end to end, never logged); cluster pull decided not to be built; differential deletions stay deferred. Adds docs/operations/bundle-signing-runbook.md. Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV --- docs/operations/bundle-signing-runbook.md | 93 +++++++++++++++++++ ...ransport-pull-and-differential-addendum.md | 18 ++-- docs/requirements/Component-Transport.md | 4 +- .../Health/BundleSigningPolicyHealthCheck.cs | 43 +++++++++ src/ZB.MOM.WW.ScadaBridge.Host/Program.cs | 6 ++ .../ServiceCollectionExtensions.cs | 3 + .../Signing/BundleSigningPolicyAdvisory.cs | 61 ++++++++++++ .../TransportOptions.cs | 11 +++ .../BundleSigningPolicyHealthCheckTests.cs | 35 +++++++ .../TransportSigningSecretExpansionTests.cs | 70 ++++++++++++++ .../BundleSigningPolicyAdvisoryTests.cs | 87 +++++++++++++++++ 11 files changed, 421 insertions(+), 10 deletions(-) create mode 100644 docs/operations/bundle-signing-runbook.md create mode 100644 src/ZB.MOM.WW.ScadaBridge.Host/Health/BundleSigningPolicyHealthCheck.cs create mode 100644 src/ZB.MOM.WW.ScadaBridge.Transport/Signing/BundleSigningPolicyAdvisory.cs create mode 100644 tests/ZB.MOM.WW.ScadaBridge.Host.Tests/BundleSigningPolicyHealthCheckTests.cs create mode 100644 tests/ZB.MOM.WW.ScadaBridge.Host.Tests/TransportSigningSecretExpansionTests.cs create mode 100644 tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Signing/BundleSigningPolicyAdvisoryTests.cs diff --git a/docs/operations/bundle-signing-runbook.md b/docs/operations/bundle-signing-runbook.md new file mode 100644 index 00000000..0c55fa2a --- /dev/null +++ b/docs/operations/bundle-signing-runbook.md @@ -0,0 +1,93 @@ +# Transport bundle signing — operator runbook (ScadaBridge#45 / #110) + +Decisions (owner, 2026-11-09): **one signing key per environment** (DEV, QA, PROD each have their own); +trust is the **hand-edited `ScadaBridge:Transport:Signing:TrustedKeys`** list (no Admin UI trust list); +the private key lives in the **Secrets store** and is read through `${secret:}` config expansion; +`RequireSignedBundles` is turned **on at the production boundary** once every exporting cluster signs. +Reference: "Bundle Signing" in `docs/requirements/Component-Transport.md`. + +A bundle names its signer by key fingerprint (`sha256:`). The importing +environment trusts a bundle only if that fingerprint matches a public key in its `TrustedKeys`. +There is no key-generation CLI; use `openssl`. + +## 1. Generate a key for an environment + +Run once per environment, on a trusted workstation (not on the application node): + +```bash +ENV=dev # dev | qa | prod +openssl ecparam -name prime256v1 -genkey -noout | openssl pkcs8 -topk8 -nocrypt -out "bundle-signing-$ENV.pem" +openssl ec -in "bundle-signing-$ENV.pem" -pubout -out "bundle-signing-$ENV.pub.pem" +``` + +`bundle-signing-$ENV.pem` is the **private** key (PKCS#8 PEM); `*.pub.pem` is the public key to distribute. +The key must be ECDSA P-256 — anything else fails boot. + +## 2. Store the private key as a secret + +On the exporting environment, create a secret (for example `SB-BUNDLE-SIGNING-KEY`) whose value is the full +contents of the private PEM, via `/admin/secrets` on a central node (or the `ZB.MOM.WW.Secrets` CLI). Both +central nodes share the secret store when `Secrets:Store:Provider=SqlServer`, and either node may run an export. +Then reference it from config — never paste the PEM into `appsettings`: + +```json +"ScadaBridge": { "Transport": { "Signing": { + "Enabled": true, + "PrivateKey": "${secret:SB-BUNDLE-SIGNING-KEY}" +} } } +``` + +The reference is expanded before startup validation (Program.cs, pre-host); a key that is missing or does not +parse as P-256 fails boot, it never silently ships unsigned bundles. The key is not logged: validation messages +name only the config key, and the signature entry carries only the public fingerprint. Destroy the local +`*.pem` private file after storing it (keep an offline backup per your key-custody policy). + +Restart both central nodes together after changing config. + +## 3. Distribute the public key into each importing environment + +On every environment that will **import** bundles from this one, add the public key to `TrustedKeys` +(hand-edited config; restart both central nodes together): + +```json +"Signing": { "TrustedKeys": [ + { "Name": "dev", "PublicKey": "-----BEGIN PUBLIC KEY-----\nMFkw...\n-----END PUBLIC KEY-----" } +] } +``` + +`Name` is shown on a verified import and recorded on the `BundleImported` audit row. Public keys are not +secret, so they may sit in `appsettings`. After this, a bundle exported from DEV shows **Verified (dev)** in +the QA import wizard; before `RequireSignedBundles` is on, an untrusted or unsigned bundle still loads and +shows Unsigned / UntrustedKey. + +## 4. Rotation + +1. Generate the new key pair (step 1) and store the new private key as a **new** secret name. +2. Add the new public key to `TrustedKeys` in **every importing environment** (keep the old one). Restart. +3. On the exporting environment, switch `PrivateKey` to the new `${secret:}` reference. Restart. New exports + are signed with the new key and verify everywhere. +4. Once no outstanding bundle signed with the old key still needs importing, remove the old public key from every + importing environment's `TrustedKeys`, and delete the old secret. + +Never remove the old trusted key before step 3 is live everywhere (it would refuse in-flight bundles when +`RequireSignedBundles` is on). A compromised key: skip the grace period — do steps 1-3, then remove the old key +immediately. + +## 5. Turning on `RequireSignedBundles` (order matters) + +The code default stays **off**. For the production boundary: + +1. Every exporting environment has a key (step 1-2) and `Signing:Enabled=true`; confirm an export shows + `Signed` on its `BundleExported` audit row. +2. Every key is in the production environment's `TrustedKeys` (step 3); confirm a real bundle from each + shows **Verified** in the import wizard preview. +3. On the production environment set `ScadaBridge:Transport:Signing:ProductionBoundary=true`. While + `RequireSignedBundles` is still false the node logs a startup **WARNING** ("ProductionBoundary is true but + RequireSignedBundles is false") and `/health/ready` shows the `bundle-signing-policy` entry as **Degraded** + (HTTP 200 — visible on Overview, not out of rotation). That is the reminder to finish the flip. +4. Set `ScadaBridge:Transport:Signing:RequireSignedBundles=true` and restart both central nodes together. + Unsigned, untrusted-key and badly-signed bundles are now refused at upload; the warning and Degraded entry clear. +5. Rollback: set `RequireSignedBundles=false` (the warning returns). Pre-signing bundles remain importable + whenever it is off. + +A signature that is present, names a trusted key and fails to verify is refused regardless of the flag. diff --git a/docs/plans/2026-11-09-transport-pull-and-differential-addendum.md b/docs/plans/2026-11-09-transport-pull-and-differential-addendum.md index 89bc7982..784044d3 100644 --- a/docs/plans/2026-11-09-transport-pull-and-differential-addendum.md +++ b/docs/plans/2026-11-09-transport-pull-and-differential-addendum.md @@ -1,6 +1,6 @@ # Transport addendum — cluster-to-cluster pull and differential bundles (ScadaBridge#45) -**Date:** 2026-11-09 · **Status:** design addendum. Not implemented, and not scheduled. +**Date:** 2026-11-09 · **Status:** design addendum. **Owner decisions 2026-11-09 (ScadaBridge#110): pull — decided NOT to build; differential deletions — deferred.** Neither is implemented. **Origin:** `2026-05-24-transport-design.md` §18 and deferred-work register row 19 (now Gitea #45). The third deferred item, bundle signing, **is** implemented. See "Bundle Signing" in `docs/requirements/Component-Transport.md`. @@ -20,6 +20,8 @@ recommendation, so either feature can be picked up when there is a concrete need ## 1. Cluster-to-cluster pull +> **DECIDED — NOT TO BUILD (owner, 2026-11-09, ScadaBridge#110).** No cluster-to-cluster pull, no cross-environment network path, no stored cross-environment credential. The CLI export → import recipe below (Option A) is the supported path, with the receiving side requiring signed bundles (`docs/operations/bundle-signing-runbook.md`). The questions and options that follow are kept as a historical record only; do not build B or C without a new owner decision. + ### What exists today A pull can already be **orchestrated** without any new server code. The CLI speaks to any @@ -74,6 +76,8 @@ design session before writing any of it. ## 2. Differential bundles +> **DEFERRED (owner, 2026-11-09, ScadaBridge#110).** Differential deletions (D2) stay out of scope: the importer never deletes and there are no tombstones. Import-side Identical auto-skip already gives the differential effect. The hash-only variant below remains an option only if file size or preview length becomes a real complaint. + ### What exists today Every export is a full snapshot of the selected artifacts plus, optionally, their dependency @@ -114,11 +118,9 @@ unless D2 is designed on its own. --- -## Decisions this addendum leaves open +## Decisions (resolved 2026-11-09, ScadaBridge#110) -1. Pull: is any cross-environment network path acceptable to change control (P3)? Nothing else - can be decided until this is. -2. Pull: which machine identity, a stored service account or a new export-scoped credential (P1/P2)? -3. Differential: is propagating deletions in scope at all (D2)? -4. Signing, which the pull depends on: see the open decisions in Component-Transport.md - "Bundle Signing". +1. Pull: **not built.** No cross-environment network path (P3) and no machine identity (P1/P2) will be introduced. +2. Differential: deletions (D2) **stay deferred**; no tombstones. +3. Signing: one key per environment, hand-edited `TrustedKeys`, `${secret:}` key source, enforcement on at the + production boundary once every exporter signs — see `docs/operations/bundle-signing-runbook.md`. diff --git a/docs/requirements/Component-Transport.md b/docs/requirements/Component-Transport.md index cea9831f..97248293 100644 --- a/docs/requirements/Component-Transport.md +++ b/docs/requirements/Component-Transport.md @@ -384,10 +384,10 @@ Signing adds **non-repudiation** on top of the passphrase encryption — the pas 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. +- **Decisions** (owner, 2026-11-09, ScadaBridge#110): (1) **one signing key per environment**; (2) trust stays the hand-edited `TrustedKeys` (no Admin UI trust list, no expiry/revocation store); (3) `RequireSignedBundles` is to be **on at the production boundary** once every exporting cluster signs — the code default stays off, and a cluster marked `Signing:ProductionBoundary=true` that still has it off logs a startup Warning and reports a Degraded `bundle-signing-policy` entry on `/health/ready` (HTTP 200); (4) the private key is read through `${secret:}` config expansion from the Secrets store (verified end to end by `TransportSigningSecretExpansionTests`; the key is never logged), not by a direct Secrets-store key source. Operator procedure (key generation, storage, distribution, rotation, the order for turning enforcement on): `docs/operations/bundle-signing-runbook.md`. - **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`. +Cluster-to-cluster pull is **decided not to be built** (CLI export → import covers it) and differential-bundle deletions stay **deferred**; the rationale is recorded in `docs/plans/2026-11-09-transport-pull-and-differential-addendum.md`. ## Configuration Audit Trail diff --git a/src/ZB.MOM.WW.ScadaBridge.Host/Health/BundleSigningPolicyHealthCheck.cs b/src/ZB.MOM.WW.ScadaBridge.Host/Health/BundleSigningPolicyHealthCheck.cs new file mode 100644 index 00000000..a86e71b0 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.Host/Health/BundleSigningPolicyHealthCheck.cs @@ -0,0 +1,43 @@ +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Options; +using ZB.MOM.WW.ScadaBridge.Transport; +using ZB.MOM.WW.ScadaBridge.Transport.Signing; + +namespace ZB.MOM.WW.ScadaBridge.Host.Health; + +/// +/// /health/ready entry (ScadaBridge#110): Degraded while a cluster marked +/// Transport:Signing:ProductionBoundary still accepts unsigned bundles. Degraded maps +/// to HTTP 200 in the shared health tier, so it is visible on the Overview dashboard without +/// taking the node out of rotation. Exposes only booleans — never key material. +/// +public sealed class BundleSigningPolicyHealthCheck : IHealthCheck +{ + private readonly IOptionsMonitor _options; + + /// Initializes the check. + /// Transport options. + public BundleSigningPolicyHealthCheck(IOptionsMonitor options) + { + _options = options ?? throw new ArgumentNullException(nameof(options)); + } + + /// + public Task CheckHealthAsync( + HealthCheckContext context, CancellationToken cancellationToken = default) + { + var signing = _options.CurrentValue.Signing; + var data = new Dictionary(StringComparer.Ordinal) + { + ["productionBoundary"] = signing.ProductionBoundary, + ["requireSignedBundles"] = signing.RequireSignedBundles, + ["signingEnabled"] = signing.Enabled, + ["trustedKeys"] = signing.TrustedKeys.Count, + }; + + var advisory = BundleSigningPolicyAdvisory.Evaluate(signing); + return Task.FromResult(advisory is null + ? HealthCheckResult.Healthy("Bundle signing policy OK.", data) + : HealthCheckResult.Degraded(advisory, data: data)); + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs b/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs index ec463aa0..17b767a1 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs @@ -568,6 +568,12 @@ try "required-singletons", failureStatus: null, tags: new[] { ZbHealthTags.Ready }) + // ScadaBridge#110: Degraded (HTTP 200) while a Transport:Signing:ProductionBoundary + // cluster still accepts unsigned bundles. + .AddTypeActivatedCheck( + "bundle-signing-policy", + failureStatus: null, + tags: new[] { ZbHealthTags.Ready }) // Oldest-Up-member check (review 01 [High]), unscoped: every central member competes for // the one active slot. Since ZB.MOM.WW.Health 0.3.0 this IS the shared check — it selects // by age, so the host-local OldestNodeActiveHealthCheck that existed only to avoid the old diff --git a/src/ZB.MOM.WW.ScadaBridge.Transport/ServiceCollectionExtensions.cs b/src/ZB.MOM.WW.ScadaBridge.Transport/ServiceCollectionExtensions.cs index 1cf0a957..37a2ccc6 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Transport/ServiceCollectionExtensions.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Transport/ServiceCollectionExtensions.cs @@ -71,6 +71,9 @@ public static class ServiceCollectionExtensions // T-007: periodic eviction sweep so abandoned sessions clear without // needing a fresh Get() to trigger lazy eviction. services.AddHostedService(); + // ScadaBridge#110: warn at startup when a production-boundary cluster does not + // require signed bundles. + services.AddHostedService(); // SemanticValidator is a stateless utility used by ApplyAsync; use // TryAdd so a host that already calls AddTemplateEngine() (which // registers the same type as Transient) wins. Either registration diff --git a/src/ZB.MOM.WW.ScadaBridge.Transport/Signing/BundleSigningPolicyAdvisory.cs b/src/ZB.MOM.WW.ScadaBridge.Transport/Signing/BundleSigningPolicyAdvisory.cs new file mode 100644 index 00000000..5ba3c1f5 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.Transport/Signing/BundleSigningPolicyAdvisory.cs @@ -0,0 +1,61 @@ +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace ZB.MOM.WW.ScadaBridge.Transport.Signing; + +/// +/// ScadaBridge#110: the production-boundary signing advisory. An environment marked +/// ScadaBridge:Transport:Signing:ProductionBoundary is expected to run with +/// RequireSignedBundles on; this is the single place that decides whether that +/// expectation is unmet, shared by the startup warning and the readiness health check. +/// +public static class BundleSigningPolicyAdvisory +{ + /// + /// Evaluates the signing options. + /// + /// The bound signing options. + /// The advisory text when a production-boundary cluster accepts unsigned bundles; otherwise null. + public static string? Evaluate(TransportSigningOptions signing) + { + ArgumentNullException.ThrowIfNull(signing); + if (!signing.ProductionBoundary || signing.RequireSignedBundles) + { + return null; + } + + return "ScadaBridge:Transport:Signing:ProductionBoundary is true but RequireSignedBundles is false: " + + "this production-boundary cluster will import unsigned bundles and bundles signed by untrusted keys. " + + "Turn on RequireSignedBundles once every exporting environment signs and its public key is in TrustedKeys."; + } +} + +/// +/// ScadaBridge#110: logs the once at startup. +/// +internal sealed class BundleSigningPolicyStartupWarning : IHostedService +{ + private readonly IOptions _options; + private readonly ILogger _logger; + + public BundleSigningPolicyStartupWarning( + IOptions options, ILogger logger) + { + _options = options ?? throw new ArgumentNullException(nameof(options)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public Task StartAsync(CancellationToken cancellationToken) + { + var advisory = BundleSigningPolicyAdvisory.Evaluate(_options.Value.Signing); + if (advisory is not null) + { + _logger.LogWarning("{Advisory}", advisory); + } + + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} diff --git a/src/ZB.MOM.WW.ScadaBridge.Transport/TransportOptions.cs b/src/ZB.MOM.WW.ScadaBridge.Transport/TransportOptions.cs index 5085c49d..2dfb7e3c 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Transport/TransportOptions.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Transport/TransportOptions.cs @@ -102,6 +102,17 @@ public sealed class TransportSigningOptions /// public bool RequireSignedBundles { get; set; } + /// + /// Owner decision (ScadaBridge#110): marks this cluster as sitting on the production + /// boundary — an environment that imports bundles from other environments. While it is + /// true and is false the node logs a + /// startup WARNING and the central bundle-signing-policy readiness check reports + /// Degraded. It changes no import behaviour: stays + /// default-off in code, and is flipped per environment once every exporting cluster signs. + /// Default false. + /// + public bool ProductionBoundary { 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 diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/BundleSigningPolicyHealthCheckTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/BundleSigningPolicyHealthCheckTests.cs new file mode 100644 index 00000000..37858ace --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/BundleSigningPolicyHealthCheckTests.cs @@ -0,0 +1,35 @@ +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Options; +using ZB.MOM.WW.ScadaBridge.Host.Health; +using ZB.MOM.WW.ScadaBridge.Transport; + +namespace ZB.MOM.WW.ScadaBridge.Host.Tests; + +/// ScadaBridge#110: the bundle-signing-policy readiness entry. +public sealed class BundleSigningPolicyHealthCheckTests +{ + private sealed class FixedMonitor(TransportOptions o) : IOptionsMonitor + { + public TransportOptions CurrentValue => o; + public TransportOptions Get(string? name) => o; + public IDisposable? OnChange(Action listener) => null; + } + + /// Degraded only when a production-boundary cluster accepts unsigned bundles. + [Theory] + [InlineData(true, false, HealthStatus.Degraded)] + [InlineData(true, true, HealthStatus.Healthy)] + [InlineData(false, false, HealthStatus.Healthy)] + public async Task Status_follows_policy(bool boundary, bool require, HealthStatus expected) + { + var o = new TransportOptions(); + o.Signing.ProductionBoundary = boundary; + o.Signing.RequireSignedBundles = require; + + var result = await new BundleSigningPolicyHealthCheck(new FixedMonitor(o)) + .CheckHealthAsync(new HealthCheckContext()); + + Assert.Equal(expected, result.Status); + Assert.Equal(boundary, result.Data["productionBoundary"]); + } +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/TransportSigningSecretExpansionTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/TransportSigningSecretExpansionTests.cs new file mode 100644 index 00000000..13373576 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/TransportSigningSecretExpansionTests.cs @@ -0,0 +1,70 @@ +using System.Security.Cryptography; +using System.Text; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; +using ZB.MOM.WW.ScadaBridge.Transport; +using ZB.MOM.WW.ScadaBridge.Transport.Signing; +using ZB.MOM.WW.Secrets.Abstractions; +using ZB.MOM.WW.Secrets.Configuration; + +namespace ZB.MOM.WW.ScadaBridge.Host.Tests; + +/// +/// ScadaBridge#110 decision 4: the bundle signing private key is supplied as a +/// ${secret:} reference, expanded by the same pre-host +/// Program.cs runs over the whole configuration. Proves the expanded multi-line PEM binds into +/// , passes startup validation and signs a verifiable bundle manifest. +/// +public sealed class TransportSigningSecretExpansionTests +{ + private sealed class DictResolver(Dictionary secrets) : ISecretResolver + { + public Task GetAsync(SecretName name, CancellationToken ct) => + Task.FromResult(secrets.TryGetValue(name.Value, out var v) ? v : null); + } + + /// A ${secret:} PrivateKey expands, validates and signs; the config file never held the key. + [Fact] + public async Task Secret_reference_private_key_expands_validates_and_signs() + { + using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256); + var privatePem = key.ExportPkcs8PrivateKeyPem(); + var publicPem = key.ExportSubjectPublicKeyInfoPem(); + + var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary + { + ["ScadaBridge:Transport:SourceEnvironment"] = "dev", + ["ScadaBridge:Transport:Signing:Enabled"] = "true", + ["ScadaBridge:Transport:Signing:PrivateKey"] = "${secret:bundle-signing-key}", + }).Build(); + + await new SecretReferenceExpander(new DictResolver(new() { ["bundle-signing-key"] = privatePem })) + .ExpandConfigurationAsync(configuration, default); + + var options = new TransportOptions(); + configuration.GetSection("ScadaBridge:Transport").Bind(options); + Assert.Equal(privatePem, options.Signing.PrivateKey); + Assert.False(new TransportOptionsValidator().Validate(null, options).Failed); + + options.Signing.TrustedKeys.Add(new TrustedBundleSigningKey { Name = "dev", PublicKey = publicPem }); + var o = Options.Create(options); + var signer = new BundleSigner(new ConfiguredBundleSigningKeySource(o), new ConfiguredBundleTrustStore(o), o); + var manifest = Encoding.UTF8.GetBytes("{\"m\":1}"); + var verdict = signer.Verify(manifest, signer.Sign(manifest)); + + Assert.Equal(ZB.MOM.WW.ScadaBridge.Commons.Types.Transport.BundleSignatureStatus.Verified, verdict.Status); + } + + /// An unresolved ${secret:} reference must not silently become an unparseable literal that boots. + [Fact] + public async Task Unresolvable_secret_reference_fails_expansion() + { + var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary + { + ["ScadaBridge:Transport:Signing:PrivateKey"] = "${secret:missing-key}", + }).Build(); + + await Assert.ThrowsAnyAsync(() => + new SecretReferenceExpander(new DictResolver(new())).ExpandConfigurationAsync(configuration, default)); + } +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Signing/BundleSigningPolicyAdvisoryTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Signing/BundleSigningPolicyAdvisoryTests.cs new file mode 100644 index 00000000..dd1be583 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Signing/BundleSigningPolicyAdvisoryTests.cs @@ -0,0 +1,87 @@ +using System.Security.Cryptography; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using ZB.MOM.WW.ScadaBridge.Transport.Signing; + +namespace ZB.MOM.WW.ScadaBridge.Transport.Tests.Signing; + +/// ScadaBridge#110: production-boundary advisory, startup warning, and key-never-logged guarantees. +public sealed class BundleSigningPolicyAdvisoryTests +{ + private sealed class CapturingLogger : ILogger + { + public List<(LogLevel Level, string Message)> Entries { get; } = new(); + public IDisposable? BeginScope(TState state) where TState : notnull => null; + public bool IsEnabled(LogLevel logLevel) => true; + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, + Func formatter) => + Entries.Add((logLevel, formatter(state, exception) + (exception?.ToString() ?? string.Empty))); + } + + /// Advisory fires only for ProductionBoundary without RequireSignedBundles. + [Theory] + [InlineData(true, false, true)] + [InlineData(true, true, false)] + [InlineData(false, false, false)] + [InlineData(false, true, false)] + public void Evaluate_flags_only_a_production_boundary_that_accepts_unsigned( + bool boundary, bool require, bool expectAdvisory) + { + var signing = new TransportSigningOptions { ProductionBoundary = boundary, RequireSignedBundles = require }; + Assert.Equal(expectAdvisory, BundleSigningPolicyAdvisory.Evaluate(signing) is not null); + } + + /// The startup service logs a Warning for the unmet expectation and stays silent otherwise. + [Theory] + [InlineData(true, false, 1)] + [InlineData(true, true, 0)] + [InlineData(false, false, 0)] + public async Task Startup_service_warns_exactly_when_advisory_applies(bool boundary, bool require, int warnings) + { + var opts = new TransportOptions(); + opts.Signing.ProductionBoundary = boundary; + opts.Signing.RequireSignedBundles = require; + var logger = new CapturingLogger(); + + await new BundleSigningPolicyStartupWarning(Options.Create(opts), logger).StartAsync(default); + + Assert.Equal(warnings, logger.Entries.Count(e => e.Level == LogLevel.Warning)); + } + + /// The ProductionBoundary flag is bound from configuration and defaults off. + [Fact] + public void ProductionBoundary_defaults_off() + { + Assert.False(new TransportOptions().Signing.ProductionBoundary); + Assert.False(new TransportOptions().Signing.RequireSignedBundles); + } + + /// A malformed private key must fail validation without echoing the key text into the failure message. + [Fact] + public void Validation_failure_for_bad_private_key_does_not_echo_the_key() + { + const string marker = "SUPER-SECRET-KEY-MATERIAL-12345"; + var opts = new TransportOptions { SourceEnvironment = "dev" }; + opts.Signing.Enabled = true; + opts.Signing.PrivateKey = "-----BEGIN PRIVATE KEY-----\n" + marker + "\n-----END PRIVATE KEY-----"; + + var result = new TransportOptionsValidator().Validate(null, opts); + + Assert.True(result.Failed); + Assert.DoesNotContain(marker, string.Join("|", result.Failures)); + } + + /// Signing never writes the private key (or its base64) into the produced signature entry. + [Fact] + public void Signature_entry_does_not_contain_private_key_material() + { + using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256); + var pem = key.ExportPkcs8PrivateKeyPem(); + var der = Convert.ToBase64String(key.ExportPkcs8PrivateKey()); + var sig = BundleSignerTests.Signer(BundleSignerTests.SigningOptions(pem)).Sign(new byte[] { 1, 2, 3 })!; + var text = System.Text.Encoding.UTF8.GetString(sig); + + Assert.DoesNotContain(der, text); + Assert.DoesNotContain("PRIVATE KEY", text); + } +}