diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs index 9baef88..3b8ed40 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs @@ -74,15 +74,9 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d // requested security policy/mode so the driver doesn't have to hand-validate. // UseSecurity=false when SecurityMode=None shortcuts around cert validation // entirely and is the typical dev-bench configuration. - var useSecurity = _options.SecurityMode != OpcUaSecurityMode.None; - // The non-obsolete SelectEndpointAsync overloads all require an ITelemetryContext - // parameter. Passing null is valid — the SDK falls through to its built-in default - // trace sink. Plumbing a telemetry context through every driver surface is out of - // scope; the driver emits its own logs via the health surface anyway. - var selected = await CoreClientUtils.SelectEndpointAsync( - appConfig, _options.EndpointUrl, useSecurity, - telemetry: null!, - ct: cancellationToken).ConfigureAwait(false); + var selected = await SelectMatchingEndpointAsync( + appConfig, _options.EndpointUrl, _options.SecurityPolicy, _options.SecurityMode, + cancellationToken).ConfigureAwait(false); var endpointConfig = EndpointConfiguration.Create(appConfig); endpointConfig.OperationTimeout = (int)_options.Timeout.TotalMilliseconds; var endpoint = new ConfiguredEndpoint(null, selected, endpointConfig); @@ -231,6 +225,67 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d return config; } + /// + /// Select the remote endpoint matching both the requested + /// and . The SDK's CoreClientUtils.SelectEndpointAsync + /// only honours a boolean "use security" flag; we need policy-aware matching so an + /// operator asking for Basic256Sha256 against a server that also offers + /// Basic128Rsa15 doesn't silently end up on the weaker cipher. + /// + private static async Task SelectMatchingEndpointAsync( + ApplicationConfiguration appConfig, + string endpointUrl, + OpcUaSecurityPolicy policy, + OpcUaSecurityMode mode, + CancellationToken ct) + { + // GetEndpoints returns everything the server advertises; policy + mode filter is + // applied client-side so the selection is explicit and fails loudly if the operator + // asks for a combination the server doesn't publish. DiscoveryClient.CreateAsync + // is the non-obsolete path in SDK 1.5.378; the synchronous Create(..) variants are + // all deprecated. + using var client = await DiscoveryClient.CreateAsync( + appConfig, new Uri(endpointUrl), Opc.Ua.DiagnosticsMasks.None, ct).ConfigureAwait(false); + var all = await client.GetEndpointsAsync(null, ct).ConfigureAwait(false); + + var wantedPolicyUri = MapSecurityPolicy(policy); + var wantedMode = mode switch + { + OpcUaSecurityMode.None => MessageSecurityMode.None, + OpcUaSecurityMode.Sign => MessageSecurityMode.Sign, + OpcUaSecurityMode.SignAndEncrypt => MessageSecurityMode.SignAndEncrypt, + _ => throw new ArgumentOutOfRangeException(nameof(mode)), + }; + + var match = all.FirstOrDefault(e => + e.SecurityPolicyUri == wantedPolicyUri && e.SecurityMode == wantedMode); + + if (match is null) + { + var advertised = string.Join(", ", all + .Select(e => $"{ShortPolicyName(e.SecurityPolicyUri)}/{e.SecurityMode}")); + throw new InvalidOperationException( + $"No endpoint at '{endpointUrl}' matches SecurityPolicy={policy} + SecurityMode={mode}. " + + $"Server advertises: {advertised}"); + } + return match; + } + + /// Convert a driver to the OPC UA policy URI. + internal static string MapSecurityPolicy(OpcUaSecurityPolicy policy) => policy switch + { + OpcUaSecurityPolicy.None => SecurityPolicies.None, + OpcUaSecurityPolicy.Basic128Rsa15 => SecurityPolicies.Basic128Rsa15, + OpcUaSecurityPolicy.Basic256 => SecurityPolicies.Basic256, + OpcUaSecurityPolicy.Basic256Sha256 => SecurityPolicies.Basic256Sha256, + OpcUaSecurityPolicy.Aes128_Sha256_RsaOaep => SecurityPolicies.Aes128_Sha256_RsaOaep, + OpcUaSecurityPolicy.Aes256_Sha256_RsaPss => SecurityPolicies.Aes256_Sha256_RsaPss, + _ => throw new ArgumentOutOfRangeException(nameof(policy), policy, null), + }; + + private static string ShortPolicyName(string policyUri) => + policyUri?.Substring(policyUri.LastIndexOf('#') + 1) ?? "(null)"; + public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken) { await ShutdownAsync(cancellationToken).ConfigureAwait(false); diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverOptions.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverOptions.cs index 87629eb..a620bae 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverOptions.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverOptions.cs @@ -16,8 +16,16 @@ public sealed class OpcUaClientDriverOptions /// Remote OPC UA endpoint URL, e.g. opc.tcp://plc.internal:4840. public string EndpointUrl { get; init; } = "opc.tcp://localhost:4840"; - /// Security policy. One of None, Basic256Sha256, Aes128_Sha256_RsaOaep. - public string SecurityPolicy { get; init; } = "None"; + /// + /// Security policy to require when selecting an endpoint. Either a + /// enum constant or a free-form string (for + /// forward-compatibility with future OPC UA policies not yet in the enum). + /// Matched against EndpointDescription.SecurityPolicyUri suffix — the driver + /// connects to the first endpoint whose policy name matches AND whose mode matches + /// . When set to + /// the driver picks any unsecured endpoint regardless of policy string. + /// + public OpcUaSecurityPolicy SecurityPolicy { get; init; } = OpcUaSecurityPolicy.None; /// Security mode. public OpcUaSecurityMode SecurityMode { get; init; } = OpcUaSecurityMode.None; @@ -96,6 +104,33 @@ public enum OpcUaSecurityMode SignAndEncrypt, } +/// +/// OPC UA security policies recognized by the driver. Maps to the standard +/// http://opcfoundation.org/UA/SecurityPolicy# URI suffixes the SDK uses for +/// endpoint matching. +/// +/// +/// and are deprecated per OPC UA +/// spec v1.04 — they remain in the enum only for brownfield interop with older servers. +/// Prefer , , or +/// for new deployments. +/// +public enum OpcUaSecurityPolicy +{ + /// No security. Unsigned, unencrypted wire. + None, + /// Deprecated (OPC UA 1.04). Retained for legacy server interop. + Basic128Rsa15, + /// Deprecated (OPC UA 1.04). Retained for legacy server interop. + Basic256, + /// Recommended baseline for current deployments. + Basic256Sha256, + /// Current OPC UA policy; AES-128 + SHA-256 + RSA-OAEP. + Aes128_Sha256_RsaOaep, + /// Current OPC UA policy; AES-256 + SHA-256 + RSA-PSS. + Aes256_Sha256_RsaPss, +} + /// User authentication type sent to the remote server. public enum OpcUaAuthType { diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientDriverScaffoldTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientDriverScaffoldTests.cs index c145cd5..02911af 100644 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientDriverScaffoldTests.cs +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientDriverScaffoldTests.cs @@ -18,6 +18,7 @@ public sealed class OpcUaClientDriverScaffoldTests var opts = new OpcUaClientDriverOptions(); opts.EndpointUrl.ShouldBe("opc.tcp://localhost:4840", "4840 is the IANA-assigned OPC UA port"); opts.SecurityMode.ShouldBe(OpcUaSecurityMode.None); + opts.SecurityPolicy.ShouldBe(OpcUaSecurityPolicy.None); opts.AuthType.ShouldBe(OpcUaAuthType.Anonymous); opts.AutoAcceptCertificates.ShouldBeFalse("production default must reject untrusted server certs"); } diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientSecurityPolicyTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientSecurityPolicyTests.cs new file mode 100644 index 0000000..c616a68 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientSecurityPolicyTests.cs @@ -0,0 +1,54 @@ +using Opc.Ua; +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests; + +[Trait("Category", "Unit")] +public sealed class OpcUaClientSecurityPolicyTests +{ + [Theory] + [InlineData(OpcUaSecurityPolicy.None)] + [InlineData(OpcUaSecurityPolicy.Basic128Rsa15)] + [InlineData(OpcUaSecurityPolicy.Basic256)] + [InlineData(OpcUaSecurityPolicy.Basic256Sha256)] + [InlineData(OpcUaSecurityPolicy.Aes128_Sha256_RsaOaep)] + [InlineData(OpcUaSecurityPolicy.Aes256_Sha256_RsaPss)] + public void MapSecurityPolicy_returns_known_non_empty_uri_for_every_enum_value(OpcUaSecurityPolicy policy) + { + var uri = OpcUaClientDriver.MapSecurityPolicy(policy); + uri.ShouldNotBeNullOrEmpty(); + // Each URI should end in the enum name (for the non-None policies) so a driver + // operator reading logs can correlate the URI back to the config value. + if (policy != OpcUaSecurityPolicy.None) + uri.ShouldContain(policy.ToString()); + } + + [Fact] + public void MapSecurityPolicy_None_matches_SDK_None_URI() + { + OpcUaClientDriver.MapSecurityPolicy(OpcUaSecurityPolicy.None) + .ShouldBe(SecurityPolicies.None); + } + + [Fact] + public void MapSecurityPolicy_Basic256Sha256_matches_SDK_URI() + { + OpcUaClientDriver.MapSecurityPolicy(OpcUaSecurityPolicy.Basic256Sha256) + .ShouldBe(SecurityPolicies.Basic256Sha256); + } + + [Fact] + public void MapSecurityPolicy_Aes256_Sha256_RsaPss_matches_SDK_URI() + { + OpcUaClientDriver.MapSecurityPolicy(OpcUaSecurityPolicy.Aes256_Sha256_RsaPss) + .ShouldBe(SecurityPolicies.Aes256_Sha256_RsaPss); + } + + [Fact] + public void Every_enum_value_has_a_mapping() + { + foreach (OpcUaSecurityPolicy p in Enum.GetValues()) + Should.NotThrow(() => OpcUaClientDriver.MapSecurityPolicy(p)); + } +}