From 2ad72130469d65eec09d7edae48cf56cc6e860fc Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 12 Nov 2026 11:09:42 -0500 Subject: [PATCH] test(validation): SDK compatibility matrix uses the production 65-char API key, adds #880 format cases The cross-version matrix (#782) used a 55-char 'otua_matrix_' key, which stayed under SDK 2's 64-byte RsaEncryptedSecret threshold and hid #880. It now uses the ApiKeySecret.Generate shape (65 chars, random per run, never recorded). Every Shared leg adds production-length-API-key-wrong-secret-reaches-key-check, which must get BadIdentityTokenRejected rather than a format failure. SDK 2 clients also add sdk2-default-RsaEncryptedSecret-control, the negative control: the SDK's default handler must be refused with BadIdentityTokenInvalid by a 1.x server and admitted by an SDK 2 server. The runner passes the server SDK version from ready.json. Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi --- .../opcua-sdk-compatibility/ProcessHost.cs | 63 ++++++++++++++++++- .../opcua-sdk-compatibility/README.md | 13 ++++ .../validation/opcua-sdk-compatibility/run.py | 9 ++- 3 files changed, 81 insertions(+), 4 deletions(-) diff --git a/scripts/validation/opcua-sdk-compatibility/ProcessHost.cs b/scripts/validation/opcua-sdk-compatibility/ProcessHost.cs index 505717a97..290b5a359 100644 --- a/scripts/validation/opcua-sdk-compatibility/ProcessHost.cs +++ b/scripts/validation/opcua-sdk-compatibility/ProcessHost.cs @@ -93,7 +93,7 @@ internal static class ProcessHost case "prepare-driver": await PrepareDriver(); return 0; case "driver": await Driver(args[2]); break; case "server": await Serve(); return 0; - case "client": await Client(args[2]); break; + case "client": await Client(args[2], args.Length > 3 ? args[3] : null); break; case "performance": await Performance(args[2]); break; default: throw new ArgumentException("Expected prepare/server/client"); } @@ -162,10 +162,69 @@ internal static class ProcessHost using var process = System.Diagnostics.Process.GetCurrentProcess(); File.WriteAllText(Path.Combine(Root, "server-resources.json"), JsonSerializer.Serialize(new { process.WorkingSet64, process.PrivateMemorySize64, process.HandleCount, threads=process.Threads.Count, cpuMilliseconds=process.TotalProcessorTime.TotalMilliseconds, allocatedBytes=GC.GetTotalAllocatedBytes(), collections=new[]{GC.CollectionCount(0),GC.CollectionCount(1),GC.CollectionCount(2)} }, new JsonSerializerOptions {WriteIndented=true})); } - static async Task Client(string endpoint) + // #880: the first ServiceResultException status in a failed connect's exception chain (null = no OPC UA status). + static StatusCode? FailureStatus(Exception e) + { + for (Exception? x = e; x is not null; x = x.InnerException) + { + if (x is ServiceResultException sre) return sre.StatusCode; + if (x is AggregateException ae && ae.InnerExceptions.Count > 0) return FailureStatus(ae.InnerExceptions[0]); + } + return null; + } + // A wrong secret of the production length: same key id, one changed character, never logged. + static string WrongSecret => Password[..^1] + (Password[^1] == 'A' ? 'B' : 'A'); + static async Task ApiKeyFormatCases(string endpoint, string? serverSdk) + { + Observations.Add(new { apiKeyLength = Password.Length, serverSdk, note = "ApiKeySecret.Generate shape; SDK 2.0.1 sends a UserName password over 64 bytes as RsaEncryptedSecret unless the client forces the legacy format (#880)" }); + // The production client (Shared, i.e. the CLI/UI) with a wrong key of the same length must reach the server's + // real key check: BadIdentityTokenRejected. BadIdentityTokenInvalid means the server could not even decrypt + // the token: the #880 format failure. + await Case("production-length-API-key-wrong-secret-reaches-key-check", async () => + { + using var probe = new OpcUaClientService(); + var settings = Settings(endpoint); settings.Password = WrongSecret; + Exception? failure = null; + try { await probe.ConnectAsync(settings, Ct); } catch (Exception e) { failure = e; } + Require(failure is not null, "A wrong key was admitted"); + var status = FailureStatus(failure!); + Observations.Add(new { wrongSecretStatus = status?.ToString() }); + Require(status == StatusCodes.BadIdentityTokenRejected, "Expected BadIdentityTokenRejected (key check), got " + (status?.ToString() ?? failure!.GetType().Name)); + }); +#if SDK2 + // Negative control: the SDK's own UserName handler, same valid key. Against an SDK 1.x server this must fail on + // format (BadIdentityTokenInvalid); this is the bug the production clients avoid. Against SDK 2 it is admitted. + await Case("sdk2-default-RsaEncryptedSecret-control", async () => + { + var serverIsSdk1 = serverSdk?.StartsWith("1.", StringComparison.Ordinal) ?? throw new InvalidOperationException("Server SDK version not supplied"); + var type = typeof(OpcUaClientService).Assembly.GetType("ZB.MOM.WW.OtOpcUa.Client.Shared.Adapters.DefaultApplicationConfigurationFactory")!; + var config = await (Task)type.GetMethod("CreateAsync")!.Invoke(Activator.CreateInstance(type, true)!, [Settings(endpoint), Ct])!; + try + { + var telemetry = DefaultTelemetry.Create(_ => { }); + var selected = await CoreClientUtils.SelectEndpointAsync(config, endpoint, true, telemetry, Ct); + Require(selected.SecurityMode == MessageSecurityMode.SignAndEncrypt, "Control did not negotiate SignAndEncrypt"); + StatusCode? status = null; + try + { + using var session = await new DefaultSessionFactory(telemetry).CreateAsync(config, new ConfiguredEndpoint(null, selected, EndpointConfiguration.Create(config)), + false, false, "Matrix880Control", 60_000, new UserIdentity("apikey", System.Text.Encoding.UTF8.GetBytes(Password)), default, Ct); + await session.CloseAsync(Ct); + } + catch (Exception e) { status = FailureStatus(e) ?? StatusCodes.Bad; } + Observations.Add(new { sdkDefaultFormatStatus = status?.ToString() ?? "Good", serverSdk }); + if (serverIsSdk1) Require(status == StatusCodes.BadIdentityTokenInvalid, "Expected the SDK 1.x server to reject RsaEncryptedSecret as BadIdentityTokenInvalid, got " + (status?.ToString() ?? "admitted")); + else Require(status is null, "SDK 2 server refused the SDK-default format: " + status); + } + finally { (config.CertificateManager as IDisposable)?.Dispose(); } + }); +#endif + } + static async Task Client(string endpoint, string? serverSdk) { using var client = new OpcUaClientService(); await Case("trusted-SignAndEncrypt-API-key", async () => { var info = await client.ConnectAsync(Settings(endpoint), Ct); Require(info.SecurityMode == "SignAndEncrypt" && info.SecurityPolicyUri == SecurityPolicies.Basic256Sha256, "Unexpected negotiated policy"); Observations.Add(new { info.SecurityMode, info.SecurityPolicyUri }); }); + await ApiKeyFormatCases(endpoint, serverSdk); if (!client.IsConnected) return; var namespaces = (string[])Clr(await client.ReadValueAsync(VariableIds.Server_NamespaceArray, Ct))!; var raw = (ushort)Array.IndexOf(namespaces, RawUri); var uns = (ushort)Array.IndexOf(namespaces, UnsUri); diff --git a/scripts/validation/opcua-sdk-compatibility/README.md b/scripts/validation/opcua-sdk-compatibility/README.md index 6b3ea2eaa..1850b5b4b 100644 --- a/scripts/validation/opcua-sdk-compatibility/README.md +++ b/scripts/validation/opcua-sdk-compatibility/README.md @@ -39,6 +39,19 @@ random, API-key-shaped credential from the process environment; production ACL, write and condition-method checks use the resulting key identity. This fixture does not replace cryptographic API-key-authenticator coverage. +The credential has the production `ApiKeySecret.Generate` shape and length, +65 characters (lmxopcua#880). The 55-character key used before then stayed +under SDK 2's 64-byte threshold, so it hid a real failure. SDK 2.0.1's own +UserName handler sends a longer password as an `RsaEncryptedSecret`, and an +SDK 1.5 server rejects that with `BadIdentityTokenInvalid`. The production +clients (Shared/CLI and the driver) force the legacy RSA format. Every Shared +leg adds two cases. The first sends a wrong secret of the same length, which +must reach the key check (`BadIdentityTokenRejected`, not a format failure). +The second runs only on an SDK 2 client and is the negative control: the +SDK's default handler with the valid key must be refused with +`BadIdentityTokenInvalid` by a 1.x server and admitted by an SDK 2 server. +The Shared client passes the server's SDK version from `ready.json` for it. + The driver and actual CLI run in a disposable `--network none` Linux container, alongside a separate baseline or candidate server process. PKI lives only in the mounted task directory; no host profile PKI or fleet endpoint is touched. diff --git a/scripts/validation/opcua-sdk-compatibility/run.py b/scripts/validation/opcua-sdk-compatibility/run.py index 5a32f1acc..e2f480395 100644 --- a/scripts/validation/opcua-sdk-compatibility/run.py +++ b/scripts/validation/opcua-sdk-compatibility/run.py @@ -42,10 +42,15 @@ def main(): 'sdkPackages': str(root / 'Directory.Packages.props')} source_info['scope'] = 'Loopback application contract and 3 warmed 50-operation rounds per scalar/Int32[3] read/write kind; shared development host, no full load/reconnect/failover/backend/soak performance claim.' source_info['dateUtc'] = time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()) + source_info['apiKey'] = 'random per run, production ApiKeySecret.Generate shape, 65 chars (#880); value never recorded' write_json(args.output / 'source-info.json', source_info) commands = [] matrix = [] - env = dict(os.environ, OPCUA_MATRIX_PASSWORD='otua_matrix_' + secrets.token_urlsafe(32)) + # The production ApiKeySecret.Generate shape: otua_ + 16 hex + _ + 43 base64url = 65 chars. The 55-char key used + # before #880 stayed under SDK 2's 64-byte RsaEncryptedSecret threshold and so hid that SDK 2 clients could not log + # in to SDK 1.5 servers with a real key. + env = dict(os.environ, OPCUA_MATRIX_PASSWORD='otua_' + secrets.token_hex(8) + '_' + secrets.token_urlsafe(32)) + assert len(env['OPCUA_MATRIX_PASSWORD']) == 65 env.pop('OPCUA_MATRIX_CLIENT_PKI', None) env.pop('OPCUA_MATRIX_SERVER_SECURITY', None) @@ -128,7 +133,7 @@ def main(): time.sleep(.1) ready = json.loads((server_root / 'ready.json').read_text()) certificates(server_root / 'server-pki/own/certs', client_root / 'client-pki/trusted/certs') - status = run(['dotnet', client, 'client', client_root, ready['endpoint']], leg / 'shared.log') + status = run(['dotnet', client, 'client', client_root, ready['endpoint'], ready['sdk']], leg / 'shared.log') result = json.loads((client_root / 'result.json').read_text()) history = [json.loads(x) for x in (server_root / 'history-requests.jsonl').read_text().splitlines()] if (server_root / 'history-requests.jsonl').exists() else [] result.update({'name': name, 'server': ready, 'status': 'passed' if status == 0 else 'failed', 'historyBackendRequests': history, 'certificateValidation': 'explicit trusted peers; server and Shared client autoaccept disabled', 'authentication': 'ephemeral API-key-shaped credential, exact fixture authenticator; real authority/ACL/method gates'})