a2e9d71824
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<T>). 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<T>; 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
173 lines
8.4 KiB
C#
173 lines
8.4 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// 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 <c>RsaEncryptedSecret</c> with a
|
|
/// null EncryptionAlgorithm, which an SDK 1.5.378 server rejects with <c>BadIdentityTokenInvalid</c>
|
|
/// ("Unexpected encryption algorithm"): under an RSA policy it accepts only the URIs below.
|
|
/// </summary>
|
|
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";
|
|
|
|
/// <summary>The OtOpcUa <c>ApiKeySecret.Generate</c> shape: otua_ + 16 hex + _ + 43 base64url = 65.</summary>
|
|
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<UserNameIdentityToken> 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<UserNameIdentityToken>(handler.Token);
|
|
}
|
|
|
|
// The unmodified SDK 2 server path: what an SDK 2 OtOpcUa server does with the token on ActivateSession.
|
|
private static async Task<byte[]?> 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!;
|
|
|
|
/// <summary>A production-length API key travels in the format an SDK 1.5 server decrypts, and an SDK 2 server still reads it.</summary>
|
|
[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));
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>Policy None is handed to the SDK handler unchanged.</summary>
|
|
[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());
|
|
}
|
|
|
|
/// <summary>The SDK session encrypts <c>TokenHandler.Copy()</c>, so the copy must keep the legacy type.</summary>
|
|
[Fact]
|
|
public async Task TheSessionCopy_KeepsTheLegacyFormat()
|
|
{
|
|
using var cert = ServerCertificate();
|
|
var identity = OpcUaUserIdentities.UserName("apikey", ProductionLengthKey());
|
|
|
|
var copy = identity.TokenHandler.Copy();
|
|
|
|
Assert.IsType<LegacyRsaUserNameIdentityTokenHandler>(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);
|
|
}
|
|
|
|
/// <summary>Token-type mapping: UserName uses the legacy handler, anonymous/unknown map to no identity.</summary>
|
|
[Fact]
|
|
public void Build_MapsTheConfiguredTokenType()
|
|
{
|
|
var userName = OpcUaUserIdentities.Build(
|
|
new OpcUaUserIdentityOptions("usernamepassword", "apikey", "k", null!, null!))!;
|
|
Assert.Equal("apikey", Assert.IsType<LegacyRsaUserNameIdentityTokenHandler>(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!)));
|
|
}
|
|
|
|
/// <summary>An X509 identity still fails fast on a missing PFX, before any session is created.</summary>
|
|
[Fact]
|
|
public void X509_MissingFile_FailsBeforeConnect()
|
|
{
|
|
var missing = Path.Combine(Path.GetTempPath(), $"sb125-missing-{Guid.NewGuid():N}.pfx");
|
|
Assert.ThrowsAny<Exception>(() => OpcUaUserIdentities.Build(
|
|
new OpcUaUserIdentityOptions("X509Certificate", null!, null!, missing, null!)));
|
|
}
|
|
|
|
/// <summary>An X509 identity is built from a PFX with a private key and carries its thumbprint.</summary>
|
|
[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<X509IdentityTokenHandler>(identity.TokenHandler);
|
|
}
|
|
finally
|
|
{
|
|
File.Delete(path);
|
|
}
|
|
}
|
|
}
|