Phase 3 PR 70 -- Apply SecurityPolicy + expand to standard OPC UA policies #69
@@ -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.
|
// requested security policy/mode so the driver doesn't have to hand-validate.
|
||||||
// UseSecurity=false when SecurityMode=None shortcuts around cert validation
|
// UseSecurity=false when SecurityMode=None shortcuts around cert validation
|
||||||
// entirely and is the typical dev-bench configuration.
|
// entirely and is the typical dev-bench configuration.
|
||||||
var useSecurity = _options.SecurityMode != OpcUaSecurityMode.None;
|
var selected = await SelectMatchingEndpointAsync(
|
||||||
// The non-obsolete SelectEndpointAsync overloads all require an ITelemetryContext
|
appConfig, _options.EndpointUrl, _options.SecurityPolicy, _options.SecurityMode,
|
||||||
// parameter. Passing null is valid — the SDK falls through to its built-in default
|
cancellationToken).ConfigureAwait(false);
|
||||||
// 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 endpointConfig = EndpointConfiguration.Create(appConfig);
|
var endpointConfig = EndpointConfiguration.Create(appConfig);
|
||||||
endpointConfig.OperationTimeout = (int)_options.Timeout.TotalMilliseconds;
|
endpointConfig.OperationTimeout = (int)_options.Timeout.TotalMilliseconds;
|
||||||
var endpoint = new ConfiguredEndpoint(null, selected, endpointConfig);
|
var endpoint = new ConfiguredEndpoint(null, selected, endpointConfig);
|
||||||
@@ -231,6 +225,67 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
|
|||||||
return config;
|
return config;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Select the remote endpoint matching both the requested <paramref name="policy"/>
|
||||||
|
/// and <paramref name="mode"/>. The SDK's <c>CoreClientUtils.SelectEndpointAsync</c>
|
||||||
|
/// only honours a boolean "use security" flag; we need policy-aware matching so an
|
||||||
|
/// operator asking for <c>Basic256Sha256</c> against a server that also offers
|
||||||
|
/// <c>Basic128Rsa15</c> doesn't silently end up on the weaker cipher.
|
||||||
|
/// </summary>
|
||||||
|
private static async Task<EndpointDescription> 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Convert a driver <see cref="OpcUaSecurityPolicy"/> to the OPC UA policy URI.</summary>
|
||||||
|
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)
|
public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
await ShutdownAsync(cancellationToken).ConfigureAwait(false);
|
await ShutdownAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|||||||
@@ -16,8 +16,16 @@ public sealed class OpcUaClientDriverOptions
|
|||||||
/// <summary>Remote OPC UA endpoint URL, e.g. <c>opc.tcp://plc.internal:4840</c>.</summary>
|
/// <summary>Remote OPC UA endpoint URL, e.g. <c>opc.tcp://plc.internal:4840</c>.</summary>
|
||||||
public string EndpointUrl { get; init; } = "opc.tcp://localhost:4840";
|
public string EndpointUrl { get; init; } = "opc.tcp://localhost:4840";
|
||||||
|
|
||||||
/// <summary>Security policy. One of <c>None</c>, <c>Basic256Sha256</c>, <c>Aes128_Sha256_RsaOaep</c>.</summary>
|
/// <summary>
|
||||||
public string SecurityPolicy { get; init; } = "None";
|
/// Security policy to require when selecting an endpoint. Either a
|
||||||
|
/// <see cref="OpcUaSecurityPolicy"/> enum constant or a free-form string (for
|
||||||
|
/// forward-compatibility with future OPC UA policies not yet in the enum).
|
||||||
|
/// Matched against <c>EndpointDescription.SecurityPolicyUri</c> suffix — the driver
|
||||||
|
/// connects to the first endpoint whose policy name matches AND whose mode matches
|
||||||
|
/// <see cref="SecurityMode"/>. When set to <see cref="OpcUaSecurityPolicy.None"/>
|
||||||
|
/// the driver picks any unsecured endpoint regardless of policy string.
|
||||||
|
/// </summary>
|
||||||
|
public OpcUaSecurityPolicy SecurityPolicy { get; init; } = OpcUaSecurityPolicy.None;
|
||||||
|
|
||||||
/// <summary>Security mode.</summary>
|
/// <summary>Security mode.</summary>
|
||||||
public OpcUaSecurityMode SecurityMode { get; init; } = OpcUaSecurityMode.None;
|
public OpcUaSecurityMode SecurityMode { get; init; } = OpcUaSecurityMode.None;
|
||||||
@@ -96,6 +104,33 @@ public enum OpcUaSecurityMode
|
|||||||
SignAndEncrypt,
|
SignAndEncrypt,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// OPC UA security policies recognized by the driver. Maps to the standard
|
||||||
|
/// <c>http://opcfoundation.org/UA/SecurityPolicy#</c> URI suffixes the SDK uses for
|
||||||
|
/// endpoint matching.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <see cref="Basic128Rsa15"/> and <see cref="Basic256"/> are <b>deprecated</b> per OPC UA
|
||||||
|
/// spec v1.04 — they remain in the enum only for brownfield interop with older servers.
|
||||||
|
/// Prefer <see cref="Basic256Sha256"/>, <see cref="Aes128_Sha256_RsaOaep"/>, or
|
||||||
|
/// <see cref="Aes256_Sha256_RsaPss"/> for new deployments.
|
||||||
|
/// </remarks>
|
||||||
|
public enum OpcUaSecurityPolicy
|
||||||
|
{
|
||||||
|
/// <summary>No security. Unsigned, unencrypted wire.</summary>
|
||||||
|
None,
|
||||||
|
/// <summary>Deprecated (OPC UA 1.04). Retained for legacy server interop.</summary>
|
||||||
|
Basic128Rsa15,
|
||||||
|
/// <summary>Deprecated (OPC UA 1.04). Retained for legacy server interop.</summary>
|
||||||
|
Basic256,
|
||||||
|
/// <summary>Recommended baseline for current deployments.</summary>
|
||||||
|
Basic256Sha256,
|
||||||
|
/// <summary>Current OPC UA policy; AES-128 + SHA-256 + RSA-OAEP.</summary>
|
||||||
|
Aes128_Sha256_RsaOaep,
|
||||||
|
/// <summary>Current OPC UA policy; AES-256 + SHA-256 + RSA-PSS.</summary>
|
||||||
|
Aes256_Sha256_RsaPss,
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>User authentication type sent to the remote server.</summary>
|
/// <summary>User authentication type sent to the remote server.</summary>
|
||||||
public enum OpcUaAuthType
|
public enum OpcUaAuthType
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ public sealed class OpcUaClientDriverScaffoldTests
|
|||||||
var opts = new OpcUaClientDriverOptions();
|
var opts = new OpcUaClientDriverOptions();
|
||||||
opts.EndpointUrl.ShouldBe("opc.tcp://localhost:4840", "4840 is the IANA-assigned OPC UA port");
|
opts.EndpointUrl.ShouldBe("opc.tcp://localhost:4840", "4840 is the IANA-assigned OPC UA port");
|
||||||
opts.SecurityMode.ShouldBe(OpcUaSecurityMode.None);
|
opts.SecurityMode.ShouldBe(OpcUaSecurityMode.None);
|
||||||
|
opts.SecurityPolicy.ShouldBe(OpcUaSecurityPolicy.None);
|
||||||
opts.AuthType.ShouldBe(OpcUaAuthType.Anonymous);
|
opts.AuthType.ShouldBe(OpcUaAuthType.Anonymous);
|
||||||
opts.AutoAcceptCertificates.ShouldBeFalse("production default must reject untrusted server certs");
|
opts.AutoAcceptCertificates.ShouldBeFalse("production default must reject untrusted server certs");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<OpcUaSecurityPolicy>())
|
||||||
|
Should.NotThrow(() => OpcUaClientDriver.MapSecurityPolicy(p));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user