using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
using ZB.MOM.WW.ScadaBridge.Transport.Signing;
namespace ZB.MOM.WW.ScadaBridge.Transport.Tests.Signing;
///
/// ScadaBridge#45: the detached-signature mechanism and the import-side policy over it.
///
public sealed class BundleSignerTests
{
internal static (string PrivatePem, string PublicPem, string KeyId) NewKeyPair()
{
using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
return (key.ExportPkcs8PrivateKeyPem(), key.ExportSubjectPublicKeyInfoPem(), BundleSigningKeys.ComputeKeyId(key));
}
internal static TransportOptions SigningOptions(
string? privatePem, bool require = false, params (string Name, string PublicPem)[] trusted)
{
var opts = new TransportOptions();
opts.Signing.Enabled = privatePem is not null;
opts.Signing.PrivateKey = privatePem;
opts.Signing.RequireSignedBundles = require;
foreach (var (name, pem) in trusted)
{
opts.Signing.TrustedKeys.Add(new TrustedBundleSigningKey { Name = name, PublicKey = pem });
}
return opts;
}
internal static BundleSigner Signer(TransportOptions opts)
{
var o = Options.Create(opts);
return new BundleSigner(new ConfiguredBundleSigningKeySource(o), new ConfiguredBundleTrustStore(o), o);
}
private static readonly byte[] Manifest = Encoding.UTF8.GetBytes("{\"BundleFormatVersion\":1,\"SourceEnvironment\":\"dev\"}");
/// A signature made with a key the importer trusts verifies and names the signer.
[Fact]
public void Sign_then_verify_with_trusted_key_is_Verified()
{
var (priv, pub, keyId) = NewKeyPair();
var sig = Signer(SigningOptions(priv)).Sign(Manifest);
Assert.NotNull(sig);
var result = Signer(SigningOptions(null, trusted: ("dev-cluster", pub))).Verify(Manifest, sig);
Assert.Equal(BundleSignatureStatus.Verified, result.Status);
Assert.Equal(keyId, result.KeyId);
Assert.Equal("dev-cluster", result.SignerName);
}
/// Signing is off by default: no key, no signature entry.
[Fact]
public void Sign_returns_null_when_signing_disabled()
{
Assert.Null(Signer(new TransportOptions()).Sign(Manifest));
}
/// Any change to the signed manifest bytes is a hard rejection, required or not.
[Fact]
public void Verify_rejects_altered_manifest()
{
var (priv, pub, _) = NewKeyPair();
var sig = Signer(SigningOptions(priv)).Sign(Manifest);
var altered = Encoding.UTF8.GetBytes("{\"BundleFormatVersion\":1,\"SourceEnvironment\":\"prd\"}");
var ex = Assert.Throws(
() => Signer(SigningOptions(null, trusted: ("dev-cluster", pub))).Verify(altered, sig));
Assert.Contains("modified after it was signed", ex.Message);
}
/// A signature claiming a trusted key id but made with another key does not verify.
[Fact]
public void Verify_rejects_signature_from_other_key_claiming_trusted_id()
{
var (_, pub, trustedId) = NewKeyPair();
var (otherPriv, _, _) = NewKeyPair();
using var other = ECDsa.Create();
other.ImportFromPem(otherPriv);
var forged = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(new BundleSignatureEnvelope(
BundleSigningKeys.Algorithm, trustedId, Convert.ToBase64String(other.SignData(Manifest, HashAlgorithmName.SHA256))));
Assert.Throws(
() => Signer(SigningOptions(null, trusted: ("dev-cluster", pub))).Verify(Manifest, forged));
}
/// Unsigned bundles load by default (every pre-signing bundle) and are refused when signing is required.
[Fact]
public void Unsigned_is_accepted_unless_required()
{
Assert.Equal(BundleSignatureStatus.Unsigned, Signer(new TransportOptions()).Verify(Manifest, null).Status);
Assert.Throws(() => Signer(SigningOptions(null, require: true)).Verify(Manifest, null));
}
/// A signature by a key outside the trust store is reported, and refused when signing is required.
[Fact]
public void Untrusted_key_is_reported_unless_required()
{
var (priv, _, keyId) = NewKeyPair();
var sig = Signer(SigningOptions(priv)).Sign(Manifest);
var result = Signer(new TransportOptions()).Verify(Manifest, sig);
Assert.Equal(BundleSignatureStatus.UntrustedKey, result.Status);
Assert.Equal(keyId, result.KeyId);
Assert.Null(result.SignerName);
Assert.Throws(() => Signer(SigningOptions(null, require: true)).Verify(Manifest, sig));
}
/// Malformed signature entries and unknown algorithms are refused.
[Theory]
[InlineData("not json")]
[InlineData("{}")]
[InlineData("{\"Algorithm\":\"RSA-PSS\",\"KeyId\":\"sha256:00\",\"SignatureB64\":\"AA==\"}")]
public void Malformed_signature_entry_is_refused(string entry)
{
Assert.Throws(
() => Signer(new TransportOptions()).Verify(Manifest, Encoding.UTF8.GetBytes(entry)));
}
/// Keys load from PEM or bare base64 DER and must be P-256.
[Fact]
public void Key_loading_accepts_pem_and_base64_and_rejects_other_curves()
{
using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
using var fromB64 = BundleSigningKeys.LoadPrivateKey(Convert.ToBase64String(key.ExportPkcs8PrivateKey()));
using var pubB64 = BundleSigningKeys.LoadPublicKey(Convert.ToBase64String(key.ExportSubjectPublicKeyInfo()));
Assert.Equal(BundleSigningKeys.ComputeKeyId(key), BundleSigningKeys.ComputeKeyId(pubB64));
using var p384 = ECDsa.Create(ECCurve.NamedCurves.nistP384);
Assert.Throws(() => BundleSigningKeys.LoadPrivateKey(p384.ExportPkcs8PrivateKeyPem()));
// A public key is not a signing key.
Assert.ThrowsAny(() => BundleSigningKeys.LoadPrivateKey(key.ExportSubjectPublicKeyInfoPem()));
}
/// Startup validation rejects an enabled-without-key or unparseable configuration and accepts a good one.
[Fact]
public void Options_validator_checks_signing_keys()
{
var validator = new TransportOptionsValidator();
var (priv, pub, _) = NewKeyPair();
var enabledNoKey = new TransportOptions();
enabledNoKey.Signing.Enabled = true;
var r1 = validator.Validate(Options.DefaultName, enabledNoKey);
Assert.True(r1.Failed);
Assert.Contains("Signing:PrivateKey", r1.FailureMessage);
var badKey = SigningOptions("-----BEGIN PRIVATE KEY-----\nAAAA\n-----END PRIVATE KEY-----");
Assert.True(validator.Validate(Options.DefaultName, badKey).Failed);
var badTrusted = SigningOptions(null, trusted: ("x", "not-a-key"));
var r3 = validator.Validate(Options.DefaultName, badTrusted);
Assert.True(r3.Failed);
Assert.Contains("TrustedKeys:0:PublicKey", r3.FailureMessage);
var good = SigningOptions(priv, require: true, trusted: ("dev", pub));
var r4 = validator.Validate(Options.DefaultName, good);
Assert.True(r4.Succeeded, r4.FailureMessage);
}
}