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