diff --git a/scripts/validation/opcua-sdk-compatibility/ProcessHost.cs b/scripts/validation/opcua-sdk-compatibility/ProcessHost.cs index 22a097f55..505717a97 100644 --- a/scripts/validation/opcua-sdk-compatibility/ProcessHost.cs +++ b/scripts/validation/opcua-sdk-compatibility/ProcessHost.cs @@ -112,8 +112,12 @@ internal static class ProcessHost PkiStoreRoot = Path.Combine(Root, "server-pki"), AllowAnonymous = false, AutoAcceptUntrustedClientCertificates = false, - EnabledSecurityProfiles = Environment.GetEnvironmentVariable("OPCUA_MATRIX_SERVER_SECURITY") == "none" - ? [OpcUaSecurityProfile.None] : [OpcUaSecurityProfile.Basic256Sha256SignAndEncrypt] + EnabledSecurityProfiles = Environment.GetEnvironmentVariable("OPCUA_MATRIX_SERVER_SECURITY") switch + { + "none" => [OpcUaSecurityProfile.None], + "sign" => [OpcUaSecurityProfile.Basic256Sha256Sign], + _ => [OpcUaSecurityProfile.Basic256Sha256SignAndEncrypt], + } }; using (var listener = new TcpListener(IPAddress.Loopback, 0)) { listener.Start(); options.OpcUaPort = ((IPEndPoint)listener.LocalEndpoint).Port; } var server = new OtOpcUaSdkServer(); @@ -124,6 +128,8 @@ internal static class ProcessHost authority.Apply("matrix", "rev1", [new ApiKey { ApiKeyId = "matrix", Name = "matrix", SecretHash = "fixture", Principal = "matrix", Enabled = true, CreatedUtc = DateTime.UtcNow.AddDays(-1) }], [new NodeAcl { NodeAclId = "all", ClusterId = "matrix", Principal = "matrix", ScopeKind = NodeAclScopeKind.Cluster, PermissionFlags = (NodePermissions)int.MaxValue }], new Dictionary>()); nm.DataPlaneAuthority = authority; + // OPCUA_MATRIX_ACL=off mirrors the shipped default (Security:Acl:Enabled=false): no node authorizer. + if (Environment.GetEnvironmentVariable("OPCUA_MATRIX_ACL") != "off") nm.Authorizer = new DataPlaneAuthorizer(authority, new TriePermissionEvaluator(authority.Tries), true, NullLogger.Instance, TimeProvider.System); var history = new Source(Root); nm.HistorianDataSource = history; sink.EnsureFolder(Folder, null, "dev", AddressSpaceRealm.Raw); diff --git a/scripts/validation/scadabridge-sdk-compatibility/Program.cs b/scripts/validation/scadabridge-sdk-compatibility/Program.cs index aa1194329..2d1c81a84 100644 --- a/scripts/validation/scadabridge-sdk-compatibility/Program.cs +++ b/scripts/validation/scadabridge-sdk-compatibility/Program.cs @@ -10,23 +10,45 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; using ZB.MOM.WW.ScadaBridge.DataConnectionLayer; using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters; -if (args.Length != 3) throw new ArgumentException("Usage: Probe "); +// ScadaBridge#121: the consumer now owns a configurable application identity. "prepare" provisions it +// through the consumer's own EnsureApplicationCertificateAsync (the same reuse/creation path a secured +// connect uses) and exports only the PUBLIC DER, which the runner places in the server's trusted store. +static OpcUaGlobalOptions IdentityOptions(string pki) => new() +{ + ApplicationName = "ScadaBridge-SDK2-compatibility", + ApplicationUri = "urn:scadabridge-sdk2-compatibility:ScadaBridge-DCL", + ApplicationCertificateSubject = "CN=ScadaBridge-SDK2-compatibility", + OwnCertificateStorePath = Path.Combine(pki, "own"), + AutoCreateApplicationCertificate = true, + TrustedIssuerStorePath = Path.Combine(pki, "issuers"), + TrustedPeerStorePath = Path.Combine(pki, "trusted"), + RejectedCertificateStorePath = Path.Combine(pki, "rejected"), +}; +if (args.Length == 3 && args[0] == "prepare") +{ + var pkiRoot = Path.GetFullPath(args[1]); + if (Directory.Exists(pkiRoot)) throw new IOException("Refusing to reuse a PKI directory."); + using var publicCert = await OpcUaApplicationIdentity.EnsureApplicationCertificateAsync(IdentityOptions(pkiRoot), NullLogger.Instance); + Require(!publicCert.HasPrivateKey, "Exported identity must be public-only"); + File.WriteAllBytes(Path.GetFullPath(args[2]), publicCert.RawData); + Console.WriteLine($"Consumer identity {publicCert.Thumbprint} {publicCert.Subject}"); + return 0; +} +if (args.Length != 3) throw new ArgumentException("Usage: Probe | Probe prepare "); var endpoint = args[0]; var mode = Enum.Parse(args[1]); var root = Path.GetFullPath(args[2]); if (Directory.Exists(root)) throw new IOException("Refusing to reuse an evidence/PKI directory."); Directory.CreateDirectory(root); +static void Require(bool value, string reason) { if (!value) throw new InvalidOperationException(reason); } using var deadline = new CancellationTokenSource(TimeSpan.FromMinutes(2)); var ct = deadline.Token; var cases = new List(); var failures = 0; -var options = new OpcUaGlobalOptions -{ - ApplicationName = "ScadaBridge-SDK2-compatibility", - TrustedIssuerStorePath = Path.Combine(root, "pki", "issuers"), - TrustedPeerStorePath = Path.Combine(root, "pki", "trusted"), - RejectedCertificateStorePath = Path.Combine(root, "pki", "rejected"), -}; +// A prepared identity (SCADABRIDGE_PROBE_PKI) is reused; otherwise a fresh task-local store. +var pki = Environment.GetEnvironmentVariable("SCADABRIDGE_PROBE_PKI") is { Length: > 0 } prepared ? Path.GetFullPath(prepared) : Path.Combine(root, "pki"); +var options = IdentityOptions(pki); +var ownBefore = Directory.Exists(Path.Combine(pki, "own", "certs")) ? Directory.GetFiles(Path.Combine(pki, "own", "certs")).Order().ToArray() : []; // Populate only this probe's trust list; never change the consumer's existing stores. var trustSource = Environment.GetEnvironmentVariable("SCADABRIDGE_PROBE_SERVER_CERTS"); if (!string.IsNullOrEmpty(trustSource)) @@ -47,7 +69,6 @@ var config = new OpcUaEndpointConfig Password = Environment.GetEnvironmentVariable("OPCUA_MATRIX_PASSWORD") ?? throw new InvalidOperationException("Missing task credential"), }, }; -static void Require(bool value, string reason) { if (!value) throw new InvalidOperationException(reason); } async Task Check(string name, Func action) { try { await action(); cases.Add(new { name, status = "passed" }); } @@ -122,6 +143,9 @@ var result = new sdk = FileVersionInfo.GetVersionInfo(sdkAssembly.Location).ProductVersion, process = Environment.ProcessId, mode = mode.ToString(), autoAccept = false, endpoint, cases, failures, + applicationUri = options.ApplicationUri, + ownCertificatesBefore = ownBefore.Select(Path.GetFileName), + ownCertificatesAfter = Directory.Exists(Path.Combine(pki, "own", "certs")) ? Directory.GetFiles(Path.Combine(pki, "own", "certs")).Order().Select(Path.GetFileName).ToArray() : [], notImplementedByConsumer = new[] { "HistoryRead/CP", "Part9 acknowledgement methods" }, }; File.WriteAllText(Path.Combine(root, "result.json"), JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true })); diff --git a/scripts/validation/scadabridge-sdk-compatibility/run.py b/scripts/validation/scadabridge-sdk-compatibility/run.py index 86ac0c0b8..fc6cac63f 100644 --- a/scripts/validation/scadabridge-sdk-compatibility/run.py +++ b/scripts/validation/scadabridge-sdk-compatibility/run.py @@ -28,19 +28,44 @@ def main(): for name, path in artifacts.items()}, "legs": [], } + # ScadaBridge#121: secure legs provision the consumer identity first, then exchange PUBLIC certs + # both ways. Each secure mode also runs two negative controls that must fail closed: + # untrusted-client - the server's trusted store does not hold the consumer certificate + # untrusted-server - the consumer's trusted store does not hold the server certificate + legs = [(m, "trusted") for m in ("None", "Sign", "SignAndEncrypt")] + legs += [(m, n) for m in ("Sign", "SignAndEncrypt") for n in ("untrusted-client", "untrusted-server")] for version in ("baseline", "candidate"): - for mode in ("None", "SignAndEncrypt"): - leg = root / f"{version}-{mode}" + for mode, trust in legs: + leg = root / (f"{version}-{mode}" if trust == "trusted" else f"{version}-{mode}-{trust}") leg.mkdir() server_root = leg / "server" env = os.environ.copy() env["OPCUA_MATRIX_PASSWORD"] = "otua_matrix_" + secrets.token_urlsafe(32) + env.pop("SCADABRIDGE_PROBE_PKI", None) + env.pop("SCADABRIDGE_PROBE_SERVER_CERTS", None) if mode == "None": env["OPCUA_MATRIX_SERVER_SECURITY"] = "none" + elif mode == "Sign": + env["OPCUA_MATRIX_SERVER_SECURITY"] = "sign" else: env.pop("OPCUA_MATRIX_SERVER_SECURITY", None) server_command = ["dotnet", str(artifacts[f"{version}_host"]), "server", str(server_root)] - result = {"server": version, "mode": mode, "serverCommand": server_command} + result = {"server": version, "mode": mode, "trust": trust, "expect": "pass" if trust == "trusted" else "fail", + "serverCommand": server_command} + if mode != "None": + pki = leg / "consumer-pki" + der = leg / "consumer-identity.der" + with (leg / "prepare.log").open("w") as log: + prepared = subprocess.run(["dotnet", str(artifacts["probe"]), "prepare", str(pki), str(der)], + env=env, stdout=log, stderr=subprocess.STDOUT, timeout=120) + if prepared.returncode != 0: + raise RuntimeError(f"Consumer identity provisioning failed; inspect {leg / 'prepare.log'}") + env["SCADABRIDGE_PROBE_PKI"] = str(pki) + if trust != "untrusted-client": + trusted = server_root / "server-pki" / "trusted" / "certs" + trusted.mkdir(parents=True) + (trusted / "scadabridge-consumer.der").write_bytes(der.read_bytes()) + result["consumerIdentitySha256"] = hashlib.sha256(der.read_bytes()).hexdigest() with (leg / "server.log").open("w") as server_log: server = subprocess.Popen(server_command, env=env, stdout=server_log, stderr=subprocess.STDOUT) try: @@ -50,7 +75,8 @@ def main(): raise RuntimeError(f"Server failed to become ready; inspect {leg / 'server.log'}") time.sleep(0.1) endpoint = (server_root / "endpoint.txt").read_text().strip() - env["SCADABRIDGE_PROBE_SERVER_CERTS"] = str(server_root / "server-pki" / "own" / "certs") + if trust != "untrusted-server": + env["SCADABRIDGE_PROBE_SERVER_CERTS"] = str(server_root / "server-pki" / "own" / "certs") command = ["dotnet", str(artifacts["probe"]), endpoint, mode, str(leg / "consumer")] with (leg / "consumer.log").open("w") as log: probe = subprocess.run(command, env=env, stdout=log, stderr=subprocess.STDOUT, timeout=150) @@ -67,8 +93,11 @@ def main(): result["serverExit"] = server.returncode receipt["legs"].append(result) (root / "receipt.json").write_text(json.dumps(receipt, indent=2)) - # A pre-existing consumer limitation is evidence to investigate, never converted into a passing leg. - return int(any(leg["exit"] != 0 or leg["serverExit"] != 0 for leg in receipt["legs"])) + # A trusted leg passes only on exit 0; a negative control passes only when the consumer fails closed. + for leg in receipt["legs"]: + leg["verdict"] = "passed" if leg["serverExit"] == 0 and (leg["exit"] == 0) == (leg["expect"] == "pass") else "failed" + (root / "receipt.json").write_text(json.dumps(receipt, indent=2)) + return int(any(leg["verdict"] != "passed" for leg in receipt["legs"])) if __name__ == "__main__":