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); } }