From 8b08566f4177c8893e494c721fcc0a93e11ba234 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 26 May 2026 10:15:04 -0400 Subject: [PATCH] =?UTF-8?q?feat(opcua):=20F13b=20endpoint=20security=20pro?= =?UTF-8?q?files=20=E2=80=94=20Sign=20+=20SignAndEncrypt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpcUaApplicationHost.BuildConfigurationAsync now populates ServerConfiguration.SecurityPolicies + UserTokenPolicies from the new OpcUaSecurityProfile enum on OpcUaApplicationHostOptions. Defaults expose all three baseline profiles (None + Basic256Sha256-Sign + Basic256Sha256-SignAndEncrypt) matching docs/security.md. UserName tokens are SDK-encrypted with the server cert so they work on None endpoints too; F13c will plug the LDAP validator into SessionManager. AutoAcceptUntrustedClientCertificates surfaces as an option for dev flows; production keeps the default (false) and operators promote rejected certs through the Admin UI. InternalsVisibleTo added so BuildSecurityPolicies / BuildUserTokenPolicies stay encapsulated but unit-testable. 6 new tests cover the pure builders + two boot-verify cases (3-profile default + hardened single-profile), bringing the suite to 34 / 34 passing. Closes #103. Unblocks #104 (F13c LDAP user-token validator). --- .../OpcUaApplicationHost.cs | 130 +++++++++++++-- .../ZB.MOM.WW.OtOpcUa.OpcUaServer.csproj | 4 + .../OpcUaApplicationHostSecurityTests.cs | 157 ++++++++++++++++++ 3 files changed, 281 insertions(+), 10 deletions(-) create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/OpcUaApplicationHostSecurityTests.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs index 0879722..9ac2847 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs @@ -5,6 +5,22 @@ using Opc.Ua.Server; namespace ZB.MOM.WW.OtOpcUa.OpcUaServer; +/// +/// Transport-security profile served by the OPC UA endpoint. F13b ships the three baseline +/// profiles defined by docs/security.md; the remaining Aes128/Aes256 variants can be added +/// later by extending + +/// — the wiring in BuildConfigurationAsync is profile-agnostic. +/// +public enum OpcUaSecurityProfile +{ + /// No signing or encryption. Dev / isolated networks only. + None, + /// Basic256Sha256 + Sign. Messages signed, payload visible on the wire. + Basic256Sha256Sign, + /// Basic256Sha256 + SignAndEncrypt. Full transport protection. + Basic256Sha256SignAndEncrypt, +} + public sealed class OpcUaApplicationHostOptions { public string ApplicationName { get; set; } = "OtOpcUa"; @@ -26,6 +42,26 @@ public sealed class OpcUaApplicationHostOptions /// to "pki" (relative to the host's working directory) to keep dev flows identical to v1. /// public string PkiStoreRoot { get; set; } = "pki"; + + /// + /// Transport-security profiles exposed by the server. The SDK publishes one endpoint + /// descriptor per profile and clients choose at session open. Default = all three + /// baseline profiles (None + Basic256Sha256 in both modes); production deployments + /// typically drop None. + /// + public IList EnabledSecurityProfiles { get; set; } = new List + { + OpcUaSecurityProfile.None, + OpcUaSecurityProfile.Basic256Sha256Sign, + OpcUaSecurityProfile.Basic256Sha256SignAndEncrypt, + }; + + /// + /// When true, unknown client certificates are auto-added to the trusted store on first + /// connection. Convenient for dev; should be false in production (operators promote via + /// the Admin UI). Has no effect on None endpoints, which don't exchange certs. + /// + public bool AutoAcceptUntrustedClientCertificates { get; set; } } /// @@ -103,21 +139,30 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable return await _application!.LoadApplicationConfiguration(_options.ApplicationConfigPath, silent: true); } - // Minimal defaults — security and certificate stores hardcoded to local files in - // the app's working directory. Full security wiring stays in legacy Server until F13. + var serverConfig = new ServerConfiguration + { + BaseAddresses = { $"opc.tcp://{_options.PublicHostname}:{_options.OpcUaPort}/OtOpcUa" }, + MinRequestThreadCount = 5, + MaxRequestThreadCount = 100, + MaxQueuedRequestCount = 200, + }; + + foreach (var policy in BuildSecurityPolicies(_options.EnabledSecurityProfiles)) + { + serverConfig.SecurityPolicies.Add(policy); + } + foreach (var token in BuildUserTokenPolicies()) + { + serverConfig.UserTokenPolicies.Add(token); + } + var config = new ApplicationConfiguration { ApplicationName = _options.ApplicationName, ApplicationUri = _options.ApplicationUri, ProductUri = _options.ProductUri, ApplicationType = ApplicationType.Server, - ServerConfiguration = new ServerConfiguration - { - BaseAddresses = { $"opc.tcp://{_options.PublicHostname}:{_options.OpcUaPort}/OtOpcUa" }, - MinRequestThreadCount = 5, - MaxRequestThreadCount = 100, - MaxQueuedRequestCount = 200, - }, + ServerConfiguration = serverConfig, SecurityConfiguration = new SecurityConfiguration { ApplicationCertificate = new CertificateIdentifier @@ -129,7 +174,7 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable 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, + AutoAcceptUntrustedCertificates = _options.AutoAcceptUntrustedClientCertificates, }, TransportQuotas = new TransportQuotas(), ClientConfiguration = new ClientConfiguration(), @@ -141,6 +186,71 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable return config; } + /// + /// Maps each configured to a SDK + /// . Duplicate profiles are silently de-duped because + /// the SDK rejects duplicate (policy,mode) pairs at Validate time. Empty input + /// falls back to a single None entry so the server doesn't refuse to start with no + /// listening endpoints — the misconfiguration is logged and very visible. + /// + internal static IEnumerable BuildSecurityPolicies(IEnumerable profiles) + { + var seen = new HashSet(); + var any = false; + foreach (var profile in profiles) + { + if (!seen.Add(profile)) continue; + any = true; + yield return profile switch + { + OpcUaSecurityProfile.None => new ServerSecurityPolicy + { + SecurityMode = MessageSecurityMode.None, + SecurityPolicyUri = SecurityPolicies.None, + }, + OpcUaSecurityProfile.Basic256Sha256Sign => new ServerSecurityPolicy + { + SecurityMode = MessageSecurityMode.Sign, + SecurityPolicyUri = SecurityPolicies.Basic256Sha256, + }, + OpcUaSecurityProfile.Basic256Sha256SignAndEncrypt => new ServerSecurityPolicy + { + SecurityMode = MessageSecurityMode.SignAndEncrypt, + SecurityPolicyUri = SecurityPolicies.Basic256Sha256, + }, + _ => throw new InvalidOperationException($"Unknown OpcUaSecurityProfile: {profile}"), + }; + } + if (!any) + { + yield return new ServerSecurityPolicy + { + SecurityMode = MessageSecurityMode.None, + SecurityPolicyUri = SecurityPolicies.None, + }; + } + } + + /// + /// Anonymous + UserName token policies. UserName tokens are always SDK-encrypted with + /// the server certificate (see docs/security.md "UserName token encryption") so the + /// policy works on None endpoints too. F13c will plug a real LDAP-bound validator into + /// StandardServer.SessionManager.ImpersonateUser. + /// + internal static IEnumerable BuildUserTokenPolicies() + { + yield return new UserTokenPolicy(UserTokenType.Anonymous) + { + PolicyId = "anonymous", + SecurityPolicyUri = SecurityPolicies.None, + }; + yield return new UserTokenPolicy(UserTokenType.UserName) + { + PolicyId = "username_basic256sha256", + SecurityPolicyUri = SecurityPolicies.Basic256Sha256, + }; + } + public ValueTask DisposeAsync() { try { _application?.Stop(); } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/ZB.MOM.WW.OtOpcUa.OpcUaServer.csproj b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/ZB.MOM.WW.OtOpcUa.OpcUaServer.csproj index bece604..c94c0b2 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/ZB.MOM.WW.OtOpcUa.OpcUaServer.csproj +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/ZB.MOM.WW.OtOpcUa.OpcUaServer.csproj @@ -19,6 +19,10 @@ + + + + diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/OpcUaApplicationHostSecurityTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/OpcUaApplicationHostSecurityTests.cs new file mode 100644 index 0000000..67e8f04 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/OpcUaApplicationHostSecurityTests.cs @@ -0,0 +1,157 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Opc.Ua; +using Opc.Ua.Server; +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; + +/// +/// F13b — verifies publishes one +/// per and emits both +/// Anonymous and UserName entries. The pure-builder tests run +/// cross-platform without touching disk; the boot-verify test reuses the F13a PKI pattern. +/// +public sealed class OpcUaApplicationHostSecurityTests : IDisposable +{ + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + private readonly string _pkiRoot = Path.Combine( + Path.GetTempPath(), + $"otopcua-pki-{Guid.NewGuid():N}"); + + [Fact] + public void BuildSecurityPolicies_default_set_emits_all_three_baseline_profiles() + { + var policies = OpcUaApplicationHost.BuildSecurityPolicies(new[] + { + OpcUaSecurityProfile.None, + OpcUaSecurityProfile.Basic256Sha256Sign, + OpcUaSecurityProfile.Basic256Sha256SignAndEncrypt, + }).ToList(); + + policies.Count.ShouldBe(3); + policies[0].SecurityMode.ShouldBe(MessageSecurityMode.None); + policies[0].SecurityPolicyUri.ShouldBe(SecurityPolicies.None); + policies[1].SecurityMode.ShouldBe(MessageSecurityMode.Sign); + policies[1].SecurityPolicyUri.ShouldBe(SecurityPolicies.Basic256Sha256); + policies[2].SecurityMode.ShouldBe(MessageSecurityMode.SignAndEncrypt); + policies[2].SecurityPolicyUri.ShouldBe(SecurityPolicies.Basic256Sha256); + } + + [Fact] + public void BuildSecurityPolicies_dedupes_repeated_profiles() + { + var policies = OpcUaApplicationHost.BuildSecurityPolicies(new[] + { + OpcUaSecurityProfile.Basic256Sha256SignAndEncrypt, + OpcUaSecurityProfile.Basic256Sha256SignAndEncrypt, + OpcUaSecurityProfile.None, + }).ToList(); + + policies.Count.ShouldBe(2); + policies[0].SecurityMode.ShouldBe(MessageSecurityMode.SignAndEncrypt); + policies[1].SecurityMode.ShouldBe(MessageSecurityMode.None); + } + + [Fact] + public void BuildSecurityPolicies_empty_input_falls_back_to_none() + { + var policies = OpcUaApplicationHost.BuildSecurityPolicies(Array.Empty()).ToList(); + + policies.Count.ShouldBe(1); + policies[0].SecurityMode.ShouldBe(MessageSecurityMode.None); + policies[0].SecurityPolicyUri.ShouldBe(SecurityPolicies.None); + } + + [Fact] + public void BuildUserTokenPolicies_emits_anonymous_and_username() + { + var tokens = OpcUaApplicationHost.BuildUserTokenPolicies().ToList(); + + tokens.Count.ShouldBe(2); + tokens.ShouldContain(t => t.TokenType == UserTokenType.Anonymous && t.PolicyId == "anonymous"); + var userName = tokens.Single(t => t.TokenType == UserTokenType.UserName); + userName.PolicyId.ShouldBe("username_basic256sha256"); + userName.SecurityPolicyUri.ShouldBe(SecurityPolicies.Basic256Sha256); + } + + [Fact] + public async Task StartAsync_populates_ServerConfiguration_with_all_enabled_profiles() + { + await using var host = new OpcUaApplicationHost( + new OpcUaApplicationHostOptions + { + ApplicationName = "OtOpcUa.SecAll", + ApplicationUri = $"urn:OtOpcUa.SecAll:{Guid.NewGuid():N}", + OpcUaPort = AllocateFreePort(), + PublicHostname = "localhost", + PkiStoreRoot = _pkiRoot, + EnabledSecurityProfiles = + { + OpcUaSecurityProfile.None, + OpcUaSecurityProfile.Basic256Sha256Sign, + OpcUaSecurityProfile.Basic256Sha256SignAndEncrypt, + }, + AutoAcceptUntrustedClientCertificates = true, + }, + NullLogger.Instance); + + await host.StartAsync(new StandardServer(), Ct); + + var config = host.ApplicationInstance!.ApplicationConfiguration; + config.ServerConfiguration.SecurityPolicies.Count.ShouldBe(3); + config.ServerConfiguration.UserTokenPolicies.Count.ShouldBe(2); + config.SecurityConfiguration.AutoAcceptUntrustedCertificates.ShouldBeTrue(); + + var modes = config.ServerConfiguration.SecurityPolicies + .Select(p => p.SecurityMode) + .OrderBy(m => (int)m) + .ToArray(); + modes.ShouldBe(new[] { MessageSecurityMode.None, MessageSecurityMode.Sign, MessageSecurityMode.SignAndEncrypt }); + } + + [Fact] + public async Task StartAsync_with_only_signandencrypt_omits_None_endpoint() + { + await using var host = new OpcUaApplicationHost( + new OpcUaApplicationHostOptions + { + ApplicationName = "OtOpcUa.SecHardened", + ApplicationUri = $"urn:OtOpcUa.SecHardened:{Guid.NewGuid():N}", + OpcUaPort = AllocateFreePort(), + PublicHostname = "localhost", + PkiStoreRoot = _pkiRoot, + EnabledSecurityProfiles = new List { OpcUaSecurityProfile.Basic256Sha256SignAndEncrypt }, + AutoAcceptUntrustedClientCertificates = false, + }, + NullLogger.Instance); + + await host.StartAsync(new StandardServer(), Ct); + + var policies = host.ApplicationInstance!.ApplicationConfiguration.ServerConfiguration.SecurityPolicies; + policies.Count.ShouldBe(1); + policies[0].SecurityMode.ShouldBe(MessageSecurityMode.SignAndEncrypt); + policies[0].SecurityPolicyUri.ShouldBe(SecurityPolicies.Basic256Sha256); + host.ApplicationInstance.ApplicationConfiguration.SecurityConfiguration + .AutoAcceptUntrustedCertificates.ShouldBeFalse(); + } + + 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 */ } + } + } +}