using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using Shouldly; using Xunit; namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests; [Trait("Category", "Unit")] public sealed class OpcUaClientCertAuthTests { /// /// Verifies that BuildCertificateIdentity rejects missing certificate path. /// [Fact] public void BuildCertificateIdentity_rejects_missing_path() { var opts = new OpcUaClientDriverOptions { AuthType = OpcUaAuthType.Certificate }; Should.Throw(() => OpcUaClientDriver.BuildCertificateIdentity(opts)) .Message.ShouldContain("UserCertificatePath"); } /// /// Verifies that BuildCertificateIdentity rejects nonexistent certificate file. /// [Fact] public void BuildCertificateIdentity_rejects_nonexistent_file() { var opts = new OpcUaClientDriverOptions { AuthType = OpcUaAuthType.Certificate, UserCertificatePath = Path.Combine(Path.GetTempPath(), $"does-not-exist-{Guid.NewGuid():N}.pfx"), }; Should.Throw(() => OpcUaClientDriver.BuildCertificateIdentity(opts)); } /// /// Verifies that BuildCertificateIdentity loads a valid PFX with private key. /// [Fact] public void BuildCertificateIdentity_loads_a_valid_PFX_with_private_key() { // Generate a self-signed cert on the fly so the test doesn't ship a static PFX. // The driver doesn't care about the issuer — just needs a cert with a private key. using var rsa = RSA.Create(2048); var req = new CertificateRequest("CN=OpcUaClientCertAuthTests", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); var cert = req.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-5), DateTimeOffset.UtcNow.AddHours(1)); var tmpPath = Path.Combine(Path.GetTempPath(), $"opcua-cert-test-{Guid.NewGuid():N}.pfx"); File.WriteAllBytes(tmpPath, cert.Export(X509ContentType.Pfx, "testpw")); try { var opts = new OpcUaClientDriverOptions { AuthType = OpcUaAuthType.Certificate, UserCertificatePath = tmpPath, UserCertificatePassword = "testpw", }; var identity = OpcUaClientDriver.BuildCertificateIdentity(opts); identity.ShouldNotBeNull(); identity.TokenType.ShouldBe(Opc.Ua.UserTokenType.Certificate); } finally { try { File.Delete(tmpPath); } catch { /* best-effort */ } } } [Fact] public async Task Certificate_identity_signs_repeatedly_and_requires_reopen_after_file_rotation() { var path = Path.Combine(Path.GetTempPath(), $"opcua-cert-rotation-{Guid.NewGuid():N}.pfx"); try { using var key = RSA.Create(2048); var request = new CertificateRequest("CN=OpcUaSdk2Signing", key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); using var original = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-5), DateTimeOffset.UtcNow.AddHours(1)); File.WriteAllBytes(path, original.Export(X509ContentType.Pfx, "test-password")); var options = new OpcUaClientDriverOptions { AuthType = OpcUaAuthType.Certificate, UserCertificatePath = path, UserCertificatePassword = "test-password" }; var identity = OpcUaClientDriver.BuildCertificateIdentity(options); byte[] challenge = [1, 2, 3, 4]; for (var i = 0; i < 2; i++) { var signature = await identity.TokenHandler.SignAsync(challenge, Opc.Ua.SecurityPolicies.Basic256Sha256, TestContext.Current.CancellationToken); using var publicKey = original.GetRSAPublicKey(); publicKey.ShouldNotBeNull().VerifyData(challenge, signature.Signature.Span, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1).ShouldBeTrue(); } using var cancelled = new CancellationTokenSource(); cancelled.Cancel(); await Should.ThrowAsync(async () => await identity.TokenHandler.SignAsync(challenge, Opc.Ua.SecurityPolicies.Basic256Sha256, cancelled.Token)); using var rotatedKey = RSA.Create(2048); var rotatedRequest = new CertificateRequest("CN=OpcUaSdk2Signing", rotatedKey, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); using var rotated = rotatedRequest.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-5), DateTimeOffset.UtcNow.AddHours(1)); File.WriteAllBytes(path, rotated.Export(X509ContentType.Pfx, "test-password")); var error = await Should.ThrowAsync(async () => await identity.TokenHandler.SignAsync(challenge, Opc.Ua.SecurityPolicies.Basic256Sha256, TestContext.Current.CancellationToken)); error.StatusCode.ShouldBe(Opc.Ua.StatusCodes.BadIdentityTokenInvalid); var fresh = OpcUaClientDriver.BuildCertificateIdentity(options); var freshSignature = await fresh.TokenHandler.SignAsync(challenge, Opc.Ua.SecurityPolicies.Basic256Sha256, TestContext.Current.CancellationToken); using var rotatedPublicKey = rotated.GetRSAPublicKey(); rotatedPublicKey.ShouldNotBeNull().VerifyData(challenge, freshSignature.Signature.Span, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1).ShouldBeTrue(); } finally { File.Delete(path); } } }