diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs index 3b8ed40..7a96725 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs @@ -90,8 +90,7 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d OpcUaAuthType.Username => new UserIdentity( _options.Username ?? string.Empty, System.Text.Encoding.UTF8.GetBytes(_options.Password ?? string.Empty)), - OpcUaAuthType.Certificate => throw new NotSupportedException( - "Certificate authentication lands in a follow-up PR; for now use Anonymous or Username"), + OpcUaAuthType.Certificate => BuildCertificateIdentity(_options), _ => new UserIdentity(new AnonymousIdentityToken()), }; @@ -271,6 +270,39 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d return match; } + /// + /// Build a carrying a client user-authentication + /// certificate loaded from . + /// Used when the remote server's endpoint advertises Certificate-type user tokens. + /// Fails fast if the path is missing, the file doesn't exist, or the certificate + /// lacks a private key (the private key is required to sign the user-token + /// challenge during session activation). + /// + internal static UserIdentity BuildCertificateIdentity(OpcUaClientDriverOptions options) + { + if (string.IsNullOrWhiteSpace(options.UserCertificatePath)) + throw new InvalidOperationException( + "OpcUaAuthType.Certificate requires OpcUaClientDriverOptions.UserCertificatePath to be set."); + if (!System.IO.File.Exists(options.UserCertificatePath)) + throw new System.IO.FileNotFoundException( + $"User certificate not found at '{options.UserCertificatePath}'.", + options.UserCertificatePath); + + // X509CertificateLoader (new in .NET 9) is the only non-obsolete way to load a PFX + // since the legacy X509Certificate2 ctors are marked obsolete on net10. Passes the + // password through verbatim; PEM files with external keys fall back to + // LoadCertificateFromFile which picks up the adjacent .key if present. + var cert = System.Security.Cryptography.X509Certificates.X509CertificateLoader + .LoadPkcs12FromFile(options.UserCertificatePath, options.UserCertificatePassword); + + if (!cert.HasPrivateKey) + throw new InvalidOperationException( + $"User certificate at '{options.UserCertificatePath}' has no private key โ€” " + + "the private key is required to sign the OPC UA user-token challenge at session activation."); + + return new UserIdentity(cert); + } + /// Convert a driver to the OPC UA policy URI. internal static string MapSecurityPolicy(OpcUaSecurityPolicy policy) => policy switch { diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverOptions.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverOptions.cs index a620bae..d61287b 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverOptions.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverOptions.cs @@ -39,6 +39,23 @@ public sealed class OpcUaClientDriverOptions /// Password (required only for ). public string? Password { get; init; } + /// + /// Filesystem path to the user-identity certificate (PFX/PEM). Required when + /// is . The driver + /// loads the cert + private key, which the remote server validates against its + /// TrustedUserCertificates store to authenticate the session's user token. + /// Leave unset to use the driver's application-instance certificate as the user + /// token (not typical โ€” most deployments have a separate user cert). + /// + public string? UserCertificatePath { get; init; } + + /// + /// Optional password that unlocks when the PFX is + /// protected. PEM files generally have their password on the adjacent key file; this + /// knob only applies to password-locked PFX. + /// + public string? UserCertificatePassword { get; init; } + /// Server-negotiated session timeout. Default 120s per driver-specs.md ยง8. public TimeSpan SessionTimeout { get; init; } = TimeSpan.FromSeconds(120); diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientCertAuthTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientCertAuthTests.cs new file mode 100644 index 0000000..2a658bb --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientCertAuthTests.cs @@ -0,0 +1,59 @@ +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 +{ + [Fact] + public void BuildCertificateIdentity_rejects_missing_path() + { + var opts = new OpcUaClientDriverOptions { AuthType = OpcUaAuthType.Certificate }; + Should.Throw(() => OpcUaClientDriver.BuildCertificateIdentity(opts)) + .Message.ShouldContain("UserCertificatePath"); + } + + [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)); + } + + [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 */ } + } + } +}