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:
Joseph Doherty
2026-11-10 13:34:55 -05:00
parent c810bd0f7c
commit ff8354478c
22 changed files with 948 additions and 74 deletions
@@ -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/&lt;leaf&gt;</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)
{