using System.Security.Cryptography.X509Certificates; using Opc.Ua; using Opc.Ua.Configuration; using Opc.Ua.Security.Certificates; using Shouldly; using Xunit; namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests; /// Exercises the target certificate manager with real issuer stores and signed certificates. public sealed class Sdk2CertificateValidationTests { [Theory] [InlineData("valid")] [InlineData("expired")] [InlineData("revoked")] [InlineData("missing-issuer")] [InlineData("bad-issuer-usage")] public async Task Trust_and_certificate_failures_keep_the_configured_rejection_policy(string scenario) { var ct = TestContext.Current.CancellationToken; await using var pki = await ValidationPki.CreateAsync(scenario, ct); var result = await pki.Configuration.CertificateManager.ValidateAsync(pki.Leaf, ct: ct); if (scenario == "valid") result.IsValid.ShouldBeTrue(result.ToString()); else { result.IsValid.ShouldBeFalse(); var expected = scenario switch { "expired" => StatusCodes.BadCertificateTimeInvalid, "revoked" => StatusCodes.BadCertificateRevoked, "missing-issuer" => StatusCodes.BadCertificateChainIncomplete, _ => StatusCodes.BadCertificateIssuerUseNotAllowed, }; (result.StatusCode == expected || result.Errors.Any(e => e.StatusCode == expected)).ShouldBeTrue( $"Expected {expected}; actual {result.StatusCode}: {string.Join(", ", result.Errors)}"); } pki.Configuration.SecurityConfiguration.AutoAcceptUntrustedCertificates.ShouldBeFalse(); } [Theory] [InlineData(true)] [InlineData(false)] public async Task A_trusted_chain_does_not_bypass_endpoint_identity_checks(bool wrongUri) { await using var pki = await ValidationPki.CreateAsync("valid", TestContext.Current.CancellationToken); (await pki.Configuration.CertificateManager.ValidateAsync(pki.Leaf, ct: TestContext.Current.CancellationToken)) .IsValid.ShouldBeTrue(); var endpoint = new ConfiguredEndpoint(null, new EndpointDescription { EndpointUrl = wrongUri ? "opc.tcp://localhost:4840" : "opc.tcp://other.invalid:4840", Server = new ApplicationDescription { ApplicationUri = wrongUri ? "urn:wrong-application" : ValidationPki.PeerUri }, }, EndpointConfiguration.Create(pki.Configuration)); var error = Should.Throw(() => { if (wrongUri) pki.Configuration.CertificateManager.ValidateApplicationUri(pki.Leaf, endpoint); else pki.Configuration.CertificateManager.ValidateDomains(pki.Leaf, endpoint); }); error.StatusCode.ShouldBe(wrongUri ? StatusCodes.BadCertificateUriInvalid : StatusCodes.BadCertificateHostNameInvalid); } private sealed class ValidationPki : IAsyncDisposable { internal const string PeerUri = "urn:Sdk2ValidationPeer"; private readonly string _root = Path.Combine(Path.GetTempPath(), $"otua-validation-{Guid.NewGuid():N}"); private ApplicationInstance? _application; private Certificate? _issuer; internal Certificate Leaf { get; private set; } = null!; internal ApplicationConfiguration Configuration { get; private set; } = null!; internal static async Task CreateAsync(string scenario, CancellationToken ct) { var pki = new ValidationPki(); try { var now = DateTime.UtcNow; var issuerBuilder = CertificateBuilder.Create("CN=Sdk2ValidationIssuer") .SetNotBefore(now.AddDays(-10)).SetNotAfter(now.AddDays(365)).SetCAConstraint(-1); if (scenario == "bad-issuer-usage") issuerBuilder.AddExtension(new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature, true)); pki._issuer = issuerBuilder.SetRSAKeySize(2048).CreateForRSA(); var names = new SubjectAlternativeNameBuilder(); names.AddUri(new Uri(PeerUri)); names.AddDnsName("localhost"); pki.Leaf = CertificateBuilder.Create("CN=Sdk2ValidationPeer") .SetNotBefore(now.AddDays(-2)).SetNotAfter(scenario == "expired" ? now.AddDays(-1) : now.AddDays(30)) .AddExtension(names.Build()).SetIssuer(pki._issuer).SetRSAKeySize(2048).CreateForRSA(); if (scenario != "missing-issuer") { var certs = Path.Combine(pki._root, "trusted", "certs"); Directory.CreateDirectory(certs); await File.WriteAllBytesAsync(Path.Combine(certs, "issuer.der"), pki._issuer.RawData, ct); if (scenario == "revoked") { var crls = Path.Combine(pki._root, "trusted", "crl"); Directory.CreateDirectory(crls); var crl = new X509CRL(CrlBuilder.Create(pki._issuer.SubjectName).AddRevokedCertificate(pki.Leaf).CreateForRSA(pki._issuer)); await File.WriteAllBytesAsync(Path.Combine(crls, "issuer.crl"), crl.RawData, ct); } } var telemetry = DefaultTelemetry.Create(_ => { }); var security = TestClientSecurity.Build(pki._root); security.AutoAcceptUntrustedCertificates = false; pki.Configuration = new ApplicationConfiguration(telemetry) { ApplicationName = "Sdk2Validation", ApplicationUri = "urn:Sdk2Validation", ApplicationType = ApplicationType.Client, SecurityConfiguration = security, ClientConfiguration = new ClientConfiguration(), }; pki._application = new ApplicationInstance(telemetry) { ApplicationConfiguration = pki.Configuration }; await pki.Configuration.ValidateAsync(ApplicationType.Client, ct); return pki; } catch { await pki.DisposeAsync(); throw; } } public async ValueTask DisposeAsync() { if (_application is not null) await _application.DisposeAsync(); Leaf?.Dispose(); _issuer?.Dispose(); if (Directory.Exists(_root)) Directory.Delete(_root, true); } } }