From a2e9d71824cf67b86307ae310ecc835715abf162 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 12 Nov 2026 22:02:53 -0500 Subject: [PATCH] feat(dcl): move the OPC UA client to OPC Foundation SDK 2.0.1 (#125) OPCFoundation.NetStandard.Opc.Ua.Client and the test-only .Server move 1.5.378.182 -> 2.0.1, the version OtOpcUa master runs (lmxopcua#782; #799 and #801 closed). Transitive Core/Core.Types/Types/Configuration/ Security.Certificates resolve to 2.0.1. API breaks fixed in the DCL adapter: - Built-in types are immutable structs (Variant, DataValue, NodeId, LocalizedText, StatusCode, ArrayOf). New OpcUaValueBoundary restores the SDK 1.5 CLR shapes for every read, subscription and alarm-event value (byte[], UTC DateTime with no-timestamp = MinValue, Guid, detached arrays) and builds write variants with SDK 1.5's new Variant(object) semantics (byte[] = ByteString scalar). - StatusCodes members are no longer constants: switches/patterns over them (verify-outcome mapping, connection-level classification, BadResourceUnavailable) match by value. - CertificateIdentifier no longer caches a certificate and the CertificateValidator/CertificateValidation event is gone: the configuration's CertificateManager owns trust lists and the loaded application certificate and is disposed with the session; auto-accept and the verify probe's capture-and-reject use AcceptError. - new UserIdentity(X509Certificate2) is gone: X509 user identities use X509IdentityTokenHandler with an on-demand PFX provider (still fails fast on a missing file/key). - DefaultSessionFactory takes the telemetry context; collections are ArrayOf; EventFilter select clauses and ContentFilter operands are built immutably. UserName identity (lmxopcua#880): SDK 2's default handler sends a password over 64 bytes - every 65-character OtOpcUa API key - as an RsaEncryptedSecret that SDK 1.5 servers reject with BadIdentityTokenInvalid and SDK 2 servers accept only within a 5-minute clock window (#873). The DCL now always uses LegacyRsaUserNameIdentityTokenHandler (ported from OtOpcUa be28aa44), i.e. exactly what it sent on SDK 1.5, so it logs in to both server generations during a mixed rollout. Tests: the in-process secured test server is ported to SDK 2 and can admit an API key; new tests cover the handler (mutation-checked: 5 of 7 fail with the SDK default handler), the value boundary, and a 65-char key end to end over None/Sign/SignAndEncrypt with a wrong key reaching the key check (BadIdentityTokenRejected). Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi --- Directory.Packages.props | 19 +- .../Component-DataConnectionLayer.md | 5 +- .../Actors/DataConnectionActor.cs | 28 +- .../LegacyRsaUserNameIdentityTokenHandler.cs | 144 +++++++++ .../Adapters/OpcUaApplicationIdentity.cs | 54 ++-- .../Adapters/OpcUaDataConnection.cs | 4 +- .../Adapters/OpcUaNodeReference.cs | 5 +- .../Adapters/OpcUaUserIdentities.cs | 117 ++++++++ .../Adapters/OpcUaValueBoundary.cs | 87 ++++++ .../Adapters/RealOpcUaClient.cs | 275 +++++++++--------- .../Adapters/OpcUaNodeReferenceTests.cs | 6 +- .../Adapters/OpcUaSecurityDowngradeTests.cs | 4 +- .../Adapters/OpcUaUserIdentitiesTests.cs | 172 +++++++++++ .../Adapters/OpcUaValueBoundaryTests.cs | 95 ++++++ .../Adapters/OpcUaWriteCoercionTests.cs | 9 +- .../Adapters/RealOpcUaAlarmLastStateTests.cs | 26 +- .../RealOpcUaClientAlarmFilterTests.cs | 20 +- .../Adapters/RealOpcUaDiscoveryBoundTests.cs | 2 +- .../Adapters/RealOpcUaSecureConnectTests.cs | 39 ++- .../Adapters/RealOpcUaSnapshotScopeTests.cs | 2 +- .../Adapters/SecureOpcUaTestServer.cs | 81 +++++- .../OpcUaDataTypeNameMapTests.cs | 5 +- .../OpcUaStatusCodeMappingTests.cs | 29 +- .../VerifyEndpointResultMappingTests.cs | 23 +- 24 files changed, 1003 insertions(+), 248 deletions(-) create mode 100644 src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/LegacyRsaUserNameIdentityTokenHandler.cs create mode 100644 src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/OpcUaUserIdentities.cs create mode 100644 src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/OpcUaValueBoundary.cs create mode 100644 tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/OpcUaUserIdentitiesTests.cs create mode 100644 tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/OpcUaValueBoundaryTests.cs 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()