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
This commit is contained in:
@@ -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:<hex of SubjectPublicKeyInfo>`). 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.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Transport addendum — cluster-to-cluster pull and differential bundles (ScadaBridge#45)
|
# 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).
|
**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
|
The third deferred item, bundle signing, **is** implemented. See "Bundle Signing" in
|
||||||
`docs/requirements/Component-Transport.md`.
|
`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
|
## 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
|
### What exists today
|
||||||
|
|
||||||
A pull can already be **orchestrated** without any new server code. The CLI speaks to any
|
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
|
## 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
|
### What exists today
|
||||||
|
|
||||||
Every export is a full snapshot of the selected artifacts plus, optionally, their dependency
|
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
|
1. Pull: **not built.** No cross-environment network path (P3) and no machine identity (P1/P2) will be introduced.
|
||||||
can be decided until this is.
|
2. Differential: deletions (D2) **stay deferred**; no tombstones.
|
||||||
2. Pull: which machine identity, a stored service account or a new export-scoped credential (P1/P2)?
|
3. Signing: one key per environment, hand-edited `TrustedKeys`, `${secret:}` key source, enforcement on at the
|
||||||
3. Differential: is propagating deletions in scope at all (D2)?
|
production boundary once every exporter signs — see `docs/operations/bundle-signing-runbook.md`.
|
||||||
4. Signing, which the pull depends on: see the open decisions in Component-Transport.md
|
|
||||||
"Bundle Signing".
|
|
||||||
|
|||||||
@@ -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.
|
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()`.
|
- **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.
|
- **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
|
## Configuration Audit Trail
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// /health/ready entry (ScadaBridge#110): Degraded while a cluster marked
|
||||||
|
/// <c>Transport:Signing:ProductionBoundary</c> 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.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class BundleSigningPolicyHealthCheck : IHealthCheck
|
||||||
|
{
|
||||||
|
private readonly IOptionsMonitor<TransportOptions> _options;
|
||||||
|
|
||||||
|
/// <summary>Initializes the check.</summary>
|
||||||
|
/// <param name="options">Transport options.</param>
|
||||||
|
public BundleSigningPolicyHealthCheck(IOptionsMonitor<TransportOptions> options)
|
||||||
|
{
|
||||||
|
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<HealthCheckResult> CheckHealthAsync(
|
||||||
|
HealthCheckContext context, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var signing = _options.CurrentValue.Signing;
|
||||||
|
var data = new Dictionary<string, object>(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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -568,6 +568,12 @@ try
|
|||||||
"required-singletons",
|
"required-singletons",
|
||||||
failureStatus: null,
|
failureStatus: null,
|
||||||
tags: new[] { ZbHealthTags.Ready })
|
tags: new[] { ZbHealthTags.Ready })
|
||||||
|
// ScadaBridge#110: Degraded (HTTP 200) while a Transport:Signing:ProductionBoundary
|
||||||
|
// cluster still accepts unsigned bundles.
|
||||||
|
.AddTypeActivatedCheck<BundleSigningPolicyHealthCheck>(
|
||||||
|
"bundle-signing-policy",
|
||||||
|
failureStatus: null,
|
||||||
|
tags: new[] { ZbHealthTags.Ready })
|
||||||
// Oldest-Up-member check (review 01 [High]), unscoped: every central member competes for
|
// 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
|
// 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
|
// by age, so the host-local OldestNodeActiveHealthCheck that existed only to avoid the old
|
||||||
|
|||||||
@@ -71,6 +71,9 @@ public static class ServiceCollectionExtensions
|
|||||||
// T-007: periodic eviction sweep so abandoned sessions clear without
|
// T-007: periodic eviction sweep so abandoned sessions clear without
|
||||||
// needing a fresh Get() to trigger lazy eviction.
|
// needing a fresh Get() to trigger lazy eviction.
|
||||||
services.AddHostedService<BundleSessionEvictionService>();
|
services.AddHostedService<BundleSessionEvictionService>();
|
||||||
|
// ScadaBridge#110: warn at startup when a production-boundary cluster does not
|
||||||
|
// require signed bundles.
|
||||||
|
services.AddHostedService<BundleSigningPolicyStartupWarning>();
|
||||||
// SemanticValidator is a stateless utility used by ApplyAsync; use
|
// SemanticValidator is a stateless utility used by ApplyAsync; use
|
||||||
// TryAdd so a host that already calls AddTemplateEngine() (which
|
// TryAdd so a host that already calls AddTemplateEngine() (which
|
||||||
// registers the same type as Transient) wins. Either registration
|
// registers the same type as Transient) wins. Either registration
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.ScadaBridge.Transport.Signing;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ScadaBridge#110: the production-boundary signing advisory. An environment marked
|
||||||
|
/// <c>ScadaBridge:Transport:Signing:ProductionBoundary</c> is expected to run with
|
||||||
|
/// <c>RequireSignedBundles</c> on; this is the single place that decides whether that
|
||||||
|
/// expectation is unmet, shared by the startup warning and the readiness health check.
|
||||||
|
/// </summary>
|
||||||
|
public static class BundleSigningPolicyAdvisory
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Evaluates the signing options.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="signing">The bound signing options.</param>
|
||||||
|
/// <returns>The advisory text when a production-boundary cluster accepts unsigned bundles; otherwise <c>null</c>.</returns>
|
||||||
|
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.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ScadaBridge#110: logs the <see cref="BundleSigningPolicyAdvisory"/> once at startup.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class BundleSigningPolicyStartupWarning : IHostedService
|
||||||
|
{
|
||||||
|
private readonly IOptions<TransportOptions> _options;
|
||||||
|
private readonly ILogger<BundleSigningPolicyStartupWarning> _logger;
|
||||||
|
|
||||||
|
public BundleSigningPolicyStartupWarning(
|
||||||
|
IOptions<TransportOptions> options, ILogger<BundleSigningPolicyStartupWarning> 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;
|
||||||
|
}
|
||||||
@@ -102,6 +102,17 @@ public sealed class TransportSigningOptions
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public bool RequireSignedBundles { get; set; }
|
public bool RequireSignedBundles { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Owner decision (ScadaBridge#110): marks this cluster as sitting on the production
|
||||||
|
/// boundary — an environment that imports bundles from other environments. While it is
|
||||||
|
/// <c>true</c> and <see cref="RequireSignedBundles"/> is <c>false</c> the node logs a
|
||||||
|
/// startup WARNING and the central <c>bundle-signing-policy</c> readiness check reports
|
||||||
|
/// Degraded. It changes no import behaviour: <see cref="RequireSignedBundles"/> stays
|
||||||
|
/// default-off in code, and is flipped per environment once every exporting cluster signs.
|
||||||
|
/// Default <c>false</c>.
|
||||||
|
/// </summary>
|
||||||
|
public bool ProductionBoundary { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Public keys this cluster trusts to sign bundles. Each entry's key id is the
|
/// Public keys this cluster trusts to sign bundles. Each entry's key id is the
|
||||||
/// <c>sha256:</c> fingerprint of its SubjectPublicKeyInfo, so no id needs to be
|
/// <c>sha256:</c> fingerprint of its SubjectPublicKeyInfo, so no id needs to be
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>ScadaBridge#110: the bundle-signing-policy readiness entry.</summary>
|
||||||
|
public sealed class BundleSigningPolicyHealthCheckTests
|
||||||
|
{
|
||||||
|
private sealed class FixedMonitor(TransportOptions o) : IOptionsMonitor<TransportOptions>
|
||||||
|
{
|
||||||
|
public TransportOptions CurrentValue => o;
|
||||||
|
public TransportOptions Get(string? name) => o;
|
||||||
|
public IDisposable? OnChange(Action<TransportOptions, string?> listener) => null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Degraded only when a production-boundary cluster accepts unsigned bundles.</summary>
|
||||||
|
[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"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ScadaBridge#110 decision 4: the bundle signing private key is supplied as a
|
||||||
|
/// <c>${secret:}</c> reference, expanded by the same pre-host <see cref="SecretReferenceExpander"/>
|
||||||
|
/// Program.cs runs over the whole configuration. Proves the expanded multi-line PEM binds into
|
||||||
|
/// <see cref="TransportOptions"/>, passes startup validation and signs a verifiable bundle manifest.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class TransportSigningSecretExpansionTests
|
||||||
|
{
|
||||||
|
private sealed class DictResolver(Dictionary<string, string> secrets) : ISecretResolver
|
||||||
|
{
|
||||||
|
public Task<string?> GetAsync(SecretName name, CancellationToken ct) =>
|
||||||
|
Task.FromResult(secrets.TryGetValue(name.Value, out var v) ? v : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A ${secret:} PrivateKey expands, validates and signs; the config file never held the key.</summary>
|
||||||
|
[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<string, string?>
|
||||||
|
{
|
||||||
|
["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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>An unresolved ${secret:} reference must not silently become an unparseable literal that boots.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Unresolvable_secret_reference_fails_expansion()
|
||||||
|
{
|
||||||
|
var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["ScadaBridge:Transport:Signing:PrivateKey"] = "${secret:missing-key}",
|
||||||
|
}).Build();
|
||||||
|
|
||||||
|
await Assert.ThrowsAnyAsync<Exception>(() =>
|
||||||
|
new SecretReferenceExpander(new DictResolver(new())).ExpandConfigurationAsync(configuration, default));
|
||||||
|
}
|
||||||
|
}
|
||||||
+87
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>ScadaBridge#110: production-boundary advisory, startup warning, and key-never-logged guarantees.</summary>
|
||||||
|
public sealed class BundleSigningPolicyAdvisoryTests
|
||||||
|
{
|
||||||
|
private sealed class CapturingLogger<T> : ILogger<T>
|
||||||
|
{
|
||||||
|
public List<(LogLevel Level, string Message)> Entries { get; } = new();
|
||||||
|
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
||||||
|
public bool IsEnabled(LogLevel logLevel) => true;
|
||||||
|
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
|
||||||
|
Func<TState, Exception?, string> formatter) =>
|
||||||
|
Entries.Add((logLevel, formatter(state, exception) + (exception?.ToString() ?? string.Empty)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Advisory fires only for ProductionBoundary without RequireSignedBundles.</summary>
|
||||||
|
[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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The startup service logs a Warning for the unmet expectation and stays silent otherwise.</summary>
|
||||||
|
[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<BundleSigningPolicyStartupWarning>();
|
||||||
|
|
||||||
|
await new BundleSigningPolicyStartupWarning(Options.Create(opts), logger).StartAsync(default);
|
||||||
|
|
||||||
|
Assert.Equal(warnings, logger.Entries.Count(e => e.Level == LogLevel.Warning));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The ProductionBoundary flag is bound from configuration and defaults off.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void ProductionBoundary_defaults_off()
|
||||||
|
{
|
||||||
|
Assert.False(new TransportOptions().Signing.ProductionBoundary);
|
||||||
|
Assert.False(new TransportOptions().Signing.RequireSignedBundles);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A malformed private key must fail validation without echoing the key text into the failure message.</summary>
|
||||||
|
[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));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Signing never writes the private key (or its base64) into the produced signature entry.</summary>
|
||||||
|
[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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user