diff --git a/Directory.Packages.props b/Directory.Packages.props index dfc1af6e..1ce69f90 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -77,6 +77,9 @@ + + diff --git a/docker-env2/site-x-node-a/appsettings.Site.json b/docker-env2/site-x-node-a/appsettings.Site.json index 4d234492..49f554e7 100644 --- a/docker-env2/site-x-node-a/appsettings.Site.json +++ b/docker-env2/site-x-node-a/appsettings.Site.json @@ -26,6 +26,13 @@ "ProbeConnectTimeoutMs": 1000 } }, + "OpcUa": { + "ApplicationUri": "urn:scadabridge-site-x-node-a:ScadaBridge-DCL", + "OwnCertificateStorePath": "/app/data/pki/own", + "TrustedPeerStorePath": "/app/data/pki/trusted", + "TrustedIssuerStorePath": "/app/data/pki/issuers", + "RejectedCertificateStorePath": "/app/data/pki/rejected" + }, "DataConnection": { "ReconnectInterval": "00:00:05", "TagResolutionRetryInterval": "00:00:10", diff --git a/docker-env2/site-x-node-b/appsettings.Site.json b/docker-env2/site-x-node-b/appsettings.Site.json index 6f09462d..28d2fc1d 100644 --- a/docker-env2/site-x-node-b/appsettings.Site.json +++ b/docker-env2/site-x-node-b/appsettings.Site.json @@ -26,6 +26,13 @@ "ProbeConnectTimeoutMs": 1000 } }, + "OpcUa": { + "ApplicationUri": "urn:scadabridge-site-x-node-b:ScadaBridge-DCL", + "OwnCertificateStorePath": "/app/data/pki/own", + "TrustedPeerStorePath": "/app/data/pki/trusted", + "TrustedIssuerStorePath": "/app/data/pki/issuers", + "RejectedCertificateStorePath": "/app/data/pki/rejected" + }, "DataConnection": { "ReconnectInterval": "00:00:05", "TagResolutionRetryInterval": "00:00:10", diff --git a/docker/site-a-node-a/appsettings.Site.json b/docker/site-a-node-a/appsettings.Site.json index 8cd166d4..a018230f 100644 --- a/docker/site-a-node-a/appsettings.Site.json +++ b/docker/site-a-node-a/appsettings.Site.json @@ -35,6 +35,13 @@ "ProbeConnectTimeoutMs": 1000 } }, + "OpcUa": { + "ApplicationUri": "urn:scadabridge-site-a-node-a:ScadaBridge-DCL", + "OwnCertificateStorePath": "/app/data/pki/own", + "TrustedPeerStorePath": "/app/data/pki/trusted", + "TrustedIssuerStorePath": "/app/data/pki/issuers", + "RejectedCertificateStorePath": "/app/data/pki/rejected" + }, "DataConnection": { "ReconnectInterval": "00:00:05", "TagResolutionRetryInterval": "00:00:10", diff --git a/docker/site-a-node-b/appsettings.Site.json b/docker/site-a-node-b/appsettings.Site.json index 8e796ef5..7b9751ae 100644 --- a/docker/site-a-node-b/appsettings.Site.json +++ b/docker/site-a-node-b/appsettings.Site.json @@ -35,6 +35,13 @@ "ProbeConnectTimeoutMs": 1000 } }, + "OpcUa": { + "ApplicationUri": "urn:scadabridge-site-a-node-b:ScadaBridge-DCL", + "OwnCertificateStorePath": "/app/data/pki/own", + "TrustedPeerStorePath": "/app/data/pki/trusted", + "TrustedIssuerStorePath": "/app/data/pki/issuers", + "RejectedCertificateStorePath": "/app/data/pki/rejected" + }, "DataConnection": { "ReconnectInterval": "00:00:05", "TagResolutionRetryInterval": "00:00:10", diff --git a/docker/site-b-node-a/appsettings.Site.json b/docker/site-b-node-a/appsettings.Site.json index 798bea85..7211f192 100644 --- a/docker/site-b-node-a/appsettings.Site.json +++ b/docker/site-b-node-a/appsettings.Site.json @@ -27,6 +27,13 @@ "ProbeConnectTimeoutMs": 1000 } }, + "OpcUa": { + "ApplicationUri": "urn:scadabridge-site-b-node-a:ScadaBridge-DCL", + "OwnCertificateStorePath": "/app/data/pki/own", + "TrustedPeerStorePath": "/app/data/pki/trusted", + "TrustedIssuerStorePath": "/app/data/pki/issuers", + "RejectedCertificateStorePath": "/app/data/pki/rejected" + }, "DataConnection": { "ReconnectInterval": "00:00:05", "TagResolutionRetryInterval": "00:00:10", diff --git a/docker/site-b-node-b/appsettings.Site.json b/docker/site-b-node-b/appsettings.Site.json index ceff79bb..eaf05651 100644 --- a/docker/site-b-node-b/appsettings.Site.json +++ b/docker/site-b-node-b/appsettings.Site.json @@ -27,6 +27,13 @@ "ProbeConnectTimeoutMs": 1000 } }, + "OpcUa": { + "ApplicationUri": "urn:scadabridge-site-b-node-b:ScadaBridge-DCL", + "OwnCertificateStorePath": "/app/data/pki/own", + "TrustedPeerStorePath": "/app/data/pki/trusted", + "TrustedIssuerStorePath": "/app/data/pki/issuers", + "RejectedCertificateStorePath": "/app/data/pki/rejected" + }, "DataConnection": { "ReconnectInterval": "00:00:05", "TagResolutionRetryInterval": "00:00:10", diff --git a/docker/site-c-node-a/appsettings.Site.json b/docker/site-c-node-a/appsettings.Site.json index 784cca5c..faa37608 100644 --- a/docker/site-c-node-a/appsettings.Site.json +++ b/docker/site-c-node-a/appsettings.Site.json @@ -27,6 +27,13 @@ "ProbeConnectTimeoutMs": 1000 } }, + "OpcUa": { + "ApplicationUri": "urn:scadabridge-site-c-node-a:ScadaBridge-DCL", + "OwnCertificateStorePath": "/app/data/pki/own", + "TrustedPeerStorePath": "/app/data/pki/trusted", + "TrustedIssuerStorePath": "/app/data/pki/issuers", + "RejectedCertificateStorePath": "/app/data/pki/rejected" + }, "DataConnection": { "ReconnectInterval": "00:00:05", "TagResolutionRetryInterval": "00:00:10", diff --git a/docker/site-c-node-b/appsettings.Site.json b/docker/site-c-node-b/appsettings.Site.json index c61616a3..dccdc106 100644 --- a/docker/site-c-node-b/appsettings.Site.json +++ b/docker/site-c-node-b/appsettings.Site.json @@ -27,6 +27,13 @@ "ProbeConnectTimeoutMs": 1000 } }, + "OpcUa": { + "ApplicationUri": "urn:scadabridge-site-c-node-b:ScadaBridge-DCL", + "OwnCertificateStorePath": "/app/data/pki/own", + "TrustedPeerStorePath": "/app/data/pki/trusted", + "TrustedIssuerStorePath": "/app/data/pki/issuers", + "RejectedCertificateStorePath": "/app/data/pki/rejected" + }, "DataConnection": { "ReconnectInterval": "00:00:05", "TagResolutionRetryInterval": "00:00:10", diff --git a/docs/components/DataConnectionLayer.md b/docs/components/DataConnectionLayer.md index 1a69b4e0..58a20aca 100644 --- a/docs/components/DataConnectionLayer.md +++ b/docs/components/DataConnectionLayer.md @@ -259,11 +259,16 @@ All settings under `DataConnectionLayer` in `appsettings.json` bind to `DataConn | Key | Default | Description | |-----|---------|-------------| | `ApplicationName` | `ScadaBridge-DCL` | Application name used in the OPC UA certificate and session negotiation. | +| `ApplicationUri` | `""` → `urn:{host}:{ApplicationName}` | ApplicationUri presented in the session and embedded in the application certificate. | +| `ApplicationCertificateSubject` | `""` → `CN={ApplicationName}` | Subject the application certificate is located by. | +| `OwnCertificateStorePath` | `""` | Own store holding the application certificate + private key (Sign / SignAndEncrypt only). | +| `AutoCreateApplicationCertificate` | `true` | Create a self-signed certificate when none exists; `false` fails a secured connect instead. | +| `ApplicationCertificateLifetimeMonths` | `24` | Lifetime of an auto-created certificate (1–600). | | `TrustedIssuerStorePath` | `""` | File path to the trusted issuer certificate store. | -| `TrustedPeerStorePath` | `""` | File path to the trusted peer certificate store. | +| `TrustedPeerStorePath` | `""` | File path to the trusted peer certificate store (certificates live in its `certs/` sub-directory). | | `RejectedCertificateStorePath` | `""` | File path to the rejected certificate store. | -Empty store paths fall back to a temp-path default so dev runs work without explicit configuration. +Empty store paths fall back to a temp-path default so dev runs work without explicit configuration — production nodes must use persistent paths, or the application certificate changes whenever temp is cleared. An existing application certificate is reused, never silently rotated; one that no longer matches the configured ApplicationUri fails the connect. See `docs/requirements/Component-DataConnectionLayer.md` → *OPC UA application identity* (Gitea #121). ### Per-connection settings (OPC UA) diff --git a/docs/requirements/Component-DataConnectionLayer.md b/docs/requirements/Component-DataConnectionLayer.md index b6b7ded9..4d49fc44 100644 --- a/docs/requirements/Component-DataConnectionLayer.md +++ b/docs/requirements/Component-DataConnectionLayer.md @@ -184,6 +184,28 @@ All settings are parsed from the data connection's configuration JSON dictionari | `SecurityMode` | string | `None` | Preferred endpoint security: `None`, `Sign`, or `SignAndEncrypt` | | `AutoAcceptUntrustedCerts` | bool | `true` | Accept untrusted server certificates | +### OPC UA application identity (deployment-wide, `ScadaBridge:OpcUa`) + +A `Sign` / `SignAndEncrypt` session presents ScadaBridge's own **application (client) certificate** to the server; `None` presents none. Before Gitea #121 the client built an empty certificate identifier, so every secured session failed before activation with `ApplicationCertificate for the security profile … cannot be found` (`BadConfigurationError`) — secured OPC UA was never actually usable. + +| Key | Default | Description | +|-----|---------|-------------| +| `ApplicationName` | `ScadaBridge-DCL` | Application name (session + certificate). | +| `ApplicationUri` | `urn:{host}:{ApplicationName}` | ApplicationUri presented in the session and carried as the certificate's SAN URI. Pin it on hosts whose name can change. | +| `ApplicationCertificateSubject` | `CN={ApplicationName}` | Subject the certificate is located by in the own store. | +| `OwnCertificateStorePath` | temp default | Own store (certificate + private key). | +| `AutoCreateApplicationCertificate` | `true` | Generate a self-signed RSA-2048 certificate the first time a secured session needs one and none exists. `false` = a missing certificate fails the connect (bring-your-own PKI). | +| `ApplicationCertificateLifetimeMonths` | `24` | Lifetime of an auto-created certificate (1–600). | +| `TrustedPeerStorePath` / `TrustedIssuerStorePath` / `RejectedCertificateStorePath` | temp default | Server trust stores. The SDK reads/writes certificates in each store's **`certs/`** sub-directory. | + +Rules (`Adapters/OpcUaApplicationIdentity.cs`, shared by `ConnectAsync` and the verify probe): + +- **Reuse, never silent rotation.** A certificate already in the own store is loaded and reused across reconnects and restarts. One that no longer validates (ApplicationUri mismatch, undersized key, unreadable private key) **fails the connect** with the SDK's "update or delete the certificate" error — it is never regenerated over an identity servers already trust. Creation happens only when the store holds no certificate for the subject, and logs a **Warning** with the thumbprint (every secured server must then trust it). Concurrent first connects are serialised per own store, so they create exactly one certificate. +- **Strict trust both ways.** Server trust is unchanged (`AutoAcceptUntrustedCerts`, off by default in the adapter; the probe forces it off). The client's own certificate is NOT copied into the trusted-peer store (`AddAppCertToTrustedStore = false`) — that store stays the operator-managed server list. The server must trust the client certificate (copy the DER from `{OwnCertificateStorePath}/certs/` into the server's trust list); until it does, the session fails with `BadSecurityChecksFailed`. +- **`None` is untouched** — it neither reads nor creates the own store. +- **Owned resources** — the private-key-bearing certificate is released (disposed) when the session is closed, on a failed connect, and at the end of every probe; a reconnect on the same client instance closes and disposes the previous session first. +- **Persist the stores.** Every store path must be on a persistent volume on a real node — on the temp default a cleared temp directory means a new identity that every server must re-trust. The docker rig points all four at `/app/data/pki/*`. + ### MxGateway Settings | Key | Type | Default | Description | @@ -285,6 +307,7 @@ IAddressSpaceSearchable Before saving or deploying an OPC UA data connection, the Central UI can ask the target site to **probe** the configured endpoint — this exercises connectivity (and TLS trust) against the live config, including edited-but-unsaved values. - `DataConnectionManagerActor` handles `VerifyEndpointCommand` (`SiteId`, protocol, config JSON). The site spins up a **temporary** `RealOpcUaClient` from the submitted config, attempts discovery + a session with a short timeout (a few seconds — the budget bounds discovery too, not only the session create), then disconnects and disposes the client. The reply is a `VerifyEndpointResult` (`Success`, a typed `FailureKind`, an error message, and an optional captured server cert). +- A `Sign` / `SignAndEncrypt` probe presents the **same application certificate** a real connect would (see *OPC UA application identity* above), so a probe success proves the real connect's mutual trust too. - The probe forces **`AutoAcceptUntrustedCerts = false`** and hooks the certificate-validation callback so it can **capture** an untrusted server certificate (Subject / Issuer / Thumbprint / NotBefore / NotAfter / DER) for the UI to display. Critically, the probe **never trusts** the cert — the validation callback always rejects (`Accept = false`); trusting is a separate, explicit, Admin-gated action that writes the cert into the site PKI store (see the cert-trust path in Component-SiteRuntime.md). The probe is read-only with respect to the trust boundary. ## Native Alarm Mirroring diff --git a/docs/requirements/Component-SiteRuntime.md b/docs/requirements/Component-SiteRuntime.md index 331759b0..ca7b9ac6 100644 --- a/docs/requirements/Component-SiteRuntime.md +++ b/docs/requirements/Component-SiteRuntime.md @@ -193,7 +193,7 @@ flowchart TD ### Server Certificate Trust (Per-Node Broadcast) - The OPC UA trusted-peer certificate store is **node-local** — it lives in each node's PKI directory, so trusting a cert on only the active node would silently fail certificate validation after failover. To keep both nodes consistent, certificate trust is handled by a per-node actor and a broadcast. - **`CertStoreActor`** is a per-node, **non-singleton** actor — it runs on **every** site node (unlike the Deployment Manager singleton). It owns its node's OPC UA PKI trusted-peer / rejected stores and handles three commands: - - **`TrustServerCertCommand`** — write a base64/DER server certificate into the node's trusted-peer store (path-traversal-guarded thumbprints; the cert is decoded and persisted as a `.der`). + - **`TrustServerCertCommand`** — write a base64/DER server certificate into the node's trusted-peer store (path-traversal-guarded thumbprints; the cert is decoded and persisted as a `.der` in the store's **`certs/`** sub-directory — where the OPC UA SDK's directory store reads it. Before Gitea #121 the actor wrote to the store root, which the SDK never reads, so an operator Trust had no effect on validation; on start the actor now moves any such legacy root-level `.der`/`.crt` into `certs/`, and the rejected listing reads `rejected/certs/` where the SDK quarantines refused server certificates). - **`RemoveServerCertCommand`** — delete a certificate (by thumbprint) from the node's stores. - **`ListServerCertsCommand`** — enumerate the node's trusted-peer + rejected store contents (Subject / Issuer / Thumbprint / validity / status). - The Deployment Manager singleton receives the central cert-trust verbs (relayed via the Communication Layer) and **broadcasts** `TrustServerCertCommand` / `RemoveServerCertCommand` to the `CertStoreActor` on **both** site nodes (via `ActorSelection`), so node-a and node-b PKI stores stay in sync. List reads from the local node. Trust is **site-local** — there is no central persistence of trusted certs (logged as a follow-up). The captured-but-untrusted server cert that seeds this flow comes from the DCL verify-endpoint probe (see Component-DataConnectionLayer.md), which never trusts on its own; the Central UI surfaces Trust / Remove (Admin-gated — see Component-CentralUI.md). diff --git a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/OpcUaApplicationIdentity.cs b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/OpcUaApplicationIdentity.cs new file mode 100644 index 00000000..e8802eee --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/OpcUaApplicationIdentity.cs @@ -0,0 +1,268 @@ +using System.Collections.Concurrent; +using System.Net; +using System.Security.Cryptography.X509Certificates; +using Microsoft.Extensions.Logging; +using Opc.Ua; +using Opc.Ua.Configuration; + +namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters; + +/// +/// Builds the OPC UA client and owns the application +/// (client) certificate that Sign / SignAndEncrypt sessions present to the server (Gitea #121). +/// +/// +/// +/// Before #121 both and the verify probe built an +/// EMPTY , so every secured session failed before activation +/// with ApplicationCertificate for the security profile … cannot be found +/// (BadConfigurationError). The identity now lives in a configurable own store +/// (), located by subject, carrying the +/// configured ApplicationUri. +/// +/// +/// Reuse, never silent rotation. An existing certificate in the own store is loaded and +/// reused. One that no longer validates (ApplicationUri mismatch, undersized key, missing private +/// key) FAILS the connect with the SDK's "update or delete the certificate" error — the SDK runs +/// silent, so it never regenerates over a persisted identity a server has already trusted. A new +/// certificate is only created when none exists and +/// is on. +/// +/// +/// None stays untouched. Unsecured sessions keep the empty identifier and never read or +/// write the own store. +/// +/// +public static class OpcUaApplicationIdentity +{ + /// Default application name when the configured one is blank. + internal const string DefaultApplicationName = "ScadaBridge-DCL"; + + /// Minimum RSA key size accepted for (and used when creating) the application certificate. + internal const ushort MinimumKeySize = 2048; + + // SDK telemetry for the non-obsolete ApplicationInstance ctor. SDK-internal logging is + // discarded; this class logs the outcome that matters (created vs reused) itself. + private static readonly Lazy SdkTelemetry = new(() => DefaultTelemetry.Create(_ => { })); + + // One gate per own-store directory: two connections starting together against an empty + // store would otherwise BOTH generate a certificate, leaving two identities for one subject. + private static readonly ConcurrentDictionary ProvisionGates = + new(StringComparer.Ordinal); + + /// + /// Resolves a configured PKI store path; empty falls back to + /// {temp}/ScadaBridge/pki/{fallbackLeaf}. The single definition shared by the client, + /// the verify probe and the site's CertStoreActor. + /// + /// The configured path (may be blank). + /// Leaf directory used under the temp root when blank. + /// The store directory. + public static string ResolveStorePath(string configured, string fallbackLeaf) => + string.IsNullOrWhiteSpace(configured) + ? Path.Combine(Path.GetTempPath(), "ScadaBridge", "pki", fallbackLeaf) + : configured; + + /// + /// Directory inside a store where the SDK's directory store keeps certificates + /// ({store}/certs). Trusting a peer means writing its DER here — a DER in the store + /// ROOT is invisible to certificate validation. + /// + /// The store root. + /// The certificate sub-directory. + public static string CertificatesDirectory(string storePath) => Path.Combine(storePath, "certs"); + + /// The effective application name. + /// Global OPC UA options. + /// The configured name, or the default when blank. + public static string ResolveApplicationName(OpcUaGlobalOptions options) => + string.IsNullOrWhiteSpace(options.ApplicationName) ? DefaultApplicationName : options.ApplicationName; + + /// The effective ApplicationUri (configured, else urn:{host}:{ApplicationName}). + /// Global OPC UA options. + /// The ApplicationUri. + public static string ResolveApplicationUri(OpcUaGlobalOptions options) => + string.IsNullOrWhiteSpace(options.ApplicationUri) + ? $"urn:{Dns.GetHostName()}:{ResolveApplicationName(options)}" + : options.ApplicationUri; + + /// The effective certificate subject (configured, else CN={ApplicationName}). + /// Global OPC UA options. + /// The subject distinguished name. + public static string ResolveSubject(OpcUaGlobalOptions options) => + string.IsNullOrWhiteSpace(options.ApplicationCertificateSubject) + ? $"CN={ResolveApplicationName(options)}" + : options.ApplicationCertificateSubject; + + /// + /// Builds and validates a client . When + /// is true the application certificate is loaded from — or, if + /// absent and allowed, created in — the own store before returning; otherwise the identifier + /// is left empty exactly as before #121. + /// + /// Global OPC UA identity and store paths. + /// True for Sign / SignAndEncrypt; false for None. + /// The connection's AutoAccept flag (the probe always passes false). + /// Default session timeout. + /// Transport operation timeout. + /// Logger for the created/reused outcome. + /// Cancellation token. + /// The validated configuration. The caller owns it — see . + internal static async Task BuildClientConfigurationAsync( + OpcUaGlobalOptions options, bool secured, bool autoAcceptUntrusted, + int sessionTimeoutMs, int operationTimeoutMs, ILogger logger, CancellationToken ct) + { + var appConfig = new ApplicationConfiguration + { + ApplicationName = ResolveApplicationName(options), + ApplicationUri = ResolveApplicationUri(options), + ApplicationType = ApplicationType.Client, + SecurityConfiguration = new SecurityConfiguration + { + AutoAcceptUntrustedCertificates = autoAcceptUntrusted, + // Our own identity belongs in the own store, not in the operator-managed trusted + // peer store that the Central UI lists — the SDK default (true) would copy it there. + AddAppCertToTrustedStore = false, + ApplicationCertificate = secured ? NewOwnIdentifier(options) : new CertificateIdentifier(), + TrustedIssuerCertificates = new CertificateTrustList { StorePath = ResolveStorePath(options.TrustedIssuerStorePath, "issuers") }, + TrustedPeerCertificates = new CertificateTrustList { StorePath = ResolveStorePath(options.TrustedPeerStorePath, "trusted") }, + RejectedCertificateStore = new CertificateTrustList { StorePath = ResolveStorePath(options.RejectedCertificateStorePath, "rejected") }, + }, + ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = sessionTimeoutMs }, + TransportQuotas = new TransportQuotas { OperationTimeout = operationTimeoutMs }, + }; + + await appConfig.ValidateAsync(ApplicationType.Client, ct).ConfigureAwait(false); + + if (secured) + await EnsureLoadedAsync(appConfig, options, logger, ct).ConfigureAwait(false); + + return appConfig; + } + + /// + /// Ensures the application certificate exists in the own store (creating it when absent and + /// allowed) and returns a PUBLIC-ONLY copy — the DER an operator hands to a server's trust + /// list. Uses the same reuse/creation path as a secured connect, so calling this never + /// rotates an identity a connect would have reused. + /// + /// Global OPC UA identity and store paths. + /// Logger for the created/reused outcome. + /// Cancellation token. + /// The application certificate without its private key; the caller disposes it. + public static async Task EnsureApplicationCertificateAsync( + OpcUaGlobalOptions options, ILogger logger, CancellationToken ct = default) + { + var appConfig = await BuildClientConfigurationAsync( + options, secured: true, autoAcceptUntrusted: false, sessionTimeoutMs: 60_000, + operationTimeoutMs: 15_000, logger, ct).ConfigureAwait(false); + try + { + var cert = appConfig.SecurityConfiguration.ApplicationCertificate.Certificate + ?? throw new InvalidOperationException("OPC UA application certificate was not loaded."); + return X509CertificateLoader.LoadCertificate(cert.RawData); + } + finally + { + ReleaseApplicationCertificate(appConfig); + } + } + + /// + /// Disposes the private-key-bearing application certificate a configuration loaded. Call + /// only once every session built from is closed and disposed. + /// + /// A configuration from ; null is a no-op. + internal static void ReleaseApplicationCertificate(ApplicationConfiguration? appConfig) + { + var certificates = appConfig?.SecurityConfiguration?.ApplicationCertificates; + if (certificates is null) + return; + foreach (var id in certificates) + { + var cert = id?.Certificate; + if (cert is null) + continue; + id!.Certificate = null; + cert.Dispose(); + } + } + + private static CertificateIdentifier NewOwnIdentifier(OpcUaGlobalOptions options) => new() + { + StoreType = CertificateStoreType.Directory, + StorePath = ResolveStorePath(options.OwnCertificateStorePath, "own"), + SubjectName = ResolveSubject(options), + CertificateType = ObjectTypeIds.RsaSha256ApplicationCertificateType, + }; + + private static async Task EnsureLoadedAsync( + ApplicationConfiguration appConfig, OpcUaGlobalOptions options, ILogger logger, CancellationToken ct) + { + var id = appConfig.SecurityConfiguration.ApplicationCertificate; + var gate = ProvisionGates.GetOrAdd(Path.GetFullPath(id.StorePath), _ => new SemaphoreSlim(1, 1)); + await gate.WaitAsync(ct).ConfigureAwait(false); + try + { + var before = ExistingThumbprints(id.StorePath); + + var app = new ApplicationInstance(appConfig, SdkTelemetry.Value) + { + ApplicationName = appConfig.ApplicationName, + ApplicationType = ApplicationType.Client, + DisableCertificateAutoCreation = !options.AutoCreateApplicationCertificate, + }; + + bool ok; + try + { + ok = await app.CheckApplicationInstanceCertificatesAsync( + silent: true, (ushort)options.ApplicationCertificateLifetimeMonths, ct).ConfigureAwait(false); + } + catch (ServiceResultException ex) + { + throw new InvalidOperationException( + $"OPC UA application certificate '{id.SubjectName}' in '{id.StorePath}' is unusable for " + + $"ApplicationUri '{appConfig.ApplicationUri}': {ex.Message}", ex); + } + + var cert = id.Certificate; + if (!ok || cert is null || !cert.HasPrivateKey) + throw new InvalidOperationException( + $"OPC UA application certificate '{id.SubjectName}' could not be loaded with its private key from '{id.StorePath}'."); + + var created = !before.Contains(cert.Thumbprint); + logger.Log(created ? LogLevel.Warning : LogLevel.Debug, + "OPC UA application certificate {Outcome}: subject {Subject}, thumbprint {Thumbprint}, " + + "ApplicationUri {ApplicationUri}, store {StorePath}, expires {NotAfter:O}.{Hint}", + created ? "CREATED" : "reused", cert.Subject, cert.Thumbprint, appConfig.ApplicationUri, + id.StorePath, cert.NotAfter.ToUniversalTime(), + created ? " Every server this node connects to with Sign/SignAndEncrypt must trust this certificate." : ""); + } + finally + { + gate.Release(); + } + } + + private static HashSet ExistingThumbprints(string storePath) + { + var set = new HashSet(StringComparer.OrdinalIgnoreCase); + var dir = CertificatesDirectory(storePath); + if (!Directory.Exists(dir)) + return set; + foreach (var file in Directory.EnumerateFiles(dir, "*.der")) + { + try + { + using var c = X509CertificateLoader.LoadCertificate(File.ReadAllBytes(file)); + set.Add(c.Thumbprint); + } + catch + { + // An unreadable file is not an identity we could have reused. + } + } + return set; + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/RealOpcUaClient.cs b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/RealOpcUaClient.cs index 60ce12c5..9c814020 100644 --- a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/RealOpcUaClient.cs +++ b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/RealOpcUaClient.cs @@ -79,6 +79,9 @@ public class RealOpcUaClient : IOpcUaClient // 0 = not fired, 1 = fired. private int _connectionLostFired; private OpcUaConnectionOptions _options = new(); + // The configuration of the current/last connect; owns the loaded application certificate + // (private key), released once the session built from it is closed. + private ApplicationConfiguration? _appConfig; private readonly OpcUaGlobalOptions _globalOptions; private readonly ILogger _logger; @@ -162,25 +165,16 @@ public class RealOpcUaClient : IOpcUaClient _ => MessageSecurityMode.None }; - var appConfig = new ApplicationConfiguration - { - ApplicationName = string.IsNullOrWhiteSpace(_globalOptions.ApplicationName) - ? "ScadaBridge-DCL" - : _globalOptions.ApplicationName, - ApplicationType = ApplicationType.Client, - SecurityConfiguration = new SecurityConfiguration - { - AutoAcceptUntrustedCertificates = opts.AutoAcceptUntrustedCerts, - ApplicationCertificate = new CertificateIdentifier(), - TrustedIssuerCertificates = new CertificateTrustList { StorePath = ResolveStorePath(_globalOptions.TrustedIssuerStorePath, "issuers") }, - TrustedPeerCertificates = new CertificateTrustList { StorePath = ResolveStorePath(_globalOptions.TrustedPeerStorePath, "trusted") }, - RejectedCertificateStore = new CertificateTrustList { StorePath = ResolveStorePath(_globalOptions.RejectedCertificateStorePath, "rejected") } - }, - ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = opts.SessionTimeoutMs }, - TransportQuotas = new TransportQuotas { OperationTimeout = opts.OperationTimeoutMs } - }; + // A reconnect on the same instance must not leak the previous session's identity. + await ReleaseSessionAsync(cancellationToken); - await appConfig.ValidateAsync(ApplicationType.Client); + // Gitea #121: secured modes load (or first-time create) the application certificate + // from the own store; None keeps the empty identifier and never touches the own store. + var appConfig = await OpcUaApplicationIdentity.BuildClientConfigurationAsync( + _globalOptions, secured: preferredSecurityMode != MessageSecurityMode.None, + opts.AutoAcceptUntrustedCerts, opts.SessionTimeoutMs, opts.OperationTimeoutMs, + _logger, cancellationToken); + _appConfig = appConfig; if (opts.AutoAcceptUntrustedCerts) { // This accepts ANY server certificate, defeating @@ -210,9 +204,18 @@ public class RealOpcUaClient : IOpcUaClient #pragma warning restore CS0618 var userIdentity = BuildUserIdentity(opts.UserIdentity); _writeDataTypes.Clear(); - _session = await sessionFactory.CreateAsync( - appConfig, configuredEndpoint, false, - "ScadaBridge-DCL-Session", (uint)opts.SessionTimeoutMs, userIdentity, null, cancellationToken); + try + { + _session = await sessionFactory.CreateAsync( + appConfig, configuredEndpoint, false, + "ScadaBridge-DCL-Session", (uint)opts.SessionTimeoutMs, userIdentity, null, cancellationToken); + } + catch + { + OpcUaApplicationIdentity.ReleaseApplicationCertificate(appConfig); + _appConfig = null; + throw; + } // Detect server going offline via keep-alive failures Interlocked.Exchange(ref _connectionLostFired, 0); @@ -372,30 +375,11 @@ public class RealOpcUaClient : IOpcUaClient _ => MessageSecurityMode.None }; - // Secure-by-default — force AutoAccept=false so an untrusted server cert is - // captured and rejected rather than silently accepted (defeating the whole probe). - var appConfig = new ApplicationConfiguration - { - ApplicationName = string.IsNullOrWhiteSpace(globalOptions.ApplicationName) - ? "ScadaBridge-DCL" - : globalOptions.ApplicationName, - ApplicationType = ApplicationType.Client, - SecurityConfiguration = new SecurityConfiguration - { - AutoAcceptUntrustedCertificates = false, - ApplicationCertificate = new CertificateIdentifier(), - TrustedIssuerCertificates = new CertificateTrustList { StorePath = ResolveStorePath(globalOptions.TrustedIssuerStorePath, "issuers") }, - TrustedPeerCertificates = new CertificateTrustList { StorePath = ResolveStorePath(globalOptions.TrustedPeerStorePath, "trusted") }, - RejectedCertificateStore = new CertificateTrustList { StorePath = ResolveStorePath(globalOptions.RejectedCertificateStorePath, "rejected") } - }, - ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = config.SessionTimeoutMs }, - TransportQuotas = new TransportQuotas { OperationTimeout = config.OperationTimeoutMs } - }; - + ApplicationConfiguration? appConfig = null; // Capture the untrusted server cert, then REJECT it (e.Accept = false). The // validator runs on the SDK's connect thread; copying the cert is the only state we // keep. Never accept — this probe must not trust anything. - appConfig.CertificateValidator.CertificateValidation += (_, e) => + CertificateValidationEventHandler captureAndReject = (_, e) => { try { @@ -415,7 +399,15 @@ public class RealOpcUaClient : IOpcUaClient try { - await appConfig.ValidateAsync(ApplicationType.Client); + // Secure-by-default — force AutoAccept=false so an untrusted server cert is captured + // and rejected rather than silently accepted (defeating the whole probe). A secured + // probe presents the SAME application certificate a real connect would (Gitea #121), + // so a probe success means the real connect's mutual trust holds too. + appConfig = await OpcUaApplicationIdentity.BuildClientConfigurationAsync( + globalOptions, secured: preferredSecurityMode != MessageSecurityMode.None, + autoAcceptUntrusted: false, config.SessionTimeoutMs, config.OperationTimeoutMs, + logger, linkedCts.Token); + appConfig.CertificateValidator.CertificateValidation += captureAndReject; // Discover endpoints, pick the preferred security mode (same logic as ConnectAsync). // Bounded by the probe's own budget (linkedCts), not just the SDK operation timeout. @@ -460,6 +452,9 @@ public class RealOpcUaClient : IOpcUaClient catch (Exception ex) { logger.LogDebug(ex, "OPC UA verify session close failed (ignored)."); } session.Dispose(); } + if (appConfig != null) + appConfig.CertificateValidator.CertificateValidation -= captureAndReject; + OpcUaApplicationIdentity.ReleaseApplicationCertificate(appConfig); } return MapVerifyOutcome(failure, capturedCert); @@ -565,12 +560,7 @@ public class RealOpcUaClient : IOpcUaClient } }, cancellationToken); - if (_session != null) - { - _session.KeepAlive -= OnSessionKeepAlive; - await _session.CloseAsync(cancellationToken); - _session = null; - } + await ReleaseSessionAsync(cancellationToken); _writeDataTypes.Clear(); _monitoredItems.Clear(); _callbacks.Clear(); @@ -580,6 +570,36 @@ public class RealOpcUaClient : IOpcUaClient _alarmLastState.Clear(); } + /// + /// Closes and disposes the current session (if any), then releases the application + /// certificate its configuration loaded. Close failures are swallowed into a debug log so the + /// owned resources are always released. + /// + private async Task ReleaseSessionAsync(CancellationToken cancellationToken) + { + var session = _session; + _session = null; + if (session != null) + { + session.KeepAlive -= OnSessionKeepAlive; + try + { + await session.CloseAsync(cancellationToken); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + // A dead link cannot close cleanly; that must not block a reconnect. + _logger.LogDebug(ex, "OPC UA session close failed (ignored)."); + } + finally + { + session.Dispose(); + } + } + OpcUaApplicationIdentity.ReleaseApplicationCertificate(_appConfig); + _appConfig = null; + } + /// public async Task CreateSubscriptionAsync( string nodeId, Action onValueChanged, @@ -1593,11 +1613,6 @@ public class RealOpcUaClient : IOpcUaClient _ => TimestampsToReturn.Source }; - private static string ResolveStorePath(string configured, string fallbackLeaf) => - string.IsNullOrWhiteSpace(configured) - ? Path.Combine(Path.GetTempPath(), "ScadaBridge", "pki", fallbackLeaf) - : configured; - // requestedMaxReferencesPerNode: cap the server's per-call references so a // huge flat folder cannot return an unbounded set. 500 leaves headroom for // the downstream frame-size budget (DataConnectionActor.CapBrowseChildren) diff --git a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/OpcUaGlobalOptions.cs b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/OpcUaGlobalOptions.cs index d423005f..0f35a60f 100644 --- a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/OpcUaGlobalOptions.cs +++ b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/OpcUaGlobalOptions.cs @@ -4,12 +4,46 @@ namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer; /// Deployment-wide OPC UA application identity. Bound from the "ScadaBridge:OpcUa" /// section of appsettings.json. Per-endpoint behavior lives on OpcUaEndpointConfig. /// Empty paths fall back to a default under Path.GetTempPath() so dev runs -/// work without explicit configuration. +/// work without explicit configuration — production nodes must point every store at a +/// persistent volume, or the application certificate is regenerated (and must be re-trusted +/// by every server) whenever the temp directory is cleared. /// public class OpcUaGlobalOptions { /// OPC UA application name used in the application certificate and session negotiation. public string ApplicationName { get; set; } = "ScadaBridge-DCL"; + + /// + /// OPC UA ApplicationUri presented in the session and embedded (as the SAN URI) in the + /// application certificate. Empty = urn:{host name}:{ApplicationName}. A persisted + /// certificate whose SAN URI no longer matches is REJECTED at connect (it is never silently + /// replaced), so pin this explicitly on hosts whose name can change. + /// + public string ApplicationUri { get; set; } = ""; + + /// + /// Subject distinguished name of the application certificate. Empty = CN={ApplicationName}. + /// The certificate is located in by this subject. + /// + public string ApplicationCertificateSubject { get; set; } = ""; + + /// + /// File system path to the application's OWN certificate store (certificate + private key), + /// used for Sign / SignAndEncrypt sessions. Empty = a default under Path.GetTempPath(). + /// + public string OwnCertificateStorePath { get; set; } = ""; + + /// + /// When true (default) a self-signed application certificate is generated in + /// the first time a secured session needs one and none + /// exists. When false a missing certificate fails the connect — for deployments that + /// provision the certificate from their own PKI. + /// + public bool AutoCreateApplicationCertificate { get; set; } = true; + + /// Lifetime, in months, of an auto-created application certificate (1–600). + public int ApplicationCertificateLifetimeMonths { get; set; } = 24; + /// File system path to the trusted issuer certificate store. public string TrustedIssuerStorePath { get; set; } = ""; /// File system path to the trusted peer certificate store. diff --git a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/OpcUaGlobalOptionsValidator.cs b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/OpcUaGlobalOptionsValidator.cs index 772aea7d..354da37d 100644 --- a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/OpcUaGlobalOptionsValidator.cs +++ b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/OpcUaGlobalOptionsValidator.cs @@ -5,7 +5,10 @@ namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer; /// Validates the OPC UA application identity before connections start. public sealed class OpcUaGlobalOptionsValidator : IValidateOptions { - /// Fails validation when the OPC UA application name is blank. + /// + /// Fails validation when the OPC UA application name is blank, the ApplicationUri is not an + /// absolute URI, or the certificate lifetime is out of range. + /// /// The named options instance being validated. /// The options to validate. /// The validation result. @@ -13,6 +16,13 @@ public sealed class OpcUaGlobalOptionsValidator : IValidateOptions 600) + return ValidateOptionsResult.Fail( + "ScadaBridge:OpcUa:ApplicationCertificateLifetimeMonths must be between 1 and 600."); return ValidateOptionsResult.Success; } } diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/CertStoreActor.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/CertStoreActor.cs index 17f5739d..ac3e9d40 100644 --- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/CertStoreActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/CertStoreActor.cs @@ -3,6 +3,7 @@ using Akka.Actor; using Akka.Event; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; using ZB.MOM.WW.ScadaBridge.DataConnectionLayer; +using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors; @@ -30,6 +31,7 @@ public class CertStoreActor : ReceiveActor public const string WellKnownName = "cert-store"; private readonly ILoggingAdapter _log = Context.GetLogger(); + private readonly string _trustedStoreRoot; private readonly string _trustedStoreDir; private readonly string _rejectedStoreDir; @@ -40,13 +42,14 @@ public class CertStoreActor : ReceiveActor /// Deployment-wide OPC UA options (store paths). public CertStoreActor(OpcUaGlobalOptions opcUaGlobalOptions) { - // Resolve store directories with the SAME fallback logic - // RealOpcUaClient.ResolveStorePath uses, so a cert trusted here lands in - // the exact store the OPC UA client validates against. That helper is a - // private static method on RealOpcUaClient; the tiny logic is replicated - // here rather than widening its visibility. - _trustedStoreDir = ResolveStorePath(opcUaGlobalOptions.TrustedPeerStorePath, "trusted"); - _rejectedStoreDir = ResolveStorePath(opcUaGlobalOptions.RejectedCertificateStorePath, "rejected"); + // Resolve store directories with the SAME helper the OPC UA client uses, then point at + // the SDK directory store's certs/ sub-folder. Gitea #121: this actor used to read and + // write the store ROOT, which the SDK never looks at — an operator "trust" was therefore + // invisible to certificate validation and the SDK's rejected certs never listed. + _trustedStoreRoot = OpcUaApplicationIdentity.ResolveStorePath(opcUaGlobalOptions.TrustedPeerStorePath, "trusted"); + _trustedStoreDir = OpcUaApplicationIdentity.CertificatesDirectory(_trustedStoreRoot); + _rejectedStoreDir = OpcUaApplicationIdentity.CertificatesDirectory( + OpcUaApplicationIdentity.ResolveStorePath(opcUaGlobalOptions.RejectedCertificateStorePath, "rejected")); Receive(HandleWrite); Receive(HandleRemove); @@ -54,14 +57,48 @@ public class CertStoreActor : ReceiveActor Receive(_ => HandleExport()); } + /// + protected override void PreStart() + { + base.PreStart(); + MigrateLegacyRootCertificates(); + } + /// - /// Replicates RealOpcUaClient.ResolveStorePath: an empty configured - /// path falls back to %TEMP%/ScadaBridge/pki/<leaf>. + /// Moves certificates a pre-#121 build trusted into the store ROOT down into certs/, + /// where the SDK reads them — so trust an operator already granted takes effect on upgrade + /// instead of silently staying inert. Idempotent; a file already present in certs/ + /// wins and the legacy copy is removed. /// - private static string ResolveStorePath(string configured, string fallbackLeaf) => - string.IsNullOrWhiteSpace(configured) - ? Path.Combine(Path.GetTempPath(), "ScadaBridge", "pki", fallbackLeaf) - : configured; + private void MigrateLegacyRootCertificates() + { + try + { + if (!Directory.Exists(_trustedStoreRoot)) + return; + var legacy = Directory.EnumerateFiles(_trustedStoreRoot) + .Where(f => f.EndsWith(".der", StringComparison.OrdinalIgnoreCase) + || f.EndsWith(".crt", StringComparison.OrdinalIgnoreCase)) + .ToList(); + if (legacy.Count == 0) + return; + Directory.CreateDirectory(_trustedStoreDir); + foreach (var file in legacy) + { + var target = Path.Combine(_trustedStoreDir, Path.GetFileName(file)); + if (File.Exists(target)) + File.Delete(file); + else + File.Move(file, target); + } + _log.Info("Moved {Count} trusted certificate(s) from the legacy store root {Root} into {Dir}", + legacy.Count, _trustedStoreRoot, _trustedStoreDir); + } + catch (Exception ex) + { + _log.Warning(ex, "Failed to migrate legacy trusted certificates from {Root}", _trustedStoreRoot); + } + } private void HandleWrite(WriteCertToLocalStore msg) { diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/RealOpcUaSecureConnectTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/RealOpcUaSecureConnectTests.cs new file mode 100644 index 00000000..2e4d0b69 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/RealOpcUaSecureConnectTests.cs @@ -0,0 +1,241 @@ +using System.Security.Cryptography.X509Certificates; +using Microsoft.Extensions.Logging.Abstractions; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; +using ZB.MOM.WW.ScadaBridge.Commons.Types.DataConnections; +using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters; + +namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Adapters; + +/// +/// Gitea #121: the real against a real, strictly-trusting +/// in-process server. Before the fix every Sign / SignAndEncrypt session failed before +/// activation with ApplicationCertificate … cannot be found (BadConfigurationError) +/// because the client never configured an application certificate. These tests run the actual +/// adapter through both and +/// , with AutoAccept OFF on both sides. +/// +public sealed class RealOpcUaSecureConnectTests : IDisposable +{ + // Server_ServerStatus_State — present on every server, readable anonymously. + private const string ServerStateNode = "i=2259"; + + private readonly string _clientPki = + Path.Combine(Path.GetTempPath(), "sb121-client", Guid.NewGuid().ToString("N")); + + /// + public void Dispose() + { + try { Directory.Delete(_clientPki, recursive: true); } catch { /* best-effort */ } + } + + private OpcUaGlobalOptions ClientOptions(string? applicationUri = null, bool autoCreate = true) => new() + { + ApplicationName = "SB121Client", + ApplicationUri = applicationUri ?? "urn:localhost:SB121Client", + OwnCertificateStorePath = Path.Combine(_clientPki, "own"), + TrustedPeerStorePath = Path.Combine(_clientPki, "trusted"), + TrustedIssuerStorePath = Path.Combine(_clientPki, "issuers"), + RejectedCertificateStorePath = Path.Combine(_clientPki, "rejected"), + AutoCreateApplicationCertificate = autoCreate, + }; + + private string OwnCertsDir => Path.Combine(_clientPki, "own", "certs"); + + /// Trusts the server on the client side exactly as the site CertStoreActor does. + private void ClientTrusts(X509Certificate2 server) + { + var dir = Path.Combine(_clientPki, "trusted", "certs"); + Directory.CreateDirectory(dir); + File.WriteAllBytes(Path.Combine(dir, $"{server.Thumbprint}.der"), server.RawData); + } + + private static OpcUaConnectionOptions Secured(string mode) => new(SecurityMode: mode, AutoAcceptUntrustedCerts: false); + + private static OpcUaEndpointConfig ProbeConfig(string url, OpcUaSecurityMode mode) => new() + { + EndpointUrl = url, + SecurityMode = mode, + AutoAcceptUntrustedCerts = false, + }; + + private async Task ProvisionClientAsync(OpcUaGlobalOptions? options = null) => + await OpcUaApplicationIdentity.EnsureApplicationCertificateAsync(options ?? ClientOptions(), NullLogger.Instance); + + /// Mutual trust: connects, reads, reconnects and restarts on ONE persisted identity. + [Theory] + [InlineData("Sign")] + [InlineData("SignAndEncrypt")] + public async Task Connect_WithMutualTrust_ReadsAndReconnectsOnTheSameIdentity(string mode) + { + using var clientCert = await ProvisionClientAsync(); + await using var server = await SecureOpcUaTestServer.StartAsync(clientCert); + ClientTrusts(server.Certificate); + + var client = new RealOpcUaClient(ClientOptions()); + await client.ConnectAsync(server.Url, Secured(mode)); + Assert.True(client.IsConnected); + Assert.Equal(0u, (await client.ReadValueAsync(ServerStateNode)).StatusCode); + + // Reconnect on the same instance. + await client.DisconnectAsync(); + Assert.False(client.IsConnected); + await client.ConnectAsync(server.Url, Secured(mode)); + Assert.Equal(0u, (await client.ReadValueAsync(ServerStateNode)).StatusCode); + await client.DisposeAsync(); + + // A fresh instance (process restart) loads the persisted identity. + await using var restarted = new RealOpcUaClient(ClientOptions()); + await restarted.ConnectAsync(server.Url, Secured(mode)); + Assert.Equal(0u, (await restarted.ReadValueAsync(ServerStateNode)).StatusCode); + + var der = Assert.Single(Directory.GetFiles(OwnCertsDir, "*.der")); + using var onDisk = X509CertificateLoader.LoadCertificate(await File.ReadAllBytesAsync(der)); + Assert.Equal(clientCert.Thumbprint, onDisk.Thumbprint); + } + + /// Strict server trust: a server the client has not trusted is refused, and quarantined in rejected/. + [Theory] + [InlineData("Sign")] + [InlineData("SignAndEncrypt")] + public async Task Connect_ServerNotTrustedByClient_IsRefused(string mode) + { + using var clientCert = await ProvisionClientAsync(); + await using var server = await SecureOpcUaTestServer.StartAsync(clientCert); + + await using var client = new RealOpcUaClient(ClientOptions()); + var ex = await Assert.ThrowsAsync(() => client.ConnectAsync(server.Url, Secured(mode))); + // A trust refusal — not the pre-#121 BadConfigurationError ("ApplicationCertificate … cannot be found"). + Assert.Equal(Opc.Ua.StatusCodes.BadCertificateUntrusted, ex.StatusCode); + Assert.False(client.IsConnected); + } + + /// Strict mutual trust: a server that has not trusted the client refuses the channel. + [Theory] + [InlineData("Sign")] + [InlineData("SignAndEncrypt")] + public async Task Connect_ClientNotTrustedByServer_IsRefused(string mode) + { + await using var server = await SecureOpcUaTestServer.StartAsync(); + ClientTrusts(server.Certificate); + + await using var client = new RealOpcUaClient(ClientOptions()); + var ex = await Assert.ThrowsAsync(() => client.ConnectAsync(server.Url, Secured(mode))); + // The server's refusal of an untrusted client channel — proves a client certificate WAS presented. + Assert.Equal(Opc.Ua.StatusCodes.BadSecurityChecksFailed, ex.StatusCode); + Assert.False(client.IsConnected); + } + + /// The verify probe presents the same identity: success under mutual trust. + [Theory] + [InlineData(OpcUaSecurityMode.Sign)] + [InlineData(OpcUaSecurityMode.SignAndEncrypt)] + public async Task Verify_WithMutualTrust_Succeeds(OpcUaSecurityMode mode) + { + using var clientCert = await ProvisionClientAsync(); + await using var server = await SecureOpcUaTestServer.StartAsync(clientCert); + ClientTrusts(server.Certificate); + + var result = await RealOpcUaClient.VerifyEndpointAsync( + ProbeConfig(server.Url, mode), ClientOptions(), NullLogger.Instance, TimeSpan.FromSeconds(30), CancellationToken.None); + + Assert.True(result.Success, $"{result.FailureKind}: {result.Error}"); + Assert.Single(Directory.GetFiles(OwnCertsDir, "*.der")); + } + + /// The probe captures — and never trusts — an untrusted server certificate. + [Theory] + [InlineData(OpcUaSecurityMode.Sign)] + [InlineData(OpcUaSecurityMode.SignAndEncrypt)] + public async Task Verify_ServerNotTrusted_ReturnsUntrustedCertificate(OpcUaSecurityMode mode) + { + using var clientCert = await ProvisionClientAsync(); + await using var server = await SecureOpcUaTestServer.StartAsync(clientCert); + + var result = await RealOpcUaClient.VerifyEndpointAsync( + ProbeConfig(server.Url, mode), ClientOptions(), NullLogger.Instance, TimeSpan.FromSeconds(30), CancellationToken.None); + + Assert.False(result.Success); + Assert.Equal(VerifyFailureKind.UntrustedCertificate, result.FailureKind); + Assert.Equal(server.Certificate.Thumbprint, result.Cert?.Thumbprint); + Assert.False(Directory.Exists(Path.Combine(_clientPki, "trusted", "certs")) + && Directory.GetFiles(Path.Combine(_clientPki, "trusted", "certs")).Length > 0, + "the probe must never trust the server certificate"); + } + + /// The probe fails when the server has not trusted the client. + [Theory] + [InlineData(OpcUaSecurityMode.Sign)] + [InlineData(OpcUaSecurityMode.SignAndEncrypt)] + public async Task Verify_ClientNotTrustedByServer_Fails(OpcUaSecurityMode mode) + { + await using var server = await SecureOpcUaTestServer.StartAsync(); + ClientTrusts(server.Certificate); + + var result = await RealOpcUaClient.VerifyEndpointAsync( + ProbeConfig(server.Url, mode), ClientOptions(), NullLogger.Instance, TimeSpan.FromSeconds(30), CancellationToken.None); + + Assert.False(result.Success); + Assert.NotNull(result.FailureKind); + Assert.Contains("80130000", result.Error); // BadSecurityChecksFailed, not BadConfigurationError + } + + /// None is unchanged: it connects without reading or creating the own store. + [Fact] + public async Task Connect_None_DoesNotTouchTheOwnStore() + { + await using var server = await SecureOpcUaTestServer.StartAsync(); + + await using var client = new RealOpcUaClient(ClientOptions()); + await client.ConnectAsync(server.Url, new OpcUaConnectionOptions()); + Assert.Equal(0u, (await client.ReadValueAsync(ServerStateNode)).StatusCode); + + var probe = await RealOpcUaClient.VerifyEndpointAsync( + ProbeConfig(server.Url, OpcUaSecurityMode.None), ClientOptions(), NullLogger.Instance, + TimeSpan.FromSeconds(30), CancellationToken.None); + Assert.True(probe.Success, $"{probe.FailureKind}: {probe.Error}"); + + Assert.False(Directory.Exists(Path.Combine(_clientPki, "own"))); + } + + /// A persisted identity is reused, never rotated — including when concurrent connects race on an empty store. + [Fact] + public async Task Provisioning_ReusesThePersistedCertificate_AndCreatesOnlyOneUnderConcurrency() + { + var certs = await Task.WhenAll(Enumerable.Range(0, 8).Select(_ => ProvisionClientAsync())); + try + { + Assert.Single(certs.Select(c => c.Thumbprint).Distinct()); + using var again = await ProvisionClientAsync(); + Assert.Equal(certs[0].Thumbprint, again.Thumbprint); + Assert.Single(Directory.GetFiles(OwnCertsDir, "*.der")); + Assert.True(Opc.Ua.X509Utils.CompareApplicationUriWithCertificate(again, "urn:localhost:SB121Client", out _), + "the certificate carries the configured ApplicationUri"); + } + finally + { + foreach (var c in certs) c.Dispose(); + } + } + + /// A persisted certificate that no longer matches the ApplicationUri fails closed instead of being replaced. + [Fact] + public async Task Provisioning_ApplicationUriMismatch_FailsWithoutRotating() + { + using var original = await ProvisionClientAsync(); + + await Assert.ThrowsAsync( + () => ProvisionClientAsync(ClientOptions(applicationUri: "urn:elsewhere:SB121Client"))); + + var der = Assert.Single(Directory.GetFiles(OwnCertsDir, "*.der")); + using var onDisk = X509CertificateLoader.LoadCertificate(await File.ReadAllBytesAsync(der)); + Assert.Equal(original.Thumbprint, onDisk.Thumbprint); + } + + /// With auto-create off, a missing certificate fails the secured connect rather than inventing one. + [Fact] + public async Task Provisioning_AutoCreateDisabled_MissingCertificateFails() + { + await Assert.ThrowsAsync(() => ProvisionClientAsync(ClientOptions(autoCreate: false))); + Assert.False(Directory.Exists(OwnCertsDir) && Directory.GetFiles(OwnCertsDir).Length > 0); + } +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/SecureOpcUaTestServer.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/SecureOpcUaTestServer.cs new file mode 100644 index 00000000..b047af5f --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/SecureOpcUaTestServer.cs @@ -0,0 +1,116 @@ +using System.Net; +using System.Net.Sockets; +using System.Security.Cryptography.X509Certificates; +using Opc.Ua; +using Opc.Ua.Configuration; +using Opc.Ua.Server; + +namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Adapters; + +/// +/// An in-process OPC Foundation exposing None, Sign and +/// SignAndEncrypt (Basic256Sha256) endpoints with STRICT client trust +/// (AutoAcceptUntrustedCertificates = false) and its own temp PKI — the Gitea #121 +/// counterpart for real mutual-trust tests of RealOpcUaClient. +/// +internal sealed class SecureOpcUaTestServer : IAsyncDisposable +{ + private readonly ApplicationInstance _app; + private readonly string _pkiRoot; + + private SecureOpcUaTestServer(ApplicationInstance app, string pkiRoot, string url, X509Certificate2 certificate) + { + _app = app; + _pkiRoot = pkiRoot; + Url = url; + Certificate = certificate; + } + + /// The endpoint URL (localhost, so the server certificate's domain matches). + public string Url { get; } + + /// The server's application certificate (public part). + public X509Certificate2 Certificate { get; } + + /// Starts a server that trusts exactly . + /// Client certificates the server's trusted peer store holds at start. + /// The running server. + public static async Task StartAsync(params X509Certificate2[] trustedClients) + { + var pkiRoot = Path.Combine(Path.GetTempPath(), "sb121-server", Guid.NewGuid().ToString("N")); + var port = FreePort(); + var url = $"opc.tcp://localhost:{port}/sb121"; + + // Trust is written BEFORE start, so no test depends on trust-list cache refresh timing. + var trustedCerts = Path.Combine(pkiRoot, "trusted", "certs"); + Directory.CreateDirectory(trustedCerts); + foreach (var c in trustedClients) + await File.WriteAllBytesAsync(Path.Combine(trustedCerts, $"{c.Thumbprint}.der"), c.RawData); + + var config = new ApplicationConfiguration + { + ApplicationName = "SB121TestServer", + ApplicationUri = $"urn:localhost:SB121TestServer:{port}", + ApplicationType = ApplicationType.Server, + SecurityConfiguration = new SecurityConfiguration + { + ApplicationCertificate = new CertificateIdentifier + { + StoreType = CertificateStoreType.Directory, + StorePath = Path.Combine(pkiRoot, "own"), + SubjectName = "CN=SB121TestServer", + CertificateType = ObjectTypeIds.RsaSha256ApplicationCertificateType, + }, + TrustedPeerCertificates = new CertificateTrustList { StorePath = Path.Combine(pkiRoot, "trusted") }, + TrustedIssuerCertificates = new CertificateTrustList { StorePath = Path.Combine(pkiRoot, "issuers") }, + RejectedCertificateStore = new CertificateTrustList { StorePath = Path.Combine(pkiRoot, "rejected") }, + AutoAcceptUntrustedCertificates = false, + AddAppCertToTrustedStore = false, + }, + TransportQuotas = new TransportQuotas { OperationTimeout = 15_000 }, + ServerConfiguration = new ServerConfiguration + { + BaseAddresses = { url }, + SecurityPolicies = + { + new ServerSecurityPolicy { SecurityMode = MessageSecurityMode.None, SecurityPolicyUri = SecurityPolicies.None }, + new ServerSecurityPolicy { SecurityMode = MessageSecurityMode.Sign, SecurityPolicyUri = SecurityPolicies.Basic256Sha256 }, + new ServerSecurityPolicy { SecurityMode = MessageSecurityMode.SignAndEncrypt, SecurityPolicyUri = SecurityPolicies.Basic256Sha256 }, + }, + UserTokenPolicies = { new UserTokenPolicy(UserTokenType.Anonymous) }, + }, + }; + + await config.ValidateAsync(ApplicationType.Server); + + var telemetry = DefaultTelemetry.Create(_ => { }); + var app = new ApplicationInstance(config, telemetry) + { + ApplicationName = config.ApplicationName, + ApplicationType = ApplicationType.Server, + }; + await app.CheckApplicationInstanceCertificatesAsync(silent: true, lifeTimeInMonths: 12); + await app.StartAsync(new StandardServer()); + + var cert = X509CertificateLoader.LoadCertificate( + config.SecurityConfiguration.ApplicationCertificate.Certificate.RawData); + return new SecureOpcUaTestServer(app, pkiRoot, url, cert); + } + + /// + public async ValueTask DisposeAsync() + { + try { await _app.StopAsync(); } catch { /* best-effort */ } + Certificate.Dispose(); + try { Directory.Delete(_pkiRoot, recursive: true); } catch { /* best-effort */ } + } + + private static int FreePort() + { + var l = new TcpListener(IPAddress.Loopback, 0); + l.Start(); + var port = ((IPEndPoint)l.LocalEndpoint).Port; + l.Stop(); + return port; + } +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/OpcUaGlobalOptionsValidatorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/OpcUaGlobalOptionsValidatorTests.cs new file mode 100644 index 00000000..273738a3 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/OpcUaGlobalOptionsValidatorTests.cs @@ -0,0 +1,27 @@ +namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests; + +/// Gitea #121: validation of the OPC UA application identity options. +public class OpcUaGlobalOptionsValidatorTests +{ + private static bool Valid(OpcUaGlobalOptions o) => new OpcUaGlobalOptionsValidator().Validate(null, o).Succeeded; + + /// Defaults (blank URI, 24-month lifetime) are valid. + [Fact] + public void Defaults_AreValid() => Assert.True(Valid(new OpcUaGlobalOptions())); + + /// An explicit urn ApplicationUri is accepted; a relative one is not. + [Theory] + [InlineData("urn:site-a-node-a:ScadaBridge-DCL", true)] + [InlineData("not a uri", false)] + public void ApplicationUri_MustBeAbsolute(string uri, bool expected) => + Assert.Equal(expected, Valid(new OpcUaGlobalOptions { ApplicationUri = uri })); + + /// Certificate lifetime is bounded to 1–600 months. + [Theory] + [InlineData(0, false)] + [InlineData(1, true)] + [InlineData(600, true)] + [InlineData(601, false)] + public void Lifetime_IsBounded(int months, bool expected) => + Assert.Equal(expected, Valid(new OpcUaGlobalOptions { ApplicationCertificateLifetimeMonths = months })); +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.csproj b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.csproj index 45040861..6fecd1f8 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.csproj +++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.csproj @@ -13,6 +13,7 @@ +