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