From 36c4751571d41db579440ed61c120c2186f4a067 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 26 May 2026 07:34:48 -0400 Subject: [PATCH] =?UTF-8?q?feat(opcua):=20F13a=20=E2=80=94=20cert=20auto-c?= =?UTF-8?q?reation=20in=20OpcUaApplicationHost?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds OPC UA SDK's CheckApplicationInstanceCertificate call to OpcUaApplicationHost.StartAsync, removing the v1 friction of needing to pre-create the PKI directory tree before booting. - New OpcUaApplicationHostOptions.PkiStoreRoot (defaults to "pki") - BuildConfigurationAsync now derives own/issuer/trusted/rejected from PkiStoreRoot so the cert paths are configurable + consistent - EnsureApplicationCertificateAsync runs before StandardServer.Start, and fails fast with a clear message if the SDK can't produce a valid cert - 2 new tests: fresh-tree creates a cert, second boot reuses it Partial slice of follow-up F13. Endpoint-security, user-token validator, and observability wiring still pending in the F13 follow-up. OpcUaServer tests: 4 → 6. --- .../OpcUaApplicationHost.cs | 45 ++++++-- .../OpcUaApplicationHostTests.cs | 100 ++++++++++++++++++ ...ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests.csproj | 1 + 3 files changed, 140 insertions(+), 6 deletions(-) create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/OpcUaApplicationHostTests.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs index 1dbaa27..0879722 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs @@ -19,6 +19,13 @@ public sealed class OpcUaApplicationHostOptions /// Application config XML path; when set, loaded instead of building from defaults. public string? ApplicationConfigPath { get; set; } + + /// + /// Root of the application's PKI hierarchy. Sub-stores (own, issuer, + /// trusted, rejected) are created under this path on first start. Defaults + /// to "pki" (relative to the host's working directory) to keep dev flows identical to v1. + /// + public string PkiStoreRoot { get; set; } = "pki"; } /// @@ -60,14 +67,35 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable }; _ = await BuildConfigurationAsync(cancellationToken); - // Certificate validation + auto-creation is part of the full extraction (F13). - // For the facade we trust that the configured cert store already exists. + await EnsureApplicationCertificateAsync(cancellationToken).ConfigureAwait(false); await _application.Start(server).ConfigureAwait(false); _logger.LogInformation("OPC UA server started on opc.tcp://{Host}:{Port}", _options.PublicHostname, _options.OpcUaPort); } + /// + /// Guarantees the application instance certificate exists in {PkiStoreRoot}/own. + /// The SDK auto-creates a self-signed certificate the first time this is called on a fresh + /// PKI tree; subsequent boots reuse the existing cert. Replaces v1's manual "you must + /// pre-create the PKI directory tree" friction. Partial slice of follow-up F13 — the + /// remaining endpoint-security, user-token validator, and observability wiring stays in + /// the follow-up queue. + /// + private async Task EnsureApplicationCertificateAsync(CancellationToken cancellationToken) + { + // silent: false → SDK logs cert creation events through its own trace plumbing. + // minimumKeySize/lifetimeInMonths: 0 → use SDK defaults (2048-bit, 12-month lifetime). + var ok = await _application!.CheckApplicationInstanceCertificate( + silent: false, minimumKeySize: 0, lifeTimeInMonths: 0, ct: cancellationToken).ConfigureAwait(false); + if (!ok) + { + throw new InvalidOperationException( + $"OPC UA application certificate validation failed for {_options.ApplicationName}. " + + $"Cert store root: {Path.GetFullPath(_options.PkiStoreRoot)}"); + } + } + private async Task BuildConfigurationAsync(CancellationToken ct) { if (!string.IsNullOrWhiteSpace(_options.ApplicationConfigPath)) @@ -92,10 +120,15 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable }, SecurityConfiguration = new SecurityConfiguration { - ApplicationCertificate = new CertificateIdentifier { StoreType = "Directory", StorePath = "pki/own", SubjectName = $"CN={_options.ApplicationName}" }, - TrustedIssuerCertificates = new CertificateTrustList { StoreType = "Directory", StorePath = "pki/issuer" }, - TrustedPeerCertificates = new CertificateTrustList { StoreType = "Directory", StorePath = "pki/trusted" }, - RejectedCertificateStore = new CertificateTrustList { StoreType = "Directory", StorePath = "pki/rejected" }, + ApplicationCertificate = new CertificateIdentifier + { + StoreType = "Directory", + StorePath = Path.Combine(_options.PkiStoreRoot, "own"), + SubjectName = $"CN={_options.ApplicationName}", + }, + TrustedIssuerCertificates = new CertificateTrustList { StoreType = "Directory", StorePath = Path.Combine(_options.PkiStoreRoot, "issuer") }, + TrustedPeerCertificates = new CertificateTrustList { StoreType = "Directory", StorePath = Path.Combine(_options.PkiStoreRoot, "trusted") }, + RejectedCertificateStore = new CertificateTrustList { StoreType = "Directory", StorePath = Path.Combine(_options.PkiStoreRoot, "rejected") }, AutoAcceptUntrustedCertificates = false, }, TransportQuotas = new TransportQuotas(), diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/OpcUaApplicationHostTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/OpcUaApplicationHostTests.cs new file mode 100644 index 0000000..7d92c35 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/OpcUaApplicationHostTests.cs @@ -0,0 +1,100 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Opc.Ua.Server; +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; + +/// +/// Verifies the F13a cert auto-creation slice of : the SDK +/// self-signs a certificate into {PkiStoreRoot}/own/certs/ on first boot against a +/// fresh PKI tree, and the file persists for reuse on the next boot. +/// +public sealed class OpcUaApplicationHostTests : IDisposable +{ + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + private readonly string _pkiRoot = Path.Combine( + Path.GetTempPath(), + $"otopcua-pki-{Guid.NewGuid():N}"); + + [Fact] + public async Task StartAsync_creates_application_certificate_in_pki_own() + { + await using var host = new OpcUaApplicationHost( + new OpcUaApplicationHostOptions + { + ApplicationName = "OtOpcUa.Test", + ApplicationUri = $"urn:OtOpcUa.Test:{Guid.NewGuid():N}", + OpcUaPort = AllocateFreePort(), + PublicHostname = "localhost", + PkiStoreRoot = _pkiRoot, + }, + NullLogger.Instance); + + await host.StartAsync(new StandardServer(), Ct); + + var ownCerts = Path.Combine(_pkiRoot, "own", "certs"); + Directory.Exists(ownCerts).ShouldBeTrue($"expected SDK to create {ownCerts}"); + Directory.EnumerateFiles(ownCerts).ShouldNotBeEmpty("expected a self-signed cert file in the own store"); + } + + [Fact] + public async Task StartAsync_reuses_existing_certificate_on_second_boot() + { + var port1 = AllocateFreePort(); + await using (var first = new OpcUaApplicationHost( + new OpcUaApplicationHostOptions + { + ApplicationName = "OtOpcUa.Reuse", + ApplicationUri = "urn:OtOpcUa.Reuse", + OpcUaPort = port1, + PublicHostname = "localhost", + PkiStoreRoot = _pkiRoot, + }, + NullLogger.Instance)) + { + await first.StartAsync(new StandardServer(), Ct); + } + + var ownCerts = Path.Combine(_pkiRoot, "own", "certs"); + var firstFiles = Directory.GetFiles(ownCerts).OrderBy(f => f).ToArray(); + firstFiles.ShouldNotBeEmpty(); + + var port2 = AllocateFreePort(); + await using (var second = new OpcUaApplicationHost( + new OpcUaApplicationHostOptions + { + ApplicationName = "OtOpcUa.Reuse", + ApplicationUri = "urn:OtOpcUa.Reuse", + OpcUaPort = port2, + PublicHostname = "localhost", + PkiStoreRoot = _pkiRoot, + }, + NullLogger.Instance)) + { + await second.StartAsync(new StandardServer(), Ct); + } + + var secondFiles = Directory.GetFiles(ownCerts).OrderBy(f => f).ToArray(); + secondFiles.ShouldBe(firstFiles, "expected the second boot to reuse the cert from the first, not create a new one"); + } + + private static int AllocateFreePort() + { + using var listener = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0); + listener.Start(); + var port = ((System.Net.IPEndPoint)listener.LocalEndpoint).Port; + listener.Stop(); + return port; + } + + public void Dispose() + { + if (Directory.Exists(_pkiRoot)) + { + try { Directory.Delete(_pkiRoot, recursive: true); } + catch { /* best-effort cleanup */ } + } + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests.csproj b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests.csproj index 7177975..01b799f 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests.csproj +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests.csproj @@ -15,6 +15,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive +