using System.Collections.Concurrent; using System.Net; using System.Text; using Microsoft.Extensions.Logging.Abstractions; using Opc.Ua; using Opc.Ua.Client; using Opc.Ua.Configuration; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Authorization; using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security; namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests; /// /// lmxopcua#833 over the wire. A few failed UserName activations used to trip the OPC Foundation SDK's /// per-client lockout, which then refused EVERY activation from that client — Anonymous included — with /// BadUserAccessDenied for 5 minutes. The host now disables the SDK lockout and enforces a /// UserName-only replacement (), counted per credential identity as well as /// per client (#844) and blind to transient denials (#845). /// public sealed class CredentialLockoutWireTests { private const string GoodKey = "otua_k-good_secret"; [Fact] public async Task Failed_credentials_never_lock_the_client_out_of_anonymous_activation() { var ct = TestContext.Current.CancellationToken; var auth = new CountingAuthenticator(); await using var rig = await Rig.StartAsync(auth, new OpcUaCredentialLockoutOptions(), ct); for (var i = 0; i < 12; i++) { var rejected = await Should.ThrowAsync(() => rig.ConnectAsync($"otua_k-bad{i}_x", ct)); rejected.StatusCode.ShouldBeOneOf(StatusCodes.BadIdentityTokenRejected, StatusCodes.BadUserAccessDenied); } using var anonymous = await rig.ConnectAsync(credential: null, ct); var ns = await anonymous.ReadValueAsync(VariableIds.Server_NamespaceArray, ct); StatusCode.IsGood(ns.StatusCode).ShouldBeTrue(); auth.AnonymousCalls.ShouldBe(1, "the anonymous activation must reach the authenticator, not be refused before it"); } [Fact] public async Task Failed_credentials_still_lock_out_further_UserName_activations_from_that_client() { var ct = TestContext.Current.CancellationToken; var auth = new CountingAuthenticator(); await using var rig = await Rig.StartAsync( auth, new OpcUaCredentialLockoutOptions { MaxFailedAttempts = 3, MaxFailedAttemptsPerClient = 3 }, ct); for (var i = 0; i < 3; i++) { var rejected = await Should.ThrowAsync(() => rig.ConnectAsync($"otua_k-bad{i}_x", ct)); rejected.StatusCode.ShouldBe(StatusCodes.BadIdentityTokenRejected); } var callsBefore = auth.UserNameCalls; var locked = await Should.ThrowAsync(() => rig.ConnectAsync(GoodKey, ct)); locked.StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied); auth.UserNameCalls.ShouldBe(callsBefore, "a locked-out client's credential is refused without being checked"); } /// /// #844 over the wire — guesses at another key id interleaved with sign-ins on the client's own valid key. The /// own-key success used to clear the whole count, so the cycle never locked. Now the victim key id locks for /// that client after MaxFailedAttempts guesses (refused unauthenticated), while the client's own key still works. /// [Fact] public async Task Own_key_successes_do_not_launder_guesses_at_another_key_id() { var ct = TestContext.Current.CancellationToken; var auth = new CountingAuthenticator(); await using var rig = await Rig.StartAsync(auth, new OpcUaCredentialLockoutOptions { MaxFailedAttempts = 3 }, ct); for (var round = 0; round < 3; round++) { var rejected = await Should.ThrowAsync( () => rig.ConnectAsync($"otua_k-victim_guess{round}", ct)); rejected.StatusCode.ShouldBe(StatusCodes.BadIdentityTokenRejected); using var own = await rig.ConnectAsync(GoodKey, ct); own.Connected.ShouldBeTrue(); } var callsBefore = auth.UserNameCalls; var locked = await Should.ThrowAsync(() => rig.ConnectAsync("otua_k-victim_guess3", ct)); locked.StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied); auth.UserNameCalls.ShouldBe(callsBefore, "the locked key id is refused without being checked"); using var stillOwn = await rig.ConnectAsync(GoodKey, ct); stillOwn.Connected.ShouldBeTrue("a per-credential lockout leaves the client's own key usable"); } /// /// #845 over the wire — a transient denial (the authenticator reports the key store unavailable) is not a bad /// credential: any number of them leave the client able to sign in once the store recovers. /// [Fact] public async Task Transient_denials_never_lock_the_client_out() { var ct = TestContext.Current.CancellationToken; var auth = new CountingAuthenticator(); await using var rig = await Rig.StartAsync(auth, new OpcUaCredentialLockoutOptions { MaxFailedAttempts = 2 }, ct); for (var i = 0; i < 6; i++) { var rejected = await Should.ThrowAsync(() => rig.ConnectAsync(StallingKey(i % 2), ct)); rejected.StatusCode.ShouldBe(StatusCodes.BadIdentityTokenRejected, "the client sees the ordinary opaque denial"); } using var session = await rig.ConnectAsync(GoodKey, ct); session.Connected.ShouldBeTrue(); auth.UserNameCalls.ShouldBe(7, "every transient attempt reached the authenticator: none were refused as locked out"); } /// /// #833 review — one client opening more keyed sessions at once than MaxFailedAttempts (a ScadaBridge site /// node behind one NAT IP) is never refused for parallelism: the extra activations wait for a slot. /// [Fact] public async Task Parallel_valid_activations_beyond_the_threshold_all_succeed() { var ct = TestContext.Current.CancellationToken; var auth = new CountingAuthenticator(); await using var rig = await Rig.StartAsync(auth, new OpcUaCredentialLockoutOptions { MaxFailedAttempts = 2 }, ct); var sessions = await Task.WhenAll(Enumerable.Range(0, 8).Select(_ => rig.ConnectAsync(GoodKey, ct))); try { sessions.ShouldAllBe(s => s.Connected); auth.UserNameCalls.ShouldBe(8); } finally { foreach (var s in sessions) s.Dispose(); } } [Fact] public async Task A_disabled_lockout_admits_a_valid_key_after_any_number_of_failures() { // Also proves the SDK's own lockout (default 5) is switched off: with ours disabled nothing may refuse. var ct = TestContext.Current.CancellationToken; var auth = new CountingAuthenticator(); await using var rig = await Rig.StartAsync( auth, new OpcUaCredentialLockoutOptions { MaxFailedAttempts = 0 }, ct); for (var i = 0; i < 7; i++) { var rejected = await Should.ThrowAsync(() => rig.ConnectAsync($"otua_k-bad{i}_x", ct)); rejected.StatusCode.ShouldBe(StatusCodes.BadIdentityTokenRejected); } using var session = await rig.ConnectAsync(GoodKey, ct); session.Connected.ShouldBeTrue(); } /// /// #845 — on a Sign endpoint the client certificate is proven, so it separates clients that share an address: /// a client locked out client-wide does not lock out a second client (its own certificate) from the same IP. /// (On a None endpoint clients commonly present no certificate and the key is effectively the IP — see /// docs/security.md.) /// [Fact] public async Task On_a_Sign_endpoint_distinct_client_certificates_are_locked_out_separately() { var ct = TestContext.Current.CancellationToken; var auth = new CountingAuthenticator(); await using var rig = await Rig.StartAsync( auth, new OpcUaCredentialLockoutOptions { MaxFailedAttempts = 2, MaxFailedAttemptsPerClient = 2 }, ct, OpcUaSecurityProfile.Basic256Sha256Sign); for (var i = 0; i < 2; i++) { var rejected = await Should.ThrowAsync( () => rig.ConnectAsync($"otua_k-bad{i}_x", ct, clientCertificate: "ClientA")); rejected.StatusCode.ShouldBe(StatusCodes.BadIdentityTokenRejected); } var locked = await Should.ThrowAsync( () => rig.ConnectAsync(GoodKey, ct, clientCertificate: "ClientA")); locked.StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied, "client A is locked out client-wide"); using var other = await rig.ConnectAsync(GoodKey, ct, clientCertificate: "ClientB"); other.Connected.ShouldBeTrue("same address, different (proven) certificate: a different client"); other.Endpoint.SecurityMode.ShouldBe(MessageSecurityMode.Sign); } /// /// #845 — the OpcUa:ApplicationConfigPath branch loads an SDK XML configuration instead of building one. /// That file may carry the SDK's own MaxFailedAuthenticationAttempts; the host must still switch it off, /// or failed sign-ins would again lock the client out of ANONYMOUS activation. /// [Fact] public async Task A_loaded_application_configuration_still_has_the_sdk_lockout_switched_off() { var ct = TestContext.Current.CancellationToken; var auth = new CountingAuthenticator(); await using var rig = await Rig.StartFromConfigFileAsync( auth, new OpcUaCredentialLockoutOptions { MaxFailedAttempts = 0 }, sdkMaxFailedAttempts: 2, ct); rig.LoadedSdkMaxFailedAttempts.ShouldBe(0, "the host zeroed the value the file carried"); for (var i = 0; i < 4; i++) { var rejected = await Should.ThrowAsync(() => rig.ConnectAsync($"otua_k-bad{i}_x", ct)); rejected.StatusCode.ShouldBe(StatusCodes.BadIdentityTokenRejected, "the SDK lockout (2) would refuse these"); } using var anonymous = await rig.ConnectAsync(credential: null, ct); anonymous.Connected.ShouldBeTrue(); using var keyed = await rig.ConnectAsync(GoodKey, ct); keyed.Connected.ShouldBeTrue(); } private static string StallingKey(int n) => $"otua_k-stall{n}_secret"; private sealed class Rig : IAsyncDisposable { private readonly string _pkiRoot; private readonly ConcurrentBag _clientApps = []; private readonly ConcurrentDictionary _clientPkiRoots = new(StringComparer.Ordinal); private OpcUaApplicationHost _host = null!; private int _port; private bool _secure; private Rig(string pkiRoot) => _pkiRoot = pkiRoot; /// The SDK lockout value of the configuration the host actually runs with. public int LoadedSdkMaxFailedAttempts => _host.ApplicationInstance!.ApplicationConfiguration!.ServerConfiguration!.MaxFailedAuthenticationAttempts; public static async Task StartAsync( IOpcUaUserAuthenticator authenticator, OpcUaCredentialLockoutOptions lockout, CancellationToken ct, OpcUaSecurityProfile profile = OpcUaSecurityProfile.None) { var rig = new Rig(Path.Combine(Path.GetTempPath(), $"otopcua-lockout-{Guid.NewGuid():N}")) { _secure = profile != OpcUaSecurityProfile.None, }; var applicationUri = $"urn:OtOpcUa.Lockout.{Guid.NewGuid():N}"; (rig._host, _) = await OpcUaHostStart.StartAsync( port => { rig._port = port; return new OpcUaApplicationHost(rig.Options(port, applicationUri, lockout, profile), NullLogger.Instance, authenticator); }, () => new OtOpcUaSdkServer(), ct); return rig; } /// /// Boots once to let the host build (and the SDK validate) a configuration, saves it as an SDK XML file /// carrying , then boots the host from that file through /// OpcUa:ApplicationConfigPath. /// public static async Task StartFromConfigFileAsync( IOpcUaUserAuthenticator authenticator, OpcUaCredentialLockoutOptions lockout, int sdkMaxFailedAttempts, CancellationToken ct) { var seed = await StartAsync(authenticator, lockout, ct); var configFile = Path.Combine(seed._pkiRoot, "OtOpcUa.Lockout.Config.xml"); var applicationUri = seed._host.ApplicationInstance!.ApplicationConfiguration!.ApplicationUri; var saved = seed._host.ApplicationInstance.ApplicationConfiguration; await seed._host.DisposeAsync(); (seed._host, _) = await OpcUaHostStart.StartAsync( port => { seed._port = port; saved.ServerConfiguration!.BaseAddresses = [$"opc.tcp://127.0.0.1:{port}/OtOpcUa"]; saved.ServerConfiguration.MaxFailedAuthenticationAttempts = sdkMaxFailedAttempts; saved.SaveToFile(configFile); var options = seed.Options(port, applicationUri!, lockout, OpcUaSecurityProfile.None); options.ApplicationConfigPath = configFile; return new OpcUaApplicationHost(options, NullLogger.Instance, authenticator); }, () => new OtOpcUaSdkServer(), ct); File.ReadAllText(configFile).ShouldContain($"{sdkMaxFailedAttempts}<"); return seed; } private OpcUaApplicationHostOptions Options( int port, string applicationUri, OpcUaCredentialLockoutOptions lockout, OpcUaSecurityProfile profile) => new() { ApplicationName = "OtOpcUa.Lockout", ApplicationUri = applicationUri, OpcUaPort = port, PublicHostname = "127.0.0.1", PkiStoreRoot = _pkiRoot, EnabledSecurityProfiles = [profile], AutoAcceptUntrustedClientCertificates = true, CredentialLockout = lockout, }; /// /// Opens a session; a null credential activates anonymously. With the /// client presents its own application certificate (one per name, created once) over a secure endpoint. /// public async Task ConnectAsync(string? credential, CancellationToken ct, string? clientCertificate = null) { var telemetry = DefaultTelemetry.Create(_ => { }); var name = clientCertificate ?? "LockoutClient"; var pki = clientCertificate is null ? TestClientSecurity.AllocatePkiRoot() : _clientPkiRoots.GetOrAdd(clientCertificate, _ => TestClientSecurity.AllocatePkiRoot()); var security = TestClientSecurity.Build(pki); security.ApplicationCertificate!.SubjectName = $"CN=OtOpcUa.{name}"; var appConfig = new ApplicationConfiguration(telemetry) { ApplicationName = $"OtOpcUa.{name}", ApplicationUri = $"urn:localhost:OtOpcUa.{name}", ApplicationType = ApplicationType.Client, SecurityConfiguration = security, ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60_000 }, }; await appConfig.ValidateAsync(ApplicationType.Client, ct); if (clientCertificate is not null) { var app = new ApplicationInstance(telemetry) { ApplicationName = appConfig.ApplicationName, ApplicationType = ApplicationType.Client, ApplicationConfiguration = appConfig, }; _clientApps.Add(app); (await app.CheckApplicationInstanceCertificatesAsync(true, lifeTimeInMonths: 12, ct)).ShouldBeTrue(); } var endpoint = await CoreClientUtils.SelectEndpointAsync( appConfig, $"opc.tcp://127.0.0.1:{_port}/OtOpcUa", _secure, telemetry, ct); return await new DefaultSessionFactory(telemetry).CreateAsync( appConfig, new ConfiguredEndpoint(null, endpoint ?? throw new InvalidOperationException("no endpoint"), EndpointConfiguration.Create(appConfig)), updateBeforeConnect: false, checkDomain: false, sessionName: "CredentialLockoutWireTests", sessionTimeout: 60_000, identity: credential is null ? new UserIdentity() : new UserIdentity("apikey", Encoding.UTF8.GetBytes(credential)), preferredLocales: default, ct: ct); } public async ValueTask DisposeAsync() { await _host.DisposeAsync(); foreach (var app in _clientApps) await app.DisposeAsync(); foreach (var dir in _clientPkiRoots.Values.Append(_pkiRoot)) { if (!Directory.Exists(dir)) continue; try { Directory.Delete(dir, recursive: true); } catch (IOException) { /* best effort */ } } } } /// /// Admits and anonymous; reports the key store unavailable (Transient) for /// s; denies everything else; counts calls. Names the credential by key id, as the /// production API-key authenticator does. /// private sealed class CountingAuthenticator : IOpcUaUserAuthenticator { private int _userNameCalls; private int _anonymousCalls; public int UserNameCalls => Volatile.Read(ref _userNameCalls); public int AnonymousCalls => Volatile.Read(ref _anonymousCalls); public Task AuthenticateUserNameAsync(string username, string password, CancellationToken ct) { Interlocked.Increment(ref _userNameCalls); return Task.FromResult( password == GoodKey ? OpcUaUserAuthResult.AllowKey("good", [], "k-good") : password.StartsWith("otua_k-stall", StringComparison.Ordinal) ? OpcUaUserAuthResult.Unavailable("Invalid API key.") : OpcUaUserAuthResult.Deny("Invalid API key.")); } public Task AuthenticateUserNameAsync( string username, string password, IPEndPoint? remoteEndpoint, CancellationToken ct) => AuthenticateUserNameAsync(username, password, ct); public string DescribeCredential(string username, string password) { var parts = password.Split('_'); return parts.Length == 3 && parts[0] == "otua" ? "key:" + parts[1] : "user:" + username; } public Task AuthenticateAnonymousAsync(CancellationToken ct) { Interlocked.Increment(ref _anonymousCalls); return Task.FromResult(OpcUaUserAuthResult.Allow(OpcUaPrincipals.Anonymous, [])); } } }