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 @@
+