271b96a644
I-1: Test Connect / browse configs may carry unsaved edits (the device modal merges the stored DriverConfig with the typed device config). The server (DriverInstanceIdentities.EffectiveOwnerAsync, used by AdminOperationsActor and BrowserSessionService) honours the named owner only when every endpoint value (new DriverCredentialRules.EndpointFields: Galaxy Endpoint, OpcUaClient EndpointUrl/EndpointUrls/DiscoveryUrl, Heidenhain Host/Port) is one that driver stores; otherwise the owner is null and its instance-named secrets are refused. Every Test Connect / browse whose config carries an in-scope secret:/env: reference is recorded (CredentialUseAudit: IAuditWriter event category CredentialUse + a structured Information line: user, driver, endpoint hosts, outcome). L-1: the UserCertificatePath rule is skipped only in an object that explicitly sets AuthType to non-Certificate (string or ordinal); the deploy gate also checks the runtime-merged driver+device config so the switch cannot be split from the path; the form no longer persists the hidden field. L-2: a ConfigDb fault in the browse check is a refusal; RawBrowseModal catches open failures into its error message. L-4: a relative UserCertificateDirectory resolves against AppContext.BaseDirectory. Co-Authored-By: Claude <noreply@anthropic.com>
163 lines
7.6 KiB
C#
163 lines
7.6 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 The_running_driver_keeps_loading_a_legacy_absolute_path_and_warns()
|
|
{
|
|
var abs = Path.Combine(_outside, "legacy.pfx");
|
|
WritePfx(abs);
|
|
var warnings = new List<string>();
|
|
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<CredentialReferenceException>(() => 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);
|
|
}
|
|
|
|
/// <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");
|
|
}
|
|
}
|
|
}
|