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).
This commit is contained in:
Joseph Doherty
2026-11-11 03:47:10 -05:00
parent 481985e20b
commit ffba75bb93
13 changed files with 1150 additions and 208 deletions
@@ -39,5 +39,12 @@ public sealed class OpcUaApplicationHostOptionsValidator : OptionsValidatorBase<
+ "OpcUa:CredentialLockout:SlotWaitSeconds must all be "
+ "greater than 0 while OpcUa:CredentialLockout:MaxFailedAttempts is greater than 0 (set "
+ "MaxFailedAttempts to 0 to disable the lockout)");
// #844: the per-client cap (which a success never resets) below the per-credential threshold would make every
// per-credential lockout a client-wide one; 0 means 2 x MaxFailedAttempts.
builder.RequireThat(lockout is null || lockout.MaxFailedAttempts <= 0 || lockout.MaxFailedAttemptsPerClient <= 0
|| lockout.MaxFailedAttemptsPerClient >= lockout.MaxFailedAttempts,
"OpcUa:CredentialLockout:MaxFailedAttemptsPerClient must be 0 (= 2 x MaxFailedAttempts) or at least "
+ "OpcUa:CredentialLockout:MaxFailedAttempts");
}
}
@@ -107,8 +107,11 @@ public sealed class ApiKeyOpcUaUserAuthenticator : IOpcUaUserAuthenticator
{
// The contract forbids throwing: an exception here escapes into the SDK's session-activation
// path and surfaces as BadInternalError. A store outage denies, it does not crash.
// lmxopcua#845: a store timeout, miss-budget refusal or outage says nothing about the credential, so it
// is reported Transient and the failed-credential lockout does not count it (a ConfigDb stall must not
// lock every keyed client out). The client still sees the same opaque denial.
_log.LogError(ex, "ApiKeyAuth: key store threw for key id {ApiKeyId}", apiKeyId);
return OpcUaUserAuthResult.Deny(DenyMessage);
return OpcUaUserAuthResult.Unavailable(DenyMessage);
}
if (lookup is null)
@@ -129,7 +132,7 @@ public sealed class ApiKeyOpcUaUserAuthenticator : IOpcUaUserAuthenticator
_log.LogError(ex,
"ApiKeyAuth: {Section}:Pepper is not configured — every key will be rejected until it is",
ApiKeyOptions.SectionName);
return OpcUaUserAuthResult.Deny(DenyMessage);
return OpcUaUserAuthResult.Unavailable(DenyMessage); // a deployment error, not a bad credential (#845)
}
if (!verified)
@@ -161,6 +164,13 @@ public sealed class ApiKeyOpcUaUserAuthenticator : IOpcUaUserAuthenticator
return OpcUaUserAuthResult.AllowKey(lookup.Key.Principal, roles, apiKeyId);
}
/// <inheritdoc />
// lmxopcua#844 — the key id is public (it is the lookup index, never the secret), so guesses at different keys
// are counted apart and a client's own successful key cannot clear the count of a key it is guessing at.
// A credential that is not a well-formed key falls back to the username (all such guesses share one count).
public string DescribeCredential(string username, string password) =>
ApiKeySecret.TryParse(password, out var apiKeyId, out _) ? "key:" + apiKeyId : "user:" + username;
/// <inheritdoc />
// Resolves the <c>anonymous</c> principal's grants through the same mapper a key's principal
// goes through, so anonymous is authorized by ordinary ACL rows rather than by a special case.
@@ -272,22 +272,30 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
// lmxopcua#833 — only credential-bearing (UserName) activations are counted and refused. Anonymous
// never consults the lockout, so failed sign-ins cannot lock a client (or its NAT neighbours) out of
// anonymous access the way the SDK's own lockout did.
// The ApplicationUri is deliberately NOT part of the key (lmxopcua#845): on a None channel it is a free,
// unauthenticated string, so an attacker could reset its count by varying it.
var lockoutKey = tokenType == UserTokenType.UserName
? FailedCredentialLockout.KeyFor(context.ChannelCertificate?.Thumbprint, peer)
: null;
// lmxopcua#844 — failures are counted per (client, credential identity) as well as per client, so a
// success clears only its own identity's count.
var credentialIdentity = lockoutKey is null
? FailedCredentialLockout.NoIdentity
: DescribeCredential(host._userAuthenticator, args.UserIdentityTokenHandler);
if (lockoutKey is not null)
{
// Waits (bounded) for one of this client's verification slots — parallel activations queue, they are
// never refused for parallelism. Refused only when locked out, or (uncounted) when no slot freed in time.
var decision = await host._credentialLockout.BeginAsync(lockoutKey, ct).ConfigureAwait(false);
var decision = await host._credentialLockout.BeginAsync(lockoutKey, credentialIdentity, ct).ConfigureAwait(false);
if (decision.Verdict == CredentialLockoutVerdict.LockedOut)
{
// Logged once per lockout episode at Information; every further refusal at Debug, so a client
// hammering a lockout cannot flood the log (#833 review).
var seconds = (long)Math.Ceiling(decision.Remaining.TotalSeconds);
host._logger.Log(decision.FirstRefusal ? LogLevel.Information : LogLevel.Debug,
"OpcUaApplicationHost: UserName activation refused — client {Client} is locked out for {Seconds}s more",
lockoutKey, seconds);
"OpcUaApplicationHost: UserName activation refused — client {Client} is locked out ({Scope}: {Identity}) for {Seconds}s more",
lockoutKey, decision.Scope,
decision.Scope == CredentialLockoutScope.Client ? "every credential" : credentialIdentity, seconds);
return AuthenticationResult.Reject(ServiceResult.Create(StatusCodes.BadUserAccessDenied,
$"Too many failed authentication attempts. Try again in {seconds} seconds."));
}
@@ -309,8 +317,9 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
try
{
// Port zero is only an adapter for the existing IP-budget seam: the SDK exposes no peer port.
await HandleImpersonationAsync(host._userAuthenticator, args, host._logger, host._options.AllowAnonymous,
peer is null ? null : new System.Net.IPEndPoint(peer, 0), ct).ConfigureAwait(false);
var transient = await HandleImpersonationAsync(host._userAuthenticator, args, host._logger,
host._options.AllowAnonymous, peer is null ? null : new System.Net.IPEndPoint(peer, 0), ct)
.ConfigureAwait(false);
var result = ServiceResult.IsBad(args.IdentityValidationError)
? AuthenticationResult.Reject(args.IdentityValidationError)
: args.Identity is { } identity
@@ -321,19 +330,29 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
{
var admitted = args.Identity is not null && !ServiceResult.IsBad(args.IdentityValidationError);
completed = true;
if (host._credentialLockout.Complete(lockoutKey, admitted))
if (!admitted && transient)
{
// lmxopcua#845 — a store timeout / miss-budget refusal / authenticator fault is not a bad
// credential: release the slot uncounted.
host._credentialLockout.Abandon(lockoutKey, credentialIdentity);
}
else if (host._credentialLockout.Complete(lockoutKey, credentialIdentity, admitted) is var scope
&& scope != CredentialLockoutScope.None)
{
host._logger.LogWarning(
"OpcUaApplicationHost: client {Client} locked out of UserName activation for {Seconds}s after "
+ "{Attempts} failed attempts (OpcUa:CredentialLockout); anonymous activation is unaffected",
lockoutKey, host._options.CredentialLockout.LockoutSeconds, host._options.CredentialLockout.MaxFailedAttempts);
"OpcUaApplicationHost: client {Client} locked out of UserName activation ({Scope}: {Identity}) "
+ "for {Seconds}s after repeated failed attempts (OpcUa:CredentialLockout: {PerCredential} per "
+ "credential, {PerClient} per client); anonymous activation is unaffected",
lockoutKey, scope, scope == CredentialLockoutScope.Client ? "every credential" : credentialIdentity,
host._options.CredentialLockout.LockoutSeconds, host._options.CredentialLockout.MaxFailedAttempts,
host._credentialLockout.ClientThreshold);
}
}
return result;
}
finally
{
if (lockoutKey is not null && !completed) host._credentialLockout.Abandon(lockoutKey);
if (lockoutKey is not null && !completed) host._credentialLockout.Abandon(lockoutKey, credentialIdentity);
}
}
}
@@ -407,7 +426,10 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
/// <param name="allowAnonymous">Whether anonymous sessions may connect.</param>
/// <param name="remoteEndpoint">The activating client's IP endpoint when known (lmxopcua#751), handed to
/// the authenticator for per-client accounting; null when it could not be resolved.</param>
internal static void HandleImpersonation(
/// <returns><see langword="true"/> when a UserName denial was TRANSIENT — not a verdict on the credential
/// (the authenticator threw or reported <see cref="OpcUaUserAuthResult.Transient"/>), so the failed-credential
/// lockout must not count it (lmxopcua#845).</returns>
internal static bool HandleImpersonation(
IOpcUaUserAuthenticator authenticator,
ImpersonateEventArgs args,
ILogger logger,
@@ -416,7 +438,7 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
=> HandleImpersonationAsync(authenticator, args, logger, allowAnonymous, remoteEndpoint)
.AsTask().GetAwaiter().GetResult();
private static async ValueTask HandleImpersonationAsync(
private static async ValueTask<bool> HandleImpersonationAsync(
IOpcUaUserAuthenticator authenticator, ImpersonateEventArgs args, ILogger logger,
bool allowAnonymous = true, System.Net.IPEndPoint? remoteEndpoint = null,
CancellationToken cancellationToken = default)
@@ -424,13 +446,13 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
if (args.UserIdentityTokenHandler.Token is AnonymousIdentityToken anonymousToken)
{
await HandleAnonymousImpersonationAsync(authenticator, args, logger, allowAnonymous, anonymousToken, cancellationToken).ConfigureAwait(false);
return;
return false;
}
if (args.UserIdentityTokenHandler.Token is not UserNameIdentityToken token)
{
// X509 tokens — let the SDK's default validation stand.
return;
return false;
}
string password;
@@ -444,7 +466,7 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
{
logger.LogWarning(ex, "OpcUaApplicationHost: failed to decrypt UserName token");
args.IdentityValidationError = ServiceResult.Create(StatusCodes.BadIdentityTokenRejected, "UserName token decryption failed");
return;
return false;
}
OpcUaUserAuthResult result;
@@ -456,22 +478,45 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
}
catch (Exception ex)
{
// Includes cancellation: neither says anything about the credential (lmxopcua#845).
logger.LogError(ex, "OpcUaApplicationHost: UserName authenticator threw for {User}", token.UserName);
args.IdentityValidationError = ServiceResult.Create(StatusCodes.BadIdentityTokenRejected, "Authentication failed");
return;
return true;
}
if (!result.Success)
{
logger.LogInformation("OpcUaApplicationHost: UserName auth denied for {User}: {Error}",
token.UserName, result.Error);
logger.LogInformation("OpcUaApplicationHost: UserName auth denied for {User}{Transient}: {Error}",
token.UserName, result.Transient ? " (transient, not counted toward the lockout)" : string.Empty, result.Error);
args.IdentityValidationError = ServiceResult.Create(StatusCodes.BadIdentityTokenRejected, result.Error ?? "Invalid credentials");
return;
return result.Transient;
}
args.Identity = new RoleCarryingUserIdentity(token, result.Roles, result.Principal, result.ApiKeyId);
logger.LogInformation("OpcUaApplicationHost: UserName auth granted for {User} as {Principal} ({Roles})",
token.UserName, result.Principal, string.Join(",", result.Roles));
return false;
}
/// <summary>
/// The credential identity a UserName activation presents, for the lockout's per-identity count
/// (lmxopcua#844): <see cref="IOpcUaUserAuthenticator.DescribeCredential"/> over the decrypted token. Never
/// throws: an unreadable token or a faulting implementation falls back to the username.
/// </summary>
internal static string DescribeCredential(IOpcUaUserAuthenticator authenticator, IUserIdentityTokenHandler handler)
{
if (handler.Token is not UserNameIdentityToken token) return FailedCredentialLockout.NoIdentity;
var username = token.UserName ?? string.Empty;
try
{
var decrypted = (handler as UserNameIdentityTokenHandler)?.DecryptedPassword;
var password = decrypted is null ? string.Empty : Encoding.UTF8.GetString(decrypted);
return authenticator.DescribeCredential(username, password) ?? "user:" + username;
}
catch (Exception)
{
return "user:" + username;
}
}
/// <summary>
@@ -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 separate record would let a parallel burst of guesses all pass
/// before the first failure lands. <see cref="BeginAsync"/> therefore hands out at most
/// <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 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 the key actually locks out (they
/// never reach the authenticator) or after <see cref="OpcUaCredentialLockoutOptions.SlotWaitSeconds"/>
/// (uncounted); a cancelled waiter 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 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>
/// 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 are
/// 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 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
@@ -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 are refused.</returns>
public bool IsLockedOut(string key, out TimeSpan remaining)
/// <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) 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);
}
}
@@ -45,6 +45,18 @@ public interface IOpcUaUserAuthenticator
string username, string password, System.Net.IPEndPoint? remoteEndpoint, CancellationToken ct) =>
AuthenticateUserNameAsync(username, password, ct);
/// <summary>
/// Names the credential identity a UserName attempt presents, for the failed-credential lockout's
/// per-identity count (lmxopcua#844): a success clears only its own identity's count, so a client holding one
/// valid credential cannot use it to launder guesses at another. Called before authentication; must be cheap,
/// do no I/O and not throw. The default is the username; an implementation whose secret carries a public
/// identifier (an API key id) should return that, so guesses at different keys are told apart.
/// </summary>
/// <param name="username">The presented username.</param>
/// <param name="password">The presented cleartext password.</param>
/// <returns>A stable, non-secret identity string. It is logged — never include secret material.</returns>
string DescribeCredential(string username, string password) => "user:" + username;
/// <summary>
/// Resolves what an <b>anonymous</b> session is allowed to do. Anonymous is a normal
/// principal — <see cref="OpcUaPrincipals.Anonymous"/> — not a special case, so it can be granted
@@ -101,6 +113,14 @@ public sealed record OpcUaUserAuthResult(
/// </summary>
public string? ApiKeyId { get; init; }
/// <summary>
/// True when the denial is NOT a verdict on the credential — the backing store timed out or refused the
/// lookup, the authenticator is misconfigured, the call was cancelled (lmxopcua#845). The failed-credential
/// lockout does not count it, so an infrastructure stall cannot lock every keyed client out. The client still
/// sees the same opaque denial.
/// </summary>
public bool Transient { get; init; }
/// <summary>Creates a successful authentication result with the specified display name and roles.</summary>
/// <param name="displayName">The display name for the authenticated user — also taken as the principal.</param>
/// <param name="roles">The roles to assign to the authenticated user.</param>
@@ -121,6 +141,15 @@ public sealed record OpcUaUserAuthResult(
/// <returns>A failed OpcUaUserAuthResult.</returns>
public static OpcUaUserAuthResult Deny(string error) =>
new(false, null, Array.Empty<string>(), error);
/// <summary>
/// Creates a denial that is not a verdict on the credential (store outage, misconfiguration, cancellation) —
/// see <see cref="Transient"/>.
/// </summary>
/// <param name="error">The error message returned to the client (keep it as opaque as a normal denial).</param>
/// <returns>A failed, transient OpcUaUserAuthResult.</returns>
public static OpcUaUserAuthResult Unavailable(string error) =>
new(false, null, Array.Empty<string>(), error) { Transient = true };
}
/// <summary>