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,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