Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ApiKeyMissBudgetPerEndpointTests.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

185 lines
8.0 KiB
C#

using System.Net;
using System.Text;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Opc.Ua;
using Opc.Ua.Client;
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;
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
/// <summary>
/// lmxopcua#751 over the wire — session activation hands the authenticator the client's remote endpoint,
/// and the per-client API-key miss budget therefore applies to a real client.
/// </summary>
/// <remarks>
/// The SDK exposes no client address on any public surface; <see cref="SecureChannelRemoteEndpoint"/>
/// reads it from the TCP listener by reflection and degrades to null when that path breaks. These tests are
/// what turn an SDK upgrade that breaks it into a failure instead of a silent fall-back to process-wide
/// accounting.
/// </remarks>
public sealed class ApiKeyMissBudgetPerEndpointTests
{
[Fact]
public async Task Session_activation_hands_the_authenticator_the_clients_address()
{
var ct = TestContext.Current.CancellationToken;
var recorder = new RecordingAuthenticator();
await using var rig = await Rig.StartAsync("RemoteEndpoint", recorder, ct);
using var session = await rig.ConnectAsync("otua_k-probe_secret", ct);
recorder.Seen.ShouldNotBeNull("the server must resolve the activating client's endpoint");
IPAddress.IsLoopback(recorder.Seen!.Address).ShouldBeTrue($"connected from loopback, got {recorder.Seen}");
recorder.Seen.Port.ShouldBeGreaterThan(0);
}
/// <summary>
/// The production authenticator over the bounded store: with a per-client burst of 2 and the known-id
/// set unavailable, a client's third and fourth unknown keys are refused without reaching the store.
/// </summary>
[Fact]
public async Task A_client_past_its_miss_budget_is_refused_without_a_store_read()
{
var ct = TestContext.Current.CancellationToken;
var inner = new UnlistingStore();
var options = Options.Create(new ApiKeyOptions
{
Username = "apikey", Pepper = "integration-pepper-xxxx", StoreTimeoutMs = 0, StoreCacheSeconds = 0,
});
var bounded = new BoundedApiKeyStore(
inner, options, NullLogger<BoundedApiKeyStore>.Instance, TimeProvider.System,
new ApiKeyStoreGuardOptions
{
MissBurst = 100, MissRefillPerSecond = 0, MissBurstPerEndpoint = 2, MissRefillPerSecondPerEndpoint = 0,
});
var authenticator = new ApiKeyOpcUaUserAuthenticator(
bounded, options, NullLogger<ApiKeyOpcUaUserAuthenticator>.Instance);
await using var rig = await Rig.StartAsync("MissBudget", authenticator, ct);
for (var i = 0; i < 4; i++)
{
var rejected = await Should.ThrowAsync<ServiceResultException>(
() => rig.ConnectAsync($"otua_k-guess-{i}_secret", ct));
rejected.StatusCode.ShouldBe(StatusCodes.BadIdentityTokenRejected);
}
inner.FindCalls.ShouldBe(2, "only the client's first two misses may reach the store");
}
private sealed class Rig : IAsyncDisposable
{
private readonly string _pkiRoot;
private OpcUaApplicationHost _host = null!;
private int _port;
private Rig(string pkiRoot) => _pkiRoot = pkiRoot;
public static async Task<Rig> StartAsync(string name, IOpcUaUserAuthenticator authenticator, CancellationToken ct)
{
var rig = new Rig(Path.Combine(Path.GetTempPath(), $"otopcua-{name}-{Guid.NewGuid():N}"));
var applicationUri = $"urn:OtOpcUa.{name}.{Guid.NewGuid():N}";
(rig._host, _) = await OpcUaHostStart.StartAsync(
port =>
{
rig._port = port;
return new OpcUaApplicationHost(
new OpcUaApplicationHostOptions
{
ApplicationName = $"OtOpcUa.{name}",
ApplicationUri = applicationUri,
OpcUaPort = port,
PublicHostname = "127.0.0.1",
PkiStoreRoot = rig._pkiRoot,
EnabledSecurityProfiles = [OpcUaSecurityProfile.None],
AutoAcceptUntrustedClientCertificates = true,
},
NullLogger<OpcUaApplicationHost>.Instance,
authenticator);
},
() => new OtOpcUaSdkServer(), ct);
return rig;
}
public async Task<ISession> ConnectAsync(string credential, CancellationToken ct)
{
var appConfig = new ApplicationConfiguration
{
ApplicationName = "OtOpcUa.MissBudgetClient",
ApplicationUri = $"urn:OtOpcUa.MissBudgetClient.{Guid.NewGuid():N}",
ApplicationType = ApplicationType.Client,
SecurityConfiguration = TestClientSecurity.Build(TestClientSecurity.AllocatePkiRoot()),
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60_000 },
};
await appConfig.ValidateAsync(ApplicationType.Client, ct);
appConfig.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;
var endpoint = await CoreClientUtils.SelectEndpointAsync(
appConfig, $"opc.tcp://127.0.0.1:{_port}/OtOpcUa", false, DefaultTelemetry.Create(_ => { }), ct);
return await new DefaultSessionFactory(DefaultTelemetry.Create(_ => { })).CreateAsync(
appConfig,
new ConfiguredEndpoint(null, endpoint, EndpointConfiguration.Create(appConfig)),
updateBeforeConnect: false,
checkDomain: false,
sessionName: "ApiKeyMissBudgetPerEndpointTests",
sessionTimeout: 60_000,
identity: new UserIdentity(new UserNameIdentityToken
{
UserName = "apikey",
DecryptedPassword = Encoding.UTF8.GetBytes(credential),
}),
preferredLocales: null,
ct: ct);
}
public async ValueTask DisposeAsync()
{
await _host.DisposeAsync();
if (Directory.Exists(_pkiRoot))
{
try { Directory.Delete(_pkiRoot, recursive: true); }
catch (IOException) { /* best effort */ }
}
}
}
/// <summary>Admits every UserName token and records the endpoint the server handed it.</summary>
private sealed class RecordingAuthenticator : IOpcUaUserAuthenticator
{
public IPEndPoint? Seen { get; private set; }
public Task<OpcUaUserAuthResult> AuthenticateUserNameAsync(string username, string password, CancellationToken ct) =>
throw new InvalidOperationException("the server must call the endpoint-carrying overload");
public Task<OpcUaUserAuthResult> AuthenticateUserNameAsync(
string username, string password, IPEndPoint? remoteEndpoint, CancellationToken ct)
{
Seen = remoteEndpoint;
return Task.FromResult(OpcUaUserAuthResult.AllowKey("probe", [], "k-probe"));
}
}
/// <summary>Holds no keys and cannot list ids, so every lookup is a miss under the budget.</summary>
private sealed class UnlistingStore : IApiKeyStore
{
private int _findCalls;
public int FindCalls => Volatile.Read(ref _findCalls);
public Task<ApiKeyLookup?> FindAsync(string apiKeyId, DateTime utcNow, CancellationToken ct)
{
Interlocked.Increment(ref _findCalls);
return Task.FromResult<ApiKeyLookup?>(null);
}
public Task<IReadOnlyList<NodeAcl>> FindGrantsAsync(string principal, CancellationToken ct) =>
Task.FromResult<IReadOnlyList<NodeAcl>>([]);
}
}