using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging.Abstractions; using Opc.Ua; using Opc.Ua.Client; using Opc.Ua.Configuration; using Shouldly; using Xunit; namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests; /// /// lmxopcua#879 over the wire. The options come from a real ConfigurationBinder bind (the path the Host /// uses), so a regression to a pre-filled list shows up as an advertised None endpoint that admits a /// session. The default case is the positive control: the same probes must find and use None there. /// public sealed class SecurityProfilesReplaceWireTests { private static CancellationToken Ct => TestContext.Current.CancellationToken; [Fact] public async Task Configuring_only_the_secure_profiles_removes_the_None_endpoint_and_refuses_a_None_connection() { await using var rig = await Rig.StartAsync(new Dictionary { ["OpcUa:EnabledSecurityProfiles:0"] = "Basic256Sha256Sign", ["OpcUa:EnabledSecurityProfiles:1"] = "Basic256Sha256SignAndEncrypt", }, Ct); var modes = (await rig.GetEndpointModesAsync(Ct)).Distinct().Order().ToArray(); modes.ShouldBe([MessageSecurityMode.Sign, MessageSecurityMode.SignAndEncrypt]); // Positive control: the secure endpoint really is usable, so the refusal below is about None, not about the rig. using (var secure = await rig.ConnectAsync(MessageSecurityMode.SignAndEncrypt, Ct)) secure.Connected.ShouldBeTrue(); var refusal = await Should.ThrowAsync(() => rig.ConnectAsync(MessageSecurityMode.None, Ct)); refusal.StatusCode.ShouldBe(StatusCodes.BadSecurityPolicyRejected); } [Fact] public async Task Nothing_configured_still_advertises_all_three_and_a_None_connection_is_admitted() { await using var rig = await Rig.StartAsync(new Dictionary(), Ct); (await rig.GetEndpointModesAsync(Ct)).Distinct().Order().ToArray() .ShouldBe([MessageSecurityMode.None, MessageSecurityMode.Sign, MessageSecurityMode.SignAndEncrypt]); using var none = await rig.ConnectAsync(MessageSecurityMode.None, Ct); none.Connected.ShouldBeTrue(); } private sealed class Rig : IAsyncDisposable { private readonly string _pki = TestClientSecurity.AllocatePkiRoot(); private readonly string _clientPki = TestClientSecurity.AllocatePkiRoot(); private OpcUaApplicationHost _host = null!; private int _port; public static async Task StartAsync(Dictionary values, CancellationToken ct) { var rig = new Rig(); var section = new ConfigurationBuilder().AddInMemoryCollection(values).Build(); try { (rig._host, _) = await OpcUaHostStart.StartAsync(port => { var options = new OpcUaApplicationHostOptions { ApplicationName = "OtOpcUa.Sec879", ApplicationUri = $"urn:OtOpcUa.Sec879:{Guid.NewGuid():N}", PublicHostname = "127.0.0.1", PkiStoreRoot = rig._pki, AutoAcceptUntrustedClientCertificates = true, }; section.GetSection("OpcUa").Bind(options); // the real binder, exactly what AddValidatedOptions does options.OpcUaPort = port; rig._port = port; return new OpcUaApplicationHost(options, NullLogger.Instance); }, () => new OtOpcUaSdkServer(), ct); return rig; } catch { await rig.DisposeAsync(); throw; } } private string Url => $"opc.tcp://127.0.0.1:{_port}/OtOpcUa"; private (ApplicationConfiguration Config, ApplicationInstance App)? _client; private async Task<(ApplicationConfiguration Config, ApplicationInstance App)> ClientAsync(CancellationToken ct) { if (_client is { } cached) return cached; var security = TestClientSecurity.Build(_clientPki); security.ApplicationCertificate!.SubjectName = "CN=OtOpcUa.Sec879Client"; var telemetry = DefaultTelemetry.Create(_ => { }); var config = new ApplicationConfiguration(telemetry) { ApplicationName = "OtOpcUa.Sec879Client", ApplicationUri = $"urn:OtOpcUa.Sec879Client.{Guid.NewGuid():N}", ApplicationType = ApplicationType.Client, SecurityConfiguration = security, ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60_000 }, }; await config.ValidateAsync(ApplicationType.Client, ct); var app = new ApplicationInstance(telemetry) { ApplicationName = config.ApplicationName, ApplicationType = ApplicationType.Client }; app.ApplicationConfiguration = config; await app.CheckApplicationInstanceCertificatesAsync(silent: true, lifeTimeInMonths: null, ct); _client = (config, app); return (config, app); } public async Task GetEndpointModesAsync(CancellationToken ct) { var (config, _) = await ClientAsync(ct); using var discovery = await DiscoveryClient.CreateAsync(config, new Uri(Url), DiagnosticsMasks.None, ct); var endpoints = await discovery.GetEndpointsAsync(default, ct); return endpoints.ToArray()!.Select(e => e.SecurityMode).ToArray(); } /// /// Opens an anonymous session on the endpoint of the requested mode. For None the endpoint /// description is synthesised rather than discovered, so a server that no longer advertises it is /// probed with exactly what a downgrading client would send. /// public async Task ConnectAsync(MessageSecurityMode mode, CancellationToken ct) { var (config, _) = await ClientAsync(ct); var telemetry = DefaultTelemetry.Create(_ => { }); // Always start from a discovered secure endpoint (its token policies are what the server really sent); // None is a clone of it with the mode and policy downgraded, i.e. what a downgrading client sends. var description = await CoreClientUtils.SelectEndpointAsync(config, Url, true, telemetry, ct) ?? throw new InvalidOperationException("No secure endpoint."); if (mode == MessageSecurityMode.None) { description = (EndpointDescription)description.Clone(); description.SecurityMode = MessageSecurityMode.None; description.SecurityPolicyUri = SecurityPolicies.None; description.ServerCertificate = default; } else description.SecurityMode.ShouldBe(mode); return await new DefaultSessionFactory(telemetry).CreateAsync( config, new ConfiguredEndpoint(null, description, EndpointConfiguration.Create(config)), updateBeforeConnect: false, checkDomain: false, sessionName: "Sec879", sessionTimeout: 60_000, identity: new UserIdentity(new AnonymousIdentityToken()), preferredLocales: default, ct: ct); } public async ValueTask DisposeAsync() { if (_client is { } client) await client.App.DisposeAsync(); if (_host is not null) await _host.DisposeAsync(); foreach (var dir in new[] { _pki, _clientPki }) if (Directory.Exists(dir)) Directory.Delete(dir, recursive: true); } } }