diff --git a/docs/plans/2026-11-10-opcua-sdk-2-rollout.md b/docs/plans/2026-11-10-opcua-sdk-2-rollout.md index 0bb70b8e7..82dce4076 100644 --- a/docs/plans/2026-11-10-opcua-sdk-2-rollout.md +++ b/docs/plans/2026-11-10-opcua-sdk-2-rollout.md @@ -338,6 +338,12 @@ thresholds or count absent environments as passed. Rebind namespaces by URI. Never replay ambiguous non-idempotent writes or alarm acknowledgements. Confirm API-key writes/ack, subscriptions, alarm counts and history continuity with the same positive and negative checks used at rollout. + **Tooling against a rolled-back (SDK 1.5) node (#880):** an SDK 2 client logs in to + it with a 65-character API key only if it forces the legacy UserName password + format. The OtOpcUa CLI/UI, the OpcUaClient driver and the AdminUI browse do so from + the #880 fix onward, but SDK 2 builds older than that fix are refused with + `BadIdentityTokenInvalid`. Generic SDK 2 tools that use the SDK's default UserName + handler are refused the same way. Use SDK 1.5 tooling or a post-fix CLI. 5. Record recovery time, checks, data reconciliation and final hold/go decision. If rolling source back, revert package and application commits together; never mix old package binaries with SDK 2 application assemblies. Keep candidate evidence diff --git a/scripts/validation/opcua-windows-service/Invoke-ServiceGate.ps1 b/scripts/validation/opcua-windows-service/Invoke-ServiceGate.ps1 index f5baca88c..0e9d521a1 100644 --- a/scripts/validation/opcua-windows-service/Invoke-ServiceGate.ps1 +++ b/scripts/validation/opcua-windows-service/Invoke-ServiceGate.ps1 @@ -39,8 +39,9 @@ param( [int]$OpcUaPort = 14841, [int]$AkkaPort = 18801, [switch]$AllowNotReady, - # The server under test is the SDK 1.5 known-good build. An SDK 2 client sends a password over 64 bytes (every - # production 65-character API key) as RsaEncryptedSecret, which that server rejects; see README.md. + # The server under test is the SDK 1.5 known-good build. The SDK 2 probe's own UserName handler sends a password over + # 64 bytes (every production 65-character API key) as RsaEncryptedSecret, which that server rejects; the production + # SDK 2 CLI forces the legacy format since lmxopcua#880 and must be admitted. See README.md. [switch]$BaselineServer, [switch]$PatchedIsolation ) @@ -393,15 +394,14 @@ switch ($Phase) { Invoke-Cli 'cli-sdk1-encrypt-key-65' $LegacyCliExe $s.ApiKey 'encrypt' Invoke-Cli 'cli-sdk2-encrypt-key-54' $CliExe $s.ShortKey 'encrypt' ) - $cli65 = Invoke-Cli 'cli-sdk2-encrypt-key-65' $CliExe $s.ApiKey 'encrypt' - if (-not $BaselineServer) { $clis += $cli65 } - # Findings recorded but not gating here (pre-existing; see README.md): the advertised endpoint set, a session - # over the None endpoint, and the SDK 2 CLI's 65-character key against the SDK 1.5 server. + # lmxopcua#880: gating against both servers. A CLI built before the fix fails this against -BaselineServer. + $clis += Invoke-Cli 'cli-sdk2-encrypt-key-65' $CliExe $s.ApiKey 'encrypt' + # Findings recorded but not gating here (pre-existing; see README.md): the advertised endpoint set and a session + # over the None endpoint. $findings = @( Invoke-Probe 'endpoints-advertised' @('endpoints', $client, $Url) '' Invoke-Probe 'none-endpoint-valid-key-54' @('probe', $client, $Url, 'none', 'allow') $s.ShortKey ) - if ($BaselineServer) { $findings += $cli65 } $failed = @($cases | Where-Object { $_.exit -ne 0 }) + @($clis | Where-Object { $_.exit -ne 0 -or -not $_.good }) $leaks = @(@($cases) + @($clis) + @($findings) | Where-Object { $_.secretOccurrencesInOutput -ne 0 }) Write-Evidence "checks-$Label" ([ordered]@{ label = $Label; baselineServer = [bool]$BaselineServer; utc = (Get-Date).ToUniversalTime().ToString('o') diff --git a/scripts/validation/opcua-windows-service/README.md b/scripts/validation/opcua-windows-service/README.md index 546263027..e253c9183 100644 --- a/scripts/validation/opcua-windows-service/README.md +++ b/scripts/validation/opcua-windows-service/README.md @@ -58,9 +58,12 @@ task secret, and the evidence keeps only the redacted copies and the occurrence server the client does not trust must be refused with `BadCertificateUntrusted`. - **Credentials.** These are gating cases. The 65-character production-format key and the 54-character key must both be admitted. A wrong secret, an unknown key id, a disabled key and anonymous must all be refused. -- **Against the SDK 1.5 server** (`-BaselineServer`), the SDK 2 client's 65-character key is expected to be refused - (lmxopcua#880). An SDK 2 client sends any password over 64 bytes as `RsaEncryptedSecret`, which 1.5.378 rejects with - `BadIdentityTokenInvalid`. The 54-character key and the SDK 1.5 CLI with the 65-character key are the positive controls. +- **Against the SDK 1.5 server** (`-BaselineServer`), the probe's 65-character key is expected to be refused. The probe + uses the SDK's own UserName handler, and SDK 2.0.1 sends any password over 64 bytes as `RsaEncryptedSecret`, which + 1.5.378 rejects with `BadIdentityTokenInvalid` (lmxopcua#880). That makes the probe the SDK-default negative control. + The production SDK 2 CLI forces the legacy format since #880, so `cli-sdk2-encrypt-key-65` gates against both servers. + A CLI built before the fix fails it against the baseline; the 2026-11-12 evidence predates the fix. The 54-character + key and the SDK 1.5 CLI with the 65-character key remain the positive controls. - **CLIs.** The CLI that matches each SDK line reads `ServerStatus` over SignAndEncrypt with an API key. The CLIs auto-accept the server certificate by design, so these reads do not count as strict-peer coverage. - **ScriptWorker isolation.** `IsolationInstall`/`IsolationUninstall` run `Install-ScriptWorkerIsolation.ps1`. The script diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/OpcUaClientService.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/OpcUaClientService.cs index 8ea477560..0951babe9 100644 --- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/OpcUaClientService.cs +++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/OpcUaClientService.cs @@ -790,7 +790,9 @@ public sealed class OpcUaClientService : IOpcUaClientService var endpoint = await _endpointDiscovery.SelectEndpointAsync(config, endpointUrl, requestedMode, ct); var identity = settings.Username != null - ? new UserIdentity(settings.Username, Encoding.UTF8.GetBytes(settings.Password ?? "")) + // #880: the driver's identity factory, so a 65-char API key reaches an SDK 1.5 server in the legacy + // encrypted format instead of SDK 2's RsaEncryptedSecret (BadIdentityTokenInvalid there). + ? ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.OpcUaUserIdentityFactory.UserName(settings.Username, settings.Password ?? "") : new UserIdentity(); var sessionTimeoutMs = (uint)(settings.SessionTimeoutSeconds * 1000); diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/ZB.MOM.WW.OtOpcUa.Client.Shared.csproj b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/ZB.MOM.WW.OtOpcUa.Client.Shared.csproj index 9cbb49092..5d3026dec 100644 --- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/ZB.MOM.WW.OtOpcUa.Client.Shared.csproj +++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/ZB.MOM.WW.OtOpcUa.Client.Shared.csproj @@ -18,6 +18,11 @@ NuGet packages, no OPC UA SDK; pinned by ContractsIsDependencyLightTests), so this adds two small first-party assemblies and nothing else to the CLI/UI. --> + + diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session/LegacyRsaUserNameIdentityTokenHandler.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session/LegacyRsaUserNameIdentityTokenHandler.cs new file mode 100644 index 000000000..d9b08f738 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session/LegacyRsaUserNameIdentityTokenHandler.cs @@ -0,0 +1,137 @@ +using Opc.Ua; +using Opc.Ua.Security.Certificates; + +namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient; + +/// +/// 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). +/// +/// +/// +/// 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. +/// +/// +public 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/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session/OpcUaUserIdentityFactory.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session/OpcUaUserIdentityFactory.cs index a043e99ec..7cda87446 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session/OpcUaUserIdentityFactory.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session/OpcUaUserIdentityFactory.cs @@ -24,13 +24,33 @@ public static class OpcUaUserIdentityFactory options.AuthType switch { OpcUaAuthType.Anonymous => new UserIdentity(new AnonymousIdentityToken()), - OpcUaAuthType.Username => new UserIdentity( - options.Username ?? string.Empty, - System.Text.Encoding.UTF8.GetBytes(options.Password ?? string.Empty)), + OpcUaAuthType.Username => UserName(options.Username ?? string.Empty, options.Password ?? string.Empty), OpcUaAuthType.Certificate => FromCertificate(options), _ => new UserIdentity(new AnonymousIdentityToken()), }; + /// + /// The UserName identity every OtOpcUa OPC UA client presents: the driver, the AdminUI browse and the Client + /// CLI/UI (through Client.Shared). Under an RSA policy the password always travels in the legacy + /// encrypted format, so a 65-character API key also logs in to an SDK 1.5 server (lmxopcua#880). See + /// . + /// + /// The user name (apikey for an OtOpcUa API key). + /// The plaintext password; sent UTF-8 encoded. + /// The for session activation. + public 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); + } + } + /// /// Build a carrying a client user-authentication certificate loaded from /// . Fails fast if the path is missing, the file diff --git a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Fakes/FakeSessionFactory.cs b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Fakes/FakeSessionFactory.cs index 427f70f7e..accc04541 100644 --- a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Fakes/FakeSessionFactory.cs +++ b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Fakes/FakeSessionFactory.cs @@ -16,6 +16,8 @@ internal sealed class FakeSessionFactory : ISessionFactory public string? LastEndpointUrl { get; private set; } /// Gets the host-name-validation flag from the last CreateSessionAsync call (#816). public bool? LastValidateServerHostName { get; private set; } + /// Gets the user identity from the last CreateSessionAsync call (#880). + public UserIdentity? LastIdentity { get; private set; } /// /// Optional gate that, when set, blocks until completed. @@ -45,6 +47,7 @@ internal sealed class FakeSessionFactory : ISessionFactory CreateCallCount++; LastEndpointUrl = endpoint.EndpointUrl ?? string.Empty; LastValidateServerHostName = validateServerHostName; + LastIdentity = identity; // One-shot: the gate holds only the call that observed it, so a later call (e.g. a user's Connect while a // failover is held) runs freely. diff --git a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/OpcUaClientServiceTests.cs b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/OpcUaClientServiceTests.cs index c021b3966..0d54bd1af 100644 --- a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/OpcUaClientServiceTests.cs +++ b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/OpcUaClientServiceTests.cs @@ -81,6 +81,35 @@ public class OpcUaClientServiceTests : IDisposable _configFactory.LastSettings!.ValidateServerHostName.ShouldBe(validate ?? false); } + /// + /// Gitea #880: the CLI/UI present the driver's UserName identity, whose RSA-policy encryption is the legacy format, + /// so a 65-character API key also logs in to an SDK 1.5 server. SDK 2's own handler would send that key as an + /// RsaEncryptedSecret, which the 1.5 server rejects as BadIdentityTokenInvalid. The format itself is pinned in + /// Driver.OpcUaClient.Tests (LegacyRsaUserNameIdentityTokenHandlerTests); this pins that Client.Shared uses it. + /// + [Fact] + public async Task ConnectAsync_presents_the_legacy_format_UserName_identity() + { + var settings = ValidSettings(); + settings.Username = "apikey"; + settings.Password = "otua_" + new string('a', 16) + "_" + new string('b', 43); + + await _service.ConnectAsync(settings, TestContext.Current.CancellationToken); + + var handler = _sessionFactory.LastIdentity.ShouldNotBeNull().TokenHandler + .ShouldBeOfType(); + handler.UserName.ShouldBe("apikey"); + } + + /// Without a user name the session stays anonymous (#880 changed only the UserName path). + [Fact] + public async Task ConnectAsync_without_a_username_stays_anonymous() + { + await _service.ConnectAsync(ValidSettings(), TestContext.Current.CancellationToken); + + _sessionFactory.LastIdentity.ShouldNotBeNull().TokenType.ShouldBe(UserTokenType.Anonymous); + } + /// /// Verifies that invalid connection settings fail validation before any OPC UA session is created. /// diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/LegacyRsaUserNameIdentityTokenHandlerTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/LegacyRsaUserNameIdentityTokenHandlerTests.cs new file mode 100644 index 000000000..3d9953b18 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/LegacyRsaUserNameIdentityTokenHandlerTests.cs @@ -0,0 +1,141 @@ +using System.Security.Cryptography; +using System.Text; +using Opc.Ua; +using Opc.Ua.Security.Certificates; +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests; + +/// +/// lmxopcua#880: a production 65-character API key must reach an SDK 1.5.378 server in the legacy RSA format. +/// SDK 2.0.1 sends any password over 64 bytes as an RsaEncryptedSecret (null EncryptionAlgorithm). The 1.5 +/// server's SecurityPolicies.Decrypt answers that with BadIdentityTokenInvalid ("Unexpected encryption +/// algorithm"), because under an RSA policy it accepts only the algorithm URI each policy names below. +/// +public sealed class LegacyRsaUserNameIdentityTokenHandlerTests +{ + // What SDK 1.5.378.182's SecurityPolicies.Decrypt demands, per policy, before it will decrypt a UserName 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 ApiKeySecret.Generate shape: otua_ + 16 hex + _ + 43 base64url = 65 bytes. Random per run, never logged. + private static string ProductionLengthKey() + { + var key = $"otua_{Convert.ToHexString(RandomNumberGenerator.GetBytes(8)).ToLowerInvariant()}_" + + Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + key.Length.ShouldBe(65); + return key; + } + + private static Certificate ServerCertificate() => + CertificateBuilder.Create("CN=lmxopcua#880 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, ct: TestContext.Current.CancellationToken); + return handler.Token.ShouldBeOfType(); + } + + // The SDK 2 server path, unmodified: 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, + ct: TestContext.Current.CancellationToken); + return server.DecryptedPassword; + } + + private static byte[] ServerNonce(string policyUri) => + Nonce.CreateNonce(SecurityPolicies.Default.GetInfo(policyUri)!.SecureChannelNonceLength).Data!; + + [Theory] + [InlineData(SecurityPolicies.Basic256Sha256, RsaOaep)] + [InlineData(SecurityPolicies.Aes128_Sha256_RsaOaep, RsaOaep)] + [InlineData(SecurityPolicies.Aes256_Sha256_RsaPss, RsaOaepSha256)] + public async Task A_production_API_key_is_sent_in_the_legacy_format_an_SDK_1_5_server_decrypts( + string policyUri, string sdk15RequiredAlgorithm) + { + using var cert = ServerCertificate(); + var key = ProductionLengthKey(); + var nonce = ServerNonce(policyUri); + var identity = OpcUaUserIdentityFactory.UserName("apikey", key); + + var token = await EncryptAsync(identity.TokenHandler, cert, policyUri, nonce); + + token.UserName.ShouldBe("apikey"); + token.EncryptionAlgorithm.ShouldBe(sdk15RequiredAlgorithm, + "an SDK 1.5 server rejects any other algorithm URI (a null one is RsaEncryptedSecret) as BadIdentityTokenInvalid"); + token.Password.ToArray().ShouldNotBe(Encoding.UTF8.GetBytes(key), "the key must never travel unencrypted"); + (await ServerDecryptAsync(token, cert, policyUri, nonce)).ShouldBe(Encoding.UTF8.GetBytes(key), + "an SDK 2 server must still read the legacy format"); + } + + /// + /// Control: pins the SDK behaviour this type exists for. If a future SDK stops choosing RsaEncryptedSecret for a + /// long password (or makes the threshold configurable), this fails and the workaround can be revisited. + /// + [Fact] + public async Task Control_the_SDK_default_handler_sends_RsaEncryptedSecret_above_64_bytes() + { + 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); + longToken.EncryptionAlgorithm.ShouldBeNull("65 bytes: SDK 2.0.1's RsaEncryptedSecret, unreadable by SDK 1.5"); + + var shortToken = await EncryptAsync(new UserIdentity("apikey", Encoding.UTF8.GetBytes(key[..64])).TokenHandler, + cert, SecurityPolicies.Basic256Sha256, nonce); + shortToken.EncryptionAlgorithm.ShouldBe(RsaOaep, "64 bytes: still the legacy format"); + } + + [Fact] + public async Task Policy_None_is_left_to_the_SDK_unchanged() + { + using var cert = ServerCertificate(); + var key = ProductionLengthKey(); + + var ours = await EncryptAsync(OpcUaUserIdentityFactory.UserName("apikey", key).TokenHandler, cert, + SecurityPolicies.None, []); + var sdk = await EncryptAsync(new UserIdentity("apikey", Encoding.UTF8.GetBytes(key)).TokenHandler, cert, + SecurityPolicies.None, []); + + ours.EncryptionAlgorithm.ShouldBe(sdk.EncryptionAlgorithm); + ours.Password.ToArray().ShouldBe(sdk.Password.ToArray()); + } + + [Fact] + public async Task The_session_copy_keeps_the_legacy_format() + { + // The SDK session encrypts identity.TokenHandler.Copy(), never the instance it was given. + using var cert = ServerCertificate(); + var key = ProductionLengthKey(); + var identity = OpcUaUserIdentityFactory.UserName("apikey", key); + + var copy = identity.TokenHandler.Copy(); + + copy.ShouldBeOfType().ShouldNotBeSameAs(identity.TokenHandler); + copy.Equals(identity.TokenHandler).ShouldBeTrue(); + (await EncryptAsync(copy, cert, SecurityPolicies.Basic256Sha256, ServerNonce(SecurityPolicies.Basic256Sha256))) + .EncryptionAlgorithm.ShouldBe(RsaOaep); + } + + [Fact] + public void The_driver_Username_identity_uses_the_legacy_handler() + { + var identity = OpcUaUserIdentityFactory.Build(new OpcUaClientDriverOptions + { + AuthType = OpcUaAuthType.Username, Username = "apikey", Password = ProductionLengthKey(), + }); + + identity.TokenHandler.ShouldBeOfType().UserName.ShouldBe("apikey"); + identity.TokenType.ShouldBe(UserTokenType.UserName); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/Sdk2TrustedChannelTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/Sdk2TrustedChannelTests.cs index 47ed176eb..961209995 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/Sdk2TrustedChannelTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/Sdk2TrustedChannelTests.cs @@ -104,6 +104,37 @@ public sealed class Sdk2TrustedChannelTests rig.Server.CurrentInstance.SessionManager.GetSessions().ShouldAllBe(s => !s.Activated); } + /// + /// #880: an SDK 2 server reads a production-length (65-char) API key in both UserName password formats, the + /// legacy one OtOpcUa's clients now always send under RSA policies (so SDK 1.5 servers can read it too) and + /// SDK 2's default RsaEncryptedSecret. A wrong key in the legacy format reaches the real key check + /// (BadIdentityTokenRejected, not a format failure), so the token is decrypted, not just tolerated. + /// The SDK 1.5 server half is the separate-process matrix (scripts/validation/opcua-sdk-compatibility). + /// + [Theory] + [InlineData(OpcUaSecurityProfile.Basic256Sha256Sign)] + [InlineData(OpcUaSecurityProfile.Basic256Sha256SignAndEncrypt)] + public async Task A_production_length_API_key_is_admitted_in_either_password_format(OpcUaSecurityProfile profile) + { + var ct = TestContext.Current.CancellationToken; + await using var rig = await TrustedRig.StartAsync(profile, ct); + TrustedRig.LongKey.Length.ShouldBe(65); + + using (var legacy = await rig.ConnectAsync(ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.OpcUaUserIdentityFactory + .UserName("apikey", TrustedRig.LongKey), ct)) + (await legacy.ReadValueAsync(rig.Node(legacy), ct)).StatusCode.ShouldBe(StatusCodes.Good); + using (var sdkDefault = await rig.ConnectAsync( + new UserIdentity("apikey", Encoding.UTF8.GetBytes(TrustedRig.LongKey)), ct)) + (await sdkDefault.ReadValueAsync(rig.Node(sdkDefault), ct)).StatusCode.ShouldBe(StatusCodes.Good); + + var before = rig.AuthCalls; + var wrong = TrustedRig.LongKey[..^1] + (TrustedRig.LongKey[^1] == 'A' ? 'B' : 'A'); + var error = await Should.ThrowAsync(() => rig.ConnectAsync( + ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.OpcUaUserIdentityFactory.UserName("apikey", wrong), ct)); + error.StatusCode.ShouldBe(StatusCodes.BadIdentityTokenRejected); + rig.AuthCalls.ShouldBe(before + 1, "the wrong key must reach the authenticator, i.e. decrypt cleanly"); + } + [Fact] public async Task An_untrusted_client_is_rejected_before_application_authentication() { @@ -161,6 +192,9 @@ public sealed class Sdk2TrustedChannelTests internal ConcurrentQueue<(LogLevel Level, string Category, string Text)> SdkLog { get; } = new(); private readonly ILoggerFactory _sdkLoggerFactory; private readonly ITelemetryContext _sdkTelemetry; + /// #880: a random key in the production ApiKeySecret.Generate shape (65 chars, over SDK 2's 64-byte threshold). + internal static readonly string LongKey = $"otua_{Convert.ToHexString(System.Security.Cryptography.RandomNumberGenerator.GetBytes(8)).ToLowerInvariant()}_" + + Convert.ToBase64String(System.Security.Cryptography.RandomNumberGenerator.GetBytes(32)).TrimEnd('=').Replace('+', '-').Replace('/', '_'); internal int Writes => Volatile.Read(ref _writes); internal int AuthCalls => Volatile.Read(ref _authCalls); private string ServerPki => System.IO.Path.Combine(_root, "server"); @@ -268,7 +302,10 @@ public sealed class Sdk2TrustedChannelTests if (_applications.TryRemove(config, out var app)) await app.DisposeAsync(); } - internal async Task ConnectAsync(string credential, CancellationToken ct, bool untrustedClient = false, string? transportHost = null) + internal Task ConnectAsync(string credential, CancellationToken ct, bool untrustedClient = false, string? transportHost = null) => + ConnectAsync(new UserIdentity("apikey", Encoding.UTF8.GetBytes(credential)), ct, untrustedClient, transportHost); + + internal async Task ConnectAsync(UserIdentity identity, CancellationToken ct, bool untrustedClient = false, string? transportHost = null) { var pki = untrustedClient ? System.IO.Path.Combine(_root, $"untrusted-{Guid.NewGuid():N}") : ClientPki; var config = await ConfigurationAsync(pki, ct); @@ -285,7 +322,7 @@ public sealed class Sdk2TrustedChannelTests if (transportHost is not null) endpoint.EndpointUrl = $"opc.tcp://{transportHost}:{_port}/OtOpcUa"; return await new DefaultSessionFactory(telemetry).CreateAsync(config, new ConfiguredEndpoint(null, endpoint, EndpointConfiguration.Create(config)), false, true, - "Sdk2TrustedContract", 60_000, new UserIdentity("apikey", Encoding.UTF8.GetBytes(credential)), default, ct); + "Sdk2TrustedContract", 60_000, identity, default, ct); } internal NodeId Node(ISession session) => new(PathId, (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri)); @@ -317,7 +354,7 @@ public sealed class Sdk2TrustedChannelTests public Task AuthenticateUserNameAsync(string username, string password, CancellationToken ct) { Interlocked.Increment(ref _authCalls); - return Task.FromResult(username == "apikey" && (password is "contract-key" or "contract-key-ipv4" or "contract-key-ipv6") + return Task.FromResult(username == "apikey" && (password is "contract-key" or "contract-key-ipv4" or "contract-key-ipv6" || password == LongKey) ? OpcUaUserAuthResult.AllowKey(Principal, [], KeyId) : OpcUaUserAuthResult.Deny("Invalid credential.")); } public Task WriteAsync(string nodeId, object? value, AddressSpaceRealm realm, CancellationToken ct)