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; /// Real, mutually trusted channels: no auto-accept callbacks or insecure fallback. 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(); 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 Concurrent_IPv4_and_IPv6_activations_keep_their_own_transport_peer(OpcUaSecurityProfile profile) { var ct = TestContext.Current.CancellationToken; await using var rig = await TrustedRig.StartAsync(profile, ct); rig.HoldPeerActivations = true; // Distinct test attempt markers share one key identity and trusted certificate. Both auth flows await the barrier. var firstConnection = rig.ConnectAsync("contract-key-ipv4", ct, transportHost: "127.0.0.1"); var secondConnection = rig.ConnectAsync("contract-key-ipv6", ct, transportHost: "[::1]"); ISession[] connections; try { connections = await Task.WhenAll(firstConnection, secondConnection); } catch { if (firstConnection.IsCompletedSuccessfully) (await firstConnection).Dispose(); if (secondConnection.IsCompletedSuccessfully) (await secondConnection).Dispose(); throw; } using var ipv4 = connections[0]; using var ipv6 = connections[1]; rig.Peers.Count.ShouldBe(2); var first4 = rig.PeerObservations.Single(x => x.Attempt == "IPv4"); var first6 = rig.PeerObservations.Single(x => x.Attempt == "IPv6"); first4.Peer.ShouldNotBeNull().Address.ShouldBe(IPAddress.Loopback); first6.Peer.ShouldNotBeNull().Address.ShouldBe(IPAddress.IPv6Loopback); first4.AfterAwaitPeer.ShouldBe(first4.Peer.Address); first6.AfterAwaitPeer.ShouldBe(first6.Peer.Address); foreach (var session in connections) (await session.ReadValueAsync(rig.Node(session), ct)).StatusCode.ShouldBe(StatusCodes.Good); await ipv4.ReconnectAsync(null, null, ct); rig.Peers.Count.ShouldBe(3); rig.Peers.Last().ShouldNotBeNull().Address.ShouldBe(IPAddress.Loopback, "reactivation cannot inherit the concurrent IPv6 activation's peer"); await ipv6.CloseAsync(ct); await ipv4.CloseAsync(ct); } [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(() => 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(() => 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 ConcurrentDictionary _applications = new(); private readonly DataPlaneAuthorityState _authority = new(); private readonly OpcUaApplicationHostOptions _options; private OpcUaApplicationHost _host = null!; private int _port; private int _writes; private int _authCalls; internal sealed record PeerObservation(string Attempt, IPEndPoint? Peer, IPAddress? AfterAwaitPeer); internal ConcurrentQueue PeerObservations { get; } = new(); internal IReadOnlyList Peers => PeerObservations.Select(x => x.Peer).ToArray(); internal bool HoldPeerActivations { get; set; } private readonly TaskCompletionSource _peerBarrier = new(TaskCreationOptions.RunContinuationsAsynchronously); private int _peerBarrierArrivals; 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 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.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 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[config] = app; return config; } catch { await app.DisposeAsync(); throw; } } private async Task ReleaseConfigurationAsync(ApplicationConfiguration config) { if (_applications.TryRemove(config, out var app)) await app.DisposeAsync(); } internal async Task ConnectAsync(string credential, CancellationToken ct, bool untrustedClient = false, string? transportHost = null) { 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."); if (transportHost is not null) endpoint.EndpointUrl = $"opc.tcp://{transportHost}:{_port}/OtOpcUa"; 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 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> { [new(NodeAclScopeKind.RawDevice, "device")] = [new([NodeAclRealms.Raw, "Plant", "Device"])] }); public async Task AuthenticateUserNameAsync(string username, string password, IPEndPoint? peer, CancellationToken ct) { var attempt = password switch { "contract-key-ipv4" => "IPv4", "contract-key-ipv6" => "IPv6", _ => "Other" }; if (HoldPeerActivations && attempt != "Other") { if (Interlocked.Increment(ref _peerBarrierArrivals) == 2) _peerBarrier.TrySetResult(); await _peerBarrier.Task.WaitAsync(TimeSpan.FromSeconds(15), ct); } PeerObservations.Enqueue(new(attempt, peer is null ? null : new IPEndPoint(peer.Address, peer.Port), Server.ActivationPeerAddress)); return await AuthenticateUserNameAsync(username, password, ct); } public Task AuthenticateUserNameAsync(string username, string password, CancellationToken ct) { Interlocked.Increment(ref _authCalls); return Task.FromResult(username == "apikey" && (password is "contract-key" or "contract-key-ipv4" or "contract-key-ipv6") ? OpcUaUserAuthResult.AllowKey(Principal, [], KeyId) : OpcUaUserAuthResult.Deny("Invalid credential.")); } public Task 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); } } }