2314a53842
A legitimate consumer opening several keyed sessions at once from one key (e.g. a ScadaBridge site node behind one NAT IP on a None endpoint) must never see BadUserAccessDenied for parallelism. BeginAsync now hands out at most MaxFailedAttempts - recordedFailures verification slots per key; further activations queue FIFO and are woken as slots free. A waiter is refused only when the key locks out (never reaching the authenticator) or, uncounted, after OpcUa:CredentialLockout:SlotWaitSeconds (default 10, BadTimeout). Cancelled waiters are dequeued; pruning accounts for waiters. The security property is unchanged: no more than MaxFailedAttempts unverified guesses in progress-or-failed per window.
303 lines
14 KiB
C#
303 lines
14 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_the_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();
|
||
|
||
await WaitUntilAsync(() => lockout.WaitingCount(Client) == 15);
|
||
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 count and hands the slot on
|
||
(await second).Verdict.ShouldBe(CredentialLockoutVerdict.Proceed);
|
||
lockout.Complete(Client, succeeded: true);
|
||
lockout.TrackedCount.ShouldBe(0);
|
||
}
|
||
|
||
/// <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).ShouldBeTrue("a new episode");
|
||
(await lockout.BeginAsync(Client, Ct)).FirstRefusal.ShouldBeTrue("each lockout episode logs its first refusal once");
|
||
}
|
||
|
||
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;
|
||
}
|
||
}
|