using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests; /// /// lmxopcua#836: the user-certificate identity loads only a PFX inside the node's user-certificate directory — the /// authored UserCertificatePath is relative, resolved (and link-checked) by ResolveSecretRefsAsync, which /// both the running driver and the AdminUI browser call before building the identity. /// [Trait("Category", "Unit")] public sealed class OpcUaClientUserCertificatePathTests : IDisposable { private readonly string _dir = Path.Combine(Path.GetTempPath(), "otopcua-836-ucert-" + Guid.NewGuid().ToString("N")); private readonly string _outside = Path.Combine(Path.GetTempPath(), "otopcua-836-outside-" + Guid.NewGuid().ToString("N")); public OpcUaClientUserCertificatePathTests() { Directory.CreateDirectory(Path.Combine(_dir, "plant1")); Directory.CreateDirectory(_outside); } public void Dispose() { foreach (var d in new[] { _dir, _outside }) try { Directory.Delete(d, recursive: true); } catch (IOException) { } catch (UnauthorizedAccessException) { } } private static void WritePfx(string path) { using var rsa = RSA.Create(2048); var req = new CertificateRequest("CN=Issue836", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); using var cert = req.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-5), DateTimeOffset.UtcNow.AddHours(1)); File.WriteAllBytes(path, cert.Export(X509ContentType.Pfx, "pw")); } private static OpcUaClientDriverOptions Cert(string path) => new() { AuthType = OpcUaAuthType.Certificate, UserCertificatePath = path, UserCertificatePassword = "pw" }; [Fact] public async Task A_relative_path_inside_the_directory_resolves_and_loads() { WritePfx(Path.Combine(_dir, "plant1", "user.pfx")); var resolved = await OpcUaUserIdentityFactory.ResolveSecretRefsAsync( Cert("plant1/user.pfx"), null, TestContext.Current.CancellationToken, userCertificateDirectory: _dir); resolved.UserCertificatePath.ShouldBe(Path.Combine(Path.GetFullPath(_dir), "plant1", "user.pfx")); OpcUaUserIdentityFactory.Build(resolved).TokenType.ShouldBe(Opc.Ua.UserTokenType.Certificate); } [Theory] [InlineData("../outside.pfx")] [InlineData("plant1/../../outside.pfx")] public async Task Traversal_is_refused_before_any_load(string path) => await Should.ThrowAsync(() => OpcUaUserIdentityFactory.ResolveSecretRefsAsync( Cert(path), null, TestContext.Current.CancellationToken, userCertificateDirectory: _dir)); [Fact] public async Task An_absolute_path_is_refused_on_the_hard_fail_path_even_inside_the_directory() { var abs = Path.Combine(_dir, "plant1", "user.pfx"); WritePfx(abs); var ex = await Should.ThrowAsync(() => OpcUaUserIdentityFactory.ResolveSecretRefsAsync( Cert(abs), null, TestContext.Current.CancellationToken, userCertificateDirectory: _dir)); ex.Message.ShouldContain("lmxopcua#836"); ex.Message.ShouldNotContain(abs); } [Fact] public async Task A_symlink_escaping_the_directory_is_refused() { var victim = Path.Combine(_outside, "victim.pfx"); WritePfx(victim); try { File.CreateSymbolicLink(Path.Combine(_dir, "user.pfx"), victim); } catch (Exception e) when (e is UnauthorizedAccessException or IOException) { return; // no symlink privilege (Windows): covered lexically above } await Should.ThrowAsync(() => OpcUaUserIdentityFactory.ResolveSecretRefsAsync( Cert("user.pfx"), null, TestContext.Current.CancellationToken, userCertificateDirectory: _dir)); } [Fact] public async Task The_running_driver_keeps_loading_a_legacy_absolute_path_and_warns() { var abs = Path.Combine(_outside, "legacy.pfx"); WritePfx(abs); var warnings = new List(); var tracker = new LegacyCredentialReferenceTracker(DriverCredentialPolicy.OpcUaClientScope, "ouc", warnings.Add); var resolved = await OpcUaUserIdentityFactory.ResolveSecretRefsAsync( Cert(abs), null, TestContext.Current.CancellationToken, legacy: tracker, userCertificateDirectory: _dir); resolved.UserCertificatePath.ShouldBe(abs); OpcUaUserIdentityFactory.Build(resolved).TokenType.ShouldBe(Opc.Ua.UserTokenType.Certificate); warnings.ShouldHaveSingleItem().ShouldContain("lmxopcua#836"); warnings[0].ShouldNotContain(abs); tracker.Diagnostics.ShouldContainKey(LegacyCredentialReferenceTracker.DiagnosticsKeyPrefix + "UserCertificatePath"); } [Fact] public async Task The_legacy_tracker_does_not_relax_a_relative_path_that_escapes() { var tracker = new LegacyCredentialReferenceTracker(DriverCredentialPolicy.OpcUaClientScope, "ouc", _ => { }); await Should.ThrowAsync(() => OpcUaUserIdentityFactory.ResolveSecretRefsAsync( Cert("../x.pfx"), null, TestContext.Current.CancellationToken, legacy: tracker, userCertificateDirectory: _dir)); } [Fact] public async Task A_non_certificate_auth_type_leaves_the_path_alone() { var options = new OpcUaClientDriverOptions { AuthType = OpcUaAuthType.Anonymous, UserCertificatePath = "/etc/stale.pfx" }; (await OpcUaUserIdentityFactory.ResolveSecretRefsAsync(options, null, TestContext.Current.CancellationToken, userCertificateDirectory: _dir)) .ShouldBeSameAs(options); } /// Review L-4: a relative configured directory is resolved against the install directory, never the CWD. [Fact] public void A_relative_configured_directory_resolves_against_the_base_directory_not_the_working_directory() { var baseDir = Path.Combine(_outside, "install"); OpcUaUserCertificatePaths.Normalize("pki/user-certificates", baseDir) .ShouldBe(Path.Combine(Path.GetFullPath(baseDir), "pki", "user-certificates")); OpcUaUserCertificatePaths.Normalize(_dir, baseDir).ShouldBe(Path.GetFullPath(_dir)); OpcUaUserCertificatePaths.Normalize(" ", baseDir).ShouldBeNull(); OpcUaUserCertificatePaths.Normalize(null, baseDir).ShouldBeNull(); } [Fact] public void LoadCertificate_refuses_an_unresolved_relative_path() { Should.Throw(() => OpcUaUserIdentityFactory.LoadCertificate(Cert("plant1/user.pfx"))) .Message.ShouldContain("not been resolved"); } [Fact] public async Task The_path_is_checked_before_any_secret_is_read() { var reads = 0; var options = Cert("/etc/x.pfx") with { UserCertificatePassword = "secret:opcuaclient/pfx-pw" }; await Should.ThrowAsync(() => OpcUaUserIdentityFactory.ResolveSecretRefsAsync( options, new CountingResolver(() => reads++), TestContext.Current.CancellationToken, userCertificateDirectory: _dir)); reads.ShouldBe(0); } private sealed class CountingResolver(Action onRead) : ZB.MOM.WW.Secrets.Abstractions.ISecretResolver { public Task GetAsync(ZB.MOM.WW.Secrets.Abstractions.SecretName name, CancellationToken ct) { onRead(); return Task.FromResult("pw"); } } }