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);
@@ -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.
@@ -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'})