fix(opcua-client): send UserName passwords in the legacy RSA format so 65-char API keys log in to SDK 1.5 servers (#880)

SDK 2.0.1's UserNameIdentityTokenHandler encrypts a password longer than 64 bytes
(a private RsaEncryptedSecretPasswordThreshold, no setting) as an RsaEncryptedSecret
with EncryptionAlgorithm=null. SDK 1.5.378 (.106 and .182) decrypts only the legacy
format under RSA policies; its SecurityPolicies.Decrypt answers a null algorithm with
BadIdentityTokenInvalid ("Unexpected encryption algorithm") before any key check.
Every production API key is 65 chars, so no SDK 2 OtOpcUa client could use one
against QA (b8b4c18f), a rollback target or an older third-party server.

LegacyRsaUserNameIdentityTokenHandler (Driver.OpcUaClient.Session, plugged in through
the public UserIdentity(IUserIdentityTokenHandler) ctor) always uses the legacy
format, RSA-OAEP(len|password|serverNonce) under the server certificate, under an RSA
policy. That is what SDK 2 sends for passwords of 64 bytes or less and what SDK 1.5
clients always send. The legacy format has no size limit (the plaintext is
length-framed over as many RSA blocks as it needs), and an SDK 2 server reads both
formats. Policy None, the ECC policies and a missing certificate go to the SDK
handler unchanged.

OpcUaUserIdentityFactory.UserName is now the one UserName identity the driver, the
AdminUI browse and Client.Shared (CLI/UI) present. Client.Shared references .Session
for it.

Tests: handler unit tests (all three RSA policies, the 1.5-required algorithm URI,
SDK 2 server-side decrypt, the session Copy() keeps the type, None unchanged, and an
SDK control pinning the 64/65-byte switch); a Client.Shared identity test; and an
SDK2<->SDK2 integration test where a 65-char key is admitted in both formats and a
wrong 65-char key reaches the key check (BadIdentityTokenRejected). The #800 Windows
gate's SDK 2 CLI 65-char case now gates against the 1.5 server too.

Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
This commit is contained in:
Joseph Doherty
2026-11-12 11:09:42 -05:00
parent 3fff439b11
commit be28aa4432
11 changed files with 400 additions and 17 deletions
@@ -16,6 +16,8 @@ internal sealed class FakeSessionFactory : ISessionFactory
public string? LastEndpointUrl { get; private set; }
/// <summary>Gets the host-name-validation flag from the last CreateSessionAsync call (#816).</summary>
public bool? LastValidateServerHostName { get; private set; }
/// <summary>Gets the user identity from the last CreateSessionAsync call (#880).</summary>
public UserIdentity? LastIdentity { get; private set; }
/// <summary>
/// Optional gate that, when set, blocks <see cref="CreateSessionAsync" /> 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.
@@ -81,6 +81,35 @@ public class OpcUaClientServiceTests : IDisposable
_configFactory.LastSettings!.ValidateServerHostName.ShouldBe(validate ?? false);
}
/// <summary>
/// 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.
/// </summary>
[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<ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.LegacyRsaUserNameIdentityTokenHandler>();
handler.UserName.ShouldBe("apikey");
}
/// <summary>Without a user name the session stays anonymous (#880 changed only the UserName path).</summary>
[Fact]
public async Task ConnectAsync_without_a_username_stays_anonymous()
{
await _service.ConnectAsync(ValidSettings(), TestContext.Current.CancellationToken);
_sessionFactory.LastIdentity.ShouldNotBeNull().TokenType.ShouldBe(UserTokenType.Anonymous);
}
/// <summary>
/// Verifies that invalid connection settings fail validation before any OPC UA session is created.
/// </summary>
@@ -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;
/// <summary>
/// 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 <c>RsaEncryptedSecret</c> (null EncryptionAlgorithm). The 1.5
/// server's <c>SecurityPolicies.Decrypt</c> answers that with <c>BadIdentityTokenInvalid</c> ("Unexpected encryption
/// algorithm"), because under an RSA policy it accepts only the algorithm URI each policy names below.
/// </summary>
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<UserNameIdentityToken> 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<UserNameIdentityToken>();
}
// The SDK 2 server path, unmodified: 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,
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");
}
/// <summary>
/// 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.
/// </summary>
[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<LegacyRsaUserNameIdentityTokenHandler>().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<LegacyRsaUserNameIdentityTokenHandler>().UserName.ShouldBe("apikey");
identity.TokenType.ShouldBe(UserTokenType.UserName);
}
}
@@ -104,6 +104,37 @@ public sealed class Sdk2TrustedChannelTests
rig.Server.CurrentInstance.SessionManager.GetSessions().ShouldAllBe(s => !s.Activated);
}
/// <summary>
/// #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).
/// </summary>
[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<ServiceResultException>(() => 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;
/// <summary>#880: a random key in the production ApiKeySecret.Generate shape (65 chars, over SDK 2's 64-byte threshold).</summary>
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<ISession> ConnectAsync(string credential, CancellationToken ct, bool untrustedClient = false, string? transportHost = null)
internal Task<ISession> 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<ISession> 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<OpcUaUserAuthResult> 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<NodeWriteOutcome> WriteAsync(string nodeId, object? value, AddressSpaceRealm realm, CancellationToken ct)