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:
+40
-3
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user