ffba75bb93
#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).
399 lines
19 KiB
C#
399 lines
19 KiB
C#
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;
|
|
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
|
|
|
|
/// <summary>
|
|
/// 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"/>), counted per credential identity as well as
|
|
/// per client (#844) and blind to transient denials (#845).
|
|
/// </summary>
|
|
public sealed class CredentialLockoutWireTests
|
|
{
|
|
private const string GoodKey = "otua_k-good_secret";
|
|
|
|
[Fact]
|
|
public async Task Failed_credentials_never_lock_the_client_out_of_anonymous_activation()
|
|
{
|
|
var ct = TestContext.Current.CancellationToken;
|
|
var auth = new CountingAuthenticator();
|
|
await using var rig = await Rig.StartAsync(auth, new OpcUaCredentialLockoutOptions(), ct);
|
|
|
|
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);
|
|
}
|
|
|
|
using var anonymous = await rig.ConnectAsync(credential: null, ct);
|
|
var ns = await anonymous.ReadValueAsync(VariableIds.Server_NamespaceArray, ct);
|
|
StatusCode.IsGood(ns.StatusCode).ShouldBeTrue();
|
|
auth.AnonymousCalls.ShouldBe(1, "the anonymous activation must reach the authenticator, not be refused before it");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Failed_credentials_still_lock_out_further_UserName_activations_from_that_client()
|
|
{
|
|
var ct = TestContext.Current.CancellationToken;
|
|
var auth = new CountingAuthenticator();
|
|
await using var rig = await Rig.StartAsync(
|
|
auth, new OpcUaCredentialLockoutOptions { MaxFailedAttempts = 3, MaxFailedAttemptsPerClient = 3 }, ct);
|
|
|
|
for (var i = 0; i < 3; i++)
|
|
{
|
|
var rejected = await Should.ThrowAsync<ServiceResultException>(() => rig.ConnectAsync($"otua_k-bad{i}_x", ct));
|
|
rejected.StatusCode.ShouldBe(StatusCodes.BadIdentityTokenRejected);
|
|
}
|
|
|
|
var callsBefore = auth.UserNameCalls;
|
|
var locked = await Should.ThrowAsync<ServiceResultException>(() => rig.ConnectAsync(GoodKey, ct));
|
|
locked.StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied);
|
|
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.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Parallel_valid_activations_beyond_the_threshold_all_succeed()
|
|
{
|
|
var ct = TestContext.Current.CancellationToken;
|
|
var auth = new CountingAuthenticator();
|
|
await using var rig = await Rig.StartAsync(auth, new OpcUaCredentialLockoutOptions { MaxFailedAttempts = 2 }, ct);
|
|
|
|
var sessions = await Task.WhenAll(Enumerable.Range(0, 8).Select(_ => rig.ConnectAsync(GoodKey, ct)));
|
|
try
|
|
{
|
|
sessions.ShouldAllBe(s => s.Connected);
|
|
auth.UserNameCalls.ShouldBe(8);
|
|
}
|
|
finally
|
|
{
|
|
foreach (var s in sessions) s.Dispose();
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_disabled_lockout_admits_a_valid_key_after_any_number_of_failures()
|
|
{
|
|
// Also proves the SDK's own lockout (default 5) is switched off: with ours disabled nothing may refuse.
|
|
var ct = TestContext.Current.CancellationToken;
|
|
var auth = new CountingAuthenticator();
|
|
await using var rig = await Rig.StartAsync(
|
|
auth, new OpcUaCredentialLockoutOptions { MaxFailedAttempts = 0 }, ct);
|
|
|
|
for (var i = 0; i < 7; i++)
|
|
{
|
|
var rejected = await Should.ThrowAsync<ServiceResultException>(() => rig.ConnectAsync($"otua_k-bad{i}_x", ct));
|
|
rejected.StatusCode.ShouldBe(StatusCodes.BadIdentityTokenRejected);
|
|
}
|
|
|
|
using var session = await rig.ConnectAsync(GoodKey, ct);
|
|
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,
|
|
OpcUaSecurityProfile profile = OpcUaSecurityProfile.None)
|
|
{
|
|
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(rig.Options(port, applicationUri, lockout, profile),
|
|
NullLogger<OpcUaApplicationHost>.Instance, authenticator);
|
|
},
|
|
() => new OtOpcUaSdkServer(), ct);
|
|
return rig;
|
|
}
|
|
|
|
/// <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 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.{name}",
|
|
ApplicationUri = $"urn:localhost:OtOpcUa.{name}",
|
|
ApplicationType = ApplicationType.Client,
|
|
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", _secure, telemetry, ct);
|
|
|
|
return await new DefaultSessionFactory(telemetry).CreateAsync(
|
|
appConfig,
|
|
new ConfiguredEndpoint(null, endpoint ?? throw new InvalidOperationException("no endpoint"), EndpointConfiguration.Create(appConfig)),
|
|
updateBeforeConnect: false,
|
|
checkDomain: false,
|
|
sessionName: "CredentialLockoutWireTests",
|
|
sessionTimeout: 60_000,
|
|
identity: credential is null
|
|
? new UserIdentity()
|
|
: new UserIdentity("apikey", Encoding.UTF8.GetBytes(credential)),
|
|
preferredLocales: default,
|
|
ct: ct);
|
|
}
|
|
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
await _host.DisposeAsync();
|
|
foreach (var app in _clientApps) await app.DisposeAsync();
|
|
foreach (var dir in _clientPkiRoots.Values.Append(_pkiRoot))
|
|
{
|
|
if (!Directory.Exists(dir)) continue;
|
|
try { Directory.Delete(dir, recursive: true); }
|
|
catch (IOException) { /* best effort */ }
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <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;
|
|
private int _anonymousCalls;
|
|
|
|
public int UserNameCalls => Volatile.Read(ref _userNameCalls);
|
|
public int AnonymousCalls => Volatile.Read(ref _anonymousCalls);
|
|
|
|
public Task<OpcUaUserAuthResult> AuthenticateUserNameAsync(string username, string password, CancellationToken ct)
|
|
{
|
|
Interlocked.Increment(ref _userNameCalls);
|
|
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."));
|
|
}
|
|
|
|
public Task<OpcUaUserAuthResult> AuthenticateUserNameAsync(
|
|
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);
|
|
return Task.FromResult(OpcUaUserAuthResult.Allow(OpcUaPrincipals.Anonymous, []));
|
|
}
|
|
}
|
|
}
|