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
This commit is contained in:
Joseph Doherty
2026-11-12 11:09:42 -05:00
parent be28aa4432
commit 2ad7213046
3 changed files with 81 additions and 4 deletions
@@ -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<ApplicationConfiguration>)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);