using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using Opc.Ua; using Shouldly; using Xunit; namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests; /// /// Unit coverage for the cert-validation knobs added in PR #277. Live revocation testing /// requires standing up a CA + CRL; we cover the parts that are testable without one: /// option defaults, the static decision pipeline, SHA-1 detection, and key-size checks. /// [Trait("Category", "Unit")] public sealed class OpcUaClientCertValidationTests { [Fact] public void Defaults_match_documented_policy() { var opts = new OpcUaClientDriverOptions(); opts.CertificateValidation.RejectSHA1SignedCertificates.ShouldBeTrue( "SHA-1 is spec-deprecated for OPC UA — default must be hard-fail."); opts.CertificateValidation.RejectUnknownRevocationStatus.ShouldBeFalse( "Default must allow brownfield deployments without CRL infrastructure."); opts.CertificateValidation.MinimumCertificateKeySize.ShouldBe(2048); } [Fact] public void Revoked_cert_is_rejected_even_when_AutoAccept_is_true() { using var cert = CreateRsaCert(2048, HashAlgorithmName.SHA256); var decision = OpcUaClientDriver.EvaluateCertificateValidation( cert, new StatusCode(StatusCodes.BadCertificateRevoked), autoAcceptUntrusted: true, new OpcUaCertificateValidationOptions()); decision.Accept.ShouldBeFalse(); decision.LogMessage!.ShouldContain("REVOKED"); } [Fact] public void Issuer_revoked_is_rejected_even_when_AutoAccept_is_true() { using var cert = CreateRsaCert(2048, HashAlgorithmName.SHA256); var decision = OpcUaClientDriver.EvaluateCertificateValidation( cert, new StatusCode(StatusCodes.BadCertificateIssuerRevoked), autoAcceptUntrusted: true, new OpcUaCertificateValidationOptions()); decision.Accept.ShouldBeFalse(); decision.LogMessage!.ShouldContain("REVOKED issuer"); } [Fact] public void RevocationUnknown_default_accepts_with_log_note() { using var cert = CreateRsaCert(2048, HashAlgorithmName.SHA256); var decision = OpcUaClientDriver.EvaluateCertificateValidation( cert, new StatusCode(StatusCodes.BadCertificateRevocationUnknown), autoAcceptUntrusted: false, new OpcUaCertificateValidationOptions { RejectUnknownRevocationStatus = false }); decision.Accept.ShouldBeTrue(); decision.LogMessage!.ShouldContain("revocation status unknown"); } [Fact] public void RevocationUnknown_with_strict_flag_rejects() { using var cert = CreateRsaCert(2048, HashAlgorithmName.SHA256); var decision = OpcUaClientDriver.EvaluateCertificateValidation( cert, new StatusCode(StatusCodes.BadCertificateRevocationUnknown), autoAcceptUntrusted: true, new OpcUaCertificateValidationOptions { RejectUnknownRevocationStatus = true }); decision.Accept.ShouldBeFalse(); decision.LogMessage!.ShouldContain("revocation status unknown"); } [Fact] public void Sha1_signed_cert_is_rejected_by_default() { using var cert = CreateRsaCert(2048, HashAlgorithmName.SHA1); var decision = OpcUaClientDriver.EvaluateCertificateValidation( cert, new StatusCode(StatusCodes.Good), autoAcceptUntrusted: false, new OpcUaCertificateValidationOptions()); decision.Accept.ShouldBeFalse(); decision.LogMessage!.ShouldContain("SHA-1"); } [Fact] public void Sha1_acceptance_can_be_opted_back_into() { using var cert = CreateRsaCert(2048, HashAlgorithmName.SHA1); // Untrusted + auto-accept = let it through; SHA-1 must NOT be the failing reason. var decision = OpcUaClientDriver.EvaluateCertificateValidation( cert, new StatusCode(StatusCodes.BadCertificateUntrusted), autoAcceptUntrusted: true, new OpcUaCertificateValidationOptions { RejectSHA1SignedCertificates = false }); decision.Accept.ShouldBeTrue(); } [Fact] public void Small_rsa_key_is_rejected_below_minimum() { using var cert = CreateRsaCert(1024, HashAlgorithmName.SHA256); var decision = OpcUaClientDriver.EvaluateCertificateValidation( cert, new StatusCode(StatusCodes.Good), autoAcceptUntrusted: false, new OpcUaCertificateValidationOptions()); decision.Accept.ShouldBeFalse(); decision.LogMessage!.ShouldContain("1024"); } [Fact] public void TryGetRsaKeySize_reports_correct_bit_count() { using var cert = CreateRsaCert(2048, HashAlgorithmName.SHA256); OpcUaClientDriver.TryGetRsaKeySize(cert, out var bits).ShouldBeTrue(); bits.ShouldBe(2048); } [Fact] public void IsSha1Signed_detects_sha1_signature() { using var sha1Cert = CreateRsaCert(2048, HashAlgorithmName.SHA1); using var sha256Cert = CreateRsaCert(2048, HashAlgorithmName.SHA256); OpcUaClientDriver.IsSha1Signed(sha1Cert).ShouldBeTrue(); OpcUaClientDriver.IsSha1Signed(sha256Cert).ShouldBeFalse(); OpcUaClientDriver.IsSha1Signed(null).ShouldBeFalse(); } [Fact] public void Untrusted_without_AutoAccept_is_rejected() { using var cert = CreateRsaCert(2048, HashAlgorithmName.SHA256); var decision = OpcUaClientDriver.EvaluateCertificateValidation( cert, new StatusCode(StatusCodes.BadCertificateUntrusted), autoAcceptUntrusted: false, new OpcUaCertificateValidationOptions()); decision.Accept.ShouldBeFalse(); decision.LogMessage!.ShouldContain("untrusted"); } [Fact] public void Good_status_with_compliant_cert_accepts_silently() { using var cert = CreateRsaCert(2048, HashAlgorithmName.SHA256); var decision = OpcUaClientDriver.EvaluateCertificateValidation( cert, new StatusCode(StatusCodes.Good), autoAcceptUntrusted: false, new OpcUaCertificateValidationOptions()); decision.Accept.ShouldBeTrue(); decision.LogMessage.ShouldBeNull("Good validations shouldn't emit log noise."); } private static X509Certificate2 CreateRsaCert(int keySize, HashAlgorithmName hash) { // .NET 10's CertificateRequest.CreateSelfSigned rejects SHA-1 outright. For the // SHA-256 path we use the supported API; for SHA-1 we route through a custom // X509SignatureGenerator that signs with SHA-1 OID so we can synthesise a SHA-1 // signed cert in-process without shipping a binary fixture. var rsa = RSA.Create(keySize); var req = new CertificateRequest( new System.Security.Cryptography.X509Certificates.X500DistinguishedName( "CN=OpcUaClientCertValidationTests"), rsa, hash == HashAlgorithmName.SHA1 ? HashAlgorithmName.SHA256 : hash, RSASignaturePadding.Pkcs1); if (hash == HashAlgorithmName.SHA1) { var generator = new Sha1RsaSignatureGenerator(rsa); var serial = new byte[8]; System.Security.Cryptography.RandomNumberGenerator.Fill(serial); var built = req.Create( req.SubjectName, generator, DateTimeOffset.UtcNow.AddMinutes(-5), DateTimeOffset.UtcNow.AddHours(1), serial); // Combine cert + key so GetRSAPublicKey works downstream. return built.CopyWithPrivateKey(rsa); } return req.CreateSelfSigned( DateTimeOffset.UtcNow.AddMinutes(-5), DateTimeOffset.UtcNow.AddHours(1)); } /// /// SHA-1 RSA signature generator. .NET 10's /// refuses SHA-1; we subclass to emit the SHA-1 RSA algorithm identifier /// (1.2.840.113549.1.1.5) and sign with SHA-1 explicitly. Test-only. /// private sealed class Sha1RsaSignatureGenerator : X509SignatureGenerator { private readonly RSA _rsa; public Sha1RsaSignatureGenerator(RSA rsa) { _rsa = rsa; } public override byte[] GetSignatureAlgorithmIdentifier(HashAlgorithmName hashAlgorithm) { // DER: SEQUENCE { OID 1.2.840.113549.1.1.5, NULL } return new byte[] { 0x30, 0x0D, 0x06, 0x09, 0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x01, 0x05, 0x05, 0x00, }; } public override byte[] SignData(byte[] data, HashAlgorithmName hashAlgorithm) => _rsa.SignData(data, HashAlgorithmName.SHA1, RSASignaturePadding.Pkcs1); protected override PublicKey BuildPublicKey() => PublicKey.CreateFromSubjectPublicKeyInfo( _rsa.ExportSubjectPublicKeyInfo(), out _); } }