dd939d3c97
Anonymous sessions used to short-circuit in HandleImpersonation — "let the SDK's
default validation stand" — so they always connected and always carried no roles.
Now admission and authority are separated:
admission -> OpcUa:AllowAnonymous, the HOST's decision (it owns token policies)
authority -> the authenticator, resolving the reserved principal "anonymous"
through the SAME grant path a key's principal goes through
So anonymous can be granted WRITE, not merely tolerated as read-only, and it
arrives at the downstream gates as the same RoleCarryingUserIdentity a key
produces — which is the real payoff: no gate carries an anonymous-shaped branch,
so there is no bypass path to get wrong.
AllowAnonymous defaults to TRUE on purpose. Defaulting it false would silently
deny every client without a key the moment this deploys, including ScadaBridge's
Data Connection Layer in another repo. Making keys mandatory is a rollout
decision, not an upgrade side effect. When false, the Anonymous token policy is
withheld so a client is refused at DISCOVERY rather than only at activation, and
activation is rejected independently in case a client attempts a token type the
endpoint never advertised.
Two fail-closed choices, both falsified:
- A failure to read anonymous's grants DENIES rather than admitting with no
roles. Admitting is the pre-#520 behaviour, and defaulting to it exactly when
the server cannot determine what anonymous may do stops being harmless once
reads are gated. Making it admit reds Anonymous_denies_when_grants_cannot_be_read.
- A key may not authenticate as the reserved "anonymous" principal, or a
credentialled client silently inherits whatever anonymous was granted.
AuthenticateAnonymousAsync is a DEFAULT interface implementation returning
admit-with-no-roles, i.e. exactly the pre-#520 behaviour, so
NullOpcUaUserAuthenticator and every test double keep working rather than being
forced to reimplement it — important because driver-only nodes run the Null one
and would otherwise black out. Note a default interface method is only reachable
through the interface-typed reference, which is how OpcUaApplicationHost holds it.
An existing test asserted the old fall-through and correctly went red; it is
rewritten to pin the new contract rather than deleted, with the reason recorded.
OpcUaServer 366, Host 42 green.
Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
193 lines
7.7 KiB
C#
193 lines
7.7 KiB
C#
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}");
|
|
|
|
/// <summary>
|
|
/// Verifies that BuildSecurityPolicies emits all three baseline security profiles.
|
|
/// </summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that BuildSecurityPolicies deduplicates repeated profiles.
|
|
/// </summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that BuildSecurityPolicies falls back to None when given empty input.
|
|
/// </summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that BuildUserTokenPolicies emits anonymous and username policies.
|
|
/// </summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// With <c>OpcUa:AllowAnonymous</c> false the Anonymous policy is not advertised, so a client is
|
|
/// refused at discovery rather than only at activation — but UserName survives, which is what
|
|
/// makes the server key-only rather than unreachable (#520).
|
|
/// </summary>
|
|
[Fact]
|
|
public void BuildUserTokenPolicies_withholds_anonymous_when_disallowed()
|
|
{
|
|
var tokens = OpcUaApplicationHost.BuildUserTokenPolicies(allowAnonymous: false).ToList();
|
|
|
|
tokens.ShouldNotContain(t => t.TokenType == UserTokenType.Anonymous);
|
|
tokens.ShouldContain(t => t.TokenType == UserTokenType.UserName);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that StartAsync populates ServerConfiguration with all enabled security profiles.
|
|
/// </summary>
|
|
[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 });
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that StartAsync with only SignAndEncrypt omits the None endpoint.
|
|
/// </summary>
|
|
[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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Cleans up temporary PKI files.
|
|
/// </summary>
|
|
public void Dispose()
|
|
{
|
|
if (Directory.Exists(_pkiRoot))
|
|
{
|
|
try { Directory.Delete(_pkiRoot, recursive: true); }
|
|
catch { /* best-effort cleanup */ }
|
|
}
|
|
}
|
|
}
|