Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientUserCertificatePathTests.cs
T
Joseph Doherty f0c6cf9640
v2-ci / unit-tests (push) Has been cancelled
fix(security): running drivers refuse out-of-namespace credential refs at resolve time (#834)
The #815 warn-only compatibility window is over. A running Galaxy driver
(InitializeAsync, every gateway ReopenAsync, the ack fallback password) and a
running OpcUaClient driver (session open) now refuse an out-of-namespace
credential reference with the same CredentialReferenceException that save,
deploy (CredentialReferenceOutOfScope), Test Connect and browse already raise,
before any store / environment / file read and before any endpoint is dialled.
The one existing scope check (CredentialReferences.Parse/ResolveAsync) is now
the only path; nothing new was added.

Removed:
- LegacyCredentialReferenceTracker (Core.Abstractions), its rate-limited WARN
  and the credential-compat:<field> diagnostics entry.
- The per-driver Legacy* resolve methods and every `legacy` parameter
  (GalaxySecretRef, GalaxyAckCredentialResolver, OpcUaUserIdentityFactory,
  OpcUaUserCertificatePaths, OpcUaClientSecretResolution).
- IDriverDiagnostics on GalaxyDriver / OpcUaClientDriver (it only carried the
  tracker's entries).
- The tracker also kept a pre-#836 absolute UserCertificatePath loading on a
  running OpcUaClient driver; that is refused now too.

Preflight / banner decision: KEPT, as a hard error. The Running source
(driver-health credential-compat entries) and CredentialReferenceBannerModel
are gone with the tracker, but the ConfigDb scan still tells an operator
something no other surface does: a Deployed finding is now a driver that is
not connecting, a Draft finding is a deploy the gate will refuse - named
before anyone deploys, e.g. after restoring an old ConfigDb backup or
importing a pre-#815 config. It also carries the #835 cross-instance and
#836/#850 path checks. CredentialReferencePreflightService now logs one Error
per finding (was Warning) with no warn-only wording, and the /raw banner is
alert-danger.

DEV and QA ConfigDbs hold no Galaxy/OpcUaClient driver (checked 2026-11-13);
no production install exists.

Tests: GalaxyDriverLegacyCredentialTests replaced by
GalaxyDriverCredentialScopeTests; new OpcUaClientDriverCredentialScopeTests;
tracker tests in OpcUaClientSecretResolutionTests /
OpcUaClientUserCertificatePathTests deleted; AdminUI preflight tests drop the
Running/banner-model cases and assert Error level. Against the old code the
new refusal tests fail (Galaxy 5/6, OpcUaClient 4/5; the two positive controls
pass on both).

docs/security.md: the migration table now says running drivers refuse; the
warn-only section is replaced by "The compatibility window is over".

Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
2026-11-13 02:06:43 -05:00

137 lines
6.2 KiB
C#

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;
/// <summary>
/// lmxopcua#836: the user-certificate identity loads only a PFX inside the node's user-certificate directory — the
/// authored <c>UserCertificatePath</c> is relative, resolved (and link-checked) by <c>ResolveSecretRefsAsync</c>, which
/// both the running driver and the AdminUI browser call before building the identity.
/// </summary>
[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<CredentialReferenceException>(() => 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<CredentialReferenceException>(() => 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<CredentialReferenceException>(() => OpcUaUserIdentityFactory.ResolveSecretRefsAsync(
Cert("user.pfx"), null, TestContext.Current.CancellationToken, 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);
}
/// <summary>Review L-4: a relative configured directory is resolved against the install directory, never the CWD.</summary>
[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<InvalidOperationException>(() => 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<CredentialReferenceException>(() => 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<string?> GetAsync(ZB.MOM.WW.Secrets.Abstractions.SecretName name, CancellationToken ct)
{
onRead();
return Task.FromResult<string?>("pw");
}
}
}