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