test(validation): ScadaBridge consumer matrix — provisioned client identity, Sign leg, trust negative controls (ScadaBridge#121)

Probe 'prepare' provisions the consumer identity via OpcUaApplicationIdentity.EnsureApplicationCertificateAsync
and exports the public DER; runner installs it in the server trusted store before start. Adds Sign
(OPCUA_MATRIX_SERVER_SECURITY=sign), untrusted-client / untrusted-server fail-closed controls per secure
mode, and OPCUA_MATRIX_ACL=off. Autoaccept stays false everywhere.
This commit is contained in:
Joseph Doherty
2026-11-12 06:28:15 -05:00
parent f127f45428
commit ea83fc58b2
3 changed files with 76 additions and 17 deletions
@@ -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 <endpoint> <None|SignAndEncrypt> <new-result-directory>");
// 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 <endpoint> <None|Sign|SignAndEncrypt> <new-result-directory> | Probe prepare <new-pki-directory> <public-der-out>");
var endpoint = args[0];
var mode = Enum.Parse<OpcUaSecurityMode>(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<object>();
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<Task> 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 }));