@@ -12,9 +12,23 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
/// </remarks>
public sealed class OpcUaCredentialLockoutOptions
{
/// <summary>Failed UserName activations that lock a client out; <c>0</c> or less disables the lockout.</summary>
/// <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 ;
@@ -31,29 +45,45 @@ public sealed class OpcUaCredentialLockoutOptions
public int SlotWaitSeconds { get ; set ; } = 10 ;
}
/// <summary>The outcome of <see cref="FailedCredentialLockout.BeginAsync"/>.</summary>
/// <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"/>.</summary>
/// <summary>A verification slot is held — authenticate, then call <see cref="FailedCredentialLockout.Complete(string, string, bool) "/>.</summary>
Proceed ,
/// <summary>The client is locked out; refuse without authenticating.</summary>
/// <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"/> decided.</summary>
/// <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 ) ;
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).
/// <b>anonymous</b> activation (lmxopcua#833), and counted per credential identity as well as per client
/// (lmxopcua#844).
/// </summary>
/// <remarks>
/// <para>
@@ -62,42 +92,56 @@ public readonly record struct CredentialLockoutDecision(
/// 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. Its key is the client certificate thumbprint when one is presented,
/// else the peer IP: on a <c>None</c> 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
/// <c>MaxFailedAuthenticationAttempts = 0</c> and enforces this narrower lockout instead.
/// activation does not clear it. The host therefore sets <c>MaxFailedAuthenticationAttempts = 0</c> and
/// enforces this narrower lockout instead.
/// </para>
/// <para>
/// <b>What it does .</b> Only UserName activations are counted and refused; Anonymous never consults it.
/// The key is the presented client certificate thumbprint <b>and</b> the observed peer address together.
/// ⚠️ That only separates clients that present a certificate: on a <c>None</c> endpoint clients commonly
/// present none, so the key is <b>effectively the IP alone</b> and NAT neighbours there share one count
/// (for UserName activations only — never for anonymous ones); and when the transport reports no peer
/// address, every certificate-less client shares the single key <c>-@-</c>. Timing: a failure within
/// <see cref="OpcUaCredentialLockoutOptions.FailureWindowSeconds"/> of the previous one adds to the
/// count; at <see cref="OpcUaCredentialLockoutOptions.MaxFailedAttempts"/> the client is refused for
/// <see cref="OpcUaCredentialLockoutOptions.LockoutSeconds"/>; a refusal during the lockout is not
/// counted (the lockout never extends itself); an expired lockout starts a fresh count (whatever the
/// window); a successful UserName activation clears the client's count.
/// <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>Concurrency — parallelism waits, it is never refused.</b> The authenticate call is awaited between the
/// check and the outcome, so a check and a separat e record would let a parallel burst of guesses all pass
/// before the first failure lands. <see cref="BeginAsync"/> therefore hand s out at mos t
/// <c>MaxFailedAttempts − recordedFailures</c> verification <b>slots</b> per key; an activation beyond that
/// <b>waits </b > (FIFO) for a slot, and <see cref="Complete"/> / <see cref="Abandon"/> release it and wake the
/// next waiter. So no more than <c>MaxFailedAttempts</c> unverified guesses are ever in progress-or-failed
/// per window, while a legi tim ate client opening many keyed sessions at once (a ScadaBridge site node behind
/// one NAT IP) just queues for milliseconds. Waiters are refused only when the key actually locks out (they
/// never reach the authenticator) or after <see cref="OpcUaCredentialLockoutOptions.SlotWaitSeconds"/>
/// (uncounted); a cancelled wa iter is removed from the queue.
/// <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 th e c redential carries no key id — AND to the client as a
/// whole. A success clears only its own identity' s c ount. A t
/// <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 ac tiv ation. 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>
/// Memory is bounded at <see cref="MaxTrackedClients"/>: entries with no failures, slots or waiters 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 a re
/// not guessable) .
/// <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 therefo re
/// can not 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
@@ -105,10 +149,21 @@ 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>
@@ -122,6 +177,13 @@ public sealed class FailedCredentialLockout
/// <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>
@@ -129,17 +191,28 @@ public sealed class FailedCredentialLockout
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="key"/>, waiting for one when the client already has as
/// many in progress as it may. On <see cref="CredentialLockoutVerdict.Proceed"/> the caller MUST release the
/// slot with <see cref="Complete"/> or <see cref="Abandon"/>.
/// 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 , CancellationToken ct = default )
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 ;
@@ -148,24 +221,25 @@ public sealed class FailedCredentialLockout
var now = _time . GetUtcNow ( ) ;
if ( ! _entries . TryGetValue ( key , out var existing ) )
{
if ( _entries . Count > = MaxTrackedClients ) Prune ( now ) ;
// Untracked when still full: it gets no slot accounting, as before (see remarks).
if ( _entries . Count > = MaxTrackedClients ) return new ( CredentialLockoutVerdict . Proceed , TimeSpan . Zero , false ) ;
existing = new Entry ( ) ;
_entries [ key ] = 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 ) ;
if ( TryLockedOut ( entry , now , out var refused ) ) return refused ;
if ( entry . Waiters . Count = = 0 & & HasFreeSlot ( entry ) )
var slotIdentity = SlotIdentity ( entry , identity ) ;
if ( TryLockedOut ( entry , slotIdentity , now , out var refused ) ) return refused ;
if ( entry . Waiters . Count = = 0 & & HasFreeSlot ( entry , slotIdentity ) )
{
entry . InFlight + + ;
TakeSlot ( entry , slotIdentity ) ;
return new ( CredentialLockoutVerdict . Proceed , TimeSpan . Zero , false ) ;
}
waiter = new Waiter ( ) ;
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 ) ) ;
@@ -183,12 +257,13 @@ public sealed class FailedCredentialLockout
// 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 , outcome : null ) ;
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 ) ;
}
}
@@ -198,79 +273,123 @@ public sealed class FailedCredentialLockout
}
}
/// <summary>Releases a slot from <see cref="BeginAsync"/> and records the activation's outcome .</summary>
/// <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><see langword="true"/> when this failure locked the client out .</returns>
public bool Complete ( string key , bool succeeded )
/// <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 false ;
if ( ! Enabled ) return CredentialLockoutScope . None ;
identity = Normalize ( identity ) ;
lock ( _gate )
{
return _entries . TryGetValue ( key , out var entry ) & & ReleaseCore ( key , entry , succeeded ) ;
return _entries . TryGetValue ( key , out var entry )
? ReleaseCore ( key , entry , identity , succeeded )
: CredentialLockoutScope . None ;
}
}
/// <summary>Releases a slot without recording an outcome (the activation was cancelled or threw) .</summary>
/// <summary><see cref="Abandon(string, string)"/> with <see cref="NoIdentity"/> .</summary>
/// <param name="key">The client key.</param>
public void Abandon ( string key )
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 , outcome : null ) ;
if ( _entries . TryGetValue ( key , out var entry ) ) ReleaseCore ( key , entry , identity , outcome : null ) ;
}
}
/// <summary>Whether <paramref name="key"/> is locked out right now (observation; reserves nothing) .</summary>
/// <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 the client's UserName activations ar e refused.</returns>
public bool IsLockedOut ( string key , out TimeSpan remaining )
/// <returns><see langword="true"/> when such an activation would b e 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 ) return false ;
remaining = entry . LockedUntil - 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>
/// <returns><see langword="true"/> when this failure locked the client out.</returns >
public bool RecordFailure ( string key )
/// <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 ) )
{
if ( _entries . Count > = MaxTrackedClients ) Prune ( now ) ;
if ( _entries . Count > = MaxTrackedClients ) return false ;
entry = new Entry ( ) ;
_entries [ key ] = entry ;
entry = TryAddEntry ( key , now ) ;
if ( entry is null ) return false ;
}
var locked = RecordFailureCore ( entry , now ) ;
var locked = RecordFailureCore ( entry , SlotIdentity ( entry , identity ) , now ) ;
Pump ( entry , now ) ;
return locked ;
return locked ! = CredentialLockoutScope . None ;
}
}
/// <summary>Clears a client 's count after a successful UserName activation (no slot).</summary>
/// <summary>Clears one identity 's count after a successful UserName activation (no slot).</summary>
/// <param name="key">The client key.</param>
public void RecordSuccess ( string key )
/// <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 ;
ClearCount ( entry ) ;
RecordSuccessCore ( entry , SlotIdentity ( entry , identity ) ) ;
Pump ( entry , _time . GetUtcNow ( ) ) ;
RemoveIfIdle ( key , entry ) ;
}
@@ -282,126 +401,305 @@ public sealed class FailedCredentialLockout
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 ;
}
private bool HasFreeSlot ( Entry entry ) = > entry . Failures + entry . InFlight < _options . MaxFailedAttempts ;
/// <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 bool TryLockedOut ( Entry entry , DateTimeOffset now , out CredentialLockoutDecision refused )
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 ) return false ;
refused = new ( CredentialLockoutVerdict . LockedOut , entry . LockedUntil - now , ! entry . RefusalLogged ) ;
entry . RefusalLogged = true ;
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 bool ReleaseCore ( string key , Entry entry , bool? outcome )
private CredentialLockoutScope ReleaseCore ( string key , Entry entry , string identity , bool? outcome )
{
var now = _time . GetUtcNow ( ) ;
if ( entry . InFlight > 0 ) entry . InFlight - - ;
var lockedNow = false ;
if ( outcome = = true ) ClearCount ( entry ) ;
else if ( outcome = = false ) lockedNow = RecordFailureCore ( entry , now ) ;
// 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 lockedNow ;
return locked ;
}
/// <summary>
/// Hands freed slots to waiters in order — or, when the key is locked out, refuses every waiter (they never
/// reach the authenticator). Called under the gate after anything that can free a slot or lock the key.
/// 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 ) ;
while ( entry . Waiters . First is { } first )
var node = entry . Waiters . First ;
while ( node is not null )
{
if ( TryLockedOut ( entry , now , out var refused ) )
var next = node . Next ;
var waiter = node . Value ;
var slotIdentity = SlotIdentity ( entry , waiter . Identity ) ;
if ( TryLockedOut ( entry , slotIdentity , now , out var refused ) )
{
entry . Waiters . RemoveFirst ( ) ;
first . Value . Completion . TrySetResult ( refused ) ;
continue ;
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
}
if ( ! HasFreeSlot ( entry ) ) return ;
entry . Waiters . RemoveFirst ( ) ;
entry . InFlight + + ;
if ( ! first . Value . Completion . TrySetResult ( new ( CredentialLockoutVerdict . Proceed , TimeSpan . Zero , false ) ) )
entry . InFlight - - ; // a waiter that already gave up never takes the slot
node = next ;
}
}
private static void ClearCount ( Entry entry )
private static void RecordSuccessCore ( Entry entry , string slotIdentity )
{
entry . Failures = 0 ;
entry . LockedUntil = default ;
entry . RefusalLogged = false ;
// 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 bool RecordFailureCore ( Entry entry , DateTimeOffset now )
private CredentialLockoutScope RecordFailureCore ( Entry entry , string slotIdentity , DateTimeOffset now )
{
Refresh ( entry , now ) ;
if ( entry . LockedUntil > now ) return false ; // a refusal (or a late in-flight failure) never extends it
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 < _options . MaxFailedAttempts ) return false ;
entry . LockedUntil = now + TimeSpan . FromSeconds ( Math . Max ( 0 , _options . LockoutSeconds ) ) ;
entry . RefusalLogged = false ;
return entry . LockedUntil > now ;
if ( entry . Failures > = ClientThreshold & & lockFor > TimeSpan . Zero )
{
entry . LockedUntil = now + lockFor ;
entry . RefusalLogged = false ;
result = CredentialLockoutScope . Client ;
}
return result ;
}
/// <summary>
/// Forgets a run of failures that has gone stale: an expired lockout always starts a fresh count (else a
/// 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.
/// 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 )
{
ClearCount ( entry ) ;
// 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 > TimeSpan . FromSeconds ( Math . Max ( 0 , _options . FailureWindowSeconds ) ) )
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 ( ) ) ) _entries . Remove ( key ) ;
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 )
{
foreach ( var key in _entries . Where ( e = > IsIdle ( e . Value , now ) ) . Select ( e = > e . Key ) . ToList ( ) )
_entries . Remove ( key ) ;
_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>Idle = nothing to remember: no failures, no lockout, no slot held and nobody waiting .</summary>
/// <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 ;
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
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 client's failure count, slots, waiters and lockout. Mutated only under the gate.</summary>
/// <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 ;
@@ -410,5 +708,8 @@ public sealed class FailedCredentialLockout
public DateTimeOffset LockedUntil ;
public bool RefusalLogged ;
public readonly LinkedList < Waiter > Waiters = new ( ) ;
public readonly Dictionary < string , IdentityState > Identities = new ( StringComparer . Ordinal ) ;
}
}