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;
+ }
+}