f0c6cf9640
v2-ci / unit-tests (push) Has been cancelled
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
137 lines
6.2 KiB
C#
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");
|
|
}
|
|
}
|
|
}
|