559e02f5c8
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
71 lines
3.3 KiB
C#
71 lines
3.3 KiB
C#
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));
|
|
}
|
|
}
|