feat(opcua): F13b endpoint security profiles — Sign + SignAndEncrypt

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).
This commit is contained in:
Joseph Doherty
2026-05-26 10:15:04 -04:00
parent 50787823d3
commit 8b08566f41
3 changed files with 281 additions and 10 deletions

View File

@@ -5,6 +5,22 @@ using Opc.Ua.Server;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
/// <summary>
/// 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 <see cref="OpcUaSecurityProfile.PolicyUri"/>+<see cref="OpcUaSecurityProfile.Mode"/>
/// — the wiring in <c>BuildConfigurationAsync</c> is profile-agnostic.
/// </summary>
public enum OpcUaSecurityProfile
{
/// <summary>No signing or encryption. Dev / isolated networks only.</summary>
None,
/// <summary>Basic256Sha256 + Sign. Messages signed, payload visible on the wire.</summary>
Basic256Sha256Sign,
/// <summary>Basic256Sha256 + SignAndEncrypt. Full transport protection.</summary>
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.
/// </summary>
public string PkiStoreRoot { get; set; } = "pki";
/// <summary>
/// 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.
/// </summary>
public IList<OpcUaSecurityProfile> EnabledSecurityProfiles { get; set; } = new List<OpcUaSecurityProfile>
{
OpcUaSecurityProfile.None,
OpcUaSecurityProfile.Basic256Sha256Sign,
OpcUaSecurityProfile.Basic256Sha256SignAndEncrypt,
};
/// <summary>
/// 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 <c>None</c> endpoints, which don't exchange certs.
/// </summary>
public bool AutoAcceptUntrustedClientCertificates { get; set; }
}
/// <summary>
@@ -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;
}
/// <summary>
/// Maps each configured <see cref="OpcUaSecurityProfile"/> to a SDK
/// <see cref="ServerSecurityPolicy"/>. Duplicate profiles are silently de-duped because
/// the SDK rejects duplicate (policy,mode) pairs at <c>Validate</c> 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.
/// </summary>
internal static IEnumerable<ServerSecurityPolicy> BuildSecurityPolicies(IEnumerable<OpcUaSecurityProfile> profiles)
{
var seen = new HashSet<OpcUaSecurityProfile>();
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,
};
}
}
/// <summary>
/// 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
/// <c>StandardServer.SessionManager.ImpersonateUser</c>.
/// </summary>
internal static IEnumerable<UserTokenPolicy> 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(); }

View File

@@ -19,6 +19,10 @@
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Configuration\ZB.MOM.WW.OtOpcUa.Configuration.csproj"/>
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests"/>
</ItemGroup>
<ItemGroup>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>

View File

@@ -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;
/// <summary>
/// F13b — verifies <see cref="OpcUaApplicationHost"/> publishes one
/// <see cref="ServerSecurityPolicy"/> per <see cref="OpcUaSecurityProfile"/> and emits both
/// Anonymous and UserName <see cref="UserTokenPolicy"/> entries. The pure-builder tests run
/// cross-platform without touching disk; the boot-verify test reuses the F13a PKI pattern.
/// </summary>
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<OpcUaSecurityProfile>()).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<OpcUaApplicationHost>.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> { OpcUaSecurityProfile.Basic256Sha256SignAndEncrypt },
AutoAcceptUntrustedClientCertificates = false,
},
NullLogger<OpcUaApplicationHost>.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 */ }
}
}
}