ffba75bb93
#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).
716 lines
34 KiB
C#
716 lines
34 KiB
C#
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"/> > 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 < MaxFailedAttempts</c> and
|
||
/// <c>clientFailures + clientInFlight < 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);
|
||
}
|
||
}
|
||
|
||
|