fix(dcl): OPC UA secure client presents a real application certificate (#121)
Sign/SignAndEncrypt sessions failed before activation with "ApplicationCertificate
for the security profile ... cannot be found" (BadConfigurationError): both
RealOpcUaClient.ConnectAsync and the verify probe built an empty CertificateIdentifier.
- New OpcUaApplicationIdentity builds the client ApplicationConfiguration for both
paths. Secured modes load the application certificate from a configurable own
store (subject + ApplicationUri), auto-creating a self-signed RSA-2048 cert only
when none exists. A persisted cert is reused across reconnect/restart and never
silently rotated: one that no longer validates (e.g. ApplicationUri mismatch)
fails closed. First-time creation is serialised per own store and logged at
Warning with the thumbprint. None mode is unchanged and never touches the store.
- New OpcUaGlobalOptions: ApplicationUri, ApplicationCertificateSubject,
OwnCertificateStorePath, AutoCreateApplicationCertificate,
ApplicationCertificateLifetimeMonths (validated).
- Owned resources: the private-key cert is disposed on disconnect, failed connect
and at probe end; sessions are now disposed after close, and a reconnect on the
same instance closes the previous session first.
- CertStoreActor wrote trusted server certs to the store ROOT, which the SDK never
reads (it uses {store}/certs), so an operator Trust never affected validation and
SDK-rejected certs never listed. It now uses certs/ and migrates legacy root files
on start.
- Rig: all site nodes (docker/, docker-env2/) put the four PKI stores on the
persistent /app/data volume with a pinned ApplicationUri.
- Tests: in-process strict-trust StandardServer (test-only Opc.Ua.Server 1.5.378.106)
exercising the real adapter through ConnectAsync and VerifyEndpointAsync for Sign
and SignAndEncrypt: allow + read + reconnect + restart on one identity, deny when
the client doesn't trust the server (BadCertificateUntrusted), deny when the server
doesn't trust the client (BadSecurityChecksFailed), None untouched, concurrent
provisioning creates one cert, URI mismatch fails without rotating, auto-create off.
Against the pre-fix client these tests fail with the exact #121 error.
This commit is contained in:
@@ -77,6 +77,9 @@
|
||||
<PackageVersion Include="Novell.Directory.Ldap.NETStandard" Version="3.6.0" />
|
||||
<PackageVersion Include="NSubstitute" Version="5.3.0" />
|
||||
<PackageVersion Include="OPCFoundation.NetStandard.Opc.Ua.Client" Version="1.5.378.106" />
|
||||
<!-- Test-only: in-process secured server for the Gitea #121 Sign/SignAndEncrypt tests.
|
||||
Kept in lockstep with the client pin above. -->
|
||||
<PackageVersion Include="OPCFoundation.NetStandard.Opc.Ua.Server" Version="1.5.378.106" />
|
||||
<PackageVersion Include="OpenTelemetry.Api" Version="1.15.3" />
|
||||
<!-- Parquet audit archival + the parquet format of `audit export` (ScadaBridge#42). 6.1.0 is fully
|
||||
managed (Snappier / ZstdSharp / K4os LZ4, no native IronCompress) and NuGetAudit-clean. -->
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the OPC UA client <see cref="ApplicationConfiguration"/> and owns the application
|
||||
/// (client) certificate that Sign / SignAndEncrypt sessions present to the server (Gitea #121).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Before #121 both <see cref="RealOpcUaClient.ConnectAsync"/> and the verify probe built an
|
||||
/// EMPTY <see cref="CertificateIdentifier"/>, so every secured session failed before activation
|
||||
/// with <c>ApplicationCertificate for the security profile … cannot be found</c>
|
||||
/// (<c>BadConfigurationError</c>). The identity now lives in a configurable own store
|
||||
/// (<see cref="OpcUaGlobalOptions.OwnCertificateStorePath"/>), located by subject, carrying the
|
||||
/// configured ApplicationUri.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Reuse, never silent rotation.</b> 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
|
||||
/// <see cref="OpcUaGlobalOptions.AutoCreateApplicationCertificate"/> is on.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>None stays untouched.</b> Unsecured sessions keep the empty identifier and never read or
|
||||
/// write the own store.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class OpcUaApplicationIdentity
|
||||
{
|
||||
/// <summary>Default application name when the configured one is blank.</summary>
|
||||
internal const string DefaultApplicationName = "ScadaBridge-DCL";
|
||||
|
||||
/// <summary>Minimum RSA key size accepted for (and used when creating) the application certificate.</summary>
|
||||
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<ITelemetryContext> 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<string, SemaphoreSlim> ProvisionGates =
|
||||
new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a configured PKI store path; empty falls back to
|
||||
/// <c>{temp}/ScadaBridge/pki/{fallbackLeaf}</c>. The single definition shared by the client,
|
||||
/// the verify probe and the site's <c>CertStoreActor</c>.
|
||||
/// </summary>
|
||||
/// <param name="configured">The configured path (may be blank).</param>
|
||||
/// <param name="fallbackLeaf">Leaf directory used under the temp root when blank.</param>
|
||||
/// <returns>The store directory.</returns>
|
||||
public static string ResolveStorePath(string configured, string fallbackLeaf) =>
|
||||
string.IsNullOrWhiteSpace(configured)
|
||||
? Path.Combine(Path.GetTempPath(), "ScadaBridge", "pki", fallbackLeaf)
|
||||
: configured;
|
||||
|
||||
/// <summary>
|
||||
/// Directory inside a store where the SDK's directory store keeps certificates
|
||||
/// (<c>{store}/certs</c>). Trusting a peer means writing its DER here — a DER in the store
|
||||
/// ROOT is invisible to certificate validation.
|
||||
/// </summary>
|
||||
/// <param name="storePath">The store root.</param>
|
||||
/// <returns>The certificate sub-directory.</returns>
|
||||
public static string CertificatesDirectory(string storePath) => Path.Combine(storePath, "certs");
|
||||
|
||||
/// <summary>The effective application name.</summary>
|
||||
/// <param name="options">Global OPC UA options.</param>
|
||||
/// <returns>The configured name, or the default when blank.</returns>
|
||||
public static string ResolveApplicationName(OpcUaGlobalOptions options) =>
|
||||
string.IsNullOrWhiteSpace(options.ApplicationName) ? DefaultApplicationName : options.ApplicationName;
|
||||
|
||||
/// <summary>The effective ApplicationUri (configured, else <c>urn:{host}:{ApplicationName}</c>).</summary>
|
||||
/// <param name="options">Global OPC UA options.</param>
|
||||
/// <returns>The ApplicationUri.</returns>
|
||||
public static string ResolveApplicationUri(OpcUaGlobalOptions options) =>
|
||||
string.IsNullOrWhiteSpace(options.ApplicationUri)
|
||||
? $"urn:{Dns.GetHostName()}:{ResolveApplicationName(options)}"
|
||||
: options.ApplicationUri;
|
||||
|
||||
/// <summary>The effective certificate subject (configured, else <c>CN={ApplicationName}</c>).</summary>
|
||||
/// <param name="options">Global OPC UA options.</param>
|
||||
/// <returns>The subject distinguished name.</returns>
|
||||
public static string ResolveSubject(OpcUaGlobalOptions options) =>
|
||||
string.IsNullOrWhiteSpace(options.ApplicationCertificateSubject)
|
||||
? $"CN={ResolveApplicationName(options)}"
|
||||
: options.ApplicationCertificateSubject;
|
||||
|
||||
/// <summary>
|
||||
/// Builds and validates a client <see cref="ApplicationConfiguration"/>. When
|
||||
/// <paramref name="secured"/> 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.
|
||||
/// </summary>
|
||||
/// <param name="options">Global OPC UA identity and store paths.</param>
|
||||
/// <param name="secured">True for Sign / SignAndEncrypt; false for None.</param>
|
||||
/// <param name="autoAcceptUntrusted">The connection's AutoAccept flag (the probe always passes false).</param>
|
||||
/// <param name="sessionTimeoutMs">Default session timeout.</param>
|
||||
/// <param name="operationTimeoutMs">Transport operation timeout.</param>
|
||||
/// <param name="logger">Logger for the created/reused outcome.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The validated configuration. The caller owns it — see <see cref="ReleaseApplicationCertificate"/>.</returns>
|
||||
internal static async Task<ApplicationConfiguration> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="options">Global OPC UA identity and store paths.</param>
|
||||
/// <param name="logger">Logger for the created/reused outcome.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The application certificate without its private key; the caller disposes it.</returns>
|
||||
public static async Task<X509Certificate2> 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disposes the private-key-bearing application certificate a configuration loaded. Call
|
||||
/// only once every session built from <paramref name="appConfig"/> is closed and disposed.
|
||||
/// </summary>
|
||||
/// <param name="appConfig">A configuration from <see cref="BuildClientConfigurationAsync"/>; null is a no-op.</param>
|
||||
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<string> ExistingThumbprints(string storePath)
|
||||
{
|
||||
var set = new HashSet<string>(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;
|
||||
}
|
||||
}
|
||||
@@ -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<RealOpcUaClient> _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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<string> CreateSubscriptionAsync(
|
||||
string nodeId, Action<string, object?, DateTime, uint> 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)
|
||||
|
||||
@@ -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.
|
||||
/// </summary>
|
||||
public class OpcUaGlobalOptions
|
||||
{
|
||||
/// <summary>OPC UA application name used in the application certificate and session negotiation.</summary>
|
||||
public string ApplicationName { get; set; } = "ScadaBridge-DCL";
|
||||
|
||||
/// <summary>
|
||||
/// OPC UA ApplicationUri presented in the session and embedded (as the SAN URI) in the
|
||||
/// application certificate. Empty = <c>urn:{host name}:{ApplicationName}</c>. 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.
|
||||
/// </summary>
|
||||
public string ApplicationUri { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Subject distinguished name of the application certificate. Empty = <c>CN={ApplicationName}</c>.
|
||||
/// The certificate is located in <see cref="OwnCertificateStorePath"/> by this subject.
|
||||
/// </summary>
|
||||
public string ApplicationCertificateSubject { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// File system path to the application's OWN certificate store (certificate + private key),
|
||||
/// used for Sign / SignAndEncrypt sessions. Empty = a default under Path.GetTempPath().
|
||||
/// </summary>
|
||||
public string OwnCertificateStorePath { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// When true (default) a self-signed application certificate is generated in
|
||||
/// <see cref="OwnCertificateStorePath"/> 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.
|
||||
/// </summary>
|
||||
public bool AutoCreateApplicationCertificate { get; set; } = true;
|
||||
|
||||
/// <summary>Lifetime, in months, of an auto-created application certificate (1–600).</summary>
|
||||
public int ApplicationCertificateLifetimeMonths { get; set; } = 24;
|
||||
|
||||
/// <summary>File system path to the trusted issuer certificate store.</summary>
|
||||
public string TrustedIssuerStorePath { get; set; } = "";
|
||||
/// <summary>File system path to the trusted peer certificate store.</summary>
|
||||
|
||||
@@ -5,7 +5,10 @@ namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer;
|
||||
/// <summary>Validates the OPC UA application identity before connections start.</summary>
|
||||
public sealed class OpcUaGlobalOptionsValidator : IValidateOptions<OpcUaGlobalOptions>
|
||||
{
|
||||
/// <summary>Fails validation when the OPC UA application name is blank.</summary>
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="name">The named options instance being validated.</param>
|
||||
/// <param name="options">The options to validate.</param>
|
||||
/// <returns>The validation result.</returns>
|
||||
@@ -13,6 +16,13 @@ public sealed class OpcUaGlobalOptionsValidator : IValidateOptions<OpcUaGlobalOp
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(options.ApplicationName))
|
||||
return ValidateOptionsResult.Fail("ScadaBridge:OpcUa:ApplicationName must not be blank.");
|
||||
if (!string.IsNullOrWhiteSpace(options.ApplicationUri)
|
||||
&& !Uri.TryCreate(options.ApplicationUri, UriKind.Absolute, out _))
|
||||
return ValidateOptionsResult.Fail(
|
||||
$"ScadaBridge:OpcUa:ApplicationUri '{options.ApplicationUri}' must be an absolute URI (e.g. urn:host:ScadaBridge-DCL).");
|
||||
if (options.ApplicationCertificateLifetimeMonths is < 1 or > 600)
|
||||
return ValidateOptionsResult.Fail(
|
||||
"ScadaBridge:OpcUa:ApplicationCertificateLifetimeMonths must be between 1 and 600.");
|
||||
return ValidateOptionsResult.Success;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
/// <param name="opcUaGlobalOptions">Deployment-wide OPC UA options (store paths).</param>
|
||||
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<WriteCertToLocalStore>(HandleWrite);
|
||||
Receive<RemoveCertFromLocalStore>(HandleRemove);
|
||||
@@ -54,14 +57,48 @@ public class CertStoreActor : ReceiveActor
|
||||
Receive<ExportLocalTrustedCerts>(_ => HandleExport());
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void PreStart()
|
||||
{
|
||||
base.PreStart();
|
||||
MigrateLegacyRootCertificates();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replicates <c>RealOpcUaClient.ResolveStorePath</c>: an empty configured
|
||||
/// path falls back to <c>%TEMP%/ScadaBridge/pki/<leaf></c>.
|
||||
/// Moves certificates a pre-#121 build trusted into the store ROOT down into <c>certs/</c>,
|
||||
/// 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 <c>certs/</c>
|
||||
/// wins and the legacy copy is removed.
|
||||
/// </summary>
|
||||
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)
|
||||
{
|
||||
|
||||
+241
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #121: the real <see cref="RealOpcUaClient"/> against a real, strictly-trusting
|
||||
/// in-process server. Before the fix every Sign / SignAndEncrypt session failed before
|
||||
/// activation with <c>ApplicationCertificate … cannot be found</c> (BadConfigurationError)
|
||||
/// because the client never configured an application certificate. These tests run the actual
|
||||
/// adapter through both <see cref="RealOpcUaClient.ConnectAsync"/> and
|
||||
/// <see cref="RealOpcUaClient.VerifyEndpointAsync"/>, with AutoAccept OFF on both sides.
|
||||
/// </summary>
|
||||
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"));
|
||||
|
||||
/// <inheritdoc />
|
||||
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");
|
||||
|
||||
/// <summary>Trusts the server on the client side exactly as the site CertStoreActor does.</summary>
|
||||
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<X509Certificate2> ProvisionClientAsync(OpcUaGlobalOptions? options = null) =>
|
||||
await OpcUaApplicationIdentity.EnsureApplicationCertificateAsync(options ?? ClientOptions(), NullLogger.Instance);
|
||||
|
||||
/// <summary>Mutual trust: connects, reads, reconnects and restarts on ONE persisted identity.</summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>Strict server trust: a server the client has not trusted is refused, and quarantined in rejected/.</summary>
|
||||
[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<Opc.Ua.ServiceResultException>(() => 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);
|
||||
}
|
||||
|
||||
/// <summary>Strict mutual trust: a server that has not trusted the client refuses the channel.</summary>
|
||||
[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<Opc.Ua.ServiceResultException>(() => 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);
|
||||
}
|
||||
|
||||
/// <summary>The verify probe presents the same identity: success under mutual trust.</summary>
|
||||
[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"));
|
||||
}
|
||||
|
||||
/// <summary>The probe captures — and never trusts — an untrusted server certificate.</summary>
|
||||
[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");
|
||||
}
|
||||
|
||||
/// <summary>The probe fails when the server has not trusted the client.</summary>
|
||||
[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
|
||||
}
|
||||
|
||||
/// <summary>None is unchanged: it connects without reading or creating the own store.</summary>
|
||||
[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")));
|
||||
}
|
||||
|
||||
/// <summary>A persisted identity is reused, never rotated — including when concurrent connects race on an empty store.</summary>
|
||||
[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();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A persisted certificate that no longer matches the ApplicationUri fails closed instead of being replaced.</summary>
|
||||
[Fact]
|
||||
public async Task Provisioning_ApplicationUriMismatch_FailsWithoutRotating()
|
||||
{
|
||||
using var original = await ProvisionClientAsync();
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => 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);
|
||||
}
|
||||
|
||||
/// <summary>With auto-create off, a missing certificate fails the secured connect rather than inventing one.</summary>
|
||||
[Fact]
|
||||
public async Task Provisioning_AutoCreateDisabled_MissingCertificateFails()
|
||||
{
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => ProvisionClientAsync(ClientOptions(autoCreate: false)));
|
||||
Assert.False(Directory.Exists(OwnCertsDir) && Directory.GetFiles(OwnCertsDir).Length > 0);
|
||||
}
|
||||
}
|
||||
+116
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// An in-process OPC Foundation <see cref="StandardServer"/> exposing None, Sign and
|
||||
/// SignAndEncrypt (Basic256Sha256) endpoints with STRICT client trust
|
||||
/// (<c>AutoAcceptUntrustedCertificates = false</c>) and its own temp PKI — the Gitea #121
|
||||
/// counterpart for real mutual-trust tests of <c>RealOpcUaClient</c>.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>The endpoint URL (localhost, so the server certificate's domain matches).</summary>
|
||||
public string Url { get; }
|
||||
|
||||
/// <summary>The server's application certificate (public part).</summary>
|
||||
public X509Certificate2 Certificate { get; }
|
||||
|
||||
/// <summary>Starts a server that trusts exactly <paramref name="trustedClients"/>.</summary>
|
||||
/// <param name="trustedClients">Client certificates the server's trusted peer store holds at start.</param>
|
||||
/// <returns>The running server.</returns>
|
||||
public static async Task<SecureOpcUaTestServer> 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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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;
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests;
|
||||
|
||||
/// <summary>Gitea #121: validation of the OPC UA application identity options.</summary>
|
||||
public class OpcUaGlobalOptionsValidatorTests
|
||||
{
|
||||
private static bool Valid(OpcUaGlobalOptions o) => new OpcUaGlobalOptionsValidator().Validate(null, o).Succeeded;
|
||||
|
||||
/// <summary>Defaults (blank URI, 24-month lifetime) are valid.</summary>
|
||||
[Fact]
|
||||
public void Defaults_AreValid() => Assert.True(Valid(new OpcUaGlobalOptions()));
|
||||
|
||||
/// <summary>An explicit urn ApplicationUri is accepted; a relative one is not.</summary>
|
||||
[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 }));
|
||||
|
||||
/// <summary>Certificate lifetime is bounded to 1–600 months.</summary>
|
||||
[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 }));
|
||||
}
|
||||
+1
@@ -13,6 +13,7 @@
|
||||
<PackageReference Include="coverlet.collector" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="NSubstitute" />
|
||||
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Server" />
|
||||
<PackageReference Include="xunit" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" />
|
||||
<!--
|
||||
|
||||
@@ -84,8 +84,9 @@ public class CertStoreActorTests : TestKit, IDisposable
|
||||
writeAck.Success.Should().BeTrue();
|
||||
writeAck.Error.Should().BeNull();
|
||||
|
||||
var expectedFile = Path.Combine(_trustedDir, $"{thumbprint}.der");
|
||||
File.Exists(expectedFile).Should().BeTrue("the cert is written into the trusted-peer store");
|
||||
// Gitea #121: the SDK's directory store reads {store}/certs — a DER in the root is inert.
|
||||
var expectedFile = Path.Combine(_trustedDir, "certs", $"{thumbprint}.der");
|
||||
File.Exists(expectedFile).Should().BeTrue("the cert is written where the OPC UA SDK validates against");
|
||||
|
||||
// List — the cert appears, not rejected
|
||||
actor.Tell(new ListLocalCerts());
|
||||
@@ -109,6 +110,43 @@ public class CertStoreActorTests : TestKit, IDisposable
|
||||
emptyAck.Certs!.Should().NotContain(c => c.Thumbprint == thumbprint);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #121: a cert a pre-fix build trusted into the store ROOT is moved into certs/ when
|
||||
/// the actor starts, so already-granted trust becomes effective on upgrade.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void LegacyRootTrustedCert_IsMovedIntoCertsOnStart()
|
||||
{
|
||||
var (derBase64, thumbprint) = BuildSelfSignedCert();
|
||||
Directory.CreateDirectory(_trustedDir);
|
||||
var legacy = Path.Combine(_trustedDir, $"{thumbprint}.der");
|
||||
File.WriteAllBytes(legacy, Convert.FromBase64String(derBase64));
|
||||
|
||||
var actor = Sys.ActorOf(Props.Create(() => new CertStoreActor(_options)));
|
||||
actor.Tell(new ListLocalCerts());
|
||||
var listAck = ExpectMsg<LocalCertOpAck>();
|
||||
|
||||
File.Exists(legacy).Should().BeFalse();
|
||||
File.Exists(Path.Combine(_trustedDir, "certs", $"{thumbprint}.der")).Should().BeTrue();
|
||||
listAck.Certs!.Should().ContainSingle(c => c.Thumbprint == thumbprint).Which.Rejected.Should().BeFalse();
|
||||
}
|
||||
|
||||
/// <summary>Gitea #121: certificates the SDK rejected (written to rejected/certs) are listed as rejected.</summary>
|
||||
[Fact]
|
||||
public void SdkRejectedCert_IsListedAsRejected()
|
||||
{
|
||||
var (derBase64, thumbprint) = BuildSelfSignedCert();
|
||||
var rejectedCerts = Path.Combine(_rejectedDir, "certs");
|
||||
Directory.CreateDirectory(rejectedCerts);
|
||||
File.WriteAllBytes(Path.Combine(rejectedCerts, $"{thumbprint}.der"), Convert.FromBase64String(derBase64));
|
||||
|
||||
var actor = Sys.ActorOf(Props.Create(() => new CertStoreActor(_options)));
|
||||
actor.Tell(new ListLocalCerts());
|
||||
|
||||
ExpectMsg<LocalCertOpAck>().Certs!.Should().ContainSingle(c => c.Thumbprint == thumbprint)
|
||||
.Which.Rejected.Should().BeTrue();
|
||||
}
|
||||
|
||||
/// <summary>Verifies the case: export Local Trusted Certs, returns Thumbprint And Der Bytes.</summary>
|
||||
[Fact]
|
||||
public void ExportLocalTrustedCerts_ReturnsThumbprintAndDerBytes()
|
||||
|
||||
Reference in New Issue
Block a user