Files
lmxopcua/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/FailedCredentialLockout.cs
T
Joseph Doherty ffba75bb93 fix(opcua): credential lockout counts per credential identity, ignores transient denials (#844, #845)
#844: a success cleared the whole per-client count, so a client holding a valid
key could make four guesses at another key id, sign in with its own, and repeat
forever. Failures are now charged to the (client, credential identity) pair --
IOpcUaUserAuthenticator.DescribeCredential, key:<keyId> for API keys -- and to
the client as a whole. A success clears only its own identity's count; the
per-identity threshold locks that identity; a new per-client cap
(OpcUa:CredentialLockout:MaxFailedAttemptsPerClient, default 2 x
MaxFailedAttempts) that a success never resets bounds spraying across key ids
and locks every credential. Slots are bounded on both counts; a waiter whose
identity has room is not held behind one whose identity has none.

#845: (1) store timeouts, miss-budget refusals, store/authenticator exceptions,
an unset pepper and cancellation are reported Transient
(OpcUaUserAuthResult.Unavailable) and released uncounted, so a ConfigDb stall
can no longer lock every keyed client out; the client still sees the same
opaque denial. (2) a full 4096-entry table defers its next prune until the
earliest entry could age out instead of rescanning under the lock per failure.
(3) ApplicationUri deliberately NOT added to the key: on a None channel it is a
free string, so it would let an attacker reset the count per guess --
documented. (4) wire tests: distinct client certificates on a Sign endpoint are
locked out separately; the ApplicationConfigPath branch keeps the SDK lockout
off (falsified by removing the loaded-branch disable).
2026-11-11 03:47:10 -05:00

716 lines
34 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
/// <summary>
/// The <c>OpcUa:CredentialLockout</c> section: how many failed UserName activations one client may make
/// before its UserName activations are refused for a while (lmxopcua#833).
/// </summary>
/// <remarks>
/// The defaults are the OPC Foundation SDK 2.0.1 <c>SessionManager</c> lockout this replaces: 5 failures,
/// each inside 60 s of the previous one, lock the client out for 5 minutes. When enabled
/// (<see cref="MaxFailedAttempts"/> &gt; 0) every duration must be positive — enforced at host start by
/// <c>OpcUaApplicationHostOptionsValidator</c>.
/// </remarks>
public sealed class OpcUaCredentialLockoutOptions
{
/// <summary>
/// Failed UserName activations for ONE credential identity (an API key id, else the username) from one client
/// that lock that client out of using that identity; <c>0</c> or less disables the whole lockout. A success
/// with the same identity clears its count (lmxopcua#844).
/// </summary>
public int MaxFailedAttempts { get; set; } = 5;
/// <summary>
/// Failed UserName activations from one client, across EVERY identity, that lock the client out of all
/// UserName activations (lmxopcua#844). A success does NOT reset this count — it only ages out through
/// <see cref="FailureWindowSeconds"/> or an expired lockout — so a client cannot make
/// <see cref="MaxFailedAttempts"/> − 1 guesses at someone else's key, sign in with its own, and repeat; nor
/// spray a few guesses at each of many key ids. <c>0</c> (the default) means 2 × <see cref="MaxFailedAttempts"/>;
/// an explicit value must be at least <see cref="MaxFailedAttempts"/>.
/// </summary>
public int MaxFailedAttemptsPerClient { get; set; }
/// <summary>A failure more than this many seconds after the previous one starts a fresh count.</summary>
public int FailureWindowSeconds { get; set; } = 60;
/// <summary>How long a locked-out client's UserName activations are refused, in seconds.</summary>
public int LockoutSeconds { get; set; } = 300;
/// <summary>
/// How long a UserName activation waits for one of its client's verification slots before that one attempt
/// is failed (<c>BadTimeout</c>, NOT counted as a credential failure). Only reached when a client has more
/// verifications in progress than <see cref="MaxFailedAttempts"/> minus its recorded failures; a verification
/// is milliseconds, so in practice this bounds a pathological store stall. Kept well under the SDK's
/// operation timeout so the client gets this answer rather than a transport timeout.
/// </summary>
public int SlotWaitSeconds { get; set; } = 10;
}
/// <summary>The outcome of <see cref="FailedCredentialLockout.BeginAsync(string, string, CancellationToken)"/>.</summary>
public enum CredentialLockoutVerdict
{
/// <summary>A verification slot is held — authenticate, then call <see cref="FailedCredentialLockout.Complete(string, string, bool)"/>.</summary>
Proceed,
/// <summary>The client, or the credential identity it presented, is locked out; refuse without authenticating.</summary>
LockedOut,
/// <summary>No slot freed within the wait; fail this one attempt, uncounted.</summary>
SlotWaitTimedOut,
}
/// <summary>What <see cref="FailedCredentialLockout.BeginAsync(string, string, CancellationToken)"/> decided.</summary>
/// <param name="Verdict">Proceed, locked out, or timed out waiting.</param>
/// <param name="Remaining">Time left on the lockout when <see cref="CredentialLockoutVerdict.LockedOut"/>.</param>
/// <param name="FirstRefusal">True for the first refusal of a lockout episode (log it at Information, the rest at Debug).</param>
/// <param name="Scope">What is locked out when <see cref="CredentialLockoutVerdict.LockedOut"/>.</param>
public readonly record struct CredentialLockoutDecision(
CredentialLockoutVerdict Verdict, TimeSpan Remaining, bool FirstRefusal,
CredentialLockoutScope Scope = CredentialLockoutScope.None);
/// <summary>What a lockout covers (lmxopcua#844).</summary>
public enum CredentialLockoutScope
{
/// <summary>Nothing is locked.</summary>
None,
/// <summary>One credential identity (key id / username) from one client.</summary>
Credential,
/// <summary>Every UserName activation from one client.</summary>
Client,
}
/// <summary>
/// Brute-force lockout for UserName (API-key) activations, scoped so it can never refuse an
/// <b>anonymous</b> activation (lmxopcua#833), and counted per credential identity as well as per client
/// (lmxopcua#844).
/// </summary>
/// <remarks>
/// <para>
/// <b>Why this exists instead of the SDK's own lockout.</b> OPC Foundation SDK 2.0.1's
/// <c>SessionManager.ActivateSessionAsync</c> counts every failed identity validation per client and,
/// at <c>ServerConfiguration.MaxFailedAuthenticationAttempts</c> (default 5), refuses <b>every</b>
/// activation from that client for 5 minutes with <c>BadUserAccessDenied</c> — 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. The host therefore sets <c>MaxFailedAuthenticationAttempts = 0</c> and
/// enforces this narrower lockout instead.
/// </para>
/// <para>
/// <b>Client key.</b> Only UserName activations are counted and refused; Anonymous never consults it. The
/// client key is the presented client certificate thumbprint <b>and</b> the observed peer address together.
/// ⚠️ On a <c>None</c> endpoint clients commonly present no certificate, so the key is <b>effectively the IP
/// alone</b> and NAT neighbours there share the client-wide count (UserName only). The client's
/// <c>ApplicationUri</c> is deliberately NOT part of the key (lmxopcua#845): it is a free, unauthenticated
/// string on a <c>None</c> channel, so adding it would let one attacker reset its count by varying it.
/// </para>
/// <para>
/// <b>Two counts (lmxopcua#844).</b> Each failure is charged to the (client, <i>credential identity</i>)
/// pair — the API key id, or the username when the credential carries no key id — AND to the client as a
/// whole. A success clears only its own identity's count. At
/// <see cref="OpcUaCredentialLockoutOptions.MaxFailedAttempts"/> failures of one identity, that client is
/// refused <i>that identity</i> for <see cref="OpcUaCredentialLockoutOptions.LockoutSeconds"/>; at the
/// per-client cap (<see cref="ClientThreshold"/>, default 2× that), which a success never resets, the client
/// is refused <i>every</i> UserName activation. So a client holding a valid key can no longer launder guesses
/// at another key id by signing in with its own between them (the success does not touch the victim
/// identity's count), and spraying a few guesses at each of many key ids still hits the client-wide cap. A
/// legitimate client with one stale key and one good key loses only the stale one. Timing per count: a
/// failure more than <see cref="OpcUaCredentialLockoutOptions.FailureWindowSeconds"/> after the previous one
/// of that count starts it afresh; a refusal during a lockout is not counted; an expired lockout starts a
/// fresh count (an expired client-wide lockout clears every identity's too).
/// </para>
/// <para>
/// <b>Only a definite bad credential counts (lmxopcua#845).</b> The caller completes a transient denial — a
/// key-store timeout, a miss-budget refusal, an authenticator exception, cancellation — with
/// <see cref="Abandon(string, string)"/>, which releases the slot uncounted; a ConfigDb stall therefore
/// cannot lock every keyed client out.
/// </para>
/// <para>
/// <b>Concurrency — parallelism waits, it is never refused.</b>
/// <see cref="BeginAsync(string, string, CancellationToken)"/> hands out a verification <b>slot</b> only
/// while both <c>identityFailures + identityInFlight &lt; MaxFailedAttempts</c> and
/// <c>clientFailures + clientInFlight &lt; ClientThreshold</c>; an activation beyond that <b>waits</b> for a
/// slot (a waiter whose identity has room is not held behind one whose identity has none). No more
/// unverified guesses than either threshold are ever in progress-or-failed, while a legitimate client opening
/// many keyed sessions at once (a ScadaBridge site node behind one NAT IP) just queues for milliseconds.
/// Waiters are refused only when what they need locks out, or (uncounted) after
/// <see cref="OpcUaCredentialLockoutOptions.SlotWaitSeconds"/>; a cancelled waiter is dequeued.
/// </para>
/// <para>
/// Memory is bounded at <see cref="MaxTrackedClients"/> clients and
/// <see cref="MaxTrackedIdentitiesPerClient"/> identities per client (identities beyond that share one
/// overflow count — conservative, never laxer). Entries with nothing to remember are pruned when the table
/// fills; a client that still finds it full is not tracked. When a prune frees nothing, the next one waits
/// until the earliest entry could have expired (lmxopcua#845), so a flood of new client keys at a full table
/// does not rescan it under the lock on every failure.
/// </para>
/// </remarks>
public sealed class FailedCredentialLockout
{
/// <summary>The most clients tracked at once.</summary>
public const int MaxTrackedClients = 4096;
/// <summary>The most credential identities tracked per client; further ones share one overflow count.</summary>
public const int MaxTrackedIdentitiesPerClient = 64;
/// <summary>The identity used when the caller supplies none (a direct caller / test seam).</summary>
public const string NoIdentity = "";
private const string OverflowIdentity = "\0overflow";
private const int MaxIdentityLength = 256;
private readonly OpcUaCredentialLockoutOptions _options;
private readonly TimeProvider _time;
private readonly Dictionary<string, Entry> _entries = new(StringComparer.Ordinal);
private readonly object _gate = new();
private DateTimeOffset _nextPruneAt = DateTimeOffset.MinValue;
private int _pruneScans;
/// <summary>Initializes a new instance of the <see cref="FailedCredentialLockout"/> class.</summary>
/// <param name="options">The configured thresholds.</param>
/// <param name="time">The clock; <see cref="TimeProvider.System"/> when null.</param>
public FailedCredentialLockout(OpcUaCredentialLockoutOptions options, TimeProvider? time = null)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
_time = time ?? TimeProvider.System;
}
/// <summary>Whether the lockout is switched on.</summary>
public bool Enabled => _options.MaxFailedAttempts > 0;
/// <summary>The effective per-client cap: the configured value, or 2 × MaxFailedAttempts when unset.</summary>
public int ClientThreshold => _options.MaxFailedAttemptsPerClient > 0
? _options.MaxFailedAttemptsPerClient
: 2 * Math.Max(0, _options.MaxFailedAttempts);
private int IdentityThreshold => _options.MaxFailedAttempts;
/// <summary>Builds the lockout key for a client.</summary>
/// <param name="certificateThumbprint">The presented client certificate's thumbprint, if any.</param>
/// <param name="peer">The transport-observed peer address, if known.</param>
/// <returns>The key; never null.</returns>
public static string KeyFor(string? certificateThumbprint, System.Net.IPAddress? peer) =>
$"{(string.IsNullOrEmpty(certificateThumbprint) ? "-" : certificateThumbprint)}@{peer?.ToString() ?? "-"}";
/// <summary><see cref="BeginAsync(string, string, CancellationToken)"/> with <see cref="NoIdentity"/>.</summary>
/// <param name="key">The client key from <see cref="KeyFor"/>.</param>
/// <param name="ct">Cancels the wait.</param>
/// <returns>The decision.</returns>
public ValueTask<CredentialLockoutDecision> BeginAsync(string key, CancellationToken ct = default) =>
BeginAsync(key, NoIdentity, ct);
/// <summary>
/// Acquires a verification slot for <paramref name="identity"/> from <paramref name="key"/>, waiting for one
/// when the client or the identity already has as many in progress as it may. On
/// <see cref="CredentialLockoutVerdict.Proceed"/> the caller MUST release the slot with
/// <see cref="Complete(string, string, bool)"/> or <see cref="Abandon(string, string)"/>, passing the same
/// identity.
/// </summary>
/// <param name="key">The client key from <see cref="KeyFor"/>.</param>
/// <param name="identity">The credential identity (key id, else username) the attempt presents.</param>
/// <param name="ct">Cancels the wait; a cancelled waiter is dequeued and the exception propagates.</param>
/// <returns>The decision.</returns>
public async ValueTask<CredentialLockoutDecision> BeginAsync(string key, string identity, CancellationToken ct = default)
{
if (!Enabled) return new(CredentialLockoutVerdict.Proceed, TimeSpan.Zero, false);
identity = Normalize(identity);
Waiter waiter;
Entry entry;
lock (_gate)
{
var now = _time.GetUtcNow();
if (!_entries.TryGetValue(key, out var existing))
{
existing = TryAddEntry(key, now);
// Untracked when still full: it gets no slot accounting (see remarks).
if (existing is null) return new(CredentialLockoutVerdict.Proceed, TimeSpan.Zero, false);
}
entry = existing;
Refresh(entry, now);
var slotIdentity = SlotIdentity(entry, identity);
if (TryLockedOut(entry, slotIdentity, now, out var refused)) return refused;
if (entry.Waiters.Count == 0 && HasFreeSlot(entry, slotIdentity))
{
TakeSlot(entry, slotIdentity);
return new(CredentialLockoutVerdict.Proceed, TimeSpan.Zero, false);
}
waiter = new Waiter(identity);
entry.Waiters.AddLast(waiter.Node = new LinkedListNode<Waiter>(waiter));
// A waiter whose identity has room is not held behind one whose identity has none.
Pump(entry, now);
}
var wait = TimeSpan.FromSeconds(Math.Max(1, _options.SlotWaitSeconds));
try
{
return await waiter.Completion.Task.WaitAsync(wait, _time, ct).ConfigureAwait(false);
}
catch (Exception ex) when (ex is TimeoutException or OperationCanceledException)
{
lock (_gate)
{
if (waiter.Completion.Task.IsCompletedSuccessfully)
{
// Decided in the same instant the wait gave up. A timeout takes the decision as made; a
// cancellation hands a granted slot back (waking the next waiter) so nothing leaks.
var decided = waiter.Completion.Task.Result;
if (ex is not OperationCanceledException) return decided;
if (decided.Verdict == CredentialLockoutVerdict.Proceed) ReleaseCore(key, entry, identity, outcome: null);
}
else
{
entry.Waiters.Remove(waiter.Node!);
waiter.Completion.TrySetCanceled();
Pump(entry, _time.GetUtcNow());
RemoveIfIdle(key, entry);
}
}
if (ex is OperationCanceledException) throw;
return new(CredentialLockoutVerdict.SlotWaitTimedOut, TimeSpan.Zero, false);
}
}
/// <summary><see cref="Complete(string, string, bool)"/> with <see cref="NoIdentity"/>.</summary>
/// <param name="key">The client key.</param>
/// <param name="succeeded">Whether the activation was admitted.</param>
/// <returns>What this failure locked out, if anything.</returns>
public CredentialLockoutScope Complete(string key, bool succeeded) => Complete(key, NoIdentity, succeeded);
/// <summary>
/// Releases a slot from <see cref="BeginAsync(string, string, CancellationToken)"/> and records a DEFINITE
/// outcome: admitted, or refused for a bad credential. A transient denial goes to
/// <see cref="Abandon(string, string)"/> instead (#845).
/// </summary>
/// <param name="key">The client key.</param>
/// <param name="identity">The identity passed to BeginAsync.</param>
/// <param name="succeeded">Whether the activation was admitted.</param>
/// <returns>What this failure locked out, if anything.</returns>
public CredentialLockoutScope Complete(string key, string identity, bool succeeded)
{
if (!Enabled) return CredentialLockoutScope.None;
identity = Normalize(identity);
lock (_gate)
{
return _entries.TryGetValue(key, out var entry)
? ReleaseCore(key, entry, identity, succeeded)
: CredentialLockoutScope.None;
}
}
/// <summary><see cref="Abandon(string, string)"/> with <see cref="NoIdentity"/>.</summary>
/// <param name="key">The client key.</param>
public void Abandon(string key) => Abandon(key, NoIdentity);
/// <summary>
/// Releases a slot without recording an outcome: the activation was cancelled or threw, or was denied for a
/// transient reason (store timeout, miss budget, authenticator fault) rather than a bad credential (#845).
/// </summary>
/// <param name="key">The client key.</param>
/// <param name="identity">The identity passed to BeginAsync.</param>
public void Abandon(string key, string identity)
{
if (!Enabled) return;
identity = Normalize(identity);
lock (_gate)
{
if (_entries.TryGetValue(key, out var entry)) ReleaseCore(key, entry, identity, outcome: null);
}
}
/// <summary>Whether <paramref name="key"/> is locked out client-wide or for <see cref="NoIdentity"/>.</summary>
/// <param name="key">The client key from <see cref="KeyFor"/>.</param>
/// <param name="remaining">The time left on the lockout when this returns true.</param>
/// <returns><see langword="true"/> when such an activation would be refused.</returns>
public bool IsLockedOut(string key, out TimeSpan remaining) => IsLockedOut(key, NoIdentity, out remaining);
/// <summary>
/// Whether an activation presenting <paramref name="identity"/> from <paramref name="key"/> would be refused
/// right now — client-wide or for that identity (observation; reserves nothing).
/// </summary>
/// <param name="key">The client key from <see cref="KeyFor"/>.</param>
/// <param name="identity">The credential identity.</param>
/// <param name="remaining">The time left on the lockout when this returns true.</param>
/// <returns><see langword="true"/> when the activation would be refused.</returns>
public bool IsLockedOut(string key, string identity, out TimeSpan remaining)
{
remaining = TimeSpan.Zero;
if (!Enabled) return false;
identity = Normalize(identity);
var now = _time.GetUtcNow();
lock (_gate)
{
if (!_entries.TryGetValue(key, out var entry)) return false;
Refresh(entry, now);
if (entry.LockedUntil > now)
{
remaining = entry.LockedUntil - now;
return true;
}
if (!entry.Identities.TryGetValue(SlotIdentity(entry, identity), out var id) || id.LockedUntil <= now)
return false;
remaining = id.LockedUntil - now;
return true;
}
}
/// <summary>Records one failed UserName activation with no slot (test seam / direct callers).</summary>
/// <param name="key">The client key.</param>
/// <param name="identity">The credential identity.</param>
/// <returns><see langword="true"/> when this failure locked the identity or the client out.</returns>
public bool RecordFailure(string key, string identity = NoIdentity)
{
if (!Enabled) return false;
identity = Normalize(identity);
var now = _time.GetUtcNow();
lock (_gate)
{
if (!_entries.TryGetValue(key, out var entry))
{
entry = TryAddEntry(key, now);
if (entry is null) return false;
}
var locked = RecordFailureCore(entry, SlotIdentity(entry, identity), now);
Pump(entry, now);
return locked != CredentialLockoutScope.None;
}
}
/// <summary>Clears one identity's count after a successful UserName activation (no slot).</summary>
/// <param name="key">The client key.</param>
/// <param name="identity">The credential identity that succeeded.</param>
public void RecordSuccess(string key, string identity = NoIdentity)
{
identity = Normalize(identity);
lock (_gate)
{
if (!_entries.TryGetValue(key, out var entry)) return;
RecordSuccessCore(entry, SlotIdentity(entry, identity));
Pump(entry, _time.GetUtcNow());
RemoveIfIdle(key, entry);
}
}
/// <summary>How many clients are tracked (test observation).</summary>
internal int TrackedCount
{
get { lock (_gate) return _entries.Count; }
}
/// <summary>How many full-table prune scans have run (test observation, #845).</summary>
internal int PruneScanCount
{
get { lock (_gate) return _pruneScans; }
}
/// <summary>How many activations are waiting for a slot on <paramref name="key"/> (test observation).</summary>
internal int WaitingCount(string key)
{
lock (_gate) return _entries.TryGetValue(key, out var e) ? e.Waiters.Count : 0;
}
/// <summary>Bounds an attacker-chosen identity string; distinct long values stay distinct.</summary>
private static string Normalize(string? identity)
{
identity ??= NoIdentity;
if (identity.Length <= MaxIdentityLength) return identity;
var hash = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(identity));
return "\0sha256:" + Convert.ToHexString(hash);
}
private Entry? TryAddEntry(string key, DateTimeOffset now)
{
if (_entries.Count >= MaxTrackedClients && now >= _nextPruneAt) Prune(now);
if (_entries.Count >= MaxTrackedClients) return null;
var entry = new Entry();
_entries[key] = entry;
return entry;
}
/// <summary>
/// The identity a slot / failure is charged to: the identity itself while it is tracked or there is room to
/// track it, else the one shared overflow identity (a slot is always reserved for it).
/// </summary>
private static string SlotIdentity(Entry entry, string identity) =>
entry.Identities.ContainsKey(identity) || entry.Identities.Count < MaxTrackedIdentitiesPerClient - 1
? identity
: OverflowIdentity;
private static IdentityState GetOrAddIdentity(Entry entry, string slotIdentity)
{
if (!entry.Identities.TryGetValue(slotIdentity, out var id))
{
id = new IdentityState();
entry.Identities[slotIdentity] = id;
}
return id;
}
private bool ClientHasRoom(Entry entry) => entry.Failures + entry.InFlight < ClientThreshold;
private bool HasFreeSlot(Entry entry, string slotIdentity) =>
ClientHasRoom(entry)
&& (!entry.Identities.TryGetValue(slotIdentity, out var id) || id.Failures + id.InFlight < IdentityThreshold);
private static void TakeSlot(Entry entry, string slotIdentity)
{
entry.InFlight++;
GetOrAddIdentity(entry, slotIdentity).InFlight++;
}
private static bool TryLockedOut(Entry entry, string slotIdentity, DateTimeOffset now, out CredentialLockoutDecision refused)
{
refused = default;
if (entry.LockedUntil > now)
{
refused = new(CredentialLockoutVerdict.LockedOut, entry.LockedUntil - now, !entry.RefusalLogged,
CredentialLockoutScope.Client);
entry.RefusalLogged = true;
return true;
}
if (!entry.Identities.TryGetValue(slotIdentity, out var id) || id.LockedUntil <= now) return false;
refused = new(CredentialLockoutVerdict.LockedOut, id.LockedUntil - now, !id.RefusalLogged,
CredentialLockoutScope.Credential);
id.RefusalLogged = true;
return true;
}
/// <summary>Releases one slot, records <paramref name="outcome"/> (null = uncounted) and wakes waiters.</summary>
private CredentialLockoutScope ReleaseCore(string key, Entry entry, string identity, bool? outcome)
{
var now = _time.GetUtcNow();
if (entry.InFlight > 0) entry.InFlight--;
// The slot was charged to the identity itself or, when the table was full, to the overflow identity.
var slotIdentity = entry.Identities.TryGetValue(identity, out var own) && own.InFlight > 0
? identity
: entry.Identities.TryGetValue(OverflowIdentity, out var overflow) && overflow.InFlight > 0
? OverflowIdentity
: SlotIdentity(entry, identity);
if (entry.Identities.TryGetValue(slotIdentity, out var held) && held.InFlight > 0) held.InFlight--;
var locked = CredentialLockoutScope.None;
if (outcome == true) RecordSuccessCore(entry, slotIdentity);
else if (outcome == false) locked = RecordFailureCore(entry, slotIdentity, now);
Pump(entry, now);
RemoveIfIdle(key, entry);
return locked;
}
/// <summary>
/// Hands freed slots to waiters in order — passing over one whose identity is full — and refuses every
/// waiter whose identity, or whose client, is locked out (they never reach the authenticator). Called under
/// the gate after anything that can free a slot or lock the key.
/// </summary>
private void Pump(Entry entry, DateTimeOffset now)
{
Refresh(entry, now);
var node = entry.Waiters.First;
while (node is not null)
{
var next = node.Next;
var waiter = node.Value;
var slotIdentity = SlotIdentity(entry, waiter.Identity);
if (TryLockedOut(entry, slotIdentity, now, out var refused))
{
entry.Waiters.Remove(node);
waiter.Completion.TrySetResult(refused);
}
else if (ClientHasRoom(entry) && HasFreeSlot(entry, slotIdentity))
{
entry.Waiters.Remove(node);
if (waiter.Completion.TrySetResult(new(CredentialLockoutVerdict.Proceed, TimeSpan.Zero, false)))
TakeSlot(entry, slotIdentity); // a waiter that already gave up never takes the slot
}
node = next;
}
}
private static void RecordSuccessCore(Entry entry, string slotIdentity)
{
// Only this identity's count — never the client-wide one (#844).
if (!entry.Identities.TryGetValue(slotIdentity, out var id)) return;
id.Failures = 0;
id.LockedUntil = default;
id.RefusalLogged = false;
if (id.InFlight == 0) entry.Identities.Remove(slotIdentity);
}
private CredentialLockoutScope RecordFailureCore(Entry entry, string slotIdentity, DateTimeOffset now)
{
Refresh(entry, now);
if (entry.LockedUntil > now) return CredentialLockoutScope.None; // a late in-flight failure never extends it
var lockFor = TimeSpan.FromSeconds(Math.Max(0, _options.LockoutSeconds));
var result = CredentialLockoutScope.None;
var id = GetOrAddIdentity(entry, slotIdentity);
if (id.LockedUntil <= now)
{
id.Failures++;
id.LastFailure = now;
if (id.Failures >= IdentityThreshold && lockFor > TimeSpan.Zero)
{
id.LockedUntil = now + lockFor;
id.RefusalLogged = false;
result = CredentialLockoutScope.Credential;
}
}
entry.Failures++;
entry.LastFailure = now;
if (entry.Failures >= ClientThreshold && lockFor > TimeSpan.Zero)
{
entry.LockedUntil = now + lockFor;
entry.RefusalLogged = false;
result = CredentialLockoutScope.Client;
}
return result;
}
/// <summary>
/// Forgets runs of failures that have gone stale: an expired lockout always starts a fresh count (else a
/// <c>LockoutSeconds</c> shorter than <c>FailureWindowSeconds</c> relocked on the first failure after it),
/// and so does a failure older than the window. Applies to the client-wide count and each identity's.
/// </summary>
private void Refresh(Entry entry, DateTimeOffset now)
{
var window = TimeSpan.FromSeconds(Math.Max(0, _options.FailureWindowSeconds));
if (entry.LockedUntil != default && entry.LockedUntil <= now)
{
// An expired client-wide lockout clears every identity's count as well.
ClearCount(ref entry.Failures, ref entry.LockedUntil, ref entry.RefusalLogged);
foreach (var id in entry.Identities.Values)
ClearCount(ref id.Failures, ref id.LockedUntil, ref id.RefusalLogged);
}
else if (entry.LockedUntil == default && entry.Failures > 0 && now - entry.LastFailure > window)
{
entry.Failures = 0;
entry.RefusalLogged = false;
}
if (entry.Identities.Count == 0) return;
List<string>? idle = null;
foreach (var (name, id) in entry.Identities)
{
if (id.LockedUntil != default && id.LockedUntil <= now)
{
ClearCount(ref id.Failures, ref id.LockedUntil, ref id.RefusalLogged);
}
else if (id.LockedUntil == default && id.Failures > 0 && now - id.LastFailure > window)
{
id.Failures = 0;
id.RefusalLogged = false;
}
if (id.Failures == 0 && id.InFlight == 0 && id.LockedUntil == default) (idle ??= []).Add(name);
}
if (idle is null) return;
foreach (var name in idle) entry.Identities.Remove(name);
}
private static void ClearCount(ref int failures, ref DateTimeOffset lockedUntil, ref bool refusalLogged)
{
failures = 0;
lockedUntil = default;
refusalLogged = false;
}
private void RemoveIfIdle(string key, Entry entry)
{
if (!IsIdle(entry, _time.GetUtcNow())) return;
_entries.Remove(key);
_nextPruneAt = DateTimeOffset.MinValue; // room was made; a full table may prune again
}
private void Prune(DateTimeOffset now)
{
_pruneScans++;
var earliest = DateTimeOffset.MaxValue;
List<string>? idle = null;
foreach (var (key, entry) in _entries)
{
if (IsIdle(entry, now)) (idle ??= []).Add(key);
else if (EarliestIdle(entry) is var at && at < earliest) earliest = at;
}
if (idle is not null)
{
foreach (var key in idle) _entries.Remove(key);
}
// #845 — when nothing could be freed, do not rescan the full table on every new client's failure: wait
// until the earliest entry could have gone idle by the clock. An entry holding a slot or a waiter goes
// idle only through a release, and RemoveIfIdle resets this when that frees room.
_nextPruneAt = _entries.Count >= MaxTrackedClients ? earliest : DateTimeOffset.MinValue;
}
/// <summary>The earliest instant <paramref name="entry"/> could be idle by the clock alone.</summary>
private DateTimeOffset EarliestIdle(Entry entry)
{
if (entry.InFlight > 0 || entry.Waiters.Count > 0) return DateTimeOffset.MaxValue;
var window = TimeSpan.FromSeconds(Math.Max(0, _options.FailureWindowSeconds));
var at = Expiry(entry.LockedUntil, entry.Failures, entry.LastFailure, window);
foreach (var id in entry.Identities.Values)
{
var idAt = Expiry(id.LockedUntil, id.Failures, id.LastFailure, window);
if (idAt > at) at = idAt;
}
return at;
static DateTimeOffset Expiry(DateTimeOffset lockedUntil, int failures, DateTimeOffset lastFailure, TimeSpan window) =>
lockedUntil != default ? lockedUntil
: failures > 0 ? lastFailure + window + TimeSpan.FromTicks(1)
: DateTimeOffset.MinValue;
}
/// <summary>Idle = nothing to remember: no failures, lockout, slot, waiter or identity.</summary>
private bool IsIdle(Entry entry, DateTimeOffset now)
{
Refresh(entry, now);
return entry.InFlight == 0 && entry.Waiters.Count == 0 && entry.Failures == 0
&& entry.LockedUntil == default && entry.Identities.Count == 0;
}
/// <summary>One activation waiting for a slot.</summary>
private sealed class Waiter(string identity)
{
public readonly TaskCompletionSource<CredentialLockoutDecision> Completion =
new(TaskCreationOptions.RunContinuationsAsynchronously);
public readonly string Identity = identity;
public LinkedListNode<Waiter>? Node;
}
/// <summary>One credential identity's count, slots and lockout within one client. Mutated only under the gate.</summary>
private sealed class IdentityState
{
public int Failures;
public int InFlight;
public DateTimeOffset LastFailure;
public DateTimeOffset LockedUntil;
public bool RefusalLogged;
}
/// <summary>One client's aggregate count, slots, waiters, lockout and identities. Mutated only under the gate.</summary>
private sealed class Entry
{
public int Failures;
public int InFlight;
public DateTimeOffset LastFailure;
public DateTimeOffset LockedUntil;
public bool RefusalLogged;
public readonly LinkedList<Waiter> Waiters = new();
public readonly Dictionary<string, IdentityState> Identities = new(StringComparer.Ordinal);
}
}