Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/FailedCredentialLockoutTests.cs
T
Joseph Doherty 2314a53842 fix(opcua): parallel UserName activations wait for a slot instead of being refused (#833)
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.
2026-11-11 02:43:38 -05:00

303 lines
14 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}
}