63331b0416
#835: inside one driver namespace, a secret:<ns>/<X>/... reference whose first segment names ANOTHER existing same-type driver instance (id or name, case-insensitive; another-instance match beats an own-name match, so renaming one's driver after a victim's unlocks nothing) is refused at save (DriverConfigSaveGate.ValidateAsync), deploy (CredentialReferenceOtherInstance, Heidenhain included), Test Connect (TestDriverConnect.DriverInstanceId, ConfigDb read off the mailbox) and live browse (BrowserSessionService, fail-closed without a ConfigDb). One rule: DriverCredentialPolicy.DescribeOtherInstanceProblems + DriverInstanceIdentities. Shared names and the driver's own id stay valid; env: is not instance-scoped. Running drivers are unchanged (warn-only window: the deploy gate and the upgrade preflight / /raw banner list them). #836: OpcUaClient UserCertificatePath is a relative path inside Drivers:OpcUaClient:UserCertificateDirectory (default <LocalAppData>/OtOpcUa/pki/user-certificates). Syntactic rule (CredentialFilePaths, a DriverCredentialPolicy path field) at every #815 seam; at load time ResolveSecretRefsAsync joins, canonicalises and requires lexical AND link-resolved containment before the PFX password is resolved, and LoadCertificate refuses an unresolved path. A running driver's pre-#836 absolute path keeps loading with a rate-limited warning (LegacyCredentialReferenceTracker.ReportPath). Co-Authored-By: Claude <noreply@anthropic.com>
132 lines
5.5 KiB
C#
132 lines
5.5 KiB
C#
using System.Security.Cryptography;
|
|
using System.Security.Cryptography.X509Certificates;
|
|
using Opc.Ua;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.Secrets.Abstractions;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests;
|
|
|
|
/// <summary>
|
|
/// Review 2026-10-21 R06:O-5 — the AdminUI browse refused <c>Certificate</c> auth "in v1", so a
|
|
/// certificate-authenticated upstream could not be browsed at all. The browse now builds its identity through
|
|
/// the same <see cref="OpcUaUserIdentityFactory"/> the runtime driver uses (incl. the <c>secret:</c> resolution
|
|
/// of the credential fields, fail-closed), so it presents exactly the credentials the deployed driver will.
|
|
/// </summary>
|
|
[Trait("Category", "Unit")]
|
|
public sealed class OpcUaClientBrowserCertificateAuthTests
|
|
{
|
|
private sealed class OneSecret(string name, string value) : ISecretResolver
|
|
{
|
|
public Task<string?> GetAsync(SecretName secretName, CancellationToken ct) =>
|
|
Task.FromResult<string?>(secretName.Value == name ? value : null);
|
|
}
|
|
|
|
private static string WritePfx(string password)
|
|
{
|
|
using var rsa = RSA.Create(2048);
|
|
var req = new CertificateRequest("CN=OpcUaClientBrowserCertAuthTests", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
|
var cert = req.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-5), DateTimeOffset.UtcNow.AddHours(1));
|
|
var path = Path.Combine(Path.GetTempPath(), $"opcua-browse-cert-{Guid.NewGuid():N}.pfx");
|
|
File.WriteAllBytes(path, cert.Export(X509ContentType.Pfx, password));
|
|
return path;
|
|
}
|
|
|
|
/// <summary>Certificate auth is no longer refused: a missing cert path fails on the certificate itself.</summary>
|
|
[Fact]
|
|
public async Task OpenAsync_with_certificate_auth_fails_on_the_certificate_not_a_refusal()
|
|
{
|
|
var json = """{"EndpointUrl":"opc.tcp://127.0.0.1:1","AuthType":"Certificate"}""";
|
|
|
|
var ex = await Should.ThrowAsync<InvalidOperationException>(
|
|
() => new OpcUaClientDriverBrowser().OpenAsync(json, TestContext.Current.CancellationToken));
|
|
|
|
ex.Message.ShouldContain("UserCertificatePath");
|
|
ex.Message.ShouldNotContain("does not support");
|
|
}
|
|
|
|
/// <summary>
|
|
/// A valid PFX (a relative path inside the user-certificate directory — lmxopcua#836) whose password is a
|
|
/// <c>secret:</c> ref resolves through the injected store.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task A_certificate_identity_is_built_with_its_secret_password_resolved()
|
|
{
|
|
var path = WritePfx("s3cret-pw");
|
|
try
|
|
{
|
|
var opts = new OpcUaClientDriverOptions
|
|
{
|
|
AuthType = OpcUaAuthType.Certificate,
|
|
UserCertificatePath = Path.GetFileName(path),
|
|
UserCertificatePassword = "secret:opcuaclient/browse-cert",
|
|
};
|
|
|
|
var identity = await OpcUaClientDriverBrowser.BuildBrowseUserIdentityAsync(
|
|
opts, new OneSecret("opcuaclient/browse-cert", "s3cret-pw"), TestContext.Current.CancellationToken,
|
|
userCertificateDirectory: Path.GetDirectoryName(path));
|
|
|
|
identity.TokenType.ShouldBe(UserTokenType.Certificate);
|
|
}
|
|
finally
|
|
{
|
|
try { File.Delete(path); } catch { /* best-effort */ }
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// lmxopcua#836: the browse is a hard-fail path — an absolute <c>UserCertificatePath</c> (any PFX the service account
|
|
/// can read) is refused before it is loaded or its password resolved, even when the file exists.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task The_browse_refuses_an_absolute_user_certificate_path()
|
|
{
|
|
var path = WritePfx("s3cret-pw");
|
|
try
|
|
{
|
|
var opts = new OpcUaClientDriverOptions
|
|
{
|
|
AuthType = OpcUaAuthType.Certificate,
|
|
UserCertificatePath = path,
|
|
UserCertificatePassword = "s3cret-pw",
|
|
};
|
|
|
|
var ex = await Should.ThrowAsync<ZB.MOM.WW.OtOpcUa.Core.Abstractions.CredentialReferenceException>(() =>
|
|
OpcUaClientDriverBrowser.BuildBrowseUserIdentityAsync(
|
|
opts, null, TestContext.Current.CancellationToken, userCertificateDirectory: Path.GetDirectoryName(path)));
|
|
ex.Message.ShouldContain("lmxopcua#836");
|
|
}
|
|
finally
|
|
{
|
|
try { File.Delete(path); } catch { /* best-effort */ }
|
|
}
|
|
}
|
|
|
|
/// <summary>With no secret store, a <c>secret:</c> ref fails closed instead of being sent as the literal.</summary>
|
|
[Fact]
|
|
public async Task A_secret_ref_with_no_secret_store_fails_closed()
|
|
{
|
|
var opts = new OpcUaClientDriverOptions
|
|
{
|
|
AuthType = OpcUaAuthType.Username,
|
|
Username = "op",
|
|
Password = "secret:opcuaclient/pw",
|
|
};
|
|
|
|
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
|
|
OpcUaClientDriverBrowser.BuildBrowseUserIdentityAsync(opts, null, TestContext.Current.CancellationToken));
|
|
ex.Message.ShouldContain("fail-closed");
|
|
}
|
|
|
|
/// <summary>Positive control: a literal password is used as-is (no store needed).</summary>
|
|
[Fact]
|
|
public async Task A_literal_username_password_needs_no_store()
|
|
{
|
|
var opts = new OpcUaClientDriverOptions { AuthType = OpcUaAuthType.Username, Username = "op", Password = "pw" };
|
|
|
|
var identity = await OpcUaClientDriverBrowser.BuildBrowseUserIdentityAsync(opts, null, TestContext.Current.CancellationToken);
|
|
|
|
identity.TokenType.ShouldBe(UserTokenType.UserName);
|
|
}
|
|
}
|