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
@@ -790,7 +790,9 @@ public sealed class OpcUaClientService : IOpcUaClientService
var endpoint = await _endpointDiscovery.SelectEndpointAsync(config, endpointUrl, requestedMode, ct);
var identity = settings.Username != null
? new UserIdentity(settings.Username, Encoding.UTF8.GetBytes(settings.Password ?? ""))
// #880: the driver's identity factory, so a 65-char API key reaches an SDK 1.5 server in the legacy
// encrypted format instead of SDK 2's RsaEncryptedSecret (BadIdentityTokenInvalid there).
? ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.OpcUaUserIdentityFactory.UserName(settings.Username, settings.Password ?? "")
: new UserIdentity();
var sessionTimeoutMs = (uint)(settings.SessionTimeoutSeconds * 1000);
@@ -18,6 +18,11 @@
NuGet packages, no OPC UA SDK; pinned by ContractsIsDependencyLightTests), so this adds two small
first-party assemblies and nothing else to the CLI/UI. -->
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts.csproj"/>
<!-- #880: OpcUaUserIdentityFactory.UserName, the one UserName identity the driver, the AdminUI browse and the
CLI/UI all present (legacy RSA password encryption, so a 65-char API key logs in to SDK 1.5 servers).
.Session is the thin SDK-typed leaf; beyond the OPC UA client SDK this already carries, it adds only
ZB.MOM.WW.Secrets.Abstractions and Core.Abstractions (already reached through .Contracts). -->
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session.csproj"/>
</ItemGroup>
<ItemGroup>