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:
@@ -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(); }
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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