Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/OpcUa/BoundedApiKeyStorePerEndpointTests.cs
T
Joseph Doherty d59e76dd9d feat(security): per-client pre-auth API-key miss budget; widen IOpcUaUserAuthenticator with the remote endpoint (lmxopcua#751)
The #733 miss budget (MissBurst / MissRefillPerSecond) was process-wide, because
the impersonation seam did not carry the client's address, so one noisy client
could drain it for everybody while the known-id set was unavailable.

- IOpcUaUserAuthenticator gains AuthenticateUserNameAsync(username, password,
  IPEndPoint? remoteEndpoint, ct) (default: ignore it and delegate); the server
  calls it. IApiKeyStore gains the matching FindAsync(..., IPAddress?, ...).
- The SDK (1.5.378) exposes no client address publicly. SecureChannelRemoteEndpoint
  reads it from the TCP listener's private channel map by reflection (through the
  protected TransportListeners, exposed by OtOpcUaSdkServer), keyed by the
  session's secure channel id. Fail-soft: null => process-wide accounting only.
- BoundedApiKeyStore charges a miss to the client's own bucket (keyed by IP
  address) and then to the process-wide one, which stays as the backstop. A
  refusal by either takes from neither; a hit refunds both. New options
  Security:ApiKeys:MissBurstPerEndpoint (10) / MissRefillPerSecondPerEndpoint (2),
  0 disables; telemetry outcome endpoint_miss_budget. At most 4096 addresses are
  tracked; idle buckets are pruned and an address beyond the cap is charged
  process-wide only.

Tests: BoundedApiKeyStorePerEndpointTests (noisy client vs quiet one, process-wide
backstop, refund + refill, off switch, authenticator forwards the address) and
over the wire ApiKeyMissBudgetPerEndpointTests (activation hands the authenticator
the loopback endpoint; real authenticator + bounded store refuse a client's 3rd
and 4th unknown key without a store read). Each new behaviour test fails with its
fix mutated out. Host.Tests 224/0, OpcUaServer.Tests 1019/1 (the one failure is the
wall-clock NodeManagerLockTimeBenchmarkTests under suite load; passes alone).
Docs: security.md.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 14:03:59 -05:00

172 lines
7.5 KiB
C#

