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); } }