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
This commit is contained in:
Joseph Doherty
2026-11-09 13:46:24 -05:00
parent f54922f94c
commit d59e76dd9d
11 changed files with 698 additions and 35 deletions
@@ -25,6 +25,26 @@ public interface IOpcUaUserAuthenticator
/// <returns>An OpcUaUserAuthResult indicating success or failure.</returns>
Task<OpcUaUserAuthResult> AuthenticateUserNameAsync(string username, string password, CancellationToken ct);
/// <summary>
/// <see cref="AuthenticateUserNameAsync(string, string, CancellationToken)"/> with the activating client's
/// remote endpoint (lmxopcua#751), so an implementation can account pre-authentication failures per
/// client rather than process-wide. This is the overload the server calls; the default ignores the
/// endpoint and delegates, so an implementation that does not need it is unchanged.
/// </summary>
/// <remarks>
/// The endpoint is best effort (<see cref="SecureChannelRemoteEndpoint"/>) and null when unknown — an
/// implementation must treat null as "no per-client information", never as a distinct client. Same
/// must-not-throw contract as the three-argument overload.
/// </remarks>
/// <param name="username">The username to authenticate.</param>
/// <param name="password">The cleartext password to authenticate.</param>
/// <param name="remoteEndpoint">The client's IP endpoint, or null when it could not be resolved.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>An OpcUaUserAuthResult indicating success or failure.</returns>
Task<OpcUaUserAuthResult> AuthenticateUserNameAsync(
string username, string password, System.Net.IPEndPoint? remoteEndpoint, CancellationToken ct) =>
AuthenticateUserNameAsync(username, password, ct);
/// <summary>
/// Resolves what an <b>anonymous</b> session is allowed to do. Anonymous is a normal
/// principal — <see cref="OpcUaPrincipals.Anonymous"/> — not a special case, so it can be granted