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:
@@ -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<AclScopeRef, IReadOnlyList<NodeAclPath>>());
|
||||
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);
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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__":
|
||||
|
||||
Reference in New Issue
Block a user