243 lines
13 KiB
C#
243 lines
13 KiB
C#
using System.Text;
|
|
using System.Net;
|
|
using System.Collections.Concurrent;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Opc.Ua;
|
|
using Opc.Ua.Client;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using Opc.Ua.Configuration;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
|
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
|
|
|
|
/// <summary>Real, mutually trusted channels: no auto-accept callbacks or insecure fallback.</summary>
|
|
public sealed class Sdk2TrustedChannelTests
|
|
{
|
|
[Theory]
|
|
[InlineData(OpcUaSecurityProfile.Basic256Sha256Sign, MessageSecurityMode.Sign)]
|
|
[InlineData(OpcUaSecurityProfile.Basic256Sha256SignAndEncrypt, MessageSecurityMode.SignAndEncrypt)]
|
|
public async Task Trusted_key_retains_narrow_authority_and_revocation_reaches_its_open_session(
|
|
OpcUaSecurityProfile profile, MessageSecurityMode mode)
|
|
{
|
|
var ct = TestContext.Current.CancellationToken;
|
|
await using var rig = await TrustedRig.StartAsync(profile, ct);
|
|
using var session = await rig.ConnectAsync("contract-key", ct);
|
|
session.Endpoint.SecurityMode.ShouldBe(mode);
|
|
(await session.ReadValueAsync(rig.Node(session), ct)).WrappedValue.GetInt32().ShouldBe(7);
|
|
(await rig.WriteAsync(session, 42, ct)).ShouldBe(StatusCodes.Good);
|
|
rig.Writes.ShouldBe(1, "a narrow grant with no application-wide roles must permit the write");
|
|
var observedPeer = rig.Peers.ShouldHaveSingleItem();
|
|
observedPeer.ShouldNotBeNull();
|
|
IPAddress.IsLoopback(observedPeer.Address).ShouldBeTrue();
|
|
observedPeer.Port.ShouldBe(0, "the public SDK evidence supplies an IP address, not an invented client port");
|
|
var originalSessionId = session.SessionId;
|
|
await session.ReconnectAsync(null, null, ct);
|
|
session.SessionId.ShouldBe(originalSessionId, "the test must reactivate the existing session on a new channel");
|
|
rig.Peers.Count.ShouldBe(2);
|
|
rig.Peers.Last().ShouldNotBeNull().Address.ShouldBe(observedPeer.Address);
|
|
(await session.ReadValueAsync(rig.Node(session), ct)).StatusCode.ShouldBe(StatusCodes.Good);
|
|
var identity = rig.Server.CurrentInstance.SessionManager.GetSessions().ShouldHaveSingleItem().EffectiveIdentity
|
|
.ShouldBeOfType<RoleCarryingUserIdentity>();
|
|
identity.Principal.ShouldBe(TrustedRig.Principal);
|
|
identity.ApiKeyId.ShouldBe(TrustedRig.KeyId);
|
|
identity.Roles.ShouldBeEmpty();
|
|
|
|
rig.Revoke();
|
|
(await rig.WriteAsync(session, 99, ct)).ShouldBe(StatusCodes.BadUserAccessDenied);
|
|
rig.Writes.ShouldBe(1, "revocation must be checked before a cached narrow grant or dispatch");
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(OpcUaSecurityProfile.Basic256Sha256Sign)]
|
|
[InlineData(OpcUaSecurityProfile.Basic256Sha256SignAndEncrypt)]
|
|
public async Task Trusted_channel_does_not_turn_invalid_credentials_into_another_identity(OpcUaSecurityProfile profile)
|
|
{
|
|
var ct = TestContext.Current.CancellationToken;
|
|
await using var rig = await TrustedRig.StartAsync(profile, ct);
|
|
var error = await Should.ThrowAsync<ServiceResultException>(() => rig.ConnectAsync("invalid", ct));
|
|
error.StatusCode.ShouldBe(StatusCodes.BadIdentityTokenRejected);
|
|
rig.Writes.ShouldBe(0);
|
|
rig.Server.CurrentInstance.SessionManager.GetSessions().ShouldAllBe(s => !s.Activated);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task An_untrusted_client_is_rejected_before_application_authentication()
|
|
{
|
|
var ct = TestContext.Current.CancellationToken;
|
|
await using var rig = await TrustedRig.StartAsync(OpcUaSecurityProfile.Basic256Sha256SignAndEncrypt, ct);
|
|
var before = rig.AuthCalls;
|
|
var error = await Should.ThrowAsync<ServiceResultException>(() => rig.ConnectAsync("contract-key", ct, untrustedClient: true));
|
|
new[] { StatusCodes.BadCertificateUntrusted, StatusCodes.BadSecurityChecksFailed, StatusCodes.BadSecureChannelClosed }
|
|
.ShouldContain(error.StatusCode);
|
|
rig.AuthCalls.ShouldBe(before);
|
|
rig.Writes.ShouldBe(0);
|
|
}
|
|
|
|
private sealed class TrustedRig : IAsyncDisposable, IOpcUaUserAuthenticator, IOpcUaNodeWriteGateway
|
|
{
|
|
internal const string Principal = "trusted-contract";
|
|
internal const string KeyId = "k-trusted-contract";
|
|
private const string Cluster = "SDK2";
|
|
private const string PathId = "Plant/Device/Value";
|
|
private readonly string _root = Path.Combine(Path.GetTempPath(), $"otua-trusted-{Guid.NewGuid():N}");
|
|
private readonly Dictionary<ApplicationConfiguration, ApplicationInstance> _applications = [];
|
|
private readonly DataPlaneAuthorityState _authority = new();
|
|
private readonly OpcUaApplicationHostOptions _options;
|
|
private OpcUaApplicationHost _host = null!;
|
|
private int _port;
|
|
private int _writes;
|
|
private int _authCalls;
|
|
internal ConcurrentQueue<IPEndPoint?> Peers { get; } = new();
|
|
internal OtOpcUaSdkServer Server { get; private set; } = null!;
|
|
internal int Writes => Volatile.Read(ref _writes);
|
|
internal int AuthCalls => Volatile.Read(ref _authCalls);
|
|
private string ServerPki => System.IO.Path.Combine(_root, "server");
|
|
private string ClientPki => System.IO.Path.Combine(_root, "client");
|
|
|
|
private TrustedRig(OpcUaSecurityProfile profile)
|
|
{
|
|
_options = new OpcUaApplicationHostOptions
|
|
{
|
|
ApplicationName = "Sdk2TrustedContract", ApplicationUri = $"urn:Sdk2TrustedContract:{Guid.NewGuid():N}",
|
|
PublicHostname = "localhost", PkiStoreRoot = ServerPki, AllowAnonymous = false,
|
|
AutoAcceptUntrustedClientCertificates = false, EnabledSecurityProfiles = [profile],
|
|
};
|
|
}
|
|
|
|
internal static async Task<TrustedRig> StartAsync(OpcUaSecurityProfile profile, CancellationToken ct)
|
|
{
|
|
var rig = new TrustedRig(profile);
|
|
try
|
|
{
|
|
// Provision both certificates, then reload the managers with explicit trusted-peer stores.
|
|
await rig.BootAsync(ct);
|
|
await rig._host.DisposeAsync();
|
|
var client = await rig.ConfigurationAsync(rig.ClientPki, ct);
|
|
CopyCertificates(rig.ServerPki, rig.ClientPki);
|
|
CopyCertificates(rig.ClientPki, rig.ServerPki);
|
|
await rig.ReleaseConfigurationAsync(client);
|
|
await rig.BootAsync(ct);
|
|
rig.InstallAuthority(enabled: true);
|
|
var nm = rig.Server.NodeManager!;
|
|
nm.DataPlaneAuthority = rig._authority;
|
|
nm.Authorizer = new DataPlaneAuthorizer(rig._authority, new TriePermissionEvaluator(rig._authority.Tries),
|
|
true, NullLogger.Instance, TimeProvider.System);
|
|
nm.NodeWriteGateway = rig;
|
|
var sink = new SdkAddressSpaceSink(nm);
|
|
sink.EnsureFolder("Plant", null, "Plant", AddressSpaceRealm.Raw);
|
|
sink.EnsureFolder("Plant/Device", "Plant", "Device", AddressSpaceRealm.Raw);
|
|
sink.EnsureVariable(PathId, "Plant/Device", "Value", "Int32", true, AddressSpaceRealm.Raw);
|
|
sink.WriteValue(PathId, 7, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
|
|
return rig;
|
|
}
|
|
catch { await rig.DisposeAsync(); throw; }
|
|
}
|
|
|
|
private async Task BootAsync(CancellationToken ct)
|
|
{
|
|
(_host, Server) = await OpcUaHostStart.StartAsync(port =>
|
|
{
|
|
_port = port; _options.OpcUaPort = port;
|
|
return new OpcUaApplicationHost(_options, NullLogger<OpcUaApplicationHost>.Instance, this);
|
|
}, () => new OtOpcUaSdkServer(), ct);
|
|
}
|
|
|
|
private static void CopyCertificates(string from, string to)
|
|
{
|
|
var destination = System.IO.Path.Combine(to, "trusted", "certs");
|
|
Directory.CreateDirectory(destination);
|
|
foreach (var file in Directory.GetFiles(System.IO.Path.Combine(from, "own", "certs"), "*.der"))
|
|
File.Copy(file, System.IO.Path.Combine(destination, System.IO.Path.GetFileName(file)), overwrite: true);
|
|
}
|
|
|
|
private async Task<ApplicationConfiguration> ConfigurationAsync(string pki, CancellationToken ct)
|
|
{
|
|
var telemetry = DefaultTelemetry.Create(_ => { });
|
|
var security = TestClientSecurity.Build(pki);
|
|
security.AutoAcceptUntrustedCertificates = false;
|
|
security.ApplicationCertificate!.SubjectName = "CN=Sdk2TrustedClient";
|
|
var config = new ApplicationConfiguration(telemetry)
|
|
{
|
|
ApplicationName = "Sdk2TrustedClient", ApplicationUri = "urn:localhost:Sdk2TrustedClient",
|
|
ApplicationType = ApplicationType.Client, SecurityConfiguration = security,
|
|
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60_000 },
|
|
};
|
|
var app = new ApplicationInstance(telemetry)
|
|
{ ApplicationName = config.ApplicationName, ApplicationType = ApplicationType.Client, ApplicationConfiguration = config };
|
|
try
|
|
{
|
|
await config.ValidateAsync(ApplicationType.Client, ct);
|
|
await app.CheckApplicationInstanceCertificatesAsync(true, lifeTimeInMonths: 12, ct);
|
|
_applications.Add(config, app);
|
|
return config;
|
|
}
|
|
catch { await app.DisposeAsync(); throw; }
|
|
}
|
|
|
|
private async Task ReleaseConfigurationAsync(ApplicationConfiguration config)
|
|
{
|
|
if (_applications.Remove(config, out var app)) await app.DisposeAsync();
|
|
}
|
|
|
|
internal async Task<ISession> ConnectAsync(string credential, CancellationToken ct, bool untrustedClient = false)
|
|
{
|
|
var pki = untrustedClient ? System.IO.Path.Combine(_root, $"untrusted-{Guid.NewGuid():N}") : ClientPki;
|
|
var config = await ConfigurationAsync(pki, ct);
|
|
if (untrustedClient)
|
|
{
|
|
await ReleaseConfigurationAsync(config);
|
|
CopyCertificates(ServerPki, pki);
|
|
config = await ConfigurationAsync(pki, ct);
|
|
}
|
|
config.SecurityConfiguration.AutoAcceptUntrustedCertificates.ShouldBeFalse();
|
|
var telemetry = DefaultTelemetry.Create(_ => { });
|
|
var endpoint = await CoreClientUtils.SelectEndpointAsync(config, $"opc.tcp://localhost:{_port}/OtOpcUa", true, telemetry, ct)
|
|
?? throw new InvalidOperationException("No secure endpoint.");
|
|
return await new DefaultSessionFactory(telemetry).CreateAsync(config,
|
|
new ConfiguredEndpoint(null, endpoint, EndpointConfiguration.Create(config)), false, true,
|
|
"Sdk2TrustedContract", 60_000, new UserIdentity("apikey", Encoding.UTF8.GetBytes(credential)), default, ct);
|
|
}
|
|
|
|
internal NodeId Node(ISession session) => new(PathId, (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri));
|
|
internal async Task<StatusCode> WriteAsync(ISession session, int value, CancellationToken ct) =>
|
|
(await session.WriteAsync(null, [new WriteValue { NodeId = Node(session), AttributeId = Attributes.Value,
|
|
Value = new DataValue(Variant.From(value)) }], ct)).Results[0];
|
|
internal void Revoke() => InstallAuthority(enabled: false);
|
|
private void InstallAuthority(bool enabled) => _authority.Apply(Cluster, enabled ? "allowed" : "revoked",
|
|
[new ApiKey { ApiKeyId = KeyId, Name = KeyId, Principal = Principal, Enabled = enabled,
|
|
SecretHash = "hash", CreatedUtc = DateTime.UtcNow.AddDays(-1) }],
|
|
[new NodeAcl { NodeAclId = "narrow", ClusterId = Cluster, Principal = Principal,
|
|
ScopeKind = NodeAclScopeKind.RawDevice, ScopeId = "device", PermissionFlags =
|
|
NodePermissions.Read | NodePermissions.Browse | NodePermissions.Subscribe | NodePermissions.WriteOperate }],
|
|
new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>
|
|
{ [new(NodeAclScopeKind.RawDevice, "device")] = [new([NodeAclRealms.Raw, "Plant", "Device"])] });
|
|
|
|
public Task<OpcUaUserAuthResult> AuthenticateUserNameAsync(string username, string password, IPEndPoint? peer, CancellationToken ct)
|
|
{
|
|
Peers.Enqueue(peer is null ? null : new IPEndPoint(peer.Address, peer.Port));
|
|
return AuthenticateUserNameAsync(username, password, ct);
|
|
}
|
|
|
|
public Task<OpcUaUserAuthResult> AuthenticateUserNameAsync(string username, string password, CancellationToken ct)
|
|
{
|
|
Interlocked.Increment(ref _authCalls);
|
|
return Task.FromResult(username == "apikey" && password == "contract-key"
|
|
? OpcUaUserAuthResult.AllowKey(Principal, [], KeyId) : OpcUaUserAuthResult.Deny("Invalid credential."));
|
|
}
|
|
public Task<NodeWriteOutcome> WriteAsync(string nodeId, object? value, AddressSpaceRealm realm, CancellationToken ct)
|
|
{ Interlocked.Increment(ref _writes); return Task.FromResult(new NodeWriteOutcome(true, null)); }
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
if (_host is not null) await _host.DisposeAsync();
|
|
foreach (var app in _applications.Values) await app.DisposeAsync();
|
|
_applications.Clear();
|
|
if (Directory.Exists(_root)) Directory.Delete(_root, true);
|
|
}
|
|
}
|
|
}
|