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:
@@ -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 */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user