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:
Joseph Doherty
2026-11-09 22:50:18 -05:00
parent a4806576af
commit 559e02f5c8
11 changed files with 421 additions and 10 deletions
+93
View File
@@ -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)
**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`.
+2 -2
View File
@@ -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
@@ -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",
failureStatus: null,
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
// 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
@@ -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<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
// TryAdd so a host that already calls AddTemplateEngine() (which
// 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>
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>
/// 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
@@ -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));
}
}
@@ -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);
}
}