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:
+20
@@ -92,4 +92,24 @@ public sealed class OpcUaApplicationHostOptionsValidatorTests
|
||||
result.Failed.ShouldBeTrue();
|
||||
result.Failures!.ShouldContain(f => f.Contains("OpcUa:CredentialLockout:SlotWaitSeconds"));
|
||||
}
|
||||
|
||||
/// <summary>#844: the per-client cap may be unset (0) or at least the per-credential threshold.</summary>
|
||||
[Theory]
|
||||
[InlineData(0, true)]
|
||||
[InlineData(5, true)]
|
||||
[InlineData(20, true)]
|
||||
[InlineData(4, false)]
|
||||
public void Credential_lockout_per_client_cap_is_unset_or_at_least_the_per_credential_threshold(int perClient, bool valid)
|
||||
{
|
||||
var result = Sut.Validate(null, new OpcUaApplicationHostOptions
|
||||
{
|
||||
CredentialLockout = new ZB.MOM.WW.OtOpcUa.OpcUaServer.Security.OpcUaCredentialLockoutOptions
|
||||
{
|
||||
MaxFailedAttempts = 5, MaxFailedAttemptsPerClient = perClient,
|
||||
},
|
||||
});
|
||||
|
||||
result.Succeeded.ShouldBe(valid);
|
||||
if (!valid) result.Failures!.ShouldContain(f => f.Contains("OpcUa:CredentialLockout:MaxFailedAttemptsPerClient"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,6 +154,42 @@ public sealed class ApiKeyAuthenticationTests
|
||||
|
||||
result.Success.ShouldBeFalse();
|
||||
result.Error.ShouldBe(ApiKeyOpcUaUserAuthenticator.DenyMessage);
|
||||
result.Transient.ShouldBeTrue("#845: a store outage / timeout / miss-budget refusal is not a bad credential");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #845 — only a definite verdict on the credential is non-transient: a wrong secret, an unknown key id, a
|
||||
/// malformed credential and the wrong username all count toward the failed-credential lockout.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Definite_credential_failures_are_not_transient()
|
||||
{
|
||||
var (presented, store) = SetUp();
|
||||
var auth = Auth(store);
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
OpcUaUserAuthResult[] results =
|
||||
[
|
||||
await auth.AuthenticateUserNameAsync("apikey", $"otua_{store.KeyId}_wrong", ct),
|
||||
await auth.AuthenticateUserNameAsync("apikey", "otua_ffffffff_whatever", ct),
|
||||
await auth.AuthenticateUserNameAsync("apikey", "not-a-key", ct),
|
||||
await auth.AuthenticateUserNameAsync("alice", presented, ct),
|
||||
];
|
||||
results.ShouldAllBe(r => !r.Success && !r.Transient);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #844 — the lockout counts per credential identity: the key id (public — the lookup index, never the
|
||||
/// secret), else the username. The secret must never appear in it (it is logged).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void The_credential_identity_is_the_key_id_and_never_the_secret()
|
||||
{
|
||||
var (presented, store) = SetUp();
|
||||
var auth = Auth(store);
|
||||
var identity = auth.DescribeCredential("apikey", presented);
|
||||
identity.ShouldBe($"key:{store.KeyId}");
|
||||
identity.ShouldNotContain(presented.Split('_')[^1]);
|
||||
auth.DescribeCredential("apikey", "not-a-key").ShouldBe("user:apikey");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -165,7 +201,9 @@ public sealed class ApiKeyAuthenticationTests
|
||||
Options.Create(new ApiKeyOptions { Username = "apikey", Pepper = "" }),
|
||||
NullLogger<ApiKeyOpcUaUserAuthenticator>.Instance);
|
||||
|
||||
(await auth.AuthenticateUserNameAsync("apikey", presented, TestContext.Current.CancellationToken)).Success.ShouldBeFalse();
|
||||
var result = await auth.AuthenticateUserNameAsync("apikey", presented, TestContext.Current.CancellationToken);
|
||||
result.Success.ShouldBeFalse();
|
||||
result.Transient.ShouldBeTrue("#845: a deployment error, not a bad credential");
|
||||
}
|
||||
|
||||
// ---------- anonymous as a normal principal (D3) ----------
|
||||
|
||||
+222
-32
@@ -1,8 +1,10 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Client;
|
||||
using Opc.Ua.Configuration;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||
@@ -14,7 +16,8 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
|
||||
/// lmxopcua#833 over the wire. A few failed UserName activations used to trip the OPC Foundation SDK's
|
||||
/// per-client lockout, which then refused EVERY activation from that client — Anonymous included — with
|
||||
/// <c>BadUserAccessDenied</c> for 5 minutes. The host now disables the SDK lockout and enforces a
|
||||
/// UserName-only replacement (<see cref="FailedCredentialLockout"/>).
|
||||
/// UserName-only replacement (<see cref="FailedCredentialLockout"/>), counted per credential identity as well as
|
||||
/// per client (#844) and blind to transient denials (#845).
|
||||
/// </summary>
|
||||
public sealed class CredentialLockoutWireTests
|
||||
{
|
||||
@@ -27,7 +30,7 @@ public sealed class CredentialLockoutWireTests
|
||||
var auth = new CountingAuthenticator();
|
||||
await using var rig = await Rig.StartAsync(auth, new OpcUaCredentialLockoutOptions(), ct);
|
||||
|
||||
for (var i = 0; i < 6; i++)
|
||||
for (var i = 0; i < 12; i++)
|
||||
{
|
||||
var rejected = await Should.ThrowAsync<ServiceResultException>(() => rig.ConnectAsync($"otua_k-bad{i}_x", ct));
|
||||
rejected.StatusCode.ShouldBeOneOf(StatusCodes.BadIdentityTokenRejected, StatusCodes.BadUserAccessDenied);
|
||||
@@ -45,7 +48,7 @@ public sealed class CredentialLockoutWireTests
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var auth = new CountingAuthenticator();
|
||||
await using var rig = await Rig.StartAsync(
|
||||
auth, new OpcUaCredentialLockoutOptions { MaxFailedAttempts = 3 }, ct);
|
||||
auth, new OpcUaCredentialLockoutOptions { MaxFailedAttempts = 3, MaxFailedAttemptsPerClient = 3 }, ct);
|
||||
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
@@ -59,6 +62,58 @@ public sealed class CredentialLockoutWireTests
|
||||
auth.UserNameCalls.ShouldBe(callsBefore, "a locked-out client's credential is refused without being checked");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #844 over the wire — guesses at another key id interleaved with sign-ins on the client's own valid key. The
|
||||
/// own-key success used to clear the whole count, so the cycle never locked. Now the victim key id locks for
|
||||
/// that client after MaxFailedAttempts guesses (refused unauthenticated), while the client's own key still works.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Own_key_successes_do_not_launder_guesses_at_another_key_id()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var auth = new CountingAuthenticator();
|
||||
await using var rig = await Rig.StartAsync(auth, new OpcUaCredentialLockoutOptions { MaxFailedAttempts = 3 }, ct);
|
||||
|
||||
for (var round = 0; round < 3; round++)
|
||||
{
|
||||
var rejected = await Should.ThrowAsync<ServiceResultException>(
|
||||
() => rig.ConnectAsync($"otua_k-victim_guess{round}", ct));
|
||||
rejected.StatusCode.ShouldBe(StatusCodes.BadIdentityTokenRejected);
|
||||
using var own = await rig.ConnectAsync(GoodKey, ct);
|
||||
own.Connected.ShouldBeTrue();
|
||||
}
|
||||
|
||||
var callsBefore = auth.UserNameCalls;
|
||||
var locked = await Should.ThrowAsync<ServiceResultException>(() => rig.ConnectAsync("otua_k-victim_guess3", ct));
|
||||
locked.StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied);
|
||||
auth.UserNameCalls.ShouldBe(callsBefore, "the locked key id is refused without being checked");
|
||||
|
||||
using var stillOwn = await rig.ConnectAsync(GoodKey, ct);
|
||||
stillOwn.Connected.ShouldBeTrue("a per-credential lockout leaves the client's own key usable");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #845 over the wire — a transient denial (the authenticator reports the key store unavailable) is not a bad
|
||||
/// credential: any number of them leave the client able to sign in once the store recovers.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Transient_denials_never_lock_the_client_out()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var auth = new CountingAuthenticator();
|
||||
await using var rig = await Rig.StartAsync(auth, new OpcUaCredentialLockoutOptions { MaxFailedAttempts = 2 }, ct);
|
||||
|
||||
for (var i = 0; i < 6; i++)
|
||||
{
|
||||
var rejected = await Should.ThrowAsync<ServiceResultException>(() => rig.ConnectAsync(StallingKey(i % 2), ct));
|
||||
rejected.StatusCode.ShouldBe(StatusCodes.BadIdentityTokenRejected, "the client sees the ordinary opaque denial");
|
||||
}
|
||||
|
||||
using var session = await rig.ConnectAsync(GoodKey, ct);
|
||||
session.Connected.ShouldBeTrue();
|
||||
auth.UserNameCalls.ShouldBe(7, "every transient attempt reached the authenticator: none were refused as locked out");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #833 review — one client opening more keyed sessions at once than MaxFailedAttempts (a ScadaBridge site
|
||||
/// node behind one NAT IP) is never refused for parallelism: the extra activations wait for a slot.
|
||||
@@ -101,59 +156,181 @@ public sealed class CredentialLockoutWireTests
|
||||
session.Connected.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #845 — on a Sign endpoint the client certificate is proven, so it separates clients that share an address:
|
||||
/// a client locked out client-wide does not lock out a second client (its own certificate) from the same IP.
|
||||
/// (On a None endpoint clients commonly present no certificate and the key is effectively the IP — see
|
||||
/// docs/security.md.)
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task On_a_Sign_endpoint_distinct_client_certificates_are_locked_out_separately()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var auth = new CountingAuthenticator();
|
||||
await using var rig = await Rig.StartAsync(
|
||||
auth, new OpcUaCredentialLockoutOptions { MaxFailedAttempts = 2, MaxFailedAttemptsPerClient = 2 }, ct,
|
||||
OpcUaSecurityProfile.Basic256Sha256Sign);
|
||||
|
||||
for (var i = 0; i < 2; i++)
|
||||
{
|
||||
var rejected = await Should.ThrowAsync<ServiceResultException>(
|
||||
() => rig.ConnectAsync($"otua_k-bad{i}_x", ct, clientCertificate: "ClientA"));
|
||||
rejected.StatusCode.ShouldBe(StatusCodes.BadIdentityTokenRejected);
|
||||
}
|
||||
|
||||
var locked = await Should.ThrowAsync<ServiceResultException>(
|
||||
() => rig.ConnectAsync(GoodKey, ct, clientCertificate: "ClientA"));
|
||||
locked.StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied, "client A is locked out client-wide");
|
||||
|
||||
using var other = await rig.ConnectAsync(GoodKey, ct, clientCertificate: "ClientB");
|
||||
other.Connected.ShouldBeTrue("same address, different (proven) certificate: a different client");
|
||||
other.Endpoint.SecurityMode.ShouldBe(MessageSecurityMode.Sign);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #845 — the <c>OpcUa:ApplicationConfigPath</c> branch loads an SDK XML configuration instead of building one.
|
||||
/// That file may carry the SDK's own <c>MaxFailedAuthenticationAttempts</c>; the host must still switch it off,
|
||||
/// or failed sign-ins would again lock the client out of ANONYMOUS activation.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_loaded_application_configuration_still_has_the_sdk_lockout_switched_off()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var auth = new CountingAuthenticator();
|
||||
await using var rig = await Rig.StartFromConfigFileAsync(
|
||||
auth, new OpcUaCredentialLockoutOptions { MaxFailedAttempts = 0 }, sdkMaxFailedAttempts: 2, ct);
|
||||
|
||||
rig.LoadedSdkMaxFailedAttempts.ShouldBe(0, "the host zeroed the value the file carried");
|
||||
for (var i = 0; i < 4; i++)
|
||||
{
|
||||
var rejected = await Should.ThrowAsync<ServiceResultException>(() => rig.ConnectAsync($"otua_k-bad{i}_x", ct));
|
||||
rejected.StatusCode.ShouldBe(StatusCodes.BadIdentityTokenRejected, "the SDK lockout (2) would refuse these");
|
||||
}
|
||||
|
||||
using var anonymous = await rig.ConnectAsync(credential: null, ct);
|
||||
anonymous.Connected.ShouldBeTrue();
|
||||
using var keyed = await rig.ConnectAsync(GoodKey, ct);
|
||||
keyed.Connected.ShouldBeTrue();
|
||||
}
|
||||
|
||||
private static string StallingKey(int n) => $"otua_k-stall{n}_secret";
|
||||
|
||||
private sealed class Rig : IAsyncDisposable
|
||||
{
|
||||
private readonly string _pkiRoot;
|
||||
private readonly ConcurrentBag<ApplicationInstance> _clientApps = [];
|
||||
private readonly ConcurrentDictionary<string, string> _clientPkiRoots = new(StringComparer.Ordinal);
|
||||
private OpcUaApplicationHost _host = null!;
|
||||
private int _port;
|
||||
private bool _secure;
|
||||
|
||||
private Rig(string pkiRoot) => _pkiRoot = pkiRoot;
|
||||
|
||||
/// <summary>The SDK lockout value of the configuration the host actually runs with.</summary>
|
||||
public int LoadedSdkMaxFailedAttempts =>
|
||||
_host.ApplicationInstance!.ApplicationConfiguration!.ServerConfiguration!.MaxFailedAuthenticationAttempts;
|
||||
|
||||
public static async Task<Rig> StartAsync(
|
||||
IOpcUaUserAuthenticator authenticator, OpcUaCredentialLockoutOptions lockout, CancellationToken ct)
|
||||
IOpcUaUserAuthenticator authenticator, OpcUaCredentialLockoutOptions lockout, CancellationToken ct,
|
||||
OpcUaSecurityProfile profile = OpcUaSecurityProfile.None)
|
||||
{
|
||||
var rig = new Rig(Path.Combine(Path.GetTempPath(), $"otopcua-lockout-{Guid.NewGuid():N}"));
|
||||
var rig = new Rig(Path.Combine(Path.GetTempPath(), $"otopcua-lockout-{Guid.NewGuid():N}"))
|
||||
{
|
||||
_secure = profile != OpcUaSecurityProfile.None,
|
||||
};
|
||||
var applicationUri = $"urn:OtOpcUa.Lockout.{Guid.NewGuid():N}";
|
||||
(rig._host, _) = await OpcUaHostStart.StartAsync(
|
||||
port =>
|
||||
{
|
||||
rig._port = port;
|
||||
return new OpcUaApplicationHost(
|
||||
new OpcUaApplicationHostOptions
|
||||
{
|
||||
ApplicationName = "OtOpcUa.Lockout",
|
||||
ApplicationUri = applicationUri,
|
||||
OpcUaPort = port,
|
||||
PublicHostname = "127.0.0.1",
|
||||
PkiStoreRoot = rig._pkiRoot,
|
||||
EnabledSecurityProfiles = [OpcUaSecurityProfile.None],
|
||||
AutoAcceptUntrustedClientCertificates = true,
|
||||
CredentialLockout = lockout,
|
||||
},
|
||||
NullLogger<OpcUaApplicationHost>.Instance,
|
||||
authenticator);
|
||||
return new OpcUaApplicationHost(rig.Options(port, applicationUri, lockout, profile),
|
||||
NullLogger<OpcUaApplicationHost>.Instance, authenticator);
|
||||
},
|
||||
() => new OtOpcUaSdkServer(), ct);
|
||||
return rig;
|
||||
}
|
||||
|
||||
/// <summary>Opens a session; a null credential activates anonymously.</summary>
|
||||
public async Task<ISession> ConnectAsync(string? credential, CancellationToken ct)
|
||||
/// <summary>
|
||||
/// Boots once to let the host build (and the SDK validate) a configuration, saves it as an SDK XML file
|
||||
/// carrying <paramref name="sdkMaxFailedAttempts"/>, then boots the host from that file through
|
||||
/// <c>OpcUa:ApplicationConfigPath</c>.
|
||||
/// </summary>
|
||||
public static async Task<Rig> StartFromConfigFileAsync(
|
||||
IOpcUaUserAuthenticator authenticator, OpcUaCredentialLockoutOptions lockout, int sdkMaxFailedAttempts,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var appConfig = new ApplicationConfiguration(DefaultTelemetry.Create(_ => { }))
|
||||
var seed = await StartAsync(authenticator, lockout, ct);
|
||||
var configFile = Path.Combine(seed._pkiRoot, "OtOpcUa.Lockout.Config.xml");
|
||||
var applicationUri = seed._host.ApplicationInstance!.ApplicationConfiguration!.ApplicationUri;
|
||||
var saved = seed._host.ApplicationInstance.ApplicationConfiguration;
|
||||
await seed._host.DisposeAsync();
|
||||
|
||||
(seed._host, _) = await OpcUaHostStart.StartAsync(
|
||||
port =>
|
||||
{
|
||||
seed._port = port;
|
||||
saved.ServerConfiguration!.BaseAddresses = [$"opc.tcp://127.0.0.1:{port}/OtOpcUa"];
|
||||
saved.ServerConfiguration.MaxFailedAuthenticationAttempts = sdkMaxFailedAttempts;
|
||||
saved.SaveToFile(configFile);
|
||||
var options = seed.Options(port, applicationUri!, lockout, OpcUaSecurityProfile.None);
|
||||
options.ApplicationConfigPath = configFile;
|
||||
return new OpcUaApplicationHost(options, NullLogger<OpcUaApplicationHost>.Instance, authenticator);
|
||||
},
|
||||
() => new OtOpcUaSdkServer(), ct);
|
||||
File.ReadAllText(configFile).ShouldContain($"<MaxFailedAuthenticationAttempts>{sdkMaxFailedAttempts}<");
|
||||
return seed;
|
||||
}
|
||||
|
||||
private OpcUaApplicationHostOptions Options(
|
||||
int port, string applicationUri, OpcUaCredentialLockoutOptions lockout, OpcUaSecurityProfile profile) => new()
|
||||
{
|
||||
ApplicationName = "OtOpcUa.Lockout",
|
||||
ApplicationUri = applicationUri,
|
||||
OpcUaPort = port,
|
||||
PublicHostname = "127.0.0.1",
|
||||
PkiStoreRoot = _pkiRoot,
|
||||
EnabledSecurityProfiles = [profile],
|
||||
AutoAcceptUntrustedClientCertificates = true,
|
||||
CredentialLockout = lockout,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Opens a session; a null credential activates anonymously. With <paramref name="clientCertificate"/> the
|
||||
/// client presents its own application certificate (one per name, created once) over a secure endpoint.
|
||||
/// </summary>
|
||||
public async Task<ISession> ConnectAsync(string? credential, CancellationToken ct, string? clientCertificate = null)
|
||||
{
|
||||
var telemetry = DefaultTelemetry.Create(_ => { });
|
||||
var name = clientCertificate ?? "LockoutClient";
|
||||
var pki = clientCertificate is null
|
||||
? TestClientSecurity.AllocatePkiRoot()
|
||||
: _clientPkiRoots.GetOrAdd(clientCertificate, _ => TestClientSecurity.AllocatePkiRoot());
|
||||
var security = TestClientSecurity.Build(pki);
|
||||
security.ApplicationCertificate!.SubjectName = $"CN=OtOpcUa.{name}";
|
||||
var appConfig = new ApplicationConfiguration(telemetry)
|
||||
{
|
||||
ApplicationName = "OtOpcUa.LockoutClient",
|
||||
ApplicationUri = "urn:OtOpcUa.LockoutClient",
|
||||
ApplicationName = $"OtOpcUa.{name}",
|
||||
ApplicationUri = $"urn:localhost:OtOpcUa.{name}",
|
||||
ApplicationType = ApplicationType.Client,
|
||||
SecurityConfiguration = TestClientSecurity.Build(TestClientSecurity.AllocatePkiRoot()),
|
||||
SecurityConfiguration = security,
|
||||
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60_000 },
|
||||
};
|
||||
await appConfig.ValidateAsync(ApplicationType.Client, ct);
|
||||
if (clientCertificate is not null)
|
||||
{
|
||||
var app = new ApplicationInstance(telemetry)
|
||||
{
|
||||
ApplicationName = appConfig.ApplicationName, ApplicationType = ApplicationType.Client,
|
||||
ApplicationConfiguration = appConfig,
|
||||
};
|
||||
_clientApps.Add(app);
|
||||
(await app.CheckApplicationInstanceCertificatesAsync(true, lifeTimeInMonths: 12, ct)).ShouldBeTrue();
|
||||
}
|
||||
|
||||
var endpoint = await CoreClientUtils.SelectEndpointAsync(
|
||||
appConfig, $"opc.tcp://127.0.0.1:{_port}/OtOpcUa", false, DefaultTelemetry.Create(_ => { }), ct);
|
||||
appConfig, $"opc.tcp://127.0.0.1:{_port}/OtOpcUa", _secure, telemetry, ct);
|
||||
|
||||
return await new DefaultSessionFactory(DefaultTelemetry.Create(_ => { })).CreateAsync(
|
||||
return await new DefaultSessionFactory(telemetry).CreateAsync(
|
||||
appConfig,
|
||||
new ConfiguredEndpoint(null, endpoint ?? throw new InvalidOperationException("no endpoint"), EndpointConfiguration.Create(appConfig)),
|
||||
updateBeforeConnect: false,
|
||||
@@ -170,15 +347,21 @@ public sealed class CredentialLockoutWireTests
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _host.DisposeAsync();
|
||||
if (Directory.Exists(_pkiRoot))
|
||||
foreach (var app in _clientApps) await app.DisposeAsync();
|
||||
foreach (var dir in _clientPkiRoots.Values.Append(_pkiRoot))
|
||||
{
|
||||
try { Directory.Delete(_pkiRoot, recursive: true); }
|
||||
if (!Directory.Exists(dir)) continue;
|
||||
try { Directory.Delete(dir, recursive: true); }
|
||||
catch (IOException) { /* best effort */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Admits <see cref="GoodKey"/> and anonymous; denies everything else; counts calls.</summary>
|
||||
/// <summary>
|
||||
/// Admits <see cref="GoodKey"/> and anonymous; reports the key store unavailable (Transient) for
|
||||
/// <see cref="StallingKey"/>s; denies everything else; counts calls. Names the credential by key id, as the
|
||||
/// production API-key authenticator does.
|
||||
/// </summary>
|
||||
private sealed class CountingAuthenticator : IOpcUaUserAuthenticator
|
||||
{
|
||||
private int _userNameCalls;
|
||||
@@ -190,8 +373,9 @@ public sealed class CredentialLockoutWireTests
|
||||
public Task<OpcUaUserAuthResult> AuthenticateUserNameAsync(string username, string password, CancellationToken ct)
|
||||
{
|
||||
Interlocked.Increment(ref _userNameCalls);
|
||||
return Task.FromResult(password == GoodKey
|
||||
? OpcUaUserAuthResult.AllowKey("good", [], "k-good")
|
||||
return Task.FromResult(
|
||||
password == GoodKey ? OpcUaUserAuthResult.AllowKey("good", [], "k-good")
|
||||
: password.StartsWith("otua_k-stall", StringComparison.Ordinal) ? OpcUaUserAuthResult.Unavailable("Invalid API key.")
|
||||
: OpcUaUserAuthResult.Deny("Invalid API key."));
|
||||
}
|
||||
|
||||
@@ -199,6 +383,12 @@ public sealed class CredentialLockoutWireTests
|
||||
string username, string password, IPEndPoint? remoteEndpoint, CancellationToken ct) =>
|
||||
AuthenticateUserNameAsync(username, password, ct);
|
||||
|
||||
public string DescribeCredential(string username, string password)
|
||||
{
|
||||
var parts = password.Split('_');
|
||||
return parts.Length == 3 && parts[0] == "otua" ? "key:" + parts[1] : "user:" + username;
|
||||
}
|
||||
|
||||
public Task<OpcUaUserAuthResult> AuthenticateAnonymousAsync(CancellationToken ct)
|
||||
{
|
||||
Interlocked.Increment(ref _anonymousCalls);
|
||||
|
||||
@@ -55,7 +55,7 @@ public sealed class FailedCredentialLockoutTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_success_clears_the_count()
|
||||
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);
|
||||
@@ -186,10 +186,10 @@ public sealed class FailedCredentialLockoutTests
|
||||
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
|
||||
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(0);
|
||||
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>
|
||||
@@ -242,10 +242,226 @@ public sealed class FailedCredentialLockoutTests
|
||||
|
||||
clock.Advance(TimeSpan.FromSeconds(11));
|
||||
(await lockout.BeginAsync(Client, Ct)).Verdict.ShouldBe(CredentialLockoutVerdict.Proceed);
|
||||
lockout.Complete(Client, succeeded: false).ShouldBeTrue("a new episode");
|
||||
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;
|
||||
|
||||
+43
-1
@@ -116,10 +116,43 @@ public sealed class OpcUaApplicationHostImpersonationTests
|
||||
var args = new ImpersonateEventArgs(token, UserNamePolicy, new EndpointDescription());
|
||||
var authenticator = new ThrowingAuthenticator(new InvalidOperationException("LDAP unreachable"));
|
||||
|
||||
OpcUaApplicationHost.HandleImpersonation(authenticator, args, NullLogger<object>.Instance);
|
||||
var transient = OpcUaApplicationHost.HandleImpersonation(authenticator, args, NullLogger<object>.Instance);
|
||||
|
||||
args.Identity.ShouldBeNull();
|
||||
args.IdentityValidationError.Code.ShouldBe(StatusCodes.BadIdentityTokenRejected.Code);
|
||||
transient.ShouldBeTrue("#845: an authenticator fault is not a verdict on the credential");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #845 — a denial the authenticator marks Transient (store timeout, miss-budget refusal) is reported as such,
|
||||
/// so the lockout does not count it, while the client sees the same opaque rejection as a bad credential.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void HandleImpersonation_reports_a_transient_denial_and_a_definite_one_apart()
|
||||
{
|
||||
OpcUaUserAuthResult[] outcomes = [OpcUaUserAuthResult.Unavailable("Invalid API key."), OpcUaUserAuthResult.Deny("Invalid API key.")];
|
||||
var verdicts = outcomes.Select(outcome =>
|
||||
{
|
||||
var token = new UserNameIdentityTokenHandler(new UserNameIdentityToken { UserName = "apikey" }) { DecryptedPassword = Encoding.UTF8.GetBytes("otua_k1_x") };
|
||||
var args = new ImpersonateEventArgs(token, UserNamePolicy, new EndpointDescription());
|
||||
var transient = OpcUaApplicationHost.HandleImpersonation(new RecordingAuthenticator(outcome), args, NullLogger<object>.Instance);
|
||||
args.IdentityValidationError.Code.ShouldBe(StatusCodes.BadIdentityTokenRejected.Code);
|
||||
args.IdentityValidationError.LocalizedText.Text!.ShouldContain("Invalid API key.");
|
||||
return transient;
|
||||
}).ToArray();
|
||||
|
||||
verdicts.ShouldBe([true, false]);
|
||||
}
|
||||
|
||||
/// <summary>#844 — the default credential identity is the username; a faulting implementation falls back to it.</summary>
|
||||
[Fact]
|
||||
public void DescribeCredential_defaults_to_the_username_and_never_throws()
|
||||
{
|
||||
var handler = new UserNameIdentityTokenHandler(new UserNameIdentityToken { UserName = "alice" }) { DecryptedPassword = Encoding.UTF8.GetBytes("pw") };
|
||||
OpcUaApplicationHost.DescribeCredential(new RecordingAuthenticator(OpcUaUserAuthResult.Deny("x")), handler)
|
||||
.ShouldBe("user:alice");
|
||||
OpcUaApplicationHost.DescribeCredential(new DescribingAuthenticator(), handler).ShouldBe("custom:alice:pw".Length.ToString());
|
||||
OpcUaApplicationHost.DescribeCredential(new DescribingAuthenticator(fail: true), handler).ShouldBe("user:alice");
|
||||
}
|
||||
|
||||
/// <summary>Verifies null username is normalized to empty string before authenticator call.</summary>
|
||||
@@ -167,6 +200,15 @@ public sealed class OpcUaApplicationHostImpersonationTests
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class DescribingAuthenticator(bool fail = false) : IOpcUaUserAuthenticator
|
||||
{
|
||||
public Task<OpcUaUserAuthResult> AuthenticateUserNameAsync(string username, string password, CancellationToken ct) =>
|
||||
Task.FromResult(OpcUaUserAuthResult.Deny("x"));
|
||||
|
||||
public string DescribeCredential(string username, string password) =>
|
||||
fail ? throw new InvalidOperationException("boom") : $"custom:{username}:{password}".Length.ToString();
|
||||
}
|
||||
|
||||
/// <summary>Test authenticator that throws an exception during authentication.</summary>
|
||||
private sealed class ThrowingAuthenticator(Exception ex) : IOpcUaUserAuthenticator
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user