diff --git a/.claude/rules/data-plane-auth.md b/.claude/rules/data-plane-auth.md index feb04ccf3..3f65d65e1 100644 --- a/.claude/rules/data-plane-auth.md +++ b/.claude/rules/data-plane-auth.md @@ -206,6 +206,12 @@ Two things to know before relying on any of this: the same `RoleCarryingUserIdentity` a key produces. Set it `false` to make keys mandatory. A key may **not** use the `anonymous` principal, and a failure to read anonymous's grants **denies** rather than admitting with no roles. +- ⚠️ **The SDK's failed-auth lockout is OFF; ours is UserName-only (#833).** SDK 2.0.1 `SessionManager` refuses + EVERY activation (Anonymous included, before the token is read) from a client with 5 failures in 60 s, for 5 min, + keyed cert-thumbprint-else-peer-IP. `OpcUaApplicationHost.DisableSdkAuthenticationLockout` zeroes + `MaxFailedAuthenticationAttempts` on built AND loaded configs; `FailedCredentialLockout` (`OpcUa:CredentialLockout`, + 5 / 60 s / 300 s) counts and refuses UserName activations only, keyed thumbprint+peer together. Do not re-enable the + SDK setting. `docs/security.md` §"Failed-credential lockout". - **Keys reach a driver-only node inside the deployment artifact** (stage 4). `ConfigComposer` snapshots the `ApiKey` rows — the peppered HMAC only, never a secret, and the pepper is node config that is deliberately *not* in the artifact — and `DeploymentArtifact.ParseDataPlaneAuthority` decodes them diff --git a/docs/Configuration.md b/docs/Configuration.md index b708fd53a..6b17cef46 100644 --- a/docs/Configuration.md +++ b/docs/Configuration.md @@ -74,6 +74,9 @@ Until #678.6 the overlay never loaded on a node carrying a `cluster-*` role (the | `EnabledSecurityProfiles` | list of `OpcUaSecurityProfile` | `[None, Basic256Sha256Sign, Basic256Sha256SignAndEncrypt]` | Transport-security profiles, one endpoint per entry. Must contain ≥1. Profile detail in [`security.md`](security.md). | | `AutoAcceptUntrustedClientCertificates` | bool | `false` | Auto-trust unknown client certs on first connect (dev convenience). Not validated. See [`security.md`](security.md). | | `PeerApplicationUris` | list of string | `[]` (empty) | Partner node `ApplicationUri`s published in `Server.ServerArray` for redundancy discovery. See [`Redundancy.md`](Redundancy.md). | +| `CredentialLockout:MaxFailedAttempts` | int | `5` | Failed UserName (API-key) activations from one client before its UserName activations are refused `BadUserAccessDenied` (#833). `≤ 0` disables. **Never refuses Anonymous.** See [`security.md`](security.md#failed-credential-lockout-opcuacredentiallockout). | +| `CredentialLockout:FailureWindowSeconds` | int | `60` | A failure more than this long after the client's previous one starts a fresh count. | +| `CredentialLockout:LockoutSeconds` | int | `300` | How long a locked-out client's UserName activations are refused. | > **Transport security profiles** (the values in `EnabledSecurityProfiles` — `None`, `Basic256Sha256Sign`, `Basic256Sha256SignAndEncrypt`) and the PKI trust flow are documented in full in [`security.md`](security.md). This page does not duplicate them. diff --git a/docs/security.md b/docs/security.md index be8288f35..74ab377c0 100644 --- a/docs/security.md +++ b/docs/security.md @@ -313,6 +313,40 @@ charged process-wide only — never handed a fresh budget. Clients behind one NA guard applies to a driver-only node: its `ArtifactApiKeyStore` is two in-memory dictionary probes and is never wrapped. +### Failed-credential lockout (`OpcUa:CredentialLockout`) + +**A failed sign-in never locks a client out of anonymous access (#833).** OPC Foundation SDK 2.0.1's +`SessionManager` has its own lockout: after `ServerConfiguration.MaxFailedAuthenticationAttempts` (SDK +default **5**) failed identity validations — each within 60 s of the previous — it refuses **every** +`ActivateSession` from that client for **5 minutes** with `BadUserAccessDenied` ("Too many failed +authentication attempts"). Its check runs before the identity token is looked at, so it refused +**Anonymous** too (even a namespace-array read needs an activated session), a successful anonymous +activation did not clear it, and the authenticator was never called (no "anonymous admitted" line). Its key +is the client certificate thumbprint when one is presented, else the peer IP — so certificate-less clients +behind one NAT share a key, and on a `None` endpoint a presented certificate is not proven. That is what +the docker-dev live gate hit: four failed `multi-role` UserName activations against central-1, then every +anonymous session refused for five minutes while central-2 (which never saw the failures) kept admitting. + +The host now sets `MaxFailedAuthenticationAttempts = 0` on every configuration it builds or loads +(`OpcUaApplicationHost.DisableSdkAuthenticationLockout`) and enforces a narrower replacement, +`FailedCredentialLockout`, in its identity-registry adapter: + +- **UserName activations only.** A failed one counts; at `MaxFailedAttempts` (5) within + `FailureWindowSeconds` (60) of each other, that client's **UserName** activations are refused + `BadUserAccessDenied` for `LockoutSeconds` (300) without the key store being consulted, and a Warning is + logged once. A refusal during the lockout is not counted (it never extends itself); a successful UserName + activation clears the count. **Anonymous activations never consult it**, from any client. +- **Keyed on certificate thumbprint *and* peer address together**, so locking a client out needs both its + certificate and its source address, and NAT neighbours with different certificates do not share a count. +- Every denial counts, including a key-store timeout or miss-budget refusal (as the SDK's did) — a ConfigDb + outage can therefore lock a keyed client out for up to `LockoutSeconds` after recovery. +- Bounded at 4096 tracked clients (expired entries are pruned; a client arriving at a full table is not + tracked). API-key secrets are not guessable, so this is defence in depth, not the brute-force control. + +Pinned over the wire by `CredentialLockoutWireTests` (OpcUaServer.IntegrationTests — anonymous admitted after +six failures; a valid key refused while locked out; `MaxFailedAttempts = 0` admits after seven failures, +proving the SDK lockout is off) and unit-tested by `FailedCredentialLockoutTests`. + ### LDAP bind flow (`OtOpcUaLdapAuthService`) LDAP is configured under the `Security:Ldap` section (bound to `LdapOptions`, `src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/LdapOptions.cs`, `SectionName = "Security:Ldap"`). The app authenticator is `OtOpcUaLdapAuthService` — a thin wrapper around the shared `ZB.MOM.WW.Auth.Ldap` directory client that adds two app-only concerns the shared library deliberately does not model: the `Enabled` master switch and `DevStubMode`. Since #520 it serves the **Admin UI cookie login (`/auth/login`) only** — `LdapOpcUaUserAuthenticator` is deleted and the OPC UA UserName path authenticates API keys instead (see [API-key identity](#api-key-identity-opc-ua-data-plane)). diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs index cfaecdca3..c5b049966 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs @@ -113,6 +113,12 @@ public sealed class OpcUaApplicationHostOptions /// own transport defaults, so an absent section changes nothing. /// public OpcUaQuotaOptions Quotas { get; set; } = new(); + + /// + /// Failed-credential lockout for UserName activations (OpcUa:CredentialLockout:*, lmxopcua#833). + /// Replaces the SDK's own lockout, which also refused Anonymous; see . + /// + public OpcUaCredentialLockoutOptions CredentialLockout { get; set; } = new(); } /// @@ -175,6 +181,7 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable private StandardServer? _server; private readonly ITelemetryContext _telemetry = DefaultTelemetry.Create(_ => { }); private readonly List _identityAuthenticators = []; + private readonly FailedCredentialLockout _credentialLockout; private readonly object _lifetimeLock = new(); private readonly TaskCompletionSource _disposeCompletion = new(TaskCreationOptions.RunContinuationsAsynchronously); private int _disposeStarted; @@ -191,6 +198,7 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable _options = options; _logger = logger; _userAuthenticator = userAuthenticator ?? NullOpcUaUserAuthenticator.Instance; + _credentialLockout = new FailedCredentialLockout(options.CredentialLockout ?? new OpcUaCredentialLockoutOptions()); } /// Gets the OPC Foundation application instance, or null if not yet started. @@ -260,14 +268,47 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable ct.ThrowIfCancellationRequested(); var args = new ImpersonateEventArgs(context.TokenHandler, context.UserTokenPolicy, context.EndpointDescription); var peer = (host._server as OtOpcUaSdkServer)?.ActivationPeerAddress; + + // lmxopcua#833 — only credential-bearing (UserName) activations are counted and refused. Anonymous + // never consults the lockout, so failed sign-ins cannot lock a client (or its NAT neighbours) out of + // anonymous access the way the SDK's own lockout did. + var lockoutKey = tokenType == UserTokenType.UserName + ? FailedCredentialLockout.KeyFor(context.ChannelCertificate?.Thumbprint, peer) + : null; + if (lockoutKey is not null && host._credentialLockout.IsLockedOut(lockoutKey, out var remaining)) + { + var seconds = (long)Math.Ceiling(remaining.TotalSeconds); + host._logger.LogInformation( + "OpcUaApplicationHost: UserName activation refused — client {Client} is locked out for {Seconds}s more", + lockoutKey, seconds); + return AuthenticationResult.Reject(ServiceResult.Create(StatusCodes.BadUserAccessDenied, + $"Too many failed authentication attempts. Try again in {seconds} seconds.")); + } + // Port zero is only an adapter for the existing IP-budget seam: the SDK exposes no peer port. await HandleImpersonationAsync(host._userAuthenticator, args, host._logger, host._options.AllowAnonymous, peer is null ? null : new System.Net.IPEndPoint(peer, 0), ct).ConfigureAwait(false); - return ServiceResult.IsBad(args.IdentityValidationError) + var result = ServiceResult.IsBad(args.IdentityValidationError) ? AuthenticationResult.Reject(args.IdentityValidationError) : args.Identity is { } identity ? AuthenticationResult.Accept(identity) : AuthenticationResult.Reject(new ServiceResult(StatusCodes.BadIdentityTokenRejected)); + + if (lockoutKey is not null) + { + if (args.Identity is not null && !ServiceResult.IsBad(args.IdentityValidationError)) + { + host._credentialLockout.RecordSuccess(lockoutKey); + } + else if (host._credentialLockout.RecordFailure(lockoutKey)) + { + host._logger.LogWarning( + "OpcUaApplicationHost: client {Client} locked out of UserName activation for {Seconds}s after " + + "{Attempts} failed attempts (OpcUa:CredentialLockout); anonymous activation is unaffected", + lockoutKey, host._options.CredentialLockout.LockoutSeconds, host._options.CredentialLockout.MaxFailedAttempts); + } + } + return result; } } @@ -488,7 +529,9 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable { if (!string.IsNullOrWhiteSpace(_options.ApplicationConfigPath)) { - return await _application!.LoadApplicationConfigurationAsync(_options.ApplicationConfigPath, true, ct); + var loaded = await _application!.LoadApplicationConfigurationAsync(_options.ApplicationConfigPath, true, ct); + if (loaded.ServerConfiguration is { } loadedServer) DisableSdkAuthenticationLockout(loadedServer); + return loaded; } var serverConfig = new ServerConfiguration @@ -496,6 +539,7 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable BaseAddresses = [$"opc.tcp://{_options.PublicHostname}:{_options.OpcUaPort}/OtOpcUa"], }; ApplyRequestQuotas(serverConfig, _options.Quotas); + DisableSdkAuthenticationLockout(serverConfig); serverConfig.SecurityPolicies = BuildSecurityPolicies(_options.EnabledSecurityProfiles).ToArray(); serverConfig.UserTokenPolicies = BuildUserTokenPolicies(_options.AllowAnonymous).ToArray(); @@ -531,6 +575,15 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable return config; } + /// + /// Switches off the SDK's per-client failed-authentication lockout (lmxopcua#833). It refuses EVERY + /// activation from a locked client — Anonymous included — for 5 minutes; + /// enforces the UserName-only replacement. + /// + /// The configuration to set. + internal static void DisableSdkAuthenticationLockout(ServerConfiguration serverConfig) => + serverConfig.MaxFailedAuthenticationAttempts = 0; + /// /// Applies the request-processing quotas (OpcUa:Quotas:*, review 01 P6 — they were hard-coded at /// 5 / 100 / 200) to the SDK server configuration. diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/FailedCredentialLockout.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/FailedCredentialLockout.cs new file mode 100644 index 000000000..cecc28ae1 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/FailedCredentialLockout.cs @@ -0,0 +1,159 @@ +namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Security; + +/// +/// The OpcUa:CredentialLockout section: how many failed UserName activations one client may make +/// before its UserName activations are refused for a while (lmxopcua#833). +/// +/// +/// The defaults are the OPC Foundation SDK 2.0.1 SessionManager lockout this replaces: 5 failures, +/// each inside 60 s of the previous one, lock the client out for 5 minutes. +/// +public sealed class OpcUaCredentialLockoutOptions +{ + /// Failed UserName activations that lock a client out; 0 or less disables the lockout. + public int MaxFailedAttempts { get; set; } = 5; + + /// A failure more than this many seconds after the previous one starts a fresh count. + public int FailureWindowSeconds { get; set; } = 60; + + /// How long a locked-out client's UserName activations are refused, in seconds. + public int LockoutSeconds { get; set; } = 300; +} + +/// +/// Brute-force lockout for UserName (API-key) activations, scoped so it can never refuse an +/// anonymous activation (lmxopcua#833). +/// +/// +/// +/// Why this exists instead of the SDK's own lockout. OPC Foundation SDK 2.0.1's +/// SessionManager.ActivateSessionAsync counts every failed identity validation per client and, +/// at ServerConfiguration.MaxFailedAuthenticationAttempts (default 5), refuses every +/// activation from that client for 5 minutes with BadUserAccessDenied — the lockout check runs +/// before the token is even looked at, so it refuses Anonymous too, and a successful anonymous +/// activation does not clear it. Its key is the client certificate thumbprint when one is presented, +/// else the peer IP: on a None endpoint the presented certificate is not proven, so a client +/// could lock out another client's sessions by presenting its certificate, and behind a NAT every +/// certificate-less client shares one key. The host therefore sets +/// MaxFailedAuthenticationAttempts = 0 and enforces this narrower lockout instead. +/// +/// +/// What it does. Only UserName activations are counted and refused. The key is the presented +/// client certificate thumbprint and the observed peer address together, so locking a victim +/// out needs both its certificate and its source address. Same timing as the SDK: a failure within +/// of the previous one adds to the +/// count; at the client is refused for +/// ; a refusal during the lockout is not +/// itself counted (the lockout never extends itself); a successful UserName activation clears the +/// client's count. +/// +/// +/// Memory is bounded at : expired entries are pruned when it fills, and +/// a client that still finds it full is not tracked (it can only evade by rotating certificates or +/// addresses, which it could do against the SDK lockout too; API-key secrets are not guessable). +/// +/// +public sealed class FailedCredentialLockout +{ + /// The most clients tracked at once. + public const int MaxTrackedClients = 4096; + + private readonly OpcUaCredentialLockoutOptions _options; + private readonly TimeProvider _time; + private readonly Dictionary _entries = new(StringComparer.Ordinal); + private readonly object _gate = new(); + + /// Initializes a new instance of the class. + /// The configured thresholds. + /// The clock; when null. + public FailedCredentialLockout(OpcUaCredentialLockoutOptions options, TimeProvider? time = null) + { + _options = options ?? throw new ArgumentNullException(nameof(options)); + _time = time ?? TimeProvider.System; + } + + /// Whether the lockout is switched on. + public bool Enabled => _options.MaxFailedAttempts > 0; + + /// Builds the lockout key for a client. + /// The presented client certificate's thumbprint, if any. + /// The transport-observed peer address, if known. + /// The key; never null. + public static string KeyFor(string? certificateThumbprint, System.Net.IPAddress? peer) => + $"{(string.IsNullOrEmpty(certificateThumbprint) ? "-" : certificateThumbprint)}@{peer?.ToString() ?? "-"}"; + + /// Whether is locked out right now. + /// The client key from . + /// The time left on the lockout when this returns true. + /// when the client's UserName activations must be refused. + public bool IsLockedOut(string key, out TimeSpan remaining) + { + remaining = TimeSpan.Zero; + if (!Enabled) return false; + var now = _time.GetUtcNow(); + lock (_gate) + { + if (!_entries.TryGetValue(key, out var entry)) return false; + if (entry.LockedUntil > now) + { + remaining = entry.LockedUntil - now; + return true; + } + if (IsExpired(entry, now)) _entries.Remove(key); + return false; + } + } + + /// Records one failed UserName activation. + /// The client key. + /// when this failure locked the client out. + public bool RecordFailure(string key) + { + if (!Enabled) return false; + var now = _time.GetUtcNow(); + lock (_gate) + { + if (_entries.TryGetValue(key, out var entry)) + { + if (entry.LockedUntil > now) return false; // a refusal never extends the lockout + entry = IsExpired(entry, now) ? new Entry(1, now, default) : entry with { Failures = entry.Failures + 1, LastFailure = now }; + } + else + { + if (_entries.Count >= MaxTrackedClients) Prune(now); + if (_entries.Count >= MaxTrackedClients) return false; + entry = new Entry(1, now, default); + } + + if (entry.Failures >= _options.MaxFailedAttempts) + entry = entry with { LockedUntil = now + TimeSpan.FromSeconds(Math.Max(0, _options.LockoutSeconds)) }; + _entries[key] = entry; + return entry.LockedUntil > now; + } + } + + /// Clears a client's count after a successful UserName activation. + /// The client key. + public void RecordSuccess(string key) + { + lock (_gate) _entries.Remove(key); + } + + /// How many clients are tracked (test observation). + internal int TrackedCount + { + get { lock (_gate) return _entries.Count; } + } + + private bool IsExpired(Entry entry, DateTimeOffset now) => + entry.LockedUntil <= now && now - entry.LastFailure > TimeSpan.FromSeconds(Math.Max(0, _options.FailureWindowSeconds)); + + private void Prune(DateTimeOffset now) + { + foreach (var key in _entries.Where(e => IsExpired(e.Value, now)).Select(e => e.Key).ToList()) + _entries.Remove(key); + } + + /// One client's failure count and lockout. + private readonly record struct Entry(int Failures, DateTimeOffset LastFailure, DateTimeOffset LockedUntil); +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/CredentialLockoutWireTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/CredentialLockoutWireTests.cs new file mode 100644 index 000000000..9519f4447 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/CredentialLockoutWireTests.cs @@ -0,0 +1,185 @@ +using System.Net; +using System.Text; +using Microsoft.Extensions.Logging.Abstractions; +using Opc.Ua; +using Opc.Ua.Client; +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 (). +/// +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 < 6; 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 }, 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"); + } + + [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(); + } + + private sealed class Rig : IAsyncDisposable + { + private readonly string _pkiRoot; + private OpcUaApplicationHost _host = null!; + private int _port; + + private Rig(string pkiRoot) => _pkiRoot = pkiRoot; + + public static async Task StartAsync( + IOpcUaUserAuthenticator authenticator, OpcUaCredentialLockoutOptions lockout, CancellationToken ct) + { + var rig = new Rig(Path.Combine(Path.GetTempPath(), $"otopcua-lockout-{Guid.NewGuid():N}")); + var applicationUri = $"urn:OtOpcUa.Lockout.{Guid.NewGuid():N}"; + (rig._host, _) = await OpcUaHostStart.StartAsync( + port => + { + rig._port = port; + return new OpcUaApplicationHost( + new OpcUaApplicationHostOptions + { + ApplicationName = "OtOpcUa.Lockout", + ApplicationUri = applicationUri, + OpcUaPort = port, + PublicHostname = "127.0.0.1", + PkiStoreRoot = rig._pkiRoot, + EnabledSecurityProfiles = [OpcUaSecurityProfile.None], + AutoAcceptUntrustedClientCertificates = true, + CredentialLockout = lockout, + }, + NullLogger.Instance, + authenticator); + }, + () => new OtOpcUaSdkServer(), ct); + return rig; + } + + /// Opens a session; a null credential activates anonymously. + public async Task ConnectAsync(string? credential, CancellationToken ct) + { + var appConfig = new ApplicationConfiguration(DefaultTelemetry.Create(_ => { })) + { + ApplicationName = "OtOpcUa.LockoutClient", + ApplicationUri = "urn:OtOpcUa.LockoutClient", + ApplicationType = ApplicationType.Client, + SecurityConfiguration = TestClientSecurity.Build(TestClientSecurity.AllocatePkiRoot()), + ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60_000 }, + }; + await appConfig.ValidateAsync(ApplicationType.Client, ct); + + var endpoint = await CoreClientUtils.SelectEndpointAsync( + appConfig, $"opc.tcp://127.0.0.1:{_port}/OtOpcUa", false, DefaultTelemetry.Create(_ => { }), ct); + + return await new DefaultSessionFactory(DefaultTelemetry.Create(_ => { })).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(); + if (Directory.Exists(_pkiRoot)) + { + try { Directory.Delete(_pkiRoot, recursive: true); } + catch (IOException) { /* best effort */ } + } + } + } + + /// Admits and anonymous; denies everything else; counts calls. + 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") + : OpcUaUserAuthResult.Deny("Invalid API key.")); + } + + public Task AuthenticateUserNameAsync( + string username, string password, IPEndPoint? remoteEndpoint, CancellationToken ct) => + AuthenticateUserNameAsync(username, password, ct); + + public Task AuthenticateAnonymousAsync(CancellationToken ct) + { + Interlocked.Increment(ref _anonymousCalls); + return Task.FromResult(OpcUaUserAuthResult.Allow(OpcUaPrincipals.Anonymous, [])); + } + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/FailedCredentialLockoutTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/FailedCredentialLockoutTests.cs new file mode 100644 index 000000000..723c2cf25 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/FailedCredentialLockoutTests.cs @@ -0,0 +1,120 @@ +using System.Net; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security; + +namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; + +/// lmxopcua#833 — the UserName-only lockout that replaces the SDK's all-token lockout. +public sealed class FailedCredentialLockoutTests +{ + private static readonly string Client = FailedCredentialLockout.KeyFor("THUMB", IPAddress.Parse("10.0.0.5")); + + [Fact] + public void Locks_at_the_threshold_and_releases_after_the_lockout() + { + var clock = new ManualClock(); + var lockout = new FailedCredentialLockout(new OpcUaCredentialLockoutOptions(), clock); + + for (var i = 0; i < 4; i++) lockout.RecordFailure(Client).ShouldBeFalse(); + lockout.IsLockedOut(Client, out _).ShouldBeFalse(); + lockout.RecordFailure(Client).ShouldBeTrue("the fifth failure locks"); + lockout.IsLockedOut(Client, out var remaining).ShouldBeTrue(); + remaining.ShouldBe(TimeSpan.FromMinutes(5)); + + clock.Advance(TimeSpan.FromSeconds(299)); + lockout.IsLockedOut(Client, out _).ShouldBeTrue(); + clock.Advance(TimeSpan.FromSeconds(1)); + lockout.IsLockedOut(Client, out _).ShouldBeFalse(); + lockout.RecordFailure(Client).ShouldBeFalse("a lockout that ran out starts a fresh count"); + } + + [Fact] + public void A_refusal_during_the_lockout_does_not_extend_it() + { + var clock = new ManualClock(); + var lockout = new FailedCredentialLockout(new OpcUaCredentialLockoutOptions { MaxFailedAttempts = 1 }, clock); + lockout.RecordFailure(Client).ShouldBeTrue(); + clock.Advance(TimeSpan.FromSeconds(200)); + lockout.RecordFailure(Client).ShouldBeFalse(); + clock.Advance(TimeSpan.FromSeconds(100)); + lockout.IsLockedOut(Client, out _).ShouldBeFalse(); + } + + [Fact] + public void Failures_spaced_wider_than_the_window_never_accumulate() + { + var clock = new ManualClock(); + var lockout = new FailedCredentialLockout(new OpcUaCredentialLockoutOptions(), clock); + for (var i = 0; i < 10; i++) + { + lockout.RecordFailure(Client).ShouldBeFalse(); + clock.Advance(TimeSpan.FromSeconds(61)); + } + lockout.IsLockedOut(Client, out _).ShouldBeFalse(); + } + + [Fact] + public void A_success_clears_the_count() + { + var lockout = new FailedCredentialLockout(new OpcUaCredentialLockoutOptions(), new ManualClock()); + for (var i = 0; i < 4; i++) lockout.RecordFailure(Client); + lockout.RecordSuccess(Client); + for (var i = 0; i < 4; i++) lockout.RecordFailure(Client).ShouldBeFalse(); + lockout.IsLockedOut(Client, out _).ShouldBeFalse(); + } + + [Fact] + public void The_key_needs_both_certificate_and_address_so_one_client_cannot_lock_out_another() + { + var lockout = new FailedCredentialLockout(new OpcUaCredentialLockoutOptions { MaxFailedAttempts = 1 }, new ManualClock()); + var attacker = FailedCredentialLockout.KeyFor("VICTIMTHUMB", IPAddress.Parse("10.0.0.99")); + var victim = FailedCredentialLockout.KeyFor("VICTIMTHUMB", IPAddress.Parse("10.0.0.5")); + var natNeighbour = FailedCredentialLockout.KeyFor("OTHERTHUMB", IPAddress.Parse("10.0.0.99")); + + lockout.RecordFailure(attacker).ShouldBeTrue(); + lockout.IsLockedOut(victim, out _).ShouldBeFalse("same certificate, different address"); + lockout.IsLockedOut(natNeighbour, out _).ShouldBeFalse("same address, different certificate"); + } + + [Fact] + public void Zero_attempts_disables_it() + { + var lockout = new FailedCredentialLockout(new OpcUaCredentialLockoutOptions { MaxFailedAttempts = 0 }, new ManualClock()); + for (var i = 0; i < 50; i++) lockout.RecordFailure(Client).ShouldBeFalse(); + lockout.IsLockedOut(Client, out _).ShouldBeFalse(); + lockout.TrackedCount.ShouldBe(0); + } + + [Fact] + public void Tracking_is_bounded_and_prunes_expired_clients_first() + { + var clock = new ManualClock(); + var lockout = new FailedCredentialLockout(new OpcUaCredentialLockoutOptions(), clock); + for (var i = 0; i < FailedCredentialLockout.MaxTrackedClients; i++) lockout.RecordFailure($"c{i}"); + lockout.TrackedCount.ShouldBe(FailedCredentialLockout.MaxTrackedClients); + + lockout.RecordFailure("overflow"); + lockout.TrackedCount.ShouldBe(FailedCredentialLockout.MaxTrackedClients, "full and nothing expired ⇒ not tracked"); + + clock.Advance(TimeSpan.FromSeconds(61)); + lockout.RecordFailure("overflow"); + lockout.TrackedCount.ShouldBe(1, "expired entries are pruned to make room"); + } + + [Fact] + public void The_host_switches_the_sdk_lockout_off() + { + var config = new Opc.Ua.ServerConfiguration(); + config.MaxFailedAuthenticationAttempts.ShouldBe(5, "the SDK default this guards against"); + OpcUaApplicationHost.DisableSdkAuthenticationLockout(config); + config.MaxFailedAuthenticationAttempts.ShouldBe(0); + } + + private sealed class ManualClock : TimeProvider + { + private DateTimeOffset _now = new(2026, 11, 11, 0, 0, 0, TimeSpan.Zero); + public override DateTimeOffset GetUtcNow() => _now; + public void Advance(TimeSpan by) => _now += by; + } +}