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 */ }
+ }
+ }
+}