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