diff --git a/Directory.Packages.props b/Directory.Packages.props
index 10abc661..4f5d038e 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -69,20 +69,21 @@
-
+
-
-
+
+
-
+
diff --git a/docs/requirements/Component-DataConnectionLayer.md b/docs/requirements/Component-DataConnectionLayer.md
index 4beeb101..e2bf89ad 100644
--- a/docs/requirements/Component-DataConnectionLayer.md
+++ b/docs/requirements/Component-DataConnectionLayer.md
@@ -70,7 +70,10 @@ All protocols produce the same value tuple consumed by Instance Actors. Before t
## Supported Protocols
### OPC UA
-- Uses the **OPC Foundation .NET Standard Library** (`OPCFoundation.NetStandard.Opc.Ua.Client`).
+- Uses the **OPC Foundation .NET Standard Library** (`OPCFoundation.NetStandard.Opc.Ua.Client`), **SDK 2.0.1** since ScadaBridge#125 (2026-11-12; the same version OtOpcUa runs). The port keeps the SDK 1.5 behaviour at the adapter boundary:
+ - **Values.** SDK 2's `Variant`/`DataValue` are immutable structs and its boxing returns SDK wrapper types; `OpcUaValueBoundary` converts every read, subscription and alarm-event value back to the SDK 1.5 CLR shapes (`byte[]` for ByteString, UTC `DateTime` with "no timestamp" as `DateTime.MinValue`, `Guid`, detached arrays), and builds write variants with SDK 1.5's `new Variant(object)` semantics (a `byte[]` is a ByteString scalar).
+ - **UserName identity.** Always sent in the legacy RSA password format (`LegacyRsaUserNameIdentityTokenHandler`, ported from OtOpcUa lmxopcua#880). SDK 2's own handler sends a password over 64 bytes — every 65-character OtOpcUa API key — as an `RsaEncryptedSecret`, which an SDK 1.5 server rejects with `BadIdentityTokenInvalid` and an SDK 2 server accepts only within a 5-minute clock window (lmxopcua#873). With the legacy format the DCL logs in to both server generations, as it did on SDK 1.5. X509 user identities resolve the PFX's private key on demand (`OpcUaUserIdentities`); a missing file or key still fails before any session is created.
+ - **Certificates.** The configuration's SDK-created `CertificateManager` owns the trust lists and the loaded application certificate, and is disposed with the session. `AutoAcceptUntrustedCerts` and the verify probe's capture-and-reject use the manager's `AcceptError` hook (the 1.5 `CertificateValidation` event is gone). SDK 2 makes two errors non-suppressible even with auto-accept on: an incomplete chain (install the issuing CA in the trusted or issuer store) and a certificate with an empty subject or issuer.
- Session-based connection with endpoint discovery, certificate handling, and configurable security modes. Endpoint discovery is **asynchronous** and bounded by the connection's `OperationTimeoutMs` (the discovery client is built from the connection's own `EndpointConfiguration`) and by the caller's cancellation; a cancelled discovery is never mistaken for "discovery unavailable". The `DataConnectionActor` starts every adapter `ConnectAsync` on the thread pool, so no adapter connect work — whatever runs before its first yielding await — can occupy the actor's dispatcher thread (review 2026-10-21; previously a synchronous discovery against a black-holed endpoint held the thread for the SDK's 120 s default on every reconnect tick).
- Subscriptions via OPC UA Monitored Items with data change notifications (1000ms sampling, queue size 10, discard-oldest).
- Read/Write via OPC UA Read/Write services with StatusCode-based quality mapping.
diff --git a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionActor.cs b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionActor.cs
index b3ffdee3..216d6b49 100644
--- a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionActor.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionActor.cs
@@ -1953,7 +1953,7 @@ public class DataConnectionActor : UntypedActor, IWithTimers
or System.Net.Sockets.SocketException
or TimeoutException
or System.IO.IOException
- || (baseEx is Opc.Ua.ServiceResultException sre && IsConnectionLevelStatus(sre.StatusCode));
+ || (baseEx is Opc.Ua.ServiceResultException sre && IsConnectionLevelStatus(sre.StatusCode.Code));
}
///
@@ -1971,16 +1971,22 @@ public class DataConnectionActor : UntypedActor, IWithTimers
/// as connection-level would put the connection into a reconnect loop over one bad tag path.
///
///
- private static bool IsConnectionLevelStatus(uint statusCode) => statusCode is
- Opc.Ua.StatusCodes.BadSessionIdInvalid
- or Opc.Ua.StatusCodes.BadSessionClosed
- or Opc.Ua.StatusCodes.BadSessionNotActivated
- or Opc.Ua.StatusCodes.BadSecureChannelClosed
- or Opc.Ua.StatusCodes.BadSecureChannelIdInvalid
- or Opc.Ua.StatusCodes.BadServerNotConnected
- or Opc.Ua.StatusCodes.BadConnectionClosed
- or Opc.Ua.StatusCodes.BadCommunicationError
- or Opc.Ua.StatusCodes.BadNotConnected;
+ private static bool IsConnectionLevelStatus(uint statusCode) => ConnectionLevelStatusCodes.Contains(statusCode);
+
+ // SDK 2 (ScadaBridge#125): StatusCodes members are StatusCode structs, not constants, so the
+ // set is built from their numeric codes rather than matched as constant patterns.
+ private static readonly HashSet ConnectionLevelStatusCodes =
+ [
+ Opc.Ua.StatusCodes.BadSessionIdInvalid.Code,
+ Opc.Ua.StatusCodes.BadSessionClosed.Code,
+ Opc.Ua.StatusCodes.BadSessionNotActivated.Code,
+ Opc.Ua.StatusCodes.BadSecureChannelClosed.Code,
+ Opc.Ua.StatusCodes.BadSecureChannelIdInvalid.Code,
+ Opc.Ua.StatusCodes.BadServerNotConnected.Code,
+ Opc.Ua.StatusCodes.BadConnectionClosed.Code,
+ Opc.Ua.StatusCodes.BadCommunicationError.Code,
+ Opc.Ua.StatusCodes.BadNotConnected.Code,
+ ];
private void HandleUnsubscribe(UnsubscribeTagsRequest request)
{
diff --git a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/LegacyRsaUserNameIdentityTokenHandler.cs b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/LegacyRsaUserNameIdentityTokenHandler.cs
new file mode 100644
index 00000000..c9e665fa
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/LegacyRsaUserNameIdentityTokenHandler.cs
@@ -0,0 +1,144 @@
+using Opc.Ua;
+using Opc.Ua.Security.Certificates;
+
+namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
+
+///
+/// UserName token handler that always encrypts the password in the legacy RSA format under an RSA security
+/// policy, whatever the password's length (lmxopcua#880, ScadaBridge#125).
+///
+///
+///
+/// Ported verbatim from OtOpcUa (Driver.OpcUaClient.Session, lmxopcua be28aa44) when ScadaBridge
+/// moved to SDK 2.0.1. Under SDK 1.5 the DCL always sent the legacy format, so this keeps its wire behaviour
+/// unchanged in both directions of a mixed rollout: an SDK 1.5 OtOpcUa server still decrypts a 65-character API
+/// key, and an SDK 2 server's RsaEncryptedSecret signing-time window (lmxopcua#873, 5 minutes) never
+/// applies to a DCL login.
+///
+///
+/// SDK 2.0.1's encrypts a password longer than 64 bytes (a private
+/// RsaEncryptedSecretPasswordThreshold constant with no setting) as an RsaEncryptedSecret, with
+/// left null. A 64-byte or shorter password is sent in
+/// the legacy format: RSA-OAEP(len ‖ password ‖ serverNonce) under the server certificate, with the
+/// algorithm URI set. An SDK 1.5.378 server decrypts only the legacy format under an RSA policy. A null
+/// algorithm reaches its SecurityPolicies.Decrypt as "Unexpected encryption algorithm", so
+/// BadIdentityTokenInvalid is returned before any credential check. Every production OtOpcUa API key
+/// is 65 characters long (otua_ + 16 hex + _ + 43), so an SDK 2 client could not use one
+/// against an SDK 1.5 server: QA, any rollback target, or an older third-party stack.
+///
+///
+/// The legacy format needs no size limit, because the SDK frames the plaintext with a length and spreads it
+/// over as many RSA blocks as it needs. An SDK 2 server accepts both formats. It uses the legacy decryptor
+/// whenever the algorithm URI is set. This handler therefore sends what SDK 1.5 clients (ScadaBridge
+/// included) always send, and what SDK 2 itself sends for every password up to 64 bytes. The crypto is the
+/// same RSA-OAEP under the server certificate, bound to the server nonce. The only thing lost is
+/// RsaEncryptedSecret's block padding of the secret's length. Policy None and the ECC policies
+/// are handed to the SDK handler unchanged, so this type never sends anything in a weaker form than the SDK
+/// would.
+///
+///
+internal sealed class LegacyRsaUserNameIdentityTokenHandler : IUserIdentityTokenHandler
+{
+ private readonly UserNameIdentityTokenHandler _inner;
+ private readonly ISecurityPolicyRegistry _policies;
+
+ /// Create the handler for a user name and its plaintext password.
+ /// The user name.
+ /// The password bytes (UTF-8 for an API key).
+ /// The policy registry, or null for .
+ public LegacyRsaUserNameIdentityTokenHandler(string username, ReadOnlySpan password,
+ ISecurityPolicyRegistry? securityPolicies = null)
+ : this(new UserNameIdentityTokenHandler(username, password, securityPolicies),
+ securityPolicies ?? SecurityPolicies.Default)
+ {
+ }
+
+ private LegacyRsaUserNameIdentityTokenHandler(UserNameIdentityTokenHandler inner, ISecurityPolicyRegistry policies)
+ {
+ _inner = inner;
+ _policies = policies;
+ }
+
+ /// The user name in the token.
+ public string UserName => _inner.UserName;
+
+ ///
+ public UserIdentityToken Token => _inner.Token;
+
+ ///
+ public string DisplayName => _inner.DisplayName;
+
+ ///
+ public UserTokenType TokenType => _inner.TokenType;
+
+ ///
+ public void UpdatePolicy(UserTokenPolicy userTokenPolicy) => _inner.UpdatePolicy(userTokenPolicy);
+
+ ///
+ public ValueTask EncryptAsync(Certificate receiverCertificate, byte[] receiverNonce, string securityPolicyUri,
+ IServiceMessageContext context, Nonce? receiverEphemeralKey = null, Certificate? senderCertificate = null,
+ CertificateCollection? senderIssuerCertificates = null, bool doNotEncodeSenderCertificate = false,
+ CancellationToken ct = default)
+ {
+ var password = _inner.DecryptedPassword;
+ var policy = password is null || string.IsNullOrEmpty(securityPolicyUri)
+ ? null
+ : _policies.GetInfo(securityPolicyUri);
+
+ // Only an RSA policy (no ephemeral key) with a receiver certificate is ours. None, ECC, an unknown policy
+ // and a missing certificate get the SDK's own handling, and its own errors.
+ if (policy is null || policy.Uri == SecurityPolicies.None
+ || policy.EphemeralKeyAlgorithm != CertificateKeyAlgorithm.None || receiverCertificate is null)
+ {
+ return _inner.EncryptAsync(receiverCertificate!, receiverNonce, securityPolicyUri, context,
+ receiverEphemeralKey, senderCertificate, senderIssuerCertificates, doNotEncodeSenderCertificate, ct);
+ }
+
+ ct.ThrowIfCancellationRequested();
+ var token = (UserNameIdentityToken)_inner.Token;
+ var plaintext = Utils.Append(password, receiverNonce);
+ try
+ {
+ var encrypted = _policies.Encrypt(receiverCertificate, policy.Uri, plaintext);
+ token.Password = encrypted.Data.ToByteString();
+ token.EncryptionAlgorithm = encrypted.Algorithm;
+ }
+ finally
+ {
+ Array.Clear(plaintext);
+ }
+
+ return ValueTask.CompletedTask;
+ }
+
+ ///
+ public ValueTask DecryptAsync(Certificate certificate, Nonce receiverNonce, string securityPolicyUri,
+ IServiceMessageContext context, Nonce? ephemeralKey = null, Certificate? senderCertificate = null,
+ CertificateCollection? senderIssuerCertificates = null, ICertificateValidatorEx? validator = null,
+ CancellationToken ct = default) =>
+ _inner.DecryptAsync(certificate, receiverNonce, securityPolicyUri, context, ephemeralKey, senderCertificate,
+ senderIssuerCertificates, validator, ct);
+
+ ///
+ public ValueTask SignAsync(byte[] dataToSign, string securityPolicyUri, CancellationToken ct = default) =>
+ _inner.SignAsync(dataToSign, securityPolicyUri, ct);
+
+ ///
+ public ValueTask VerifyAsync(byte[] dataToVerify, SignatureData signatureData, string securityPolicyUri,
+ CancellationToken ct = default) =>
+ _inner.VerifyAsync(dataToVerify, signatureData, securityPolicyUri, ct);
+
+ ///
+ /// The SDK session encrypts a Copy() of the handler, so the clone must keep this type.
+ public object Clone() => new LegacyRsaUserNameIdentityTokenHandler((UserNameIdentityTokenHandler)_inner.Clone(), _policies);
+
+ ///
+ public bool Equals(IUserIdentityTokenHandler? other) =>
+ other is LegacyRsaUserNameIdentityTokenHandler legacy ? _inner.Equals(legacy._inner) : _inner.Equals(other);
+
+ ///
+ public override bool Equals(object? obj) => obj is IUserIdentityTokenHandler other && Equals(other);
+
+ ///
+ public override int GetHashCode() => StringComparer.Ordinal.GetHashCode(UserName ?? string.Empty);
+}
diff --git a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/OpcUaApplicationIdentity.cs b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/OpcUaApplicationIdentity.cs
index e8802eee..53f8b2f9 100644
--- a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/OpcUaApplicationIdentity.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/OpcUaApplicationIdentity.cs
@@ -43,7 +43,7 @@ public static class OpcUaApplicationIdentity
// SDK telemetry for the non-obsolete ApplicationInstance ctor. SDK-internal logging is
// discarded; this class logs the outcome that matters (created vs reused) itself.
- private static readonly Lazy SdkTelemetry = new(() => DefaultTelemetry.Create(_ => { }));
+ internal static readonly Lazy SdkTelemetry = new(() => DefaultTelemetry.Create(_ => { }));
// One gate per own-store directory: two connections starting together against an empty
// store would otherwise BOTH generate a certificate, leaving two identities for one subject.
@@ -112,7 +112,10 @@ public static class OpcUaApplicationIdentity
OpcUaGlobalOptions options, bool secured, bool autoAcceptUntrusted,
int sessionTimeoutMs, int operationTimeoutMs, ILogger logger, CancellationToken ct)
{
- var appConfig = new ApplicationConfiguration
+ // SDK 2 (ScadaBridge#125): the configuration carries the telemetry context, and ValidateAsync
+ // creates the CertificateManager that owns the trust lists, the validator and (for a secured
+ // configuration) the loaded application certificate.
+ var appConfig = new ApplicationConfiguration(SdkTelemetry.Value)
{
ApplicationName = ResolveApplicationName(options),
ApplicationUri = ResolveApplicationUri(options),
@@ -158,9 +161,9 @@ public static class OpcUaApplicationIdentity
operationTimeoutMs: 15_000, logger, ct).ConfigureAwait(false);
try
{
- var cert = appConfig.SecurityConfiguration.ApplicationCertificate.Certificate
+ using var entry = AcquireApplicationCertificate(appConfig)
?? throw new InvalidOperationException("OPC UA application certificate was not loaded.");
- return X509CertificateLoader.LoadCertificate(cert.RawData);
+ return X509CertificateLoader.LoadCertificate(entry.Certificate.RawData);
}
finally
{
@@ -169,25 +172,34 @@ public static class OpcUaApplicationIdentity
}
///
- /// Disposes the private-key-bearing application certificate a configuration loaded. Call
- /// only once every session built from is closed and disposed.
+ /// Disposes the certificate manager a configuration created, which releases the
+ /// private-key-bearing application certificate it loaded. Call only once every session built
+ /// from is closed and disposed.
///
+ ///
+ /// SDK 2 (ScadaBridge#125): no longer caches a certificate.
+ /// The loaded identity and the trust-list stores belong to the configuration's
+ /// , which ValidateAsync creates
+ /// and reuses, so disposing that manager releases everything.
+ ///
/// A configuration from ; null is a no-op.
internal static void ReleaseApplicationCertificate(ApplicationConfiguration? appConfig)
{
- var certificates = appConfig?.SecurityConfiguration?.ApplicationCertificates;
- if (certificates is null)
+ if (appConfig?.CertificateManager is not { } manager)
return;
- foreach (var id in certificates)
- {
- var cert = id?.Certificate;
- if (cert is null)
- continue;
- id!.Certificate = null;
- cert.Dispose();
- }
+ appConfig.CertificateManager = null!;
+ (manager as IDisposable)?.Dispose();
}
+ ///
+ /// The configuration's RSA application certificate entry, or null when none is loaded. The
+ /// caller disposes the entry.
+ ///
+ private static Opc.Ua.Security.Certificates.CertificateEntry? AcquireApplicationCertificate(
+ ApplicationConfiguration appConfig) =>
+ appConfig.CertificateManager?.AcquireApplicationCertificateByType(
+ ObjectTypeIds.RsaSha256ApplicationCertificateType);
+
private static CertificateIdentifier NewOwnIdentifier(OpcUaGlobalOptions options) => new()
{
StoreType = CertificateStoreType.Directory,
@@ -199,8 +211,11 @@ public static class OpcUaApplicationIdentity
private static async Task EnsureLoadedAsync(
ApplicationConfiguration appConfig, OpcUaGlobalOptions options, ILogger logger, CancellationToken ct)
{
- var id = appConfig.SecurityConfiguration.ApplicationCertificate;
- var gate = ProvisionGates.GetOrAdd(Path.GetFullPath(id.StorePath), _ => new SemaphoreSlim(1, 1));
+ var id = appConfig.SecurityConfiguration.ApplicationCertificate
+ ?? throw new InvalidOperationException("OPC UA application certificate identifier is not configured.");
+ var storePath = id.StorePath
+ ?? throw new InvalidOperationException("OPC UA application certificate store path is not configured.");
+ var gate = ProvisionGates.GetOrAdd(Path.GetFullPath(storePath), _ => new SemaphoreSlim(1, 1));
await gate.WaitAsync(ct).ConfigureAwait(false);
try
{
@@ -226,7 +241,8 @@ public static class OpcUaApplicationIdentity
$"ApplicationUri '{appConfig.ApplicationUri}': {ex.Message}", ex);
}
- var cert = id.Certificate;
+ using var entry = ok ? AcquireApplicationCertificate(appConfig) : null;
+ var cert = entry?.Certificate;
if (!ok || cert is null || !cert.HasPrivateKey)
throw new InvalidOperationException(
$"OPC UA application certificate '{id.SubjectName}' could not be loaded with its private key from '{id.StorePath}'.");
diff --git a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/OpcUaDataConnection.cs b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/OpcUaDataConnection.cs
index 3f927c84..25dd20fb 100644
--- a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/OpcUaDataConnection.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/OpcUaDataConnection.cs
@@ -482,7 +482,7 @@ public class OpcUaDataConnection
/// connection fault, so it must not reach RaiseDisconnected or the connection-level
/// status set the DataConnectionActor reconnects on.
///
- internal const uint BadResourceUnavailableCode = Opc.Ua.StatusCodes.BadResourceUnavailable;
+ internal static readonly uint BadResourceUnavailableCode = Opc.Ua.StatusCodes.BadResourceUnavailable.Code;
/// True when 's code part is BadResourceUnavailable
/// (the low 16 bits are info bits and do not change the meaning).
@@ -494,7 +494,7 @@ public class OpcUaDataConnection
/// transient source-side condition from a configuration or security error.
internal static string DescribeBadRead(uint statusCode)
{
- var name = Opc.Ua.StatusCodes.LookupSymbolicId(statusCode & 0xFFFF0000u);
+ var name = new Opc.Ua.StatusCode(statusCode & 0xFFFF0000u).SymbolicId;
var text = string.IsNullOrEmpty(name)
? $"OPC UA read returned bad status: 0x{statusCode:X8}"
: $"OPC UA read returned bad status: 0x{statusCode:X8} {name}";
diff --git a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/OpcUaNodeReference.cs b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/OpcUaNodeReference.cs
index 44221186..1754be20 100644
--- a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/OpcUaNodeReference.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/OpcUaNodeReference.cs
@@ -72,7 +72,7 @@ public static class OpcUaNodeReference
// Maps nsu= -> the live index; leaves an ns= reference's index untouched.
var nodeId = ExpandedNodeId.ToNodeId(expanded, namespaceUris);
- if (NodeId.IsNull(nodeId))
+ if (nodeId.IsNull)
{
throw new ArgumentException(
$"'{reference}' names namespace URI '{expanded.NamespaceUri}', which this server " +
@@ -95,14 +95,13 @@ public static class OpcUaNodeReference
///
public static string ToDurable(ExpandedNodeId nodeId, NamespaceTable namespaceUris)
{
- ArgumentNullException.ThrowIfNull(nodeId);
ArgumentNullException.ThrowIfNull(namespaceUris);
// A browse can hand back a reference carrying a NamespaceUri or a server index
// rather than a plain index. Resolving to session-local first collapses those
// shapes; without it the raw ToString() can emit a string Resolve cannot re-read.
var local = ExpandedNodeId.ToNodeId(nodeId, namespaceUris);
- if (NodeId.IsNull(local))
+ if (local.IsNull)
return nodeId.ToString() ?? string.Empty;
// ns=0 is spec-fixed, so its short form is already durable.
diff --git a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/OpcUaUserIdentities.cs b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/OpcUaUserIdentities.cs
new file mode 100644
index 00000000..c1302018
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/OpcUaUserIdentities.cs
@@ -0,0 +1,117 @@
+using System.Security.Cryptography.X509Certificates;
+using Opc.Ua;
+using Opc.Ua.Security.Certificates;
+
+namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
+
+///
+/// Builds the OPC UA user identity a DCL session (and the verify probe) presents (ScadaBridge#125).
+///
+///
+///
+/// UserName. Always the : SDK 2's default
+/// handler sends a password over 64 bytes, such as a 65-character OtOpcUa API key, as an
+/// RsaEncryptedSecret that an SDK 1.5 server rejects with BadIdentityTokenInvalid
+/// (lmxopcua#880). The legacy handler sends what the DCL sent under SDK 1.5, which both server
+/// generations decrypt.
+///
+///
+/// X509Certificate. SDK 2 removed new UserIdentity(X509Certificate2); the identity is
+/// now a that resolves its private key on demand through an
+/// . The PFX is read once here, so a missing file, a bad password or
+/// a certificate without a private key still fails the connect before any session is created, as it
+/// did under SDK 1.5. The provider re-reads the same file whenever the SDK asks for the key.
+///
+///
+internal static class OpcUaUserIdentities
+{
+ ///
+ /// The identity for , or null for anonymous (no options, or a token
+ /// type this client does not implement).
+ ///
+ /// The configured user identity.
+ /// The identity, or null.
+ internal static UserIdentity? Build(OpcUaUserIdentityOptions? options)
+ {
+ if (options is null) return null;
+ return options.TokenType.ToUpperInvariant() switch
+ {
+ "USERNAMEPASSWORD" => UserName(options.Username ?? string.Empty, options.Password ?? string.Empty),
+ "X509CERTIFICATE" => FromCertificateFile(options.CertificatePath, options.CertificatePassword),
+ _ => null
+ };
+ }
+
+ /// A UserName identity that always uses the legacy RSA password format.
+ /// The user name.
+ /// The plaintext password, sent UTF-8 encoded.
+ /// The identity.
+ internal static UserIdentity UserName(string username, string password)
+ {
+ var bytes = System.Text.Encoding.UTF8.GetBytes(password);
+ try
+ {
+ return new UserIdentity(new LegacyRsaUserNameIdentityTokenHandler(username, bytes));
+ }
+ finally
+ {
+ Array.Clear(bytes);
+ }
+ }
+
+ /// An X509 identity backed by a PFX file.
+ /// The PFX path.
+ /// The PFX password, or null.
+ /// The identity.
+ internal static UserIdentity FromCertificateFile(string? path, string? password)
+ {
+ using var certificate = Load(path, password);
+ var identifier = new CertificateIdentifier { Thumbprint = certificate.Thumbprint };
+ return new UserIdentity(new X509IdentityTokenHandler(
+ identifier, new CertificatePasswordProvider(),
+ new PfxFileCertificateProvider(path!, password, certificate.Thumbprint)));
+ }
+
+ private static X509Certificate2 Load(string? path, string? password)
+ {
+ if (string.IsNullOrWhiteSpace(path))
+ throw new InvalidOperationException(
+ "An X509Certificate OPC UA user identity requires a certificate path.");
+ var certificate = X509CertificateLoader.LoadPkcs12FromFile(path, password);
+ if (!certificate.HasPrivateKey)
+ {
+ certificate.Dispose();
+ throw new InvalidOperationException(
+ $"The OPC UA user certificate at '{path}' has no private key; the key is required to sign the " +
+ "user-token challenge when the session activates.");
+ }
+ return certificate;
+ }
+
+ ///
+ /// Resolves the user certificate's private key from its PFX file. The thumbprint read when the
+ /// identity was built is pinned: a file replaced underneath a live identity fails the activation
+ /// rather than presenting a different certificate.
+ ///
+ private sealed class PfxFileCertificateProvider(string path, string? password, string thumbprint)
+ : ICertificateProvider
+ {
+ public Certificate? TryGetPrivateKeyCertificate(string requestedThumbprint) => null;
+
+ public ValueTask GetPrivateKeyCertificateAsync(CertificateIdentifier identifier,
+ ICertificatePasswordProvider? passwordProvider = null, string? applicationUri = null,
+ CancellationToken ct = default)
+ {
+ ct.ThrowIfCancellationRequested();
+ var certificate = Load(path, password);
+ if (!string.Equals(certificate.Thumbprint, thumbprint, StringComparison.OrdinalIgnoreCase)
+ || !string.Equals(identifier.Thumbprint, thumbprint, StringComparison.OrdinalIgnoreCase))
+ {
+ certificate.Dispose();
+ throw new ServiceResultException(StatusCodes.BadIdentityTokenInvalid,
+ "The OPC UA user certificate file changed; reconnect to present the new certificate.");
+ }
+ return ValueTask.FromResult(Certificate.From(certificate));
+ }
+ }
+}
diff --git a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/OpcUaValueBoundary.cs b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/OpcUaValueBoundary.cs
new file mode 100644
index 00000000..11aecf01
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/OpcUaValueBoundary.cs
@@ -0,0 +1,87 @@
+using Opc.Ua;
+
+namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
+
+///
+/// Converts between OPC UA SDK 2 values and the CLR payloads the rest of ScadaBridge consumes
+/// (ScadaBridge#125).
+///
+///
+///
+/// SDK 2 made , , and the
+/// other built-in types immutable structs, and its legacy boxing still hands back a few SDK
+/// wrapper types (, , )
+/// where SDK 1.5 handed back CLR ones. Everything downstream of the DCL (the codec, scripts,
+/// the alarm mapper) was written against the 1.5 shapes, so this boundary restores them:
+/// byte[], (UTC) and , and copies arrays so a
+/// retained value never aliases an SDK buffer.
+///
+///
+/// The same mapping OtOpcUa uses at its SDK 2 driver boundary (UpstreamValueMapping),
+/// except that a matrix is passed through as the SDK boxes it rather than refused: SDK 1.5
+/// passed one through too, and a value-change callback has no way to report a refusal.
+///
+///
+internal static class OpcUaValueBoundary
+{
+ /// The CLR payload of , in its SDK 1.5 shape.
+ /// The SDK value.
+ /// The detached CLR payload; null for a null variant.
+ internal static object? ToClr(Variant value)
+ {
+ var boxed = value.AsBoxedObject(Variant.BoxingBehavior.Legacy);
+ return value.TypeInfo.IsMatrix ? boxed : Detach(boxed);
+ }
+
+ private static object? Detach(object? value) => value switch
+ {
+ ByteString bytes => bytes.IsNull ? null : bytes.ToArray(),
+ DateTimeUtc time => ToClrTimestamp(time),
+ Uuid uuid => (Guid)uuid,
+ ByteString[] bytes => bytes.Select(b => b.IsNull ? null : b.ToArray()).ToArray(),
+ DateTimeUtc[] times => times.Select(ToClrTimestamp).ToArray(),
+ Uuid[] uuids => uuids.Select(u => (Guid)u).ToArray(),
+ Variant[] variants => variants.Select(ToClr).ToArray(),
+ object?[] objects when objects.GetType() == typeof(object[]) => objects.Select(Detach).ToArray(),
+ Array array => array.Clone(),
+ _ => value,
+ };
+
+ ///
+ /// A UTC for an SDK timestamp. The OPC UA "no timestamp" value maps to
+ /// , which is what SDK 1.5 decoded it to.
+ ///
+ /// The SDK timestamp.
+ /// The CLR timestamp.
+ internal static DateTime ToClrTimestamp(DateTimeUtc value) =>
+ value == DateTimeUtc.MinValue ? DateTime.MinValue : value.ToDateTime();
+
+ ///
+ /// The SDK value to write for a CLR payload, with SDK 1.5's new Variant(object)
+ /// semantics: a byte[] is a ByteString scalar, other supported scalars and
+ /// one-dimensional arrays map to their built-in type, and caller buffers are copied.
+ ///
+ /// The CLR payload (null writes a null variant).
+ /// The variant.
+ /// The payload has no OPC UA built-in representation.
+ internal static Variant ToVariant(object? value)
+ {
+ switch (value)
+ {
+ case null:
+ return Variant.Null;
+ case byte[] bytes:
+ return Variant.From(ByteString.From(bytes.AsSpan()));
+ case Array { Rank: > 1 }:
+ throw new ArgumentException("Multi-dimensional write values are not supported.", nameof(value));
+ case Array array:
+ value = array.Clone();
+ break;
+ }
+
+ if (!VariantHelper.TryCastFrom(value, out Variant variant))
+ throw new ArgumentException(
+ $"A {value.GetType().Name} value has no OPC UA built-in type to write it as.", nameof(value));
+ return variant;
+ }
+}
diff --git a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/RealOpcUaClient.cs b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/RealOpcUaClient.cs
index 9c814020..f49da3b5 100644
--- a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/RealOpcUaClient.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/RealOpcUaClient.cs
@@ -116,13 +116,14 @@ public class RealOpcUaClient : IOpcUaClient
/// the SDK's default 120 s operation timeout, and could not be cancelled by a verify probe's
/// budget.
///
- private static async Task DiscoverEndpointsAsync(
+ private static async Task> DiscoverEndpointsAsync(
ApplicationConfiguration appConfig, string endpointUrl, CancellationToken ct)
{
var discoveryConfig = EndpointConfiguration.Create(appConfig);
using var discoveryClient = await DiscoveryClient.CreateAsync(
appConfig, new Uri(endpointUrl), discoveryConfig, DiagnosticsMasks.None, ct).ConfigureAwait(false);
- return await discoveryClient.GetEndpointsAsync(null, ct).ConfigureAwait(false);
+ var endpoints = await discoveryClient.GetEndpointsAsync(default, ct).ConfigureAwait(false);
+ return endpoints.IsNull ? [] : endpoints.ToList();
}
/// Raised when the OPC UA connection is lost.
@@ -138,9 +139,9 @@ public class RealOpcUaClient : IOpcUaClient
///
/// The endpoint matching the requested security mode.
internal static async Task DiscoverEndpointAsync(string url, MessageSecurityMode requested,
- Func> discover, CancellationToken ct = default)
+ Func>> discover, CancellationToken ct = default)
{
- EndpointDescriptionCollection endpoints;
+ IReadOnlyList endpoints;
try { endpoints = await discover(ct).ConfigureAwait(false); }
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
catch (Exception ex)
@@ -184,7 +185,10 @@ public class RealOpcUaClient : IOpcUaClient
"OPC UA connection to {Endpoint} has AutoAcceptUntrustedCerts enabled — every " +
"server certificate is accepted unconditionally. This defeats Sign / " +
"SignAndEncrypt protection against a man-in-the-middle.", endpointUrl);
- appConfig.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;
+ // SDK 2 replaced the CertificateValidation event with a per-error AcceptError hook on the
+ // configuration's certificate manager (ScadaBridge#125). Errors the SDK classes as
+ // non-suppressible (an incomplete chain, an empty distinguished name) never reach it.
+ appConfig.CertificateManager.AcceptError = (_, _) => true;
}
// Discover endpoints from the server, pick the preferred security mode
@@ -199,16 +203,14 @@ public class RealOpcUaClient : IOpcUaClient
var endpointConfig = EndpointConfiguration.Create(appConfig);
var configuredEndpoint = new ConfiguredEndpoint(null, endpoint, endpointConfig);
-#pragma warning disable CS0618 // Allow obsolete DefaultSessionFactory constructor for compatibility
- var sessionFactory = new DefaultSessionFactory();
-#pragma warning restore CS0618
+ var sessionFactory = new DefaultSessionFactory(OpcUaApplicationIdentity.SdkTelemetry.Value);
var userIdentity = BuildUserIdentity(opts.UserIdentity);
_writeDataTypes.Clear();
try
{
_session = await sessionFactory.CreateAsync(
appConfig, configuredEndpoint, false,
- "ScadaBridge-DCL-Session", (uint)opts.SessionTimeoutMs, userIdentity, null, cancellationToken);
+ "ScadaBridge-DCL-Session", (uint)opts.SessionTimeoutMs, userIdentity, default, cancellationToken);
}
catch
{
@@ -379,19 +381,22 @@ public class RealOpcUaClient : IOpcUaClient
// Capture the untrusted server cert, then REJECT it (e.Accept = false). The
// validator runs on the SDK's connect thread; copying the cert is the only state we
// keep. Never accept — this probe must not trust anything.
- CertificateValidationEventHandler captureAndReject = (_, e) =>
+ // SDK 2 (ScadaBridge#125): the per-error AcceptError hook replaces the CertificateValidation
+ // event. Returning false rejects the error exactly as e.Accept = false did.
+ Func captureAndReject = (certificate, _) =>
{
try
{
- // Copy into a stable instance so disposing the SDK's chain doesn't invalidate it.
- capturedCert = X509CertificateLoader.LoadCertificate(e.Certificate.RawData);
+ // Copy into a stable instance: the SDK's Certificate is reference-counted and is
+ // released once validation returns.
+ capturedCert ??= X509CertificateLoader.LoadCertificate(certificate.RawData);
}
catch
{
- // Best-effort capture: fall back to the original reference if the copy fails.
- capturedCert = e.Certificate;
+ // Best-effort capture: the SDK's own copy is the fallback.
+ capturedCert ??= certificate.AsX509Certificate2();
}
- e.Accept = false;
+ return false;
};
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
@@ -407,7 +412,7 @@ public class RealOpcUaClient : IOpcUaClient
globalOptions, secured: preferredSecurityMode != MessageSecurityMode.None,
autoAcceptUntrusted: false, config.SessionTimeoutMs, config.OperationTimeoutMs,
logger, linkedCts.Token);
- appConfig.CertificateValidator.CertificateValidation += captureAndReject;
+ appConfig.CertificateManager.AcceptError = captureAndReject;
// Discover endpoints, pick the preferred security mode (same logic as ConnectAsync).
// Bounded by the probe's own budget (linkedCts), not just the SDK operation timeout.
@@ -421,9 +426,7 @@ public class RealOpcUaClient : IOpcUaClient
var endpointConfig = EndpointConfiguration.Create(appConfig);
var configuredEndpoint = new ConfiguredEndpoint(null, endpoint, endpointConfig);
-#pragma warning disable CS0618 // Allow obsolete DefaultSessionFactory constructor for compatibility
- var sessionFactory = new DefaultSessionFactory();
-#pragma warning restore CS0618
+ var sessionFactory = new DefaultSessionFactory(OpcUaApplicationIdentity.SdkTelemetry.Value);
var userIdentity = BuildUserIdentity(config.UserIdentity is { } ui
? new OpcUaUserIdentityOptions(
@@ -434,7 +437,7 @@ public class RealOpcUaClient : IOpcUaClient
session = await sessionFactory.CreateAsync(
appConfig, configuredEndpoint, false,
"ScadaBridge-DCL-Verify", (uint)config.SessionTimeoutMs,
- userIdentity, null, linkedCts.Token);
+ userIdentity, default, linkedCts.Token);
}
catch (Exception ex)
{
@@ -452,8 +455,8 @@ public class RealOpcUaClient : IOpcUaClient
catch (Exception ex) { logger.LogDebug(ex, "OPC UA verify session close failed (ignored)."); }
session.Dispose();
}
- if (appConfig != null)
- appConfig.CertificateValidator.CertificateValidation -= captureAndReject;
+ if (appConfig?.CertificateManager is { } manager)
+ manager.AcceptError = null;
OpcUaApplicationIdentity.ReleaseApplicationCertificate(appConfig);
}
@@ -502,24 +505,18 @@ public class RealOpcUaClient : IOpcUaClient
if (HasSocketCause(sre))
return new VerifyEndpointResult(false, VerifyFailureKind.Unreachable, sre.Message, null);
- switch (sre.StatusCode)
- {
- case StatusCodes.BadRequestTimeout:
- case StatusCodes.BadTimeout:
- return new VerifyEndpointResult(false, VerifyFailureKind.Timeout, sre.Message, null);
- case StatusCodes.BadUserAccessDenied:
- case StatusCodes.BadIdentityTokenRejected:
- case StatusCodes.BadIdentityTokenInvalid:
- return new VerifyEndpointResult(false, VerifyFailureKind.AuthFailed, sre.Message, null);
- case StatusCodes.BadConnectionRejected:
- case StatusCodes.BadNotConnected:
- case StatusCodes.BadConnectionClosed:
- case StatusCodes.BadNoCommunication:
- case StatusCodes.BadServerNotConnected:
- return new VerifyEndpointResult(false, VerifyFailureKind.Unreachable, sre.Message, null);
- default:
- return new VerifyEndpointResult(false, VerifyFailureKind.ServerError, sre.Message, null);
- }
+ // SDK 2: StatusCodes members are StatusCode structs, not constants, so match by value.
+ var status = sre.StatusCode;
+ if (status == StatusCodes.BadRequestTimeout || status == StatusCodes.BadTimeout)
+ return new VerifyEndpointResult(false, VerifyFailureKind.Timeout, sre.Message, null);
+ if (status == StatusCodes.BadUserAccessDenied || status == StatusCodes.BadIdentityTokenRejected
+ || status == StatusCodes.BadIdentityTokenInvalid)
+ return new VerifyEndpointResult(false, VerifyFailureKind.AuthFailed, sre.Message, null);
+ if (status == StatusCodes.BadConnectionRejected || status == StatusCodes.BadNotConnected
+ || status == StatusCodes.BadConnectionClosed || status == StatusCodes.BadNoCommunication
+ || status == StatusCodes.BadServerNotConnected)
+ return new VerifyEndpointResult(false, VerifyFailureKind.Unreachable, sre.Message, null);
+ return new VerifyEndpointResult(false, VerifyFailureKind.ServerError, sre.Message, null);
}
// A bare socket failure (DNS / connection refused) before the SDK wrapped it.
@@ -754,9 +751,11 @@ public class RealOpcUaClient : IOpcUaClient
{
if (e.NotificationValue is MonitoredItemNotification notification)
{
- var value = notification.Value?.Value;
- var timestamp = notification.Value?.SourceTimestamp ?? DateTime.UtcNow;
- var statusCode = notification.Value?.StatusCode.Code ?? 0;
+ // SDK 2: DataValue is a struct; restore the SDK 1.5 CLR payload shapes.
+ var dataValue = notification.Value;
+ var value = OpcUaValueBoundary.ToClr(dataValue.WrappedValue);
+ var timestamp = OpcUaValueBoundary.ToClrTimestamp(dataValue.SourceTimestamp);
+ var statusCode = dataValue.StatusCode.Code;
if (_callbacks.TryGetValue(handle, out var cb))
{
@@ -1039,11 +1038,11 @@ public class RealOpcUaClient : IOpcUaClient
/// The friendly type name when known; otherwise the NodeId string (or "" when null).
internal static string ResolveAlarmTypeName(NodeId? eventType)
{
- if (eventType is null)
+ if (eventType is not { } type || type.IsNull)
return "";
- return ConditionTypeNamesById.TryGetValue(eventType, out var friendly)
+ return ConditionTypeNamesById.TryGetValue(type, out var friendly)
? friendly
- : eventType.ToString();
+ : type.ToString() ?? "";
}
///
@@ -1067,20 +1066,22 @@ public class RealOpcUaClient : IOpcUaClient
internal static EventFilter BuildAlarmEventFilter(AlarmConditionFilter conditionFilter)
{
var filter = new EventFilter();
+ // SDK 2: SelectClauses is an immutable ArrayOf; collect the clauses, then assign once.
+ var selectClauses = new List();
foreach (var name in AlarmStateFields)
- filter.SelectClauses.Add(SelectField(ObjectTypeIds.BaseEventType, name));
+ selectClauses.Add(SelectField(ObjectTypeIds.BaseEventType, name));
// Two-state sub-condition /Id booleans + shelving current-state + identity.
- filter.SelectClauses.Add(SelectField(ObjectTypeIds.AlarmConditionType, "ActiveState", "Id")); // 6
- filter.SelectClauses.Add(SelectField(ObjectTypeIds.AcknowledgeableConditionType, "AckedState", "Id")); // 7
- filter.SelectClauses.Add(SelectField(ObjectTypeIds.AcknowledgeableConditionType, "ConfirmedState", "Id"));// 8
- filter.SelectClauses.Add(SelectField(ObjectTypeIds.AlarmConditionType, "SuppressedState", "Id")); // 9
- filter.SelectClauses.Add(SelectField(ObjectTypeIds.AlarmConditionType, "ShelvingState", "CurrentState"));// 10
- filter.SelectClauses.Add(SelectField(ObjectTypeIds.ConditionType, "ConditionName")); // 11
- filter.SelectClauses.Add(SelectField(ObjectTypeIds.ConditionType, "Comment")); // 12
+ selectClauses.Add(SelectField(ObjectTypeIds.AlarmConditionType, "ActiveState", "Id")); // 6
+ selectClauses.Add(SelectField(ObjectTypeIds.AcknowledgeableConditionType, "AckedState", "Id")); // 7
+ selectClauses.Add(SelectField(ObjectTypeIds.AcknowledgeableConditionType, "ConfirmedState", "Id"));// 8
+ selectClauses.Add(SelectField(ObjectTypeIds.AlarmConditionType, "SuppressedState", "Id")); // 9
+ selectClauses.Add(SelectField(ObjectTypeIds.AlarmConditionType, "ShelvingState", "CurrentState"));// 10
+ selectClauses.Add(SelectField(ObjectTypeIds.ConditionType, "ConditionName")); // 11
+ selectClauses.Add(SelectField(ObjectTypeIds.ConditionType, "Comment")); // 12
// APPENDED fields (indices 13+): optional — only present on specific derived types.
- // Guard all reads with fields.Count > N so base-ConditionType events still process.
+ // Guard all reads with fields.Length > N so base-ConditionType events still process.
// 13: AlarmConditionType/ActiveState/TransitionTime — the UTC instant the active-state
// last flipped to TRUE. Mapped to OriginalRaiseTime; absent on non-AlarmCondition
@@ -1089,16 +1090,16 @@ public class RealOpcUaClient : IOpcUaClient
// rather than the historical raise instant (OPC UA Part 9 §5.5.2 makes it advisory),
// so a snapshot-derived OriginalRaiseTime can look like the refresh time — it is
// display-only and not treated as authoritative.
- filter.SelectClauses.Add(SelectField(ObjectTypeIds.AlarmConditionType, "ActiveState", "TransitionTime")); // 13
+ selectClauses.Add(SelectField(ObjectTypeIds.AlarmConditionType, "ActiveState", "TransitionTime")); // 13
// 14–17: LimitAlarmType limit thresholds — configuration-time set-points exposed as
// event fields by LimitAlarmType and all its subtypes (Exclusive/NonExclusive
// Level/Deviation/RateOfChange). Absent on non-limit alarm types (e.g. discrete,
- // off-normal) — guarded by fields.Count > N below.
- filter.SelectClauses.Add(SelectField(ObjectTypeIds.LimitAlarmType, "HighHighLimit")); // 14
- filter.SelectClauses.Add(SelectField(ObjectTypeIds.LimitAlarmType, "HighLimit")); // 15
- filter.SelectClauses.Add(SelectField(ObjectTypeIds.LimitAlarmType, "LowLimit")); // 16
- filter.SelectClauses.Add(SelectField(ObjectTypeIds.LimitAlarmType, "LowLowLimit")); // 17
+ // off-normal) — guarded by fields.Length > N below.
+ selectClauses.Add(SelectField(ObjectTypeIds.LimitAlarmType, "HighHighLimit")); // 14
+ selectClauses.Add(SelectField(ObjectTypeIds.LimitAlarmType, "HighLimit")); // 15
+ selectClauses.Add(SelectField(ObjectTypeIds.LimitAlarmType, "LowLimit")); // 16
+ selectClauses.Add(SelectField(ObjectTypeIds.LimitAlarmType, "LowLowLimit")); // 17
// 18: AcknowledgeableConditionType/AckedState/TransitionTime — the UTC instant the
// acked-state last flipped. When the condition is currently ACKED that instant IS
@@ -1107,8 +1108,9 @@ public class RealOpcUaClient : IOpcUaClient
// base ConditionType events and on servers that do not expose it, in which case
// OpcUaAlarmMapper.DeriveAckTime falls back to the event's own Time field. Same
// ConditionRefresh caveat as index 13: a replayed snapshot may re-stamp it.
- filter.SelectClauses.Add(
+ selectClauses.Add(
SelectField(ObjectTypeIds.AcknowledgeableConditionType, "AckedState", "TransitionTime")); // 18
+ filter.SelectClauses = selectClauses;
// UNAVAILABLE via standard OPC UA A&C event fields (documented here so future
// maintainers know these were considered, not overlooked):
@@ -1159,15 +1161,13 @@ public class RealOpcUaClient : IOpcUaClient
for (var i = 1; i < typeIds.Count; i++)
{
var next = where.Push(FilterOperator.OfType, typeIds[i]);
- element = where.Push(FilterOperator.Or, element, next);
+ element = where.Push(FilterOperator.Or, Variant.FromStructure(element), Variant.FromStructure(next));
}
}
private static SimpleAttributeOperand SelectField(NodeId typeDefinitionId, params string[] browse)
{
- var path = new QualifiedNameCollection();
- foreach (var b in browse)
- path.Add(new QualifiedName(b));
+ var path = browse.Select(b => new QualifiedName(b)).ToArray();
return new SimpleAttributeOperand
{
TypeDefinitionId = typeDefinitionId,
@@ -1198,11 +1198,15 @@ public class RealOpcUaClient : IOpcUaClient
string handle, string? subscriptionSourceReference, EventFieldList efl,
Action onTransition)
{
- var fields = efl.EventFields;
- if (fields == null || fields.Count < AlarmStateFields.Length)
+ // SDK 2: event fields are an ArrayOf; unbox each one to its SDK 1.5 CLR shape once,
+ // so every index check below reads the same values it did before ScadaBridge#125.
+ if (efl.EventFields.IsNull || efl.EventFields.Count < AlarmStateFields.Length)
return;
+ var fields = new object?[efl.EventFields.Count];
+ for (var i = 0; i < fields.Length; i++)
+ fields[i] = OpcUaValueBoundary.ToClr(efl.EventFields[i]);
- var eventType = fields[0].Value as NodeId;
+ NodeId? eventType = fields[0] is NodeId et ? et : null;
// RefreshStart/End bracket the snapshot replay.
if (eventType == ObjectTypeIds.RefreshStartEventType)
@@ -1226,30 +1230,30 @@ public class RealOpcUaClient : IOpcUaClient
// event's name), so DataConnectionActor's NodeId-keyed routing matches — see
// OpcUaAlarmMapper.BuildIdentity. SourceName seeds the readable per-condition
// key; fall back to the SourceNode NodeId string only when it is absent.
- var sourceName = fields[2].Value as string;
+ var sourceName = fields[2] as string;
if (string.IsNullOrEmpty(sourceName))
- sourceName = (fields[1].Value as NodeId)?.ToString() ?? "";
- var conditionName = fields.Count > 11 ? fields[11].Value as string : null;
+ sourceName = fields[1] is NodeId sourceNode ? sourceNode.ToString() ?? "" : "";
+ var conditionName = fields.Length > 11 ? fields[11] as string : null;
var (sourceRef, sourceObjectRef) =
OpcUaAlarmMapper.BuildIdentity(subscriptionSourceReference, sourceName, conditionName);
if (string.IsNullOrEmpty(sourceRef))
return; // not a condition event we can key
- var time = fields[3].Value is DateTime dt ? new DateTimeOffset(dt, TimeSpan.Zero) : DateTimeOffset.UtcNow;
- var message = (fields[4].Value as LocalizedText)?.Text ?? "";
- var severity = fields[5].Value is null ? 0 : Convert.ToInt32(fields[5].Value);
+ var time = fields[3] is DateTime dt ? new DateTimeOffset(dt, TimeSpan.Zero) : DateTimeOffset.UtcNow;
+ var message = TextOf(fields[4]) ?? "";
+ var severity = fields[5] is null ? 0 : Convert.ToInt32(fields[5]);
- var active = fields.Count > 6 && fields[6].Value is bool a && a;
- var acked = fields.Count <= 7 || fields[7].Value is not bool ak || ak; // default acked when absent
- bool? confirmed = fields.Count > 8 && fields[8].Value is bool cf ? cf : null;
- var suppressed = fields.Count > 9 && fields[9].Value is bool sp && sp;
- var shelve = OpcUaAlarmMapper.MapShelve(fields.Count > 10 ? (fields[10].Value as LocalizedText)?.Text : null);
- var comment = fields.Count > 12 ? (fields[12].Value as LocalizedText)?.Text ?? "" : "";
+ var active = fields.Length > 6 && fields[6] is bool a && a;
+ var acked = fields.Length <= 7 || fields[7] is not bool ak || ak; // default acked when absent
+ bool? confirmed = fields.Length > 8 && fields[8] is bool cf ? cf : null;
+ var suppressed = fields.Length > 9 && fields[9] is bool sp && sp;
+ var shelve = OpcUaAlarmMapper.MapShelve(fields.Length > 10 ? TextOf(fields[10]) : null);
+ var comment = fields.Length > 12 ? TextOf(fields[12]) ?? "" : "";
// Index 13: ActiveState/TransitionTime → OriginalRaiseTime (when active-state last
// transitioned to TRUE). Absent on non-AlarmCondition events → guard + null fallback.
DateTimeOffset? originalRaiseTime = null;
- if (fields.Count > 13 && fields[13].Value is DateTime activeTransitionTime)
+ if (fields.Length > 13 && fields[13] is DateTime activeTransitionTime)
// OPC UA mandates UTC for DateTime fields; a TimeSpan.Zero offset treats an
// Unspecified Kind as UTC (consistent with the Time→TransitionTime mapping above).
originalRaiseTime = new DateTimeOffset(activeTransitionTime, TimeSpan.Zero);
@@ -1260,16 +1264,16 @@ public class RealOpcUaClient : IOpcUaClient
// display-only representative limit; the caller is responsible for interpreting
// which limit is active using AlarmTypeName or ConditionName.
var limitValue = OpcUaAlarmMapper.PickLimitValue(
- fields.Count > 14 ? fields[14].Value : null,
- fields.Count > 15 ? fields[15].Value : null,
- fields.Count > 16 ? fields[16].Value : null,
- fields.Count > 17 ? fields[17].Value : null);
+ fields.Length > 14 ? fields[14] : null,
+ fields.Length > 15 ? fields[15] : null,
+ fields.Length > 16 ? fields[16] : null,
+ fields.Length > 17 ? fields[17] : null);
// Index 18: AckedState/TransitionTime → the true source ack instant when the condition
// is currently acked. Absent on non-acknowledgeable events / servers that omit it →
// guard + null fallback, which DeriveAckTime resolves to the event time below.
DateTimeOffset? sourceAckTime = null;
- if (fields.Count > 18 && fields[18].Value is DateTime ackTransitionTime)
+ if (fields.Length > 18 && fields[18] is DateTime ackTransitionTime)
// OPC UA mandates UTC for DateTime fields; TimeSpan.Zero treats an Unspecified
// Kind as UTC (consistent with the Time and ActiveState/TransitionTime mappings).
sourceAckTime = new DateTimeOffset(ackTransitionTime, TimeSpan.Zero);
@@ -1320,6 +1324,9 @@ public class RealOpcUaClient : IOpcUaClient
SnapshotId: inRefresh && _alarmSnapshotIds.TryGetValue(handle, out var epoch) ? epoch : null));
}
+ /// The text of a LocalizedText event field (a struct in SDK 2), or null.
+ private static string? TextOf(object? field) => field is LocalizedText text ? text.Text : null;
+
private static NativeAlarmTransition SnapshotBoundary(AlarmTransitionKind kind, string? source, Guid epoch) => new(
"", source ?? string.Empty, "", kind,
new Commons.Types.Alarms.AlarmConditionState(false, true, null, AlarmShelveState.Unshelved, false, 0),
@@ -1369,31 +1376,33 @@ public class RealOpcUaClient : IOpcUaClient
}
catch (Exception ex)
{
- outcomes[i] = new OpcUaReadOutcome(nodeIds[i], null, DateTime.UtcNow, StatusCodes.BadNodeIdUnknown, ex.Message);
+ outcomes[i] = new OpcUaReadOutcome(nodeIds[i], null, DateTime.UtcNow, StatusCodes.BadNodeIdUnknown.Code, ex.Message);
}
}
for (var offset = 0; offset < resolvable.Count; offset += MaxNodesPerServiceCall)
{
var chunk = resolvable.GetRange(offset, Math.Min(MaxNodesPerServiceCall, resolvable.Count - offset));
- var collection = new ReadValueIdCollection(chunk.Select(c => c.Read));
+ ArrayOf collection = chunk.Select(c => c.Read).ToArray();
var response = await session.ReadAsync(
null, 0, MapTimestampsToReturn(_options.TimestampsToReturn), collection, cancellationToken);
+ var results = response.Results;
for (var i = 0; i < chunk.Count; i++)
{
var (index, nodeId, _) = chunk[i];
- if (response.Results is { Count: > 0 } results && i < results.Count)
+ if (!results.IsNull && i < results.Count)
{
var result = results[i];
outcomes[index] = new OpcUaReadOutcome(
- nodeId, result.Value, result.SourceTimestamp, result.StatusCode.Code, null);
+ nodeId, OpcUaValueBoundary.ToClr(result.WrappedValue),
+ OpcUaValueBoundary.ToClrTimestamp(result.SourceTimestamp), result.StatusCode.Code, null);
}
else
{
// Non-conformant server: fewer results than requested nodes.
outcomes[index] = new OpcUaReadOutcome(
- nodeId, null, DateTime.UtcNow, StatusCodes.BadUnexpectedError,
+ nodeId, null, DateTime.UtcNow, StatusCodes.BadUnexpectedError.Code,
"OPC UA read returned no result for this node.");
}
}
@@ -1431,7 +1440,7 @@ public class RealOpcUaClient : IOpcUaClient
}
catch (Exception ex)
{
- outcomes[i] = new OpcUaWriteOutcome(values[i].NodeId, StatusCodes.BadNodeIdUnknown, ex.Message);
+ outcomes[i] = new OpcUaWriteOutcome(values[i].NodeId, StatusCodes.BadNodeIdUnknown.Code, ex.Message);
}
}
@@ -1451,7 +1460,7 @@ public class RealOpcUaClient : IOpcUaClient
var coerced = OpcUaWriteCoercion.Coerce(value, builtInType);
if (!coerced.Success)
{
- outcomes[i] = new OpcUaWriteOutcome(values[i].NodeId, StatusCodes.BadTypeMismatch, coerced.Error);
+ outcomes[i] = new OpcUaWriteOutcome(values[i].NodeId, StatusCodes.BadTypeMismatch.Code, coerced.Error);
continue;
}
value = coerced.Value;
@@ -1463,32 +1472,33 @@ public class RealOpcUaClient : IOpcUaClient
{
NodeId = nodeId,
AttributeId = Attributes.Value,
- Value = new DataValue(new Variant(value))
+ Value = new DataValue(OpcUaValueBoundary.ToVariant(value))
}));
}
catch (Exception ex)
{
- outcomes[i] = new OpcUaWriteOutcome(values[i].NodeId, StatusCodes.BadTypeMismatch, ex.Message);
+ outcomes[i] = new OpcUaWriteOutcome(values[i].NodeId, StatusCodes.BadTypeMismatch.Code, ex.Message);
}
}
for (var offset = 0; offset < resolvable.Count; offset += MaxNodesPerServiceCall)
{
var chunk = resolvable.GetRange(offset, Math.Min(MaxNodesPerServiceCall, resolvable.Count - offset));
- var collection = new WriteValueCollection(chunk.Select(c => c.Write));
+ ArrayOf collection = chunk.Select(c => c.Write).ToArray();
var response = await session.WriteAsync(null, collection, cancellationToken);
+ var results = response.Results;
for (var i = 0; i < chunk.Count; i++)
{
var (index, nodeId, _) = chunk[i];
- if (response.Results is { Count: > 0 } results && i < results.Count)
+ if (!results.IsNull && i < results.Count)
{
outcomes[index] = new OpcUaWriteOutcome(nodeId, results[i].Code, null);
}
else
{
outcomes[index] = new OpcUaWriteOutcome(
- nodeId, StatusCodes.BadUnexpectedError,
+ nodeId, StatusCodes.BadUnexpectedError.Code,
"OPC UA write returned no result for this node.");
}
}
@@ -1513,16 +1523,18 @@ public class RealOpcUaClient : IOpcUaClient
var chunk = missing.GetRange(offset, Math.Min(MaxNodesPerServiceCall, missing.Count - offset));
try
{
- var readIds = new ReadValueIdCollection(
- chunk.Select(n => new ReadValueId { NodeId = n, AttributeId = Attributes.DataType }));
+ ArrayOf readIds = chunk
+ .Select(n => new ReadValueId { NodeId = n, AttributeId = Attributes.DataType }).ToArray();
var response = await session.ReadAsync(
null, 0, TimestampsToReturn.Neither, readIds, cancellationToken).ConfigureAwait(false);
- if (response.Results is not { } results || results.Count != chunk.Count)
+ var results = response.Results;
+ if (results.IsNull || results.Count != chunk.Count)
continue;
for (var i = 0; i < chunk.Count; i++)
{
- if (!StatusCode.IsGood(results[i].StatusCode) || results[i].Value is not NodeId dataTypeId)
+ if (!StatusCode.IsGood(results[i].StatusCode)
+ || OpcUaValueBoundary.ToClr(results[i].WrappedValue) is not NodeId dataTypeId)
continue;
var builtIn = TypeInfo.GetBuiltInType(dataTypeId);
@@ -1574,20 +1586,8 @@ public class RealOpcUaClient : IOpcUaClient
await DisconnectAsync();
}
- private static UserIdentity? BuildUserIdentity(OpcUaUserIdentityOptions? options)
- {
- if (options is null) return null;
- return options.TokenType.ToUpperInvariant() switch
- {
- "USERNAMEPASSWORD" => new UserIdentity(
- options.Username,
- System.Text.Encoding.UTF8.GetBytes(options.Password ?? "")),
- "X509CERTIFICATE" => new UserIdentity(
- X509CertificateLoader.LoadPkcs12FromFile(
- options.CertificatePath, options.CertificatePassword)),
- _ => null
- };
- }
+ private static UserIdentity? BuildUserIdentity(OpcUaUserIdentityOptions? options) =>
+ OpcUaUserIdentities.Build(options);
private static MonitoringFilter? BuildDataChangeFilter(OpcUaDeadbandOptions? deadband)
{
@@ -1666,12 +1666,12 @@ public class RealOpcUaClient : IOpcUaClient
// can be fetched; we pass back exactly the one point we were handed.
var (_, revisedPoints, results, _) = await session.BrowseNextAsync(
null,
- new ByteStringCollection { Convert.FromBase64String(continuationToken) },
+ new[] { ByteString.From(Convert.FromBase64String(continuationToken)) },
false,
cancellationToken).ConfigureAwait(false);
- var nextPoint = revisedPoints is { Count: > 0 } ? revisedPoints[0] : null;
- var refs = results is { Count: > 0 } ? results[0] : null;
+ var nextPoint = !revisedPoints.IsNull && revisedPoints.Count > 0 ? revisedPoints[0] : default;
+ var refs = results is { Count: > 0 } ? results[0] : default;
return await BuildResultAsync(session, refs, nextPoint, cancellationToken).ConfigureAwait(false);
}
catch (ServiceResultException ex) when (
@@ -1743,11 +1743,11 @@ public class RealOpcUaClient : IOpcUaClient
///
private async Task BuildResultAsync(
ISession session,
- ReferenceDescriptionCollection? references,
- byte[]? continuationPoint,
+ ArrayOf references,
+ ByteString continuationPoint,
CancellationToken cancellationToken)
{
- var refs = references ?? new ReferenceDescriptionCollection();
+ var refs = references.IsNull ? [] : references.ToList();
var children = new List(refs.Count);
foreach (var r in refs)
{
@@ -1757,7 +1757,7 @@ public class RealOpcUaClient : IOpcUaClient
// read back, and a bare ns= index rots the moment the server's
// namespace table changes. What the picker shows is what gets stored.
NodeId: OpcUaNodeReference.ToDurable(r.NodeId, session.NamespaceUris),
- DisplayName: r.DisplayName?.Text ?? r.BrowseName?.Name ?? "(unnamed)",
+ DisplayName: r.DisplayName.Text ?? r.BrowseName.Name ?? "(unnamed)",
NodeClass: MapNodeClass(r.NodeClass),
HasChildren: r.NodeClass == NodeClass.Object));
}
@@ -1775,8 +1775,8 @@ public class RealOpcUaClient : IOpcUaClient
// A non-empty continuation point means the server has more refs than
// were returned on this page. Surface it as an opaque Base64 token the
// caller passes back to fetch the next page via BrowseNext.
- var hasMore = continuationPoint != null && continuationPoint.Length > 0;
- var token = hasMore ? Convert.ToBase64String(continuationPoint!) : null;
+ var hasMore = !continuationPoint.IsNull && !continuationPoint.IsEmpty;
+ var token = hasMore ? Convert.ToBase64String(continuationPoint.ToArray()) : null;
return new Commons.Interfaces.Protocol.BrowseChildrenResult(children, hasMore, token);
}
@@ -1795,7 +1795,7 @@ public class RealOpcUaClient : IOpcUaClient
// BrowseNode list, so a swallowed failure simply means no type columns.
private async Task> EnrichVariableTypeInfoAsync(
ISession session,
- ReferenceDescriptionCollection refs,
+ List refs,
List children,
CancellationToken cancellationToken)
{
@@ -1804,7 +1804,7 @@ public class RealOpcUaClient : IOpcUaClient
// Build a flat read of (DataType, ValueRank, UserAccessLevel) per
// Variable child, remembering which children-list slot each triple
// maps back to so the read values can be re-stitched in order.
- var readIds = new ReadValueIdCollection();
+ var readIds = new List();
var variableSlots = new List();
for (var i = 0; i < refs.Count; i++)
{
@@ -1814,7 +1814,7 @@ public class RealOpcUaClient : IOpcUaClient
}
var nodeId = ExpandedNodeId.ToNodeId(refs[i].NodeId, session.NamespaceUris);
- if (nodeId is null)
+ if (nodeId.IsNull)
{
continue;
}
@@ -1838,7 +1838,7 @@ public class RealOpcUaClient : IOpcUaClient
cancellationToken).ConfigureAwait(false);
var results = readResponse.Results;
- if (results is null || results.Count != readIds.Count)
+ if (results.IsNull || results.Count != readIds.Count)
{
// Defensive: a non-conformant server. Skip enrichment entirely
// rather than risk mis-aligning values to the wrong nodes.
@@ -1853,19 +1853,22 @@ public class RealOpcUaClient : IOpcUaClient
var accessLevelResult = results[baseIdx + 2];
string? dataType = null;
- if (StatusCode.IsGood(dataTypeResult.StatusCode) && dataTypeResult.Value is NodeId dtNodeId)
+ if (StatusCode.IsGood(dataTypeResult.StatusCode)
+ && OpcUaValueBoundary.ToClr(dataTypeResult.WrappedValue) is NodeId dtNodeId)
{
dataType = OpcUaBuiltInTypeNames.Resolve(dtNodeId);
}
int? valueRank = null;
- if (StatusCode.IsGood(valueRankResult.StatusCode) && valueRankResult.Value is int vr)
+ if (StatusCode.IsGood(valueRankResult.StatusCode)
+ && OpcUaValueBoundary.ToClr(valueRankResult.WrappedValue) is int vr)
{
valueRank = vr;
}
bool? writable = null;
- if (StatusCode.IsGood(accessLevelResult.StatusCode) && accessLevelResult.Value is byte accessLevel)
+ if (StatusCode.IsGood(accessLevelResult.StatusCode)
+ && OpcUaValueBoundary.ToClr(accessLevelResult.WrappedValue) is byte accessLevel)
{
writable = ((AccessLevelType)accessLevel).HasFlag(AccessLevelType.CurrentWrite);
}
@@ -1949,14 +1952,14 @@ internal static class OpcUaBuiltInTypeNames
/// Friendly name for built-ins; NodeId string for unknowns; empty string for null.
public static string Resolve(NodeId? dataTypeNodeId)
{
- if (dataTypeNodeId is null)
+ if (dataTypeNodeId is not { } dataType || dataType.IsNull)
{
return string.Empty;
}
- return Names.TryGetValue(dataTypeNodeId, out var name)
+ return Names.TryGetValue(dataType, out var name)
? name
- : dataTypeNodeId.ToString();
+ : dataType.ToString() ?? string.Empty;
}
}
diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/OpcUaNodeReferenceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/OpcUaNodeReferenceTests.cs
index 40a3af30..5ec2ad8a 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/OpcUaNodeReferenceTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/OpcUaNodeReferenceTests.cs
@@ -40,7 +40,7 @@ public class OpcUaNodeReferenceTests
var nodeId = OpcUaNodeReference.Resolve("ns=2;s=pymodbus/plc/HR200", V3Table());
Assert.Equal(2, nodeId.NamespaceIndex);
- Assert.Equal("pymodbus/plc/HR200", nodeId.Identifier);
+ Assert.Equal("pymodbus/plc/HR200", nodeId.IdentifierAsString);
}
/// Verifies that the namespace URI form resolves to the server's live namespace index.
@@ -50,7 +50,7 @@ public class OpcUaNodeReferenceTests
var nodeId = OpcUaNodeReference.Resolve($"nsu={RawUri};s=pymodbus/plc/HR200", V3Table());
Assert.Equal(2, nodeId.NamespaceIndex);
- Assert.Equal("pymodbus/plc/HR200", nodeId.Identifier);
+ Assert.Equal("pymodbus/plc/HR200", nodeId.IdentifierAsString);
}
/// Verifies that the namespace URI form tracks the index across servers with different namespace tables.
@@ -159,7 +159,7 @@ public class OpcUaNodeReferenceTests
var resolved = OpcUaNodeReference.Resolve(durable, table);
Assert.Equal(3, resolved.NamespaceIndex);
- Assert.Equal("Area/Line/Equip/Speed", resolved.Identifier);
+ Assert.Equal("Area/Line/Equip/Speed", resolved.IdentifierAsString);
}
/// Verifies that a URI missing from the namespace table falls back instead of throwing.
diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/OpcUaSecurityDowngradeTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/OpcUaSecurityDowngradeTests.cs
index f3f23699..f163f862 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/OpcUaSecurityDowngradeTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/OpcUaSecurityDowngradeTests.cs
@@ -13,7 +13,7 @@ public class OpcUaSecurityDowngradeTests
await Assert.ThrowsAsync(() => RealOpcUaClient.DiscoverEndpointAsync("opc.tcp://test:4840",
MessageSecurityMode.SignAndEncrypt, _ => discoveryFails
? throw new Exception("discovery unavailable")
- : Task.FromResult(new EndpointDescriptionCollection { new("opc.tcp://test:4840") { SecurityMode = MessageSecurityMode.None } })));
+ : Task.FromResult>([new("opc.tcp://test:4840") { SecurityMode = MessageSecurityMode.None }])));
}
/// Verifies that DC4: exact requested mode is selected.
[Fact]
@@ -21,6 +21,6 @@ public class OpcUaSecurityDowngradeTests
{
var encrypted = new EndpointDescription("opc.tcp://test:4840") { SecurityMode = MessageSecurityMode.SignAndEncrypt };
Assert.Same(encrypted, await RealOpcUaClient.DiscoverEndpointAsync("opc.tcp://test:4840", MessageSecurityMode.SignAndEncrypt,
- _ => Task.FromResult(new EndpointDescriptionCollection { new("opc.tcp://test:4840"), encrypted })));
+ _ => Task.FromResult>([new("opc.tcp://test:4840"), encrypted])));
}
}
diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/OpcUaUserIdentitiesTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/OpcUaUserIdentitiesTests.cs
new file mode 100644
index 00000000..22dbef82
--- /dev/null
+++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/OpcUaUserIdentitiesTests.cs
@@ -0,0 +1,172 @@
+using System.Security.Cryptography;
+using System.Text;
+using Opc.Ua;
+using Opc.Ua.Security.Certificates;
+using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
+
+namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Adapters;
+
+///
+/// ScadaBridge#125 / lmxopcua#880: under SDK 2.0.1 a DCL UserName login must still send the
+/// password in the legacy RSA format, whatever its length. SDK 2's own handler sends a password
+/// over 64 bytes, such as a 65-character OtOpcUa API key, as an RsaEncryptedSecret with a
+/// null EncryptionAlgorithm, which an SDK 1.5.378 server rejects with BadIdentityTokenInvalid
+/// ("Unexpected encryption algorithm"): under an RSA policy it accepts only the URIs below.
+///
+public sealed class OpcUaUserIdentitiesTests
+{
+ // What SDK 1.5.378's SecurityPolicies.Decrypt demands, per policy, before it decrypts a password.
+ private const string RsaOaep = "http://www.w3.org/2001/04/xmlenc#rsa-oaep";
+ private const string RsaOaepSha256 = "http://opcfoundation.org/UA/security/rsa-oaep-sha2-256";
+
+ /// The OtOpcUa ApiKeySecret.Generate shape: otua_ + 16 hex + _ + 43 base64url = 65.
+ internal static string ProductionLengthKey()
+ {
+ var key = $"otua_{Convert.ToHexString(RandomNumberGenerator.GetBytes(8)).ToLowerInvariant()}_"
+ + Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)).TrimEnd('=').Replace('+', '-').Replace('/', '_');
+ Assert.Equal(65, key.Length);
+ return key;
+ }
+
+ private static Certificate ServerCertificate() =>
+ CertificateBuilder.Create("CN=ScadaBridge#125 receiver").SetRSAKeySize(2048).CreateForRSA();
+
+ private static async Task EncryptAsync(
+ IUserIdentityTokenHandler handler, Certificate cert, string policyUri, byte[] serverNonce)
+ {
+ var context = ServiceMessageContext.CreateEmpty(DefaultTelemetry.Create(_ => { }));
+ await handler.EncryptAsync(cert, serverNonce, policyUri, context);
+ return Assert.IsType(handler.Token);
+ }
+
+ // The unmodified SDK 2 server path: what an SDK 2 OtOpcUa server does with the token on ActivateSession.
+ private static async Task ServerDecryptAsync(
+ UserNameIdentityToken token, Certificate cert, string policyUri, byte[] serverNonce)
+ {
+ var context = ServiceMessageContext.CreateEmpty(DefaultTelemetry.Create(_ => { }));
+ var policy = SecurityPolicies.Default.GetInfo(policyUri)!;
+ var server = new UserNameIdentityTokenHandler(token);
+ await server.DecryptAsync(cert, Nonce.CreateNonce(policy, serverNonce), policyUri, context);
+ return server.DecryptedPassword;
+ }
+
+ private static byte[] ServerNonce(string policyUri) =>
+ Nonce.CreateNonce(SecurityPolicies.Default.GetInfo(policyUri)!.SecureChannelNonceLength).Data!;
+
+ /// A production-length API key travels in the format an SDK 1.5 server decrypts, and an SDK 2 server still reads it.
+ [Theory]
+ [InlineData(SecurityPolicies.Basic256Sha256, RsaOaep)]
+ [InlineData(SecurityPolicies.Aes128_Sha256_RsaOaep, RsaOaep)]
+ [InlineData(SecurityPolicies.Aes256_Sha256_RsaPss, RsaOaepSha256)]
+ public async Task ProductionLengthApiKey_IsSentInTheLegacyFormat(string policyUri, string sdk15RequiredAlgorithm)
+ {
+ using var cert = ServerCertificate();
+ var key = ProductionLengthKey();
+ var nonce = ServerNonce(policyUri);
+ var identity = OpcUaUserIdentities.Build(
+ new OpcUaUserIdentityOptions("UsernamePassword", "apikey", key, null!, null!))!;
+
+ var token = await EncryptAsync(identity.TokenHandler, cert, policyUri, nonce);
+
+ Assert.Equal("apikey", token.UserName);
+ // An SDK 1.5 server rejects any other algorithm URI (null is RsaEncryptedSecret) as BadIdentityTokenInvalid.
+ Assert.Equal(sdk15RequiredAlgorithm, token.EncryptionAlgorithm);
+ Assert.NotEqual(Encoding.UTF8.GetBytes(key), token.Password.ToArray());
+ Assert.Equal(Encoding.UTF8.GetBytes(key), await ServerDecryptAsync(token, cert, policyUri, nonce));
+ }
+
+ ///
+ /// Control: pins the SDK behaviour the handler exists for. If a later SDK stops choosing
+ /// RsaEncryptedSecret for a long password, this fails and the workaround can be revisited.
+ ///
+ [Fact]
+ public async Task Control_TheSdkDefaultHandler_SendsRsaEncryptedSecretAbove64Bytes()
+ {
+ using var cert = ServerCertificate();
+ var key = ProductionLengthKey();
+ var nonce = ServerNonce(SecurityPolicies.Basic256Sha256);
+
+ var longToken = await EncryptAsync(new UserIdentity("apikey", Encoding.UTF8.GetBytes(key)).TokenHandler,
+ cert, SecurityPolicies.Basic256Sha256, nonce);
+ Assert.Null(longToken.EncryptionAlgorithm);
+
+ var shortToken = await EncryptAsync(new UserIdentity("apikey", Encoding.UTF8.GetBytes(key[..64])).TokenHandler,
+ cert, SecurityPolicies.Basic256Sha256, nonce);
+ Assert.Equal(RsaOaep, shortToken.EncryptionAlgorithm);
+ }
+
+ /// Policy None is handed to the SDK handler unchanged.
+ [Fact]
+ public async Task PolicyNone_IsLeftToTheSdk()
+ {
+ using var cert = ServerCertificate();
+ var key = ProductionLengthKey();
+
+ var ours = await EncryptAsync(OpcUaUserIdentities.UserName("apikey", key).TokenHandler, cert,
+ SecurityPolicies.None, []);
+ var sdk = await EncryptAsync(new UserIdentity("apikey", Encoding.UTF8.GetBytes(key)).TokenHandler, cert,
+ SecurityPolicies.None, []);
+
+ Assert.Equal(sdk.EncryptionAlgorithm, ours.EncryptionAlgorithm);
+ Assert.Equal(sdk.Password.ToArray(), ours.Password.ToArray());
+ }
+
+ /// The SDK session encrypts TokenHandler.Copy(), so the copy must keep the legacy type.
+ [Fact]
+ public async Task TheSessionCopy_KeepsTheLegacyFormat()
+ {
+ using var cert = ServerCertificate();
+ var identity = OpcUaUserIdentities.UserName("apikey", ProductionLengthKey());
+
+ var copy = identity.TokenHandler.Copy();
+
+ Assert.IsType(copy);
+ Assert.NotSame(identity.TokenHandler, copy);
+ Assert.True(copy.Equals(identity.TokenHandler));
+ var token = await EncryptAsync(copy, cert, SecurityPolicies.Basic256Sha256,
+ ServerNonce(SecurityPolicies.Basic256Sha256));
+ Assert.Equal(RsaOaep, token.EncryptionAlgorithm);
+ }
+
+ /// Token-type mapping: UserName uses the legacy handler, anonymous/unknown map to no identity.
+ [Fact]
+ public void Build_MapsTheConfiguredTokenType()
+ {
+ var userName = OpcUaUserIdentities.Build(
+ new OpcUaUserIdentityOptions("usernamepassword", "apikey", "k", null!, null!))!;
+ Assert.Equal("apikey", Assert.IsType(userName.TokenHandler).UserName);
+ Assert.Equal(UserTokenType.UserName, userName.TokenType);
+
+ Assert.Null(OpcUaUserIdentities.Build(null));
+ Assert.Null(OpcUaUserIdentities.Build(new OpcUaUserIdentityOptions("Anonymous", null!, null!, null!, null!)));
+ }
+
+ /// An X509 identity still fails fast on a missing PFX, before any session is created.
+ [Fact]
+ public void X509_MissingFile_FailsBeforeConnect()
+ {
+ var missing = Path.Combine(Path.GetTempPath(), $"sb125-missing-{Guid.NewGuid():N}.pfx");
+ Assert.ThrowsAny(() => OpcUaUserIdentities.Build(
+ new OpcUaUserIdentityOptions("X509Certificate", null!, null!, missing, null!)));
+ }
+
+ /// An X509 identity is built from a PFX with a private key and carries its thumbprint.
+ [Fact]
+ public void X509_FromPfx_BuildsACertificateIdentity()
+ {
+ using var cert = CertificateBuilder.Create("CN=ScadaBridge#125 user").SetRSAKeySize(2048).CreateForRSA();
+ var path = Path.Combine(Path.GetTempPath(), $"sb125-user-{Guid.NewGuid():N}.pfx");
+ File.WriteAllBytes(path, cert.Export(System.Security.Cryptography.X509Certificates.X509ContentType.Pfx, "pw"));
+ try
+ {
+ var identity = OpcUaUserIdentities.Build(
+ new OpcUaUserIdentityOptions("X509Certificate", null!, null!, path, "pw"))!;
+ Assert.Equal(UserTokenType.Certificate, identity.TokenType);
+ Assert.IsType(identity.TokenHandler);
+ }
+ finally
+ {
+ File.Delete(path);
+ }
+ }
+}
diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/OpcUaValueBoundaryTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/OpcUaValueBoundaryTests.cs
new file mode 100644
index 00000000..63bdaf33
--- /dev/null
+++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/OpcUaValueBoundaryTests.cs
@@ -0,0 +1,95 @@
+using Opc.Ua;
+using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
+
+namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Adapters;
+
+///
+/// ScadaBridge#125: SDK 2's legacy boxing hands back SDK wrapper types where SDK 1.5 handed back
+/// CLR ones. The DCL boundary must restore the SDK 1.5 payload shapes the codec, scripts and the
+/// alarm mapper were written against, and must not alias SDK buffers.
+///
+public class OpcUaValueBoundaryTests
+{
+ [Fact]
+ public void Scalars_KeepTheirClrTypes()
+ {
+ Assert.Equal(42, OpcUaValueBoundary.ToClr(Variant.From(42)));
+ Assert.Equal(42.5, OpcUaValueBoundary.ToClr(Variant.From(42.5)));
+ Assert.Equal(true, OpcUaValueBoundary.ToClr(Variant.From(true)));
+ Assert.Equal("abc", OpcUaValueBoundary.ToClr(Variant.From("abc")));
+ Assert.Equal((ushort)500, OpcUaValueBoundary.ToClr(Variant.From((ushort)500)));
+ Assert.Null(OpcUaValueBoundary.ToClr(Variant.Null));
+ }
+
+ [Fact]
+ public void ByteString_BecomesAByteArray()
+ {
+ var value = OpcUaValueBoundary.ToClr(Variant.From(ByteString.From(new byte[] { 1, 2, 3 })));
+ Assert.Equal(new byte[] { 1, 2, 3 }, Assert.IsType(value));
+ }
+
+ [Fact]
+ public void DateTime_BecomesAUtcDateTime_AndNoTimestampIsMinValue()
+ {
+ var when = new DateTime(2026, 11, 12, 10, 30, 0, DateTimeKind.Utc);
+ var value = Assert.IsType(OpcUaValueBoundary.ToClr(Variant.From(when)));
+ Assert.Equal(when, value);
+ Assert.Equal(DateTimeKind.Utc, value.Kind);
+
+ Assert.Equal(DateTime.MinValue, OpcUaValueBoundary.ToClrTimestamp(DateTimeUtc.MinValue));
+ Assert.Equal(when, OpcUaValueBoundary.ToClrTimestamp(when));
+ }
+
+ [Fact]
+ public void Guid_BecomesASystemGuid()
+ {
+ var id = Guid.NewGuid();
+ Assert.Equal(id, Assert.IsType(OpcUaValueBoundary.ToClr(Variant.From(new Uuid(id)))));
+ }
+
+ [Fact]
+ public void Int32Array_BecomesADetachedClrArray()
+ {
+ var source = new[] { 1, 2, 3 };
+ var value = Assert.IsType(OpcUaValueBoundary.ToClr(Variant.From((ArrayOf)source)));
+ Assert.Equal(source, value);
+ Assert.NotSame(source, value);
+ }
+
+ [Fact]
+ public void ToVariant_ByteArray_IsAByteStringScalar()
+ {
+ // SDK 1.5's new Variant(byte[]) was a ByteString scalar; a Byte[] array would change the wire type.
+ var variant = OpcUaValueBoundary.ToVariant(new byte[] { 9, 8 });
+ Assert.Equal(BuiltInType.ByteString, variant.TypeInfo.BuiltInType);
+ Assert.Equal(ValueRanks.Scalar, variant.TypeInfo.ValueRank);
+ }
+
+ [Fact]
+ public void ToVariant_Int32Array_IsAOneDimensionalInt32Array_AndCopiesTheBuffer()
+ {
+ var source = new[] { 4, 5, 6 };
+ var variant = OpcUaValueBoundary.ToVariant(source);
+ source[0] = 99;
+
+ Assert.Equal(BuiltInType.Int32, variant.TypeInfo.BuiltInType);
+ Assert.Equal(ValueRanks.OneDimension, variant.TypeInfo.ValueRank);
+ Assert.Equal(new[] { 4, 5, 6 }, Assert.IsType(OpcUaValueBoundary.ToClr(variant)));
+ }
+
+ [Fact]
+ public void ToVariant_Scalars_MapToTheirBuiltInType()
+ {
+ Assert.Equal(BuiltInType.Double, OpcUaValueBoundary.ToVariant(1.5).TypeInfo.BuiltInType);
+ Assert.Equal(BuiltInType.Boolean, OpcUaValueBoundary.ToVariant(true).TypeInfo.BuiltInType);
+ Assert.Equal(BuiltInType.String, OpcUaValueBoundary.ToVariant("x").TypeInfo.BuiltInType);
+ Assert.True(OpcUaValueBoundary.ToVariant(null).IsNull);
+ }
+
+ [Fact]
+ public void ToVariant_UnsupportedType_Throws()
+ {
+ Assert.Throws(() => OpcUaValueBoundary.ToVariant(new object()));
+ Assert.Throws(() => OpcUaValueBoundary.ToVariant(new int[2, 2]));
+ }
+}
diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/OpcUaWriteCoercionTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/OpcUaWriteCoercionTests.cs
index 73fb21de..55172e24 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/OpcUaWriteCoercionTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/OpcUaWriteCoercionTests.cs
@@ -187,11 +187,12 @@ public class OpcUaWriteCoercionTests
[Fact]
public void Coerced_string_becomes_a_typed_variant()
{
- // The #58 failure: new Variant("True") is a String Variant. After coercion it is Boolean.
+ // The #58 failure: "True" written as-is is a String Variant. After coercion it is Boolean.
+ // OpcUaValueBoundary.ToVariant is what RealOpcUaClient wraps each write value with.
var coerced = OpcUaWriteCoercion.Coerce("True", BuiltInType.Boolean).Value;
- Assert.Equal(BuiltInType.String, new Variant("True").TypeInfo.BuiltInType);
- Assert.Equal(BuiltInType.Boolean, new Variant(coerced).TypeInfo.BuiltInType);
- Assert.Equal(ValueRanks.Scalar, new Variant(coerced).TypeInfo.ValueRank);
+ Assert.Equal(BuiltInType.String, OpcUaValueBoundary.ToVariant("True").TypeInfo.BuiltInType);
+ Assert.Equal(BuiltInType.Boolean, OpcUaValueBoundary.ToVariant(coerced).TypeInfo.BuiltInType);
+ Assert.Equal(ValueRanks.Scalar, OpcUaValueBoundary.ToVariant(coerced).TypeInfo.ValueRank);
}
}
diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/RealOpcUaAlarmLastStateTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/RealOpcUaAlarmLastStateTests.cs
index cc0aa8c0..cc043b6e 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/RealOpcUaAlarmLastStateTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/RealOpcUaAlarmLastStateTests.cs
@@ -31,20 +31,20 @@ public class RealOpcUaAlarmLastStateTests
private static void Emit(RealOpcUaClient client, string condition, bool active, bool acked, List sink)
{
- var fields = new VariantCollection
+ var fields = new Variant[]
{
- new(ObjectTypeIds.AlarmConditionType), // 0 EventType
- new(new NodeId("Tank01", 2)), // 1 SourceNode
- new("Tank01"), // 2 SourceName
- new(DateTime.UtcNow), // 3 Time
- new(new LocalizedText("msg")), // 4 Message
- new((ushort)500), // 5 Severity
- new(active), // 6 ActiveState/Id
- new(acked), // 7 AckedState/Id
- Variant.Null, // 8 ConfirmedState/Id
- new(false), // 9 SuppressedOrShelved
- Variant.Null, // 10 ShelvingState
- new(condition), // 11 ConditionName
+ Variant.From(ObjectTypeIds.AlarmConditionType), // 0 EventType
+ Variant.From(new NodeId("Tank01", 2)), // 1 SourceNode
+ Variant.From("Tank01"), // 2 SourceName
+ Variant.From(DateTime.UtcNow), // 3 Time
+ Variant.From(new LocalizedText("msg")), // 4 Message
+ Variant.From((ushort)500), // 5 Severity
+ Variant.From(active), // 6 ActiveState/Id
+ Variant.From(acked), // 7 AckedState/Id
+ Variant.Null, // 8 ConfirmedState/Id
+ Variant.From(false), // 9 SuppressedOrShelved
+ Variant.Null, // 10 ShelvingState
+ Variant.From(condition), // 11 ConditionName
};
Handler.Invoke(client, [Handle, "ns=2;s=Tank01", new EventFieldList { EventFields = fields },
(Action)sink.Add]);
diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/RealOpcUaClientAlarmFilterTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/RealOpcUaClientAlarmFilterTests.cs
index 8b3d4633..7e7aa309 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/RealOpcUaClientAlarmFilterTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/RealOpcUaClientAlarmFilterTests.cs
@@ -17,8 +17,8 @@ public class RealOpcUaClientAlarmFilterTests
public void BuildAlarmEventFilter_NoFilter_HasNoWhereClause()
{
var filter = RealOpcUaClient.BuildAlarmEventFilter(AlarmConditionFilter.AllowAll);
- Assert.NotEmpty(filter.SelectClauses);
- Assert.Empty(filter.WhereClause.Elements);
+ Assert.NotEmpty(filter.SelectClauses.ToList());
+ Assert.Empty(filter.WhereClause.Elements.ToList());
}
/// Verifies that known condition types produce a non-empty where clause.
@@ -28,11 +28,11 @@ public class RealOpcUaClientAlarmFilterTests
var parsed = AlarmConditionFilter.Parse("LimitAlarmType,DiscreteAlarmType");
var filter = RealOpcUaClient.BuildAlarmEventFilter(parsed);
- Assert.NotEmpty(filter.WhereClause.Elements);
+ Assert.NotEmpty(filter.WhereClause.Elements.ToList());
// Two known types → two OfType operands (OR'd when more than one).
- var ofTypeCount = filter.WhereClause.Elements.Count(e => e.FilterOperator == FilterOperator.OfType);
+ var ofTypeCount = filter.WhereClause.Elements.ToList().Count(e => e.FilterOperator == FilterOperator.OfType);
Assert.Equal(2, ofTypeCount);
- Assert.Contains(filter.WhereClause.Elements, e => e.FilterOperator == FilterOperator.Or);
+ Assert.Contains(filter.WhereClause.Elements.ToList(), e => e.FilterOperator == FilterOperator.Or);
}
/// Verifies that a single known condition type produces a single of-type clause with no OR.
@@ -42,8 +42,8 @@ public class RealOpcUaClientAlarmFilterTests
var parsed = AlarmConditionFilter.Parse("AlarmConditionType");
var filter = RealOpcUaClient.BuildAlarmEventFilter(parsed);
- Assert.Single(filter.WhereClause.Elements);
- Assert.Equal(FilterOperator.OfType, filter.WhereClause.Elements[0].FilterOperator);
+ Assert.Single(filter.WhereClause.Elements.ToList());
+ Assert.Equal(FilterOperator.OfType, filter.WhereClause.Elements.ToList()[0].FilterOperator);
}
/// Verifies that condition type matching is case-insensitive.
@@ -52,7 +52,7 @@ public class RealOpcUaClientAlarmFilterTests
{
var parsed = AlarmConditionFilter.Parse("limitalarmtype");
var filter = RealOpcUaClient.BuildAlarmEventFilter(parsed);
- Assert.Single(filter.WhereClause.Elements, e => e.FilterOperator == FilterOperator.OfType);
+ Assert.Single(filter.WhereClause.Elements.ToList(), e => e.FilterOperator == FilterOperator.OfType);
}
/// Verifies that a filter of only unknown types omits the where clause.
@@ -65,7 +65,7 @@ public class RealOpcUaClientAlarmFilterTests
// unmapped types at the server and break correctness.
var parsed = AlarmConditionFilter.Parse("MyVendorCustomAlarm,AnotherCustomThing");
var filter = RealOpcUaClient.BuildAlarmEventFilter(parsed);
- Assert.Empty(filter.WhereClause.Elements);
+ Assert.Empty(filter.WhereClause.Elements.ToList());
}
/// Verifies that a mix of known and unknown types omits the where clause.
@@ -77,7 +77,7 @@ public class RealOpcUaClientAlarmFilterTests
// and let the (authoritative) client gate do the filtering.
var parsed = AlarmConditionFilter.Parse("LimitAlarmType,MyVendorCustomAlarm");
var filter = RealOpcUaClient.BuildAlarmEventFilter(parsed);
- Assert.Empty(filter.WhereClause.Elements);
+ Assert.Empty(filter.WhereClause.Elements.ToList());
}
// ── SelectClause index alignment ───────────────────────────
diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/RealOpcUaDiscoveryBoundTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/RealOpcUaDiscoveryBoundTests.cs
index 94ca76b6..20b2e2f4 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/RealOpcUaDiscoveryBoundTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/RealOpcUaDiscoveryBoundTests.cs
@@ -78,7 +78,7 @@ public class RealOpcUaDiscoveryBoundTests
async ct =>
{
await Task.Delay(Timeout.Infinite, ct);
- return new Opc.Ua.EndpointDescriptionCollection();
+ return (IReadOnlyList)[];
},
cts.Token));
}
diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/RealOpcUaSecureConnectTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/RealOpcUaSecureConnectTests.cs
index 33ecb8a4..f0d7b48e 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/RealOpcUaSecureConnectTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/RealOpcUaSecureConnectTests.cs
@@ -216,6 +216,42 @@ public sealed class RealOpcUaSecureConnectTests : IDisposable
}
}
+ ///
+ /// ScadaBridge#125 / lmxopcua#880: a production-length (65-character) API key logs in over every
+ /// security mode, end to end through the real adapter, and a wrong key of the same length reaches
+ /// the server's key check (BadIdentityTokenRejected) rather than failing to decrypt
+ /// (BadIdentityTokenInvalid). The server here is SDK 2, which reads both password formats;
+ /// the SDK 1.5 direction is covered by the wire-compat harness and the handler's own tests.
+ ///
+ [Theory]
+ [InlineData("None")]
+ [InlineData("Sign")]
+ [InlineData("SignAndEncrypt")]
+ public async Task Connect_ProductionLengthApiKey_IsAdmitted_AndAWrongKeyReachesTheKeyCheck(string mode)
+ {
+ var key = OpcUaUserIdentitiesTests.ProductionLengthKey();
+ using var clientCert = await ProvisionClientAsync();
+ await using var server = await SecureOpcUaTestServer.StartWithApiKeyAsync(key, clientCert);
+ ClientTrusts(server.Certificate);
+
+ OpcUaConnectionOptions WithKey(string password) => Secured(mode) with
+ {
+ UserIdentity = new OpcUaUserIdentityOptions("UsernamePassword", "apikey", password, null!, null!),
+ };
+
+ await using (var client = new RealOpcUaClient(ClientOptions()))
+ {
+ await client.ConnectAsync(server.Url, WithKey(key));
+ Assert.Equal(0u, (await client.ReadValueAsync(ServerStateNode)).StatusCode);
+ }
+
+ var wrong = key[..^1] + (key[^1] == 'A' ? 'B' : 'A');
+ await using var refused = new RealOpcUaClient(ClientOptions());
+ var ex = await Assert.ThrowsAsync(
+ () => refused.ConnectAsync(server.Url, WithKey(wrong)));
+ Assert.Equal(Opc.Ua.StatusCodes.BadIdentityTokenRejected, ex.StatusCode);
+ }
+
/// None is unchanged: it connects without reading or creating the own store.
[Fact]
public async Task Connect_None_DoesNotTouchTheOwnStore()
@@ -245,7 +281,8 @@ public sealed class RealOpcUaSecureConnectTests : IDisposable
using var again = await ProvisionClientAsync();
Assert.Equal(certs[0].Thumbprint, again.Thumbprint);
Assert.Single(Directory.GetFiles(OwnCertsDir, "*.der"));
- Assert.True(Opc.Ua.X509Utils.CompareApplicationUriWithCertificate(again, "urn:localhost:SB121Client", out _),
+ using var wrapped = new Opc.Ua.Security.Certificates.Certificate(again.RawData);
+ Assert.True(Opc.Ua.X509Utils.CompareApplicationUriWithCertificate(wrapped, "urn:localhost:SB121Client", out _),
"the certificate carries the configured ApplicationUri");
}
finally
diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/RealOpcUaSnapshotScopeTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/RealOpcUaSnapshotScopeTests.cs
index c69e7b19..c6d7e0b7 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/RealOpcUaSnapshotScopeTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/RealOpcUaSnapshotScopeTests.cs
@@ -15,7 +15,7 @@ public class RealOpcUaSnapshotScopeTests
var events = new List();
var handler = typeof(RealOpcUaClient).GetMethod("HandleAlarmEvent", BindingFlags.Instance | BindingFlags.NonPublic)!;
void Emit(string handle, string source, NodeId type) => handler.Invoke(client,
- [handle, source, new EventFieldList { EventFields = new VariantCollection { new(type), new(), new(), new(), new(), new() } }, (Action)events.Add]);
+ [handle, source, new EventFieldList { EventFields = new Variant[] { Variant.From(type), Variant.Null, Variant.Null, Variant.Null, Variant.Null, Variant.Null } }, (Action)events.Add]);
Emit("a", "A", ObjectTypeIds.RefreshStartEventType);
Emit("b", "B", ObjectTypeIds.RefreshStartEventType);
Emit("a", "A", ObjectTypeIds.RefreshEndEventType);
diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/SecureOpcUaTestServer.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/SecureOpcUaTestServer.cs
index b047af5f..b932ce82 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/SecureOpcUaTestServer.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/SecureOpcUaTestServer.cs
@@ -3,7 +3,9 @@ using System.Net.Sockets;
using System.Security.Cryptography.X509Certificates;
using Opc.Ua;
using Opc.Ua.Configuration;
+using Opc.Ua.Identity;
using Opc.Ua.Server;
+using Opc.Ua.Server.Hosting;
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Adapters;
@@ -35,7 +37,22 @@ internal sealed class SecureOpcUaTestServer : IAsyncDisposable
/// Starts a server that trusts exactly .
/// Client certificates the server's trusted peer store holds at start.
/// The running server.
- public static async Task StartAsync(params X509Certificate2[] trustedClients)
+ public static Task StartAsync(params X509Certificate2[] trustedClients) =>
+ StartCoreAsync(apiKey: null, trustedClients);
+
+ ///
+ /// Starts a server that also admits the UserName apikey with exactly
+ /// as its password (ScadaBridge#125), and refuses any other password
+ /// with BadIdentityTokenRejected once it has decrypted the token. The UserName token
+ /// policy is Basic256Sha256, so the password is encrypted on every channel, None included.
+ ///
+ /// The one password the server accepts.
+ /// Client certificates the server's trusted peer store holds at start.
+ /// The running server.
+ public static Task StartWithApiKeyAsync(string apiKey, params X509Certificate2[] trustedClients) =>
+ StartCoreAsync(apiKey, trustedClients);
+
+ private static async Task StartCoreAsync(string? apiKey, X509Certificate2[] trustedClients)
{
var pkiRoot = Path.Combine(Path.GetTempPath(), "sb121-server", Guid.NewGuid().ToString("N"));
var port = FreePort();
@@ -47,7 +64,8 @@ internal sealed class SecureOpcUaTestServer : IAsyncDisposable
foreach (var c in trustedClients)
await File.WriteAllBytesAsync(Path.Combine(trustedCerts, $"{c.Thumbprint}.der"), c.RawData);
- var config = new ApplicationConfiguration
+ var telemetry = DefaultTelemetry.Create(_ => { });
+ var config = new ApplicationConfiguration(telemetry)
{
ApplicationName = "SB121TestServer",
ApplicationUri = $"urn:localhost:SB121TestServer:{port}",
@@ -70,30 +88,35 @@ internal sealed class SecureOpcUaTestServer : IAsyncDisposable
TransportQuotas = new TransportQuotas { OperationTimeout = 15_000 },
ServerConfiguration = new ServerConfiguration
{
- BaseAddresses = { url },
+ BaseAddresses = [url],
SecurityPolicies =
- {
+ [
new ServerSecurityPolicy { SecurityMode = MessageSecurityMode.None, SecurityPolicyUri = SecurityPolicies.None },
new ServerSecurityPolicy { SecurityMode = MessageSecurityMode.Sign, SecurityPolicyUri = SecurityPolicies.Basic256Sha256 },
new ServerSecurityPolicy { SecurityMode = MessageSecurityMode.SignAndEncrypt, SecurityPolicyUri = SecurityPolicies.Basic256Sha256 },
- },
- UserTokenPolicies = { new UserTokenPolicy(UserTokenType.Anonymous) },
+ ],
+ UserTokenPolicies = apiKey is null
+ ? [new UserTokenPolicy(UserTokenType.Anonymous)]
+ : [new UserTokenPolicy(UserTokenType.Anonymous),
+ new UserTokenPolicy(UserTokenType.UserName) { SecurityPolicyUri = SecurityPolicies.Basic256Sha256 }],
},
};
await config.ValidateAsync(ApplicationType.Server);
- var telemetry = DefaultTelemetry.Create(_ => { });
var app = new ApplicationInstance(config, telemetry)
{
ApplicationName = config.ApplicationName,
ApplicationType = ApplicationType.Server,
};
await app.CheckApplicationInstanceCertificatesAsync(silent: true, lifeTimeInMonths: 12);
- await app.StartAsync(new StandardServer());
+ await app.StartAsync(new TestServer(telemetry, apiKey));
- var cert = X509CertificateLoader.LoadCertificate(
- config.SecurityConfiguration.ApplicationCertificate.Certificate.RawData);
+ // SDK 2: the loaded application certificate lives in the certificate manager, not on the identifier.
+ using var entry = config.CertificateManager.AcquireApplicationCertificateByType(
+ ObjectTypeIds.RsaSha256ApplicationCertificateType)
+ ?? throw new InvalidOperationException("The test server has no application certificate.");
+ var cert = X509CertificateLoader.LoadCertificate(entry.Certificate.RawData);
return new SecureOpcUaTestServer(app, pkiRoot, url, cert);
}
@@ -105,6 +128,44 @@ internal sealed class SecureOpcUaTestServer : IAsyncDisposable
try { Directory.Delete(_pkiRoot, recursive: true); } catch { /* best-effort */ }
}
+ /// A that registers the API-key authenticator before it starts.
+ private sealed class TestServer : StandardServer
+ {
+ public TestServer(ITelemetryContext telemetry, string? apiKey) : base(telemetry)
+ {
+ if (apiKey is not null)
+ AddPreStartupTask(new RegisterAuthenticator(new ApiKeyAuthenticator(apiKey)));
+ }
+ }
+
+ private sealed class RegisterAuthenticator(IUserTokenAuthenticator authenticator) : IServerPreStartupTask
+ {
+ public ValueTask OnServerStartingAsync(IServerContext server, CancellationToken cancellationToken = default)
+ {
+ ((IServerInternal)server).IdentityRegistry.Register(authenticator);
+ return ValueTask.CompletedTask;
+ }
+ }
+
+ private sealed class ApiKeyAuthenticator(string apiKey) : IUserTokenAuthenticator
+ {
+ public UserTokenType TokenType => UserTokenType.UserName;
+
+ public string? IssuedTokenProfileUri => null;
+
+ public ValueTask AuthenticateAsync(AuthenticationContext context, CancellationToken ct = default)
+ {
+ if (context.TokenHandler is not UserNameIdentityTokenHandler userName)
+ return ValueTask.FromResult(AuthenticationResult.NotHandled);
+ var accepted = userName.UserName == "apikey"
+ && userName.DecryptedPassword is { } password
+ && System.Text.Encoding.UTF8.GetString(password) == apiKey;
+ return ValueTask.FromResult(accepted
+ ? AuthenticationResult.Accept(new UserIdentity(userName))
+ : AuthenticationResult.Reject(new ServiceResult(StatusCodes.BadIdentityTokenRejected)));
+ }
+ }
+
private static int FreePort()
{
var l = new TcpListener(IPAddress.Loopback, 0);
diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/OpcUaDataTypeNameMapTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/OpcUaDataTypeNameMapTests.cs
index 6923dbae..36d03135 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/OpcUaDataTypeNameMapTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/OpcUaDataTypeNameMapTests.cs
@@ -34,8 +34,9 @@ public class OpcUaDataTypeNameMapTests
// Look up the well-known DataType NodeId by its standard name on
// DataTypeIds (e.g. DataTypeIds.Double) so the test mirrors exactly
// the constants the helper maps against.
- var field = typeof(DataTypeIds).GetField(typeName)!;
- var nodeId = (NodeId)field.GetValue(null)!;
+ // SDK 2 (ScadaBridge#125) exposes the ids as static properties, not fields.
+ var property = typeof(DataTypeIds).GetProperty(typeName)!;
+ var nodeId = (NodeId)property.GetValue(null)!;
Assert.Equal(typeName, OpcUaBuiltInTypeNames.Resolve(nodeId));
}
diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/OpcUaStatusCodeMappingTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/OpcUaStatusCodeMappingTests.cs
index f669c0b5..bd3bbff0 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/OpcUaStatusCodeMappingTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/OpcUaStatusCodeMappingTests.cs
@@ -7,18 +7,25 @@ namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests;
/// ScadaBridge#117: quality follows the StatusCode severity bits, not "== 0".
public class OpcUaStatusCodeMappingTests
{
+ // SDK 2 (ScadaBridge#125): StatusCodes members are StatusCode structs, not constants, so the
+ // cases are a TheoryData rather than InlineData.
+ public static TheoryData Cases => new()
+ {
+ { StatusCodes.Good.Code, QualityCode.Good },
+ { StatusCodes.GoodLocalOverride.Code, QualityCode.Good },
+ { StatusCodes.GoodClamped.Code, QualityCode.Good },
+ { StatusCodes.Good.Code | 0x0480u, QualityCode.Good }, // info bits set
+ { StatusCodes.Uncertain.Code, QualityCode.Uncertain },
+ { StatusCodes.UncertainLastUsableValue.Code, QualityCode.Uncertain },
+ { StatusCodes.UncertainSensorNotAccurate.Code, QualityCode.Uncertain },
+ { StatusCodes.Bad.Code, QualityCode.Bad },
+ { StatusCodes.BadNodeIdUnknown.Code, QualityCode.Bad },
+ { StatusCodes.BadResourceUnavailable.Code, QualityCode.Bad },
+ { 0xC0000000u, QualityCode.Bad },
+ };
+
[Theory]
- [InlineData(StatusCodes.Good, QualityCode.Good)]
- [InlineData(StatusCodes.GoodLocalOverride, QualityCode.Good)]
- [InlineData(StatusCodes.GoodClamped, QualityCode.Good)]
- [InlineData(StatusCodes.Good | 0x0480u, QualityCode.Good)] // info bits set
- [InlineData(StatusCodes.Uncertain, QualityCode.Uncertain)]
- [InlineData(StatusCodes.UncertainLastUsableValue, QualityCode.Uncertain)]
- [InlineData(StatusCodes.UncertainSensorNotAccurate, QualityCode.Uncertain)]
- [InlineData(StatusCodes.Bad, QualityCode.Bad)]
- [InlineData(StatusCodes.BadNodeIdUnknown, QualityCode.Bad)]
- [InlineData(StatusCodes.BadResourceUnavailable, QualityCode.Bad)]
- [InlineData(0xC0000000u, QualityCode.Bad)]
+ [MemberData(nameof(Cases))]
public void MapStatusCode_UsesSeverityBits(uint code, QualityCode expected) =>
Assert.Equal(expected, OpcUaDataConnection.MapStatusCode(code));
}
diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/VerifyEndpointResultMappingTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/VerifyEndpointResultMappingTests.cs
index 00196a8f..7d9bc98a 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/VerifyEndpointResultMappingTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/VerifyEndpointResultMappingTests.cs
@@ -109,12 +109,12 @@ public class VerifyEndpointResultMappingTests
/// Verifies that ServiceResultException authentication status codes map to an auth-failed result.
/// The status code.
[Theory]
- [InlineData(StatusCodes.BadUserAccessDenied)]
- [InlineData(StatusCodes.BadIdentityTokenRejected)]
- [InlineData(StatusCodes.BadIdentityTokenInvalid)]
- public void ServiceResultException_AuthCodes_MapToAuthFailed(uint statusCode)
+ [InlineData(nameof(StatusCodes.BadUserAccessDenied))]
+ [InlineData(nameof(StatusCodes.BadIdentityTokenRejected))]
+ [InlineData(nameof(StatusCodes.BadIdentityTokenInvalid))]
+ public void ServiceResultException_AuthCodes_MapToAuthFailed(string statusCode)
{
- var ex = new ServiceResultException(statusCode, "access denied");
+ var ex = new ServiceResultException(Status(statusCode), "access denied");
var result = RealOpcUaClient.MapVerifyOutcome(ex, null);
@@ -126,11 +126,11 @@ public class VerifyEndpointResultMappingTests
/// Verifies that ServiceResultException connection status codes map to an unreachable result.
/// The status code.
[Theory]
- [InlineData(StatusCodes.BadConnectionRejected)]
- [InlineData(StatusCodes.BadNotConnected)]
- public void ServiceResultException_ConnectionCodes_MapToUnreachable(uint statusCode)
+ [InlineData(nameof(StatusCodes.BadConnectionRejected))]
+ [InlineData(nameof(StatusCodes.BadNotConnected))]
+ public void ServiceResultException_ConnectionCodes_MapToUnreachable(string statusCode)
{
- var ex = new ServiceResultException(statusCode, "no route");
+ var ex = new ServiceResultException(Status(statusCode), "no route");
var result = RealOpcUaClient.MapVerifyOutcome(ex, null);
@@ -138,6 +138,11 @@ public class VerifyEndpointResultMappingTests
Assert.Equal(VerifyFailureKind.Unreachable, result.FailureKind);
}
+ // SDK 2 (ScadaBridge#125): StatusCodes members are StatusCode structs, not constants, so the
+ // theories name them and resolve the value here.
+ private static StatusCode Status(string name) =>
+ (StatusCode)typeof(StatusCodes).GetField(name)!.GetValue(null)!;
+
/// Verifies that a socket exception maps to an unreachable result.
[Fact]
public void SocketException_MapsToUnreachable()