9aaab9ee42
- MeshPskProviderRefreshTests: GatedResolver.Entered was set by the priming refresh and never reset, so Wait() returned before the held refresh reached the store and the mid-refresh call-count snapshot raced its increment (expected 1, was 2). Hold() now resets it. - FailedCredentialLockoutTests all-bad burst: the 15 waiters could queue before a slot holder ran the increment after BeginAsync (authenticated 0 vs 5). The wait now covers both conditions; the security assertions are unchanged. Both looped 20x locally, 0 failures. Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk
521 lines
26 KiB
C#
521 lines
26 KiB
C#
using System.Net;
|
||
using Shouldly;
|
||
using Xunit;
|
||
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
||
|
||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
||
|
||
/// <summary>lmxopcua#833 — the UserName-only lockout that replaces the SDK's all-token lockout.</summary>
|
||
public sealed class FailedCredentialLockoutTests
|
||
{
|
||
private static readonly string Client = FailedCredentialLockout.KeyFor("THUMB", IPAddress.Parse("10.0.0.5"));
|
||
|
||
[Fact]
|
||
public void Locks_at_the_threshold_and_releases_after_the_lockout()
|
||
{
|
||
var clock = new ManualClock();
|
||
var lockout = new FailedCredentialLockout(new OpcUaCredentialLockoutOptions(), clock);
|
||
|
||
for (var i = 0; i < 4; i++) lockout.RecordFailure(Client).ShouldBeFalse();
|
||
lockout.IsLockedOut(Client, out _).ShouldBeFalse();
|
||
lockout.RecordFailure(Client).ShouldBeTrue("the fifth failure locks");
|
||
lockout.IsLockedOut(Client, out var remaining).ShouldBeTrue();
|
||
remaining.ShouldBe(TimeSpan.FromMinutes(5));
|
||
|
||
clock.Advance(TimeSpan.FromSeconds(299));
|
||
lockout.IsLockedOut(Client, out _).ShouldBeTrue();
|
||
clock.Advance(TimeSpan.FromSeconds(1));
|
||
lockout.IsLockedOut(Client, out _).ShouldBeFalse();
|
||
lockout.RecordFailure(Client).ShouldBeFalse("a lockout that ran out starts a fresh count");
|
||
}
|
||
|
||
[Fact]
|
||
public void A_refusal_during_the_lockout_does_not_extend_it()
|
||
{
|
||
var clock = new ManualClock();
|
||
var lockout = new FailedCredentialLockout(new OpcUaCredentialLockoutOptions { MaxFailedAttempts = 1 }, clock);
|
||
lockout.RecordFailure(Client).ShouldBeTrue();
|
||
clock.Advance(TimeSpan.FromSeconds(200));
|
||
lockout.RecordFailure(Client).ShouldBeFalse();
|
||
clock.Advance(TimeSpan.FromSeconds(100));
|
||
lockout.IsLockedOut(Client, out _).ShouldBeFalse();
|
||
}
|
||
|
||
[Fact]
|
||
public void Failures_spaced_wider_than_the_window_never_accumulate()
|
||
{
|
||
var clock = new ManualClock();
|
||
var lockout = new FailedCredentialLockout(new OpcUaCredentialLockoutOptions(), clock);
|
||
for (var i = 0; i < 10; i++)
|
||
{
|
||
lockout.RecordFailure(Client).ShouldBeFalse();
|
||
clock.Advance(TimeSpan.FromSeconds(61));
|
||
}
|
||
lockout.IsLockedOut(Client, out _).ShouldBeFalse();
|
||
}
|
||
|
||
[Fact]
|
||
public void A_success_clears_that_identitys_count()
|
||
{
|
||
var lockout = new FailedCredentialLockout(new OpcUaCredentialLockoutOptions(), new ManualClock());
|
||
for (var i = 0; i < 4; i++) lockout.RecordFailure(Client);
|
||
lockout.RecordSuccess(Client);
|
||
for (var i = 0; i < 4; i++) lockout.RecordFailure(Client).ShouldBeFalse();
|
||
lockout.IsLockedOut(Client, out _).ShouldBeFalse();
|
||
}
|
||
|
||
[Fact]
|
||
public void The_key_needs_both_certificate_and_address_so_one_client_cannot_lock_out_another()
|
||
{
|
||
var lockout = new FailedCredentialLockout(new OpcUaCredentialLockoutOptions { MaxFailedAttempts = 1 }, new ManualClock());
|
||
var attacker = FailedCredentialLockout.KeyFor("VICTIMTHUMB", IPAddress.Parse("10.0.0.99"));
|
||
var victim = FailedCredentialLockout.KeyFor("VICTIMTHUMB", IPAddress.Parse("10.0.0.5"));
|
||
var natNeighbour = FailedCredentialLockout.KeyFor("OTHERTHUMB", IPAddress.Parse("10.0.0.99"));
|
||
|
||
lockout.RecordFailure(attacker).ShouldBeTrue();
|
||
lockout.IsLockedOut(victim, out _).ShouldBeFalse("same certificate, different address");
|
||
lockout.IsLockedOut(natNeighbour, out _).ShouldBeFalse("same address, different certificate");
|
||
}
|
||
|
||
[Fact]
|
||
public void Zero_attempts_disables_it()
|
||
{
|
||
var lockout = new FailedCredentialLockout(new OpcUaCredentialLockoutOptions { MaxFailedAttempts = 0 }, new ManualClock());
|
||
for (var i = 0; i < 50; i++) lockout.RecordFailure(Client).ShouldBeFalse();
|
||
lockout.IsLockedOut(Client, out _).ShouldBeFalse();
|
||
lockout.TrackedCount.ShouldBe(0);
|
||
}
|
||
|
||
[Fact]
|
||
public void Tracking_is_bounded_and_prunes_expired_clients_first()
|
||
{
|
||
var clock = new ManualClock();
|
||
var lockout = new FailedCredentialLockout(new OpcUaCredentialLockoutOptions(), clock);
|
||
for (var i = 0; i < FailedCredentialLockout.MaxTrackedClients; i++) lockout.RecordFailure($"c{i}");
|
||
lockout.TrackedCount.ShouldBe(FailedCredentialLockout.MaxTrackedClients);
|
||
|
||
lockout.RecordFailure("overflow");
|
||
lockout.TrackedCount.ShouldBe(FailedCredentialLockout.MaxTrackedClients, "full and nothing expired ⇒ not tracked");
|
||
|
||
clock.Advance(TimeSpan.FromSeconds(61));
|
||
lockout.RecordFailure("overflow");
|
||
lockout.TrackedCount.ShouldBe(1, "expired entries are pruned to make room");
|
||
}
|
||
|
||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||
|
||
/// <summary>
|
||
/// #833 review — a legitimate client opening many keyed sessions at once (a ScadaBridge site node behind one
|
||
/// NAT IP) must never be refused for parallelism: at most MaxFailedAttempts verify concurrently, the rest
|
||
/// WAIT for a slot, and all 20 succeed.
|
||
/// </summary>
|
||
[Fact]
|
||
public async Task An_all_valid_parallel_burst_queues_for_slots_and_every_activation_succeeds()
|
||
{
|
||
var lockout = new FailedCredentialLockout(new OpcUaCredentialLockoutOptions { MaxFailedAttempts = 5 }, new ManualClock());
|
||
var concurrent = 0;
|
||
var maxConcurrent = 0;
|
||
var proceeded = 0;
|
||
|
||
await Task.WhenAll(Enumerable.Range(0, 20).Select(attempt => Task.Run(async () =>
|
||
{
|
||
var decision = await lockout.BeginAsync(Client, Ct);
|
||
decision.Verdict.ShouldBe(CredentialLockoutVerdict.Proceed);
|
||
Interlocked.Increment(ref proceeded);
|
||
var now = Interlocked.Increment(ref concurrent);
|
||
InterlockedMax(ref maxConcurrent, now);
|
||
await Task.Delay(20, Ct); // the authenticate call
|
||
Interlocked.Decrement(ref concurrent);
|
||
lockout.Complete(Client, succeeded: true);
|
||
}, Ct)));
|
||
|
||
proceeded.ShouldBe(20);
|
||
maxConcurrent.ShouldBe(5, "exactly MaxFailedAttempts verify at once — no more, and the cap is actually reached");
|
||
lockout.TrackedCount.ShouldBe(0, "nothing left behind once the burst drains");
|
||
}
|
||
|
||
/// <summary>
|
||
/// #833 review — the security half: a parallel burst of guesses that all arrive before any failure is
|
||
/// recorded still reaches the authenticator only MaxFailedAttempts times; once those fail, every waiter is
|
||
/// refused as locked out without being authenticated.
|
||
/// </summary>
|
||
[Fact]
|
||
public async Task An_all_bad_parallel_burst_authenticates_only_max_attempts_and_refuses_the_waiters_as_locked_out()
|
||
{
|
||
var lockout = new FailedCredentialLockout(new OpcUaCredentialLockoutOptions { MaxFailedAttempts = 5 }, new ManualClock());
|
||
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||
var authenticated = 0;
|
||
var lockedOut = 0;
|
||
|
||
var attempts = Enumerable.Range(0, 20).Select(attempt => Task.Run(async () =>
|
||
{
|
||
var decision = await lockout.BeginAsync(Client, Ct);
|
||
if (decision.Verdict == CredentialLockoutVerdict.LockedOut)
|
||
{
|
||
Interlocked.Increment(ref lockedOut);
|
||
return;
|
||
}
|
||
|
||
decision.Verdict.ShouldBe(CredentialLockoutVerdict.Proceed);
|
||
Interlocked.Increment(ref authenticated);
|
||
await release.Task; // hold the slots until all 15 others are queued behind them
|
||
lockout.Complete(Client, succeeded: false);
|
||
}, Ct)).ToArray();
|
||
|
||
// Wait for the slot holders' increments too: the 15 waiters can queue before a slot holder's thread has run
|
||
// the line after BeginAsync, which read authenticated == 0 on the 2-core hosted runner.
|
||
await WaitUntilAsync(() => lockout.WaitingCount(Client) == 15 && Volatile.Read(ref authenticated) == 5);
|
||
authenticated.ShouldBe(5);
|
||
release.SetResult();
|
||
await Task.WhenAll(attempts);
|
||
|
||
authenticated.ShouldBe(5, "no waiter may reach the authenticator once the key locks out");
|
||
lockedOut.ShouldBe(15);
|
||
lockout.IsLockedOut(Client, out _).ShouldBeTrue();
|
||
lockout.WaitingCount(Client).ShouldBe(0);
|
||
}
|
||
|
||
/// <summary>After a recorded failure the client has one slot fewer: capacity is MaxFailedAttempts − failures.</summary>
|
||
[Fact]
|
||
public async Task Recorded_failures_shrink_the_number_of_concurrent_verifications()
|
||
{
|
||
var lockout = new FailedCredentialLockout(new OpcUaCredentialLockoutOptions { MaxFailedAttempts = 3 }, new ManualClock());
|
||
lockout.RecordFailure(Client);
|
||
lockout.RecordFailure(Client);
|
||
|
||
(await lockout.BeginAsync(Client, Ct)).Verdict.ShouldBe(CredentialLockoutVerdict.Proceed);
|
||
var second = lockout.BeginAsync(Client, Ct).AsTask();
|
||
await WaitUntilAsync(() => lockout.WaitingCount(Client) == 1);
|
||
second.IsCompleted.ShouldBeFalse("one failure short of the threshold leaves exactly one slot");
|
||
|
||
lockout.Complete(Client, succeeded: true); // a success clears the identity's count and hands the slot on
|
||
(await second).Verdict.ShouldBe(CredentialLockoutVerdict.Proceed);
|
||
lockout.Complete(Client, succeeded: true);
|
||
lockout.TrackedCount.ShouldBe(1, "#844: the client-wide count survives a success until it ages out");
|
||
}
|
||
|
||
/// <summary>A cancelled waiter is dequeued and leaves nothing behind (no leaked slot, entry prunable).</summary>
|
||
[Fact]
|
||
public async Task A_cancelled_waiter_is_removed_and_leaks_nothing()
|
||
{
|
||
var lockout = new FailedCredentialLockout(new OpcUaCredentialLockoutOptions { MaxFailedAttempts = 1 }, new ManualClock());
|
||
(await lockout.BeginAsync(Client, Ct)).Verdict.ShouldBe(CredentialLockoutVerdict.Proceed);
|
||
|
||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(Ct);
|
||
var waiting = lockout.BeginAsync(Client, cts.Token).AsTask();
|
||
await WaitUntilAsync(() => lockout.WaitingCount(Client) == 1);
|
||
cts.Cancel();
|
||
await Should.ThrowAsync<OperationCanceledException>(waiting);
|
||
lockout.WaitingCount(Client).ShouldBe(0);
|
||
|
||
lockout.Complete(Client, succeeded: true);
|
||
lockout.TrackedCount.ShouldBe(0, "the held slot and the cancelled waiter are both gone");
|
||
(await lockout.BeginAsync(Client, Ct)).Verdict.ShouldBe(CredentialLockoutVerdict.Proceed);
|
||
lockout.Abandon(Client);
|
||
lockout.TrackedCount.ShouldBe(0);
|
||
}
|
||
|
||
/// <summary>A waiter that never gets a slot times out, uncounted — the client is not one failure closer.</summary>
|
||
[Fact]
|
||
public async Task A_slot_wait_timeout_fails_that_attempt_without_counting_it()
|
||
{
|
||
var lockout = new FailedCredentialLockout(
|
||
new OpcUaCredentialLockoutOptions { MaxFailedAttempts = 1, SlotWaitSeconds = 1 }, new ManualClock());
|
||
(await lockout.BeginAsync(Client, Ct)).Verdict.ShouldBe(CredentialLockoutVerdict.Proceed);
|
||
|
||
(await lockout.BeginAsync(Client, Ct)).Verdict.ShouldBe(CredentialLockoutVerdict.SlotWaitTimedOut);
|
||
lockout.WaitingCount(Client).ShouldBe(0);
|
||
|
||
lockout.Complete(Client, succeeded: true);
|
||
lockout.TrackedCount.ShouldBe(0, "the timed-out attempt recorded no failure");
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Only_the_first_refusal_of_a_lockout_episode_is_flagged_for_Information_logging()
|
||
{
|
||
var clock = new ManualClock();
|
||
var lockout = new FailedCredentialLockout(new OpcUaCredentialLockoutOptions { MaxFailedAttempts = 1, LockoutSeconds = 10 }, clock);
|
||
lockout.RecordFailure(Client).ShouldBeTrue();
|
||
|
||
var r1 = await lockout.BeginAsync(Client, Ct);
|
||
r1.Verdict.ShouldBe(CredentialLockoutVerdict.LockedOut);
|
||
r1.FirstRefusal.ShouldBeTrue();
|
||
(await lockout.BeginAsync(Client, Ct)).FirstRefusal.ShouldBeFalse();
|
||
|
||
clock.Advance(TimeSpan.FromSeconds(11));
|
||
(await lockout.BeginAsync(Client, Ct)).Verdict.ShouldBe(CredentialLockoutVerdict.Proceed);
|
||
lockout.Complete(Client, succeeded: false).ShouldNotBe(CredentialLockoutScope.None, "a new episode");
|
||
(await lockout.BeginAsync(Client, Ct)).FirstRefusal.ShouldBeTrue("each lockout episode logs its first refusal once");
|
||
}
|
||
|
||
// ---- lmxopcua#844: per-identity counts + a client-wide cap a success never resets ----
|
||
|
||
/// <summary>
|
||
/// #844 — the laundering attack: four guesses at another key id, then a sign-in with the client's own valid
|
||
/// key, repeated. The success used to clear the whole per-client count, so this ran forever. Now the success
|
||
/// clears only the client's own identity: the fifth guess at the victim locks that identity.
|
||
/// </summary>
|
||
[Fact]
|
||
public void A_success_with_one_key_does_not_launder_guesses_at_another()
|
||
{
|
||
var lockout = new FailedCredentialLockout(new OpcUaCredentialLockoutOptions(), new ManualClock());
|
||
for (var i = 0; i < 4; i++) lockout.RecordFailure(Client, "key:victim").ShouldBeFalse();
|
||
lockout.RecordSuccess(Client, "key:own");
|
||
lockout.RecordFailure(Client, "key:victim").ShouldBeTrue("the own-key success did not touch the victim's count");
|
||
|
||
lockout.IsLockedOut(Client, "key:victim", out var remaining).ShouldBeTrue();
|
||
remaining.ShouldBe(TimeSpan.FromMinutes(5));
|
||
lockout.IsLockedOut(Client, "key:own", out _).ShouldBeFalse(
|
||
"a per-credential lockout leaves the client's other credentials usable");
|
||
}
|
||
|
||
/// <summary>
|
||
/// #844 — spraying a few guesses at each of many key ids, with own-key successes in between, still hits the
|
||
/// client-wide cap (default 2 × MaxFailedAttempts), which a success never resets; then every credential —
|
||
/// the client's own valid key included — is refused.
|
||
/// </summary>
|
||
[Fact]
|
||
public async Task Spraying_many_identities_with_successes_in_between_hits_the_client_wide_cap()
|
||
{
|
||
var lockout = new FailedCredentialLockout(new OpcUaCredentialLockoutOptions(), new ManualClock());
|
||
lockout.ClientThreshold.ShouldBe(10);
|
||
var locked = false;
|
||
var failures = 0;
|
||
for (var target = 0; !locked && target < 10; target++)
|
||
{
|
||
for (var guess = 0; guess < 4 && !locked; guess++)
|
||
{
|
||
locked = lockout.RecordFailure(Client, $"key:victim{target}");
|
||
failures++;
|
||
}
|
||
|
||
lockout.RecordSuccess(Client, "key:own");
|
||
}
|
||
|
||
locked.ShouldBeTrue();
|
||
failures.ShouldBe(10, "the tenth failure across identities locks the client");
|
||
lockout.IsLockedOut(Client, "key:own", out _).ShouldBeTrue("a client-wide lockout covers the client's own key too");
|
||
var refused = await lockout.BeginAsync(Client, "key:own", Ct);
|
||
refused.Verdict.ShouldBe(CredentialLockoutVerdict.LockedOut);
|
||
refused.Scope.ShouldBe(CredentialLockoutScope.Client);
|
||
}
|
||
|
||
[Fact]
|
||
public void An_explicit_per_client_cap_is_honoured()
|
||
{
|
||
var lockout = new FailedCredentialLockout(
|
||
new OpcUaCredentialLockoutOptions { MaxFailedAttempts = 5, MaxFailedAttemptsPerClient = 6 }, new ManualClock());
|
||
for (var i = 0; i < 5; i++) lockout.RecordFailure(Client, $"key:k{i}").ShouldBeFalse();
|
||
lockout.RecordFailure(Client, "key:k5").ShouldBeTrue();
|
||
lockout.IsLockedOut(Client, "key:anything", out _).ShouldBeTrue();
|
||
}
|
||
|
||
/// <summary>The client-wide count also ages out through the failure window — it is not a lifetime count.</summary>
|
||
[Fact]
|
||
public void The_client_wide_count_ages_out_through_the_window()
|
||
{
|
||
var clock = new ManualClock();
|
||
var lockout = new FailedCredentialLockout(new OpcUaCredentialLockoutOptions(), clock);
|
||
for (var i = 0; i < 9; i++) lockout.RecordFailure(Client, $"key:k{i}");
|
||
clock.Advance(TimeSpan.FromSeconds(61));
|
||
for (var i = 0; i < 9; i++) lockout.RecordFailure(Client, $"key:j{i}").ShouldBeFalse();
|
||
lockout.IsLockedOut(Client, "key:own", out _).ShouldBeFalse();
|
||
}
|
||
|
||
/// <summary>
|
||
/// #844 — a client with a stale (revoked) key and a good one: the stale key locks only itself; the good key's
|
||
/// activations keep getting slots and are not held behind the stale identity's waiter.
|
||
/// </summary>
|
||
[Fact]
|
||
public async Task A_locked_identity_does_not_block_another_identitys_slots()
|
||
{
|
||
var lockout = new FailedCredentialLockout(new OpcUaCredentialLockoutOptions { MaxFailedAttempts = 2 }, new ManualClock());
|
||
lockout.RecordFailure(Client, "key:stale");
|
||
|
||
(await lockout.BeginAsync(Client, "key:stale", Ct)).Verdict.ShouldBe(CredentialLockoutVerdict.Proceed);
|
||
var staleWaiter = lockout.BeginAsync(Client, "key:stale", Ct).AsTask(); // no stale slot left: waits
|
||
await WaitUntilAsync(() => lockout.WaitingCount(Client) == 1);
|
||
|
||
// The good key is not queued behind the stale waiter.
|
||
(await lockout.BeginAsync(Client, "key:good", Ct)).Verdict.ShouldBe(CredentialLockoutVerdict.Proceed);
|
||
|
||
lockout.Complete(Client, "key:stale", succeeded: false).ShouldBe(CredentialLockoutScope.Credential);
|
||
var refused = await staleWaiter;
|
||
refused.Verdict.ShouldBe(CredentialLockoutVerdict.LockedOut, "the waiter's identity locked: refused unauthenticated");
|
||
refused.Scope.ShouldBe(CredentialLockoutScope.Credential);
|
||
|
||
lockout.Complete(Client, "key:good", succeeded: true).ShouldBe(CredentialLockoutScope.None);
|
||
(await lockout.BeginAsync(Client, "key:good", Ct)).Verdict.ShouldBe(CredentialLockoutVerdict.Proceed);
|
||
lockout.Complete(Client, "key:good", succeeded: true);
|
||
}
|
||
|
||
/// <summary>A parallel burst of guesses at one identity still reaches the authenticator only MaxFailedAttempts times.</summary>
|
||
[Fact]
|
||
public async Task A_parallel_burst_at_one_identity_is_capped_at_the_per_credential_threshold()
|
||
{
|
||
var lockout = new FailedCredentialLockout(new OpcUaCredentialLockoutOptions { MaxFailedAttempts = 3 }, new ManualClock());
|
||
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||
var authenticated = 0;
|
||
var attempts = Enumerable.Range(0, 10).Select(_ => Task.Run(async () =>
|
||
{
|
||
var decision = await lockout.BeginAsync(Client, "key:victim", Ct);
|
||
if (decision.Verdict != CredentialLockoutVerdict.Proceed) return;
|
||
Interlocked.Increment(ref authenticated);
|
||
await release.Task;
|
||
lockout.Complete(Client, "key:victim", succeeded: false);
|
||
}, Ct)).ToArray();
|
||
|
||
await WaitUntilAsync(() => lockout.WaitingCount(Client) == 7);
|
||
authenticated.ShouldBe(3);
|
||
release.SetResult();
|
||
await Task.WhenAll(attempts);
|
||
authenticated.ShouldBe(3);
|
||
lockout.IsLockedOut(Client, "key:victim", out _).ShouldBeTrue();
|
||
}
|
||
|
||
/// <summary>Identities beyond the per-client bound share one overflow count — conservative, never laxer.</summary>
|
||
[Fact]
|
||
public void Identities_beyond_the_per_client_bound_share_one_overflow_count()
|
||
{
|
||
var lockout = new FailedCredentialLockout(
|
||
new OpcUaCredentialLockoutOptions { MaxFailedAttempts = 2, MaxFailedAttemptsPerClient = 1000 }, new ManualClock());
|
||
for (var i = 0; i < FailedCredentialLockout.MaxTrackedIdentitiesPerClient - 1; i++)
|
||
lockout.RecordFailure(Client, $"key:k{i}").ShouldBeFalse();
|
||
|
||
lockout.RecordFailure(Client, "key:overflow-a").ShouldBeFalse();
|
||
lockout.RecordFailure(Client, "key:overflow-b").ShouldBeTrue("both untracked identities share the overflow count");
|
||
lockout.IsLockedOut(Client, "key:overflow-c", out _).ShouldBeTrue();
|
||
lockout.IsLockedOut(Client, "key:k0", out _).ShouldBeFalse("tracked identities keep their own counts");
|
||
}
|
||
|
||
[Fact]
|
||
public void Oversized_identities_are_bounded_but_stay_distinct()
|
||
{
|
||
var lockout = new FailedCredentialLockout(new OpcUaCredentialLockoutOptions { MaxFailedAttempts = 1 }, new ManualClock());
|
||
var a = "key:" + new string('a', 1000) + "1";
|
||
var b = "key:" + new string('a', 1000) + "2";
|
||
lockout.RecordFailure(Client, a).ShouldBeTrue();
|
||
lockout.IsLockedOut(Client, a, out _).ShouldBeTrue();
|
||
lockout.IsLockedOut(Client, b, out _).ShouldBeFalse();
|
||
}
|
||
|
||
// ---- lmxopcua#845: transient outcomes are uncounted; a full table backs off its prune ----
|
||
|
||
/// <summary>
|
||
/// #845 — a transient denial (store timeout, miss budget, authenticator fault) is released with Abandon and
|
||
/// never counts: a ConfigDb stall cannot lock the client out.
|
||
/// </summary>
|
||
[Fact]
|
||
public async Task Abandoned_attempts_never_count_toward_either_threshold()
|
||
{
|
||
var lockout = new FailedCredentialLockout(new OpcUaCredentialLockoutOptions { MaxFailedAttempts = 2 }, new ManualClock());
|
||
for (var i = 0; i < 20; i++)
|
||
{
|
||
(await lockout.BeginAsync(Client, "key:k", Ct)).Verdict.ShouldBe(CredentialLockoutVerdict.Proceed);
|
||
lockout.Abandon(Client, "key:k");
|
||
}
|
||
|
||
lockout.IsLockedOut(Client, "key:k", out _).ShouldBeFalse();
|
||
lockout.TrackedCount.ShouldBe(0);
|
||
}
|
||
|
||
/// <summary>
|
||
/// #845 — at a full table with nothing expired, a new client key's failure used to rescan all 4096 entries
|
||
/// under the global lock every time. The prune now waits until the earliest entry could have expired.
|
||
/// </summary>
|
||
[Fact]
|
||
public void A_full_table_does_not_rescan_until_the_earliest_entry_could_expire()
|
||
{
|
||
var clock = new ManualClock();
|
||
var lockout = new FailedCredentialLockout(new OpcUaCredentialLockoutOptions(), clock);
|
||
for (var i = 0; i < FailedCredentialLockout.MaxTrackedClients; i++)
|
||
{
|
||
lockout.RecordFailure($"c{i}");
|
||
if (i == 0) clock.Advance(TimeSpan.FromSeconds(10)); // c0 is the earliest to age out
|
||
}
|
||
|
||
for (var i = 0; i < 100; i++) lockout.RecordFailure($"new{i}");
|
||
lockout.PruneScanCount.ShouldBe(1, "one scan found nothing to free; the next 99 did not rescan");
|
||
lockout.TrackedCount.ShouldBe(FailedCredentialLockout.MaxTrackedClients);
|
||
|
||
clock.Advance(TimeSpan.FromSeconds(51)); // c0's 60 s window has now passed; nobody else's has
|
||
lockout.RecordFailure("late").ShouldBeFalse();
|
||
lockout.PruneScanCount.ShouldBe(2);
|
||
lockout.TrackedCount.ShouldBe(FailedCredentialLockout.MaxTrackedClients, "c0 pruned, 'late' took its place");
|
||
lockout.RecordFailure("later");
|
||
lockout.PruneScanCount.ShouldBe(3, "the table was full again: one scan…");
|
||
lockout.RecordFailure("latest");
|
||
lockout.PruneScanCount.ShouldBe(3, "…and then it backs off again");
|
||
}
|
||
|
||
/// <summary>A release that frees an entry lets a full table prune again immediately.</summary>
|
||
[Fact]
|
||
public async Task Freeing_an_entry_resets_the_prune_backoff()
|
||
{
|
||
var lockout = new FailedCredentialLockout(new OpcUaCredentialLockoutOptions(), new ManualClock());
|
||
(await lockout.BeginAsync("holder", Ct)).Verdict.ShouldBe(CredentialLockoutVerdict.Proceed);
|
||
for (var i = 1; i < FailedCredentialLockout.MaxTrackedClients; i++) lockout.RecordFailure($"c{i}");
|
||
lockout.RecordFailure("blocked");
|
||
lockout.PruneScanCount.ShouldBe(1);
|
||
|
||
lockout.Complete("holder", succeeded: true); // removes the holder's entry
|
||
for (var i = 0; i < 4; i++) lockout.RecordFailure("admitted").ShouldBeFalse();
|
||
lockout.RecordFailure("admitted").ShouldBeTrue("tracked now: its count reached the threshold");
|
||
}
|
||
|
||
|
||
private static void InterlockedMax(ref int target, int value)
|
||
{
|
||
int seen;
|
||
while ((seen = Volatile.Read(ref target)) < value && Interlocked.CompareExchange(ref target, value, seen) != seen)
|
||
{
|
||
}
|
||
}
|
||
|
||
private static async Task WaitUntilAsync(Func<bool> condition)
|
||
{
|
||
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(10);
|
||
while (!condition())
|
||
{
|
||
DateTime.UtcNow.ShouldBeLessThan(deadline, "condition not reached in time");
|
||
await Task.Delay(5, Ct);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// #833 review — with LockoutSeconds shorter than FailureWindowSeconds, the count survived the expired lockout
|
||
/// (the last failure was still inside the window), so the very next failure relocked the client.
|
||
/// </summary>
|
||
[Fact]
|
||
public void An_expired_lockout_starts_a_fresh_count_even_inside_the_failure_window()
|
||
{
|
||
var clock = new ManualClock();
|
||
var lockout = new FailedCredentialLockout(
|
||
new OpcUaCredentialLockoutOptions { MaxFailedAttempts = 2, FailureWindowSeconds = 600, LockoutSeconds = 10 }, clock);
|
||
lockout.RecordFailure(Client).ShouldBeFalse();
|
||
lockout.RecordFailure(Client).ShouldBeTrue();
|
||
|
||
clock.Advance(TimeSpan.FromSeconds(11));
|
||
lockout.IsLockedOut(Client, out _).ShouldBeFalse();
|
||
lockout.RecordFailure(Client).ShouldBeFalse("one failure after the lockout is the first of a new run");
|
||
lockout.IsLockedOut(Client, out _).ShouldBeFalse();
|
||
lockout.RecordFailure(Client).ShouldBeTrue();
|
||
}
|
||
|
||
[Fact]
|
||
public void The_host_switches_the_sdk_lockout_off()
|
||
{
|
||
var config = new Opc.Ua.ServerConfiguration();
|
||
config.MaxFailedAuthenticationAttempts.ShouldBe(5, "the SDK default this guards against");
|
||
OpcUaApplicationHost.DisableSdkAuthenticationLockout(config);
|
||
config.MaxFailedAuthenticationAttempts.ShouldBe(0);
|
||
}
|
||
|
||
private sealed class ManualClock : TimeProvider
|
||
{
|
||
private DateTimeOffset _now = new(2026, 11, 11, 0, 0, 0, TimeSpan.Zero);
|
||
public override DateTimeOffset GetUtcNow() => _now;
|
||
public void Advance(TimeSpan by) => _now += by;
|
||
}
|
||
}
|