using System.Net;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
namespace ZB.MOM.WW.OtOpcUa.Host.Tests.OpcUa;
/// <summary>
/// lmxopcua#751 — the pre-authentication miss budget was process-wide only, so one noisy client could drain
/// it for everybody while the known-id set was unavailable. Misses are now charged to the client's own
/// bucket first (keyed by IP address), with the process-wide bucket kept as the backstop.
/// </summary>
public sealed class BoundedApiKeyStorePerEndpointTests
{
private static readonly DateTimeOffset T0 = new(2026, 11, 9, 12, 0, 0, TimeSpan.Zero);
private static readonly IPAddress Noisy = IPAddress.Parse("10.0.0.66");
private static readonly IPAddress Quiet = IPAddress.Parse("10.0.0.7");
/// <summary>
/// THE property: a noisy client exhausts its OWN budget and is refused without a store read, while a
/// different client's unknown id still reaches the store — the process-wide budget was not drained.
/// </summary>
[Fact]
public async Task A_noisy_client_exhausts_its_own_budget_without_draining_everyone_elses()
{
var inner = new UnlistingStore("k-real");
var sut = Bounded(inner, new FakeClock(T0), missBurst: 100, perEndpointBurst: 3);
for (var i = 0; i < 3; i++)
(await sut.FindAsync($"k-guess-{i}", T0.UtcDateTime, Noisy, default)).ShouldBeNull();
inner.FindCalls.ShouldBe(3);
var refused = await Should.ThrowAsync<InvalidOperationException>(
() => sut.FindAsync("k-guess-x", T0.UtcDateTime, Noisy, default));
refused.Message.ShouldContain("MissBurstPerEndpoint");
inner.FindCalls.ShouldBe(3, "the exhausted client is refused without a store read");
// Positive control: another client still reaches the store.
(await sut.FindAsync("k-guess-q", T0.UtcDateTime, Quiet, default)).ShouldBeNull();
inner.FindCalls.ShouldBe(4);
}
/// <summary>The process-wide bucket still bounds the total across many clients (and unknown-address callers).</summary>
[Fact]
public async Task The_process_wide_budget_stays_the_backstop()
{
var inner = new UnlistingStore("k-real");
var sut = Bounded(inner, new FakeClock(T0), missBurst: 2, perEndpointBurst: 10);
(await sut.FindAsync("k-a", T0.UtcDateTime, Noisy, default)).ShouldBeNull();
(await sut.FindAsync("k-b", T0.UtcDateTime, remoteAddress: null, default)).ShouldBeNull();
var refused = await Should.ThrowAsync<InvalidOperationException>(
() => sut.FindAsync("k-c", T0.UtcDateTime, Quiet, default));
refused.Message.ShouldContain("MissRefillPerSecond");
refused.Message.ShouldNotContain("PerEndpoint");
inner.FindCalls.ShouldBe(2);
}
/// <summary>A hit is not a miss: a client presenting a real key keeps its whole budget; time refills it.</summary>
[Fact]
public async Task A_hit_is_refunded_and_time_refills_a_clients_bucket()
{
var inner = new UnlistingStore("k-real");
var clock = new FakeClock(T0);
var sut = Bounded(inner, clock, missBurst: 100, perEndpointBurst: 2, perEndpointRefill: 1);
for (var i = 0; i < 5; i++)
(await sut.FindAsync("k-real", T0.UtcDateTime, Noisy, default)).ShouldNotBeNull();
(await sut.FindAsync("k-g1", T0.UtcDateTime, Noisy, default)).ShouldBeNull();
(await sut.FindAsync("k-g2", T0.UtcDateTime, Noisy, default)).ShouldBeNull();
await Should.ThrowAsync<InvalidOperationException>(() => sut.FindAsync("k-g3", T0.UtcDateTime, Noisy, default));
clock.Advance(TimeSpan.FromSeconds(1));
(await sut.FindAsync("k-g4", clock.GetUtcNow().UtcDateTime, Noisy, default)).ShouldBeNull();
inner.FindCalls.ShouldBe(8);
}
/// <summary><c>MissBurstPerEndpoint = 0</c> restores process-wide-only accounting.</summary>
[Fact]
public async Task Per_client_accounting_can_be_switched_off()
{
var inner = new UnlistingStore("k-real");
var sut = Bounded(inner, new FakeClock(T0), missBurst: 100, perEndpointBurst: 0);
for (var i = 0; i < 20; i++)
(await sut.FindAsync($"k-guess-{i}", T0.UtcDateTime, Noisy, default)).ShouldBeNull();
inner.FindCalls.ShouldBe(20);
}
/// <summary>
/// The production authenticator forwards the client's address into the store — without it the per-client
/// budget would never see an address and silently degrade to process-wide.
/// </summary>
[Fact]
public async Task The_authenticator_hands_the_remote_address_to_the_store()
{
var store = new UnlistingStore("k-real");
var authenticator = new ApiKeyOpcUaUserAuthenticator(
store,
Options.Create(new ApiKeyOptions { Username = "apikey", Pepper = "unit-test-pepper-xxxx" }),
NullLogger<ApiKeyOpcUaUserAuthenticator>.Instance);
var result = await authenticator.AuthenticateUserNameAsync(
"apikey", "otua_k-guess_secret", new IPEndPoint(Noisy, 50123), default);
result.Success.ShouldBeFalse();
store.LastAddress.ShouldBe(Noisy);
}
private static BoundedApiKeyStore Bounded(
IApiKeyStore inner, TimeProvider clock, int missBurst, int perEndpointBurst, double perEndpointRefill = 0) => new(
inner,
Options.Create(new ApiKeyOptions
{
Username = "apikey", Pepper = "unit-test-pepper-xxxx", StoreTimeoutMs = 0, StoreCacheSeconds = 0,
}),
NullLogger<BoundedApiKeyStore>.Instance,
clock,
new ApiKeyStoreGuardOptions
{
MissBurst = missBurst,
MissRefillPerSecond = 0,
MissBurstPerEndpoint = perEndpointBurst,
MissRefillPerSecondPerEndpoint = perEndpointRefill,
});
/// <summary>A store that cannot list ids, so every lookup runs under the miss budget.</summary>
private sealed class UnlistingStore(params string[] ids) : IApiKeyStore
{
private readonly HashSet<string> _ids = ids.ToHashSet(StringComparer.Ordinal);
private int _findCalls;
public int FindCalls => Volatile.Read(ref _findCalls);
/// <summary>The address the last four-argument lookup was made for.</summary>
public IPAddress? LastAddress { get; private set; }
public Task<ApiKeyLookup?> FindAsync(string apiKeyId, DateTime utcNow, CancellationToken ct)
{
Interlocked.Increment(ref _findCalls);
return Task.FromResult(_ids.Contains(apiKeyId)
? new ApiKeyLookup(new ApiKey { ApiKeyId = apiKeyId, Name = "k", SecretHash = "h", Principal = "p" }, [])
: null);
}
public Task<ApiKeyLookup?> FindAsync(
string apiKeyId, DateTime utcNow, IPAddress? remoteAddress, CancellationToken ct)
{
LastAddress = remoteAddress;
return FindAsync(apiKeyId, utcNow, ct);
}
public Task<IReadOnlyList<NodeAcl>> FindGrantsAsync(string principal, CancellationToken ct) =>
Task.FromResult<IReadOnlyList<NodeAcl>>([]);
}
private sealed class FakeClock(DateTimeOffset utcNow) : TimeProvider
{
private DateTimeOffset _now = utcNow;
public override DateTimeOffset GetUtcNow() => _now;
public void Advance(TimeSpan by) => _now += by;
}
}