diff --git a/docs/security.md b/docs/security.md
index c4d3f052..396b68f0 100644
--- a/docs/security.md
+++ b/docs/security.md
@@ -264,11 +264,22 @@ guards in front of the store, bound from the same `Security:ApiKeys` section
| `KnownIdSet` | `true` | Keep an in-memory set of every `ApiKeyId` the ConfigDb holds, refreshed every `StoreCacheSeconds` (single-flight; a failed refresh keeps the previous set). An id outside it is refused in memory — `otopcua.opcua.apikey_store.bounded{outcome=unknown_id}` — and the store is never asked. A key minted less than `StoreCacheSeconds` ago is refused until the next refresh (the same window `Enabled` revocation already has). `StoreCacheSeconds = 0` disables the set with the cache. |
| `MissBurst` | `100` | Token bucket on lookups that **reach** the store and **miss**, for when the set is unavailable (not loaded yet, or the store cannot list ids). Exhausted ⇒ unknown ids are refused without a store read (`{outcome=miss_budget}`, a Warning-free deny through the authenticator's catch-all). A hit refunds its token, so legitimate clients are never charged. `0` disables the budget. |
| `MissRefillPerSecond` | `20` | Refill rate of the miss bucket. |
+| `MissBurstPerEndpoint` | `10` | Per-client miss bucket, keyed by the client's **IP address** (#751). A miss is charged to the client's own bucket first and then to the process-wide one, so one noisy client exhausts its own budget (`{outcome=endpoint_miss_budget}`) long before it can drain `MissBurst` for everyone. A refusal by either bucket takes from neither; a hit refunds both. `0` disables per-client accounting. |
+| `MissRefillPerSecondPerEndpoint` | `2` | Refill rate of each per-client bucket. |
-The budget is **process-wide, not per remote endpoint**: the OPC UA SDK's impersonation callback (the
-`IOpcUaUserAuthenticator` seam) does not carry the client's address, so per-endpoint accounting needs that
-seam widened in the OpcUaServer project (a Wave 3 follow-up → #751). Neither guard applies to a driver-only node:
-its `ArtifactApiKeyStore` is two in-memory dictionary probes and is never wrapped.
+**Per-client accounting and its limits (#751).** `IOpcUaUserAuthenticator` has an endpoint-carrying
+`AuthenticateUserNameAsync(username, password, remoteEndpoint, ct)` overload (default: ignore the endpoint
+and delegate), which the server calls and `ApiKeyOpcUaUserAuthenticator` forwards to
+`IApiKeyStore.FindAsync(..., remoteAddress, ...)`. The OPC UA SDK (1.5.378) exposes no client address on any
+public surface, so `SecureChannelRemoteEndpoint` reads it from the TCP listener's private channel map by
+reflection, keyed by the session's secure channel id. It is fail-soft: an unresolvable address (an SDK upgrade
+that renamed the members, or a client re-activating over a new channel) is charged to the process-wide bucket
+only, which therefore stays as the backstop. `ApiKeyMissBudgetPerEndpointTests` (OpcUaServer.IntegrationTests)
+pins the resolution over the wire so such an upgrade fails a test. At most `BoundedApiKeyStore.MaxTrackedEndpoints`
+(4096) addresses are tracked; when full, idle (refilled) buckets are pruned and a new address beyond that is
+charged process-wide only — never handed a fresh budget. Clients behind one NAT address share a bucket. Neither
+guard applies to a driver-only node: its `ArtifactApiKeyStore` is two in-memory dictionary probes and is never
+wrapped.
### LDAP bind flow (`OtOpcUaLdapAuthService`)
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/ApiKeyOpcUaUserAuthenticator.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/ApiKeyOpcUaUserAuthenticator.cs
index 949665bf..78747f04 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/ApiKeyOpcUaUserAuthenticator.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/ApiKeyOpcUaUserAuthenticator.cs
@@ -69,8 +69,14 @@ public sealed class ApiKeyOpcUaUserAuthenticator : IOpcUaUserAuthenticator
}
///
+ public Task AuthenticateUserNameAsync(string username, string password, CancellationToken ct) =>
+ AuthenticateUserNameAsync(username, password, remoteEndpoint: null, ct);
+
+ ///
+ // The client's address rides into the store so BoundedApiKeyStore can charge a miss to that client's own
+ // budget before the process-wide one (lmxopcua#751).
public async Task AuthenticateUserNameAsync(
- string username, string password, CancellationToken ct)
+ string username, string password, System.Net.IPEndPoint? remoteEndpoint, CancellationToken ct)
{
var opts = _options.Value;
@@ -95,7 +101,7 @@ public sealed class ApiKeyOpcUaUserAuthenticator : IOpcUaUserAuthenticator
ApiKeyLookup? lookup;
try
{
- lookup = await _store.FindAsync(apiKeyId, now, ct).ConfigureAwait(false);
+ lookup = await _store.FindAsync(apiKeyId, now, remoteEndpoint?.Address, ct).ConfigureAwait(false);
}
catch (Exception ex)
{
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/ApiKeyStoreGuardOptions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/ApiKeyStoreGuardOptions.cs
index 3103b982..b888952b 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/ApiKeyStoreGuardOptions.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/ApiKeyStoreGuardOptions.cs
@@ -18,9 +18,11 @@ namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa;
/// set is unavailable.
///
///
-/// The budget is process-wide, not per remote endpoint: the OPC UA SDK's impersonation callback
-/// (the IOpcUaUserAuthenticator seam) does not carry the client's address. Per-endpoint
-/// accounting needs that seam widened in the OpcUaServer project — a follow-up, not this lane.
+/// Since lmxopcua#751 the miss budget is also kept per client IP address
+/// ( / ): a miss is charged
+/// to the client's own bucket first and then to the process-wide one, which stays as the backstop. One
+/// noisy client therefore exhausts its own budget long before it can drain the process-wide one for
+/// everybody. A client whose address cannot be resolved is charged to the process-wide bucket only.
///
///
public sealed class ApiKeyStoreGuardOptions
@@ -44,6 +46,17 @@ public sealed class ApiKeyStoreGuardOptions
/// How many miss tokens are restored per second, up to .
public double MissRefillPerSecond { get; set; } = 20;
+ ///
+ /// How many store misses ONE client IP address may burst before its further lookups for unknown ids are
+ /// refused without a store read (lmxopcua#751). Deliberately a tenth of : a
+ /// legitimate client presents a known key, which is never charged, so only a misconfigured or hostile
+ /// client spends from it. 0 disables per-client accounting (process-wide only).
+ ///
+ public int MissBurstPerEndpoint { get; set; } = 10;
+
+ /// How many per-client miss tokens are restored per second, up to .
+ public double MissRefillPerSecondPerEndpoint { get; set; } = 2;
+
/// Binds the section, with defaults for anything absent.
/// The app configuration; null yields the defaults.
/// The bound options.
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/BoundedApiKeyStore.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/BoundedApiKeyStore.cs
index 6ebb7626..1b5eda6b 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/BoundedApiKeyStore.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/BoundedApiKeyStore.cs
@@ -84,6 +84,32 @@ public sealed class BoundedApiKeyStore : IApiKeyStore
private double _missTokens;
private DateTimeOffset _missRefilledUtc;
+ ///
+ /// How many client addresses the per-endpoint miss budget tracks at once (lmxopcua#751). When full, idle
+ /// (refilled) buckets are pruned; if every tracked client is still active, a new address is charged to the
+ /// process-wide bucket only — never a fresh, full per-client budget.
+ ///
+ public const int MaxTrackedEndpoints = 4096;
+
+ // lmxopcua#751 — the per-client miss buckets, keyed by IP address (not port: one client can open many
+ // connections). Guarded by _bucketGate, like the process-wide bucket they sit in front of.
+ private readonly Dictionary _endpointBuckets = new();
+
+ /// One client's miss bucket.
+ private sealed class EndpointBucket
+ {
+ public double Tokens;
+ public DateTimeOffset RefilledUtc;
+ }
+
+ /// Which budget refused a lookup.
+ private enum MissBudget
+ {
+ Granted,
+ EndpointExhausted,
+ ProcessExhausted,
+ }
+
/// Initializes a new instance of the class.
/// The store being bounded.
/// The Security:ApiKeys options carrying the deadline and TTL.
@@ -136,7 +162,12 @@ public sealed class BoundedApiKeyStore : IApiKeyStore
// that has expired by the clock in the meantime. The TTL therefore bounds how stale an
// Enabled revocation may be, and nothing else.
///
- public async Task FindAsync(string apiKeyId, DateTime utcNow, CancellationToken ct)
+ public Task FindAsync(string apiKeyId, DateTime utcNow, CancellationToken ct) =>
+ FindAsync(apiKeyId, utcNow, remoteAddress: null, ct);
+
+ ///
+ public async Task FindAsync(
+ string apiKeyId, DateTime utcNow, System.Net.IPAddress? remoteAddress, CancellationToken ct)
{
var ttl = Ttl;
if (ttl > TimeSpan.Zero
@@ -159,18 +190,31 @@ public sealed class BoundedApiKeyStore : IApiKeyStore
}
// R03:3.3, guard 2 — a lookup that will reach the store needs a miss token unless the id is known
- // to exist (a known id is a real key being verified, never a guess).
- if (known is null && !TryTakeMissToken())
+ // to exist (a known id is a real key being verified, never a guess). lmxopcua#751: the client's own
+ // bucket is charged first, so one noisy client cannot drain the process-wide budget for everyone.
+ if (known is null)
{
- OtOpcUaTelemetry.ApiKeyStoreBounded.Add(1, new KeyValuePair("outcome", "miss_budget"));
- throw new InvalidOperationException(
- "ApiKeyStore: the unknown-key miss budget is exhausted (Security:ApiKeys:MissBurst / "
- + "MissRefillPerSecond); refusing this lookup without a store read.");
+ switch (TryTakeMissToken(remoteAddress))
+ {
+ case MissBudget.EndpointExhausted:
+ OtOpcUaTelemetry.ApiKeyStoreBounded.Add(
+ 1, new KeyValuePair("outcome", "endpoint_miss_budget"));
+ throw new InvalidOperationException(
+ "ApiKeyStore: this client's unknown-key miss budget is exhausted (Security:ApiKeys:"
+ + "MissBurstPerEndpoint / MissRefillPerSecondPerEndpoint); refusing this lookup without a "
+ + "store read.");
+ case MissBudget.ProcessExhausted:
+ OtOpcUaTelemetry.ApiKeyStoreBounded.Add(
+ 1, new KeyValuePair("outcome", "miss_budget"));
+ throw new InvalidOperationException(
+ "ApiKeyStore: the unknown-key miss budget is exhausted (Security:ApiKeys:MissBurst / "
+ + "MissRefillPerSecond); refusing this lookup without a store read.");
+ }
}
var fresh = await BoundAsync(token => _inner.FindAsync(apiKeyId, utcNow, token), ct)
.ConfigureAwait(false);
- if (fresh is not null && known is null) RefundMissToken();
+ if (fresh is not null && known is null) RefundMissToken(remoteAddress);
if (ttl > TimeSpan.Zero) Store(_keys, apiKeyId, fresh);
return fresh;
}
@@ -219,32 +263,99 @@ public sealed class BoundedApiKeyStore : IApiKeyStore
}
}
- /// Takes one miss token, refilling by elapsed time first; always succeeds when the budget is disabled.
- /// when the budget is exhausted.
- private bool TryTakeMissToken()
+ ///
+ /// Takes one miss token from 's bucket (when known and per-client accounting
+ /// is on) AND from the process-wide bucket, refilling both by elapsed time first. All or nothing: a refusal
+ /// by either takes from neither. A disabled budget always grants.
+ ///
+ /// The client's IP address; null charges the process-wide bucket only.
+ /// Which budget, if any, refused the lookup.
+ private MissBudget TryTakeMissToken(System.Net.IPAddress? remoteAddress)
{
var burst = _guard.MissBurst;
- if (burst <= 0) return true;
+ var endpointBurst = _guard.MissBurstPerEndpoint;
+ if (burst <= 0 && (endpointBurst <= 0 || remoteAddress is null)) return MissBudget.Granted;
lock (_bucketGate)
{
var now = _time.GetUtcNow();
- var elapsed = (now - _missRefilledUtc).TotalSeconds;
- if (elapsed > 0)
+ if (burst > 0)
{
- _missTokens = Math.Min(burst, _missTokens + elapsed * Math.Max(0, _guard.MissRefillPerSecond));
- _missRefilledUtc = now;
+ var elapsed = (now - _missRefilledUtc).TotalSeconds;
+ if (elapsed > 0)
+ {
+ _missTokens = Math.Min(burst, _missTokens + elapsed * Math.Max(0, _guard.MissRefillPerSecond));
+ _missRefilledUtc = now;
+ }
}
- if (_missTokens < 1) return false;
- _missTokens -= 1;
- return true;
+
+ var endpoint = endpointBurst > 0 && remoteAddress is not null ? EndpointBucketFor(remoteAddress, now) : null;
+ if (endpoint is not null && endpoint.Tokens < 1) return MissBudget.EndpointExhausted;
+ if (burst > 0 && _missTokens < 1) return MissBudget.ProcessExhausted;
+
+ if (endpoint is not null) endpoint.Tokens -= 1;
+ if (burst > 0) _missTokens -= 1;
+ return MissBudget.Granted;
}
}
- /// A lookup that reached the store and HIT was not a miss: its token goes back.
- private void RefundMissToken()
+ ///
+ /// The refilled bucket for , created full on first sight; null when the table is
+ /// at even after pruning idle buckets. Caller holds _bucketGate.
+ ///
+ /// The client's IP address.
+ /// The current time.
+ /// The bucket, or null when no slot is free.
+ private EndpointBucket? EndpointBucketFor(System.Net.IPAddress address, DateTimeOffset now)
{
- if (_guard.MissBurst <= 0) return;
- lock (_bucketGate) _missTokens = Math.Min(_guard.MissBurst, _missTokens + 1);
+ var burst = _guard.MissBurstPerEndpoint;
+ if (_endpointBuckets.TryGetValue(address, out var bucket))
+ {
+ Refill(bucket, now);
+ return bucket;
+ }
+
+ if (_endpointBuckets.Count >= MaxTrackedEndpoints)
+ {
+ // An idle bucket (refilled to full) carries no history worth keeping — dropping it is the same as
+ // re-creating it full later. A bucket still below full is a client being throttled; never reset it.
+ foreach (var (key, candidate) in _endpointBuckets.ToList())
+ {
+ Refill(candidate, now);
+ if (candidate.Tokens >= burst) _endpointBuckets.Remove(key);
+ }
+
+ if (_endpointBuckets.Count >= MaxTrackedEndpoints) return null;
+ }
+
+ bucket = new EndpointBucket { Tokens = burst, RefilledUtc = now };
+ _endpointBuckets[address] = bucket;
+ return bucket;
+ }
+
+ /// Refills a per-client bucket by elapsed time. Caller holds _bucketGate.
+ private void Refill(EndpointBucket bucket, DateTimeOffset now)
+ {
+ var elapsed = (now - bucket.RefilledUtc).TotalSeconds;
+ if (elapsed <= 0) return;
+ bucket.Tokens = Math.Min(
+ _guard.MissBurstPerEndpoint,
+ bucket.Tokens + elapsed * Math.Max(0, _guard.MissRefillPerSecondPerEndpoint));
+ bucket.RefilledUtc = now;
+ }
+
+ /// A lookup that reached the store and HIT was not a miss: its tokens go back.
+ /// The client the token was charged to, or null.
+ private void RefundMissToken(System.Net.IPAddress? remoteAddress)
+ {
+ lock (_bucketGate)
+ {
+ if (_guard.MissBurst > 0) _missTokens = Math.Min(_guard.MissBurst, _missTokens + 1);
+ if (_guard.MissBurstPerEndpoint > 0 && remoteAddress is not null
+ && _endpointBuckets.TryGetValue(remoteAddress, out var bucket))
+ {
+ bucket.Tokens = Math.Min(_guard.MissBurstPerEndpoint, bucket.Tokens + 1);
+ }
+ }
}
// Keyed by principal — the anonymous activation path, which runs on every unauthenticated connect.
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/IApiKeyStore.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/IApiKeyStore.cs
index 93057153..520b8cdb 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/IApiKeyStore.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/IApiKeyStore.cs
@@ -27,6 +27,20 @@ public interface IApiKeyStore
/// The key and its principal's grants, or null when no usable key matches.
Task FindAsync(string apiKeyId, DateTime utcNow, CancellationToken ct);
+ ///
+ /// on behalf of the client at
+ /// (lmxopcua#751), so a decorator can account pre-authentication misses
+ /// per client. The default ignores the address; only uses it.
+ ///
+ /// The public key id parsed from the presented credential.
+ /// Current UTC time, for the expiry check.
+ /// The activating client's IP address; null when unknown.
+ /// The cancellation token.
+ /// The key and its principal's grants, or null when no usable key matches.
+ Task FindAsync(
+ string apiKeyId, DateTime utcNow, System.Net.IPAddress? remoteAddress, CancellationToken ct) =>
+ FindAsync(apiKeyId, utcNow, ct);
+
///
/// Returns the ACL grants naming a principal, with no key involved. Used for the
/// anonymous principal, which has grants but obviously no credential.
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs
index 4b12e0d1..6073a4a7 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs
@@ -303,7 +303,21 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
}
private void OnImpersonateUser(ISession session, ImpersonateEventArgs args) =>
- HandleImpersonation(_userAuthenticator, args, _logger, _options.AllowAnonymous);
+ HandleImpersonation(_userAuthenticator, args, _logger, _options.AllowAnonymous,
+ // Only a UserName activation consults the endpoint (the per-client API-key miss budget, #751), so
+ // the reflection walk is skipped for anonymous / X509.
+ args.NewIdentity is UserNameIdentityToken ? ResolveRemoteEndpoint(session) : null);
+
+ /// The remote endpoint of the client activating , or null when unknown.
+ /// The session being activated.
+ /// The client's IP endpoint, or null.
+ ///
+ /// Read from the session's secure channel — the SDK raises impersonation without the request's channel
+ /// context (SecureChannelContext.Current is obsolete in 1.5.378). A client that re-activates over
+ /// a NEW channel after its old one closed resolves to null and is accounted process-wide only.
+ ///
+ private System.Net.IPEndPoint? ResolveRemoteEndpoint(ISession? session) =>
+ _server is OtOpcUaSdkServer sdk ? sdk.TryResolveRemoteEndpoint(session?.SecureChannelId) : null;
///
/// Pure(-ish) impersonation handler: extracted so unit tests can drive it without booting
@@ -329,11 +343,14 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
/// The impersonation event arguments to process.
/// The logger for diagnostic output.
/// Whether anonymous sessions may connect.
+ /// The activating client's IP endpoint when known (lmxopcua#751), handed to
+ /// the authenticator for per-client accounting; null when it could not be resolved.
internal static void HandleImpersonation(
IOpcUaUserAuthenticator authenticator,
ImpersonateEventArgs args,
ILogger logger,
- bool allowAnonymous = true)
+ bool allowAnonymous = true,
+ System.Net.IPEndPoint? remoteEndpoint = null)
{
if (args.NewIdentity is AnonymousIdentityToken anonymousToken)
{
@@ -367,7 +384,7 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
try
{
result = authenticator
- .AuthenticateUserNameAsync(token.UserName ?? string.Empty, password, CancellationToken.None)
+ .AuthenticateUserNameAsync(token.UserName ?? string.Empty, password, remoteEndpoint, CancellationToken.None)
.GetAwaiter().GetResult();
}
catch (Exception ex)
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaSdkServer.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaSdkServer.cs
index 0a05f131..77248c19 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaSdkServer.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaSdkServer.cs
@@ -24,6 +24,16 @@ public sealed class OtOpcUaSdkServer : StandardServer
/// null leaves it on a no-op logger.
public OtOpcUaSdkServer(ILogger? nodeManagerLogger = null) => _nodeManagerLogger = nodeManagerLogger;
+ ///
+ /// The client's remote IP endpoint for the secure channel , or null when it
+ /// cannot be resolved (lmxopcua#751). Best effort — see ; the SDK
+ /// exposes the listeners only to a subclass, which is why this lives here.
+ ///
+ /// The global secure channel id a request arrived on.
+ /// The remote endpoint, or null.
+ public System.Net.IPEndPoint? TryResolveRemoteEndpoint(string? secureChannelId) =>
+ SecureChannelRemoteEndpoint.Resolve(TransportListeners, secureChannelId);
+
/// The custom node manager once StartAsync has called
/// . Null until the SDK has bootstrapped.
public OtOpcUaNodeManager? NodeManager => _otOpcUaNodeManager;
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/IOpcUaUserAuthenticator.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/IOpcUaUserAuthenticator.cs
index 1866c349..7f053d6b 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/IOpcUaUserAuthenticator.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/IOpcUaUserAuthenticator.cs
@@ -25,6 +25,26 @@ public interface IOpcUaUserAuthenticator
/// An OpcUaUserAuthResult indicating success or failure.
Task AuthenticateUserNameAsync(string username, string password, CancellationToken ct);
+ ///
+ /// 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.
+ ///
+ ///
+ /// The endpoint is best effort () 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.
+ ///
+ /// The username to authenticate.
+ /// The cleartext password to authenticate.
+ /// The client's IP endpoint, or null when it could not be resolved.
+ /// The cancellation token.
+ /// An OpcUaUserAuthResult indicating success or failure.
+ Task AuthenticateUserNameAsync(
+ string username, string password, System.Net.IPEndPoint? remoteEndpoint, CancellationToken ct) =>
+ AuthenticateUserNameAsync(username, password, ct);
+
///
/// Resolves what an anonymous session is allowed to do. Anonymous is a normal
/// principal — — not a special case, so it can be granted
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/SecureChannelRemoteEndpoint.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/SecureChannelRemoteEndpoint.cs
new file mode 100644
index 00000000..384af2dc
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/SecureChannelRemoteEndpoint.cs
@@ -0,0 +1,106 @@
+using System.Collections;
+using System.Net;
+using System.Reflection;
+using Opc.Ua;
+
+namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
+
+///
+/// Resolves the client's remote IP endpoint for a secure channel, so session activation can account
+/// pre-authentication API-key misses per client (lmxopcua#751).
+///
+///
+///
+/// ⚠️ The OPC UA SDK (1.5.378) does not expose the client address on any public surface — not
+/// on ISession, SecureChannelContext, ImpersonateEventArgs nor the transport
+/// listener. The TCP listener holds it privately: TcpTransportListener.m_channels (channel id
+/// → channel) → the channel's non-public Socket → the public
+/// IMessageSocket.RemoteEndpoint. This class walks that path by reflection.
+///
+///
+/// It is strictly best effort and fail-soft: any missing member (an SDK upgrade renamed it), an
+/// unknown channel or a closed socket yields , and a caller treats null as
+/// "address unknown" — the per-endpoint budget is skipped and the process-wide one still applies. It
+/// never throws. An over-the-wire test (RemoteEndpointResolutionTests) pins that it resolves on
+/// the SDK version in use, so an upgrade that breaks the path fails a test instead of silently
+/// degrading to process-wide accounting.
+///
+///
+public static class SecureChannelRemoteEndpoint
+{
+ private const BindingFlags Instance = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;
+
+ ///
+ /// Finds the remote endpoint of the secure channel among
+ /// .
+ ///
+ /// The server's transport listeners.
+ /// The global secure channel id ({ListenerId}-{ChannelId}).
+ /// The client's IP endpoint (IPv4-mapped addresses normalised to IPv4), or null when unknown.
+ public static IPEndPoint? Resolve(IEnumerable? listeners, string? secureChannelId)
+ {
+ if (listeners is null || string.IsNullOrEmpty(secureChannelId)) return null;
+ try
+ {
+ foreach (var listener in listeners)
+ {
+ if (listener is null) continue;
+ var listenerId = listener.ListenerId;
+ if (string.IsNullOrEmpty(listenerId)
+ || !secureChannelId.StartsWith(listenerId + "-", StringComparison.Ordinal)
+ || !uint.TryParse(secureChannelId.AsSpan(listenerId.Length + 1), out var channelId))
+ {
+ continue;
+ }
+
+ if (FindChannel(listener, channelId) is { } channel && RemoteEndpointOf(channel) is { } endpoint)
+ return endpoint;
+ }
+ }
+ catch
+ {
+ // Fail-soft by contract: the caller falls back to process-wide accounting.
+ }
+
+ return null;
+ }
+
+ /// The listener's channel with id , read from its private channel map.
+ private static object? FindChannel(ITransportListener listener, uint channelId)
+ {
+ var field = FindField(listener.GetType(), "m_channels");
+ if (field?.GetValue(listener) is not IDictionary channels) return null;
+ return channels.Contains(channelId) ? channels[channelId] : null;
+ }
+
+ /// The channel's socket's remote endpoint, as an .
+ private static IPEndPoint? RemoteEndpointOf(object channel)
+ {
+ var socketProperty = FindProperty(channel.GetType(), "Socket");
+ var socket = socketProperty?.GetValue(channel);
+ if (socket is null) return null;
+ var remote = FindProperty(socket.GetType(), "RemoteEndpoint")?.GetValue(socket) as IPEndPoint;
+ if (remote is null) return null;
+ return remote.Address.IsIPv4MappedToIPv6 ? new IPEndPoint(remote.Address.MapToIPv4(), remote.Port) : remote;
+ }
+
+ private static FieldInfo? FindField(Type? type, string name)
+ {
+ for (; type is not null; type = type.BaseType)
+ {
+ if (type.GetField(name, Instance | BindingFlags.DeclaredOnly) is { } field) return field;
+ }
+
+ return null;
+ }
+
+ private static PropertyInfo? FindProperty(Type? type, string name)
+ {
+ for (; type is not null; type = type.BaseType)
+ {
+ if (type.GetProperty(name, Instance | BindingFlags.DeclaredOnly) is { } property) return property;
+ }
+
+ return null;
+ }
+}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/OpcUa/BoundedApiKeyStorePerEndpointTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/OpcUa/BoundedApiKeyStorePerEndpointTests.cs
new file mode 100644
index 00000000..2a43369d
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/OpcUa/BoundedApiKeyStorePerEndpointTests.cs
@@ -0,0 +1,171 @@
+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;
+
+///
+/// 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.
+///
+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");
+
+ ///
+ /// 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.
+ ///
+ [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(
+ () => 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);
+ }
+
+ /// The process-wide bucket still bounds the total across many clients (and unknown-address callers).
+ [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(
+ () => sut.FindAsync("k-c", T0.UtcDateTime, Quiet, default));
+ refused.Message.ShouldContain("MissRefillPerSecond");
+ refused.Message.ShouldNotContain("PerEndpoint");
+ inner.FindCalls.ShouldBe(2);
+ }
+
+ /// A hit is not a miss: a client presenting a real key keeps its whole budget; time refills it.
+ [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(() => 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);
+ }
+
+ /// MissBurstPerEndpoint = 0 restores process-wide-only accounting.
+ [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);
+ }
+
+ ///
+ /// 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.
+ ///
+ [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.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.Instance,
+ clock,
+ new ApiKeyStoreGuardOptions
+ {
+ MissBurst = missBurst,
+ MissRefillPerSecond = 0,
+ MissBurstPerEndpoint = perEndpointBurst,
+ MissRefillPerSecondPerEndpoint = perEndpointRefill,
+ });
+
+ /// A store that cannot list ids, so every lookup runs under the miss budget.
+ private sealed class UnlistingStore(params string[] ids) : IApiKeyStore
+ {
+ private readonly HashSet _ids = ids.ToHashSet(StringComparer.Ordinal);
+ private int _findCalls;
+
+ public int FindCalls => Volatile.Read(ref _findCalls);
+
+ /// The address the last four-argument lookup was made for.
+ public IPAddress? LastAddress { get; private set; }
+
+ public Task 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 FindAsync(
+ string apiKeyId, DateTime utcNow, IPAddress? remoteAddress, CancellationToken ct)
+ {
+ LastAddress = remoteAddress;
+ return FindAsync(apiKeyId, utcNow, ct);
+ }
+
+ public Task> FindGrantsAsync(string principal, CancellationToken ct) =>
+ Task.FromResult>([]);
+ }
+
+ private sealed class FakeClock(DateTimeOffset utcNow) : TimeProvider
+ {
+ private DateTimeOffset _now = utcNow;
+
+ public override DateTimeOffset GetUtcNow() => _now;
+
+ public void Advance(TimeSpan by) => _now += by;
+ }
+}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ApiKeyMissBudgetPerEndpointTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ApiKeyMissBudgetPerEndpointTests.cs
new file mode 100644
index 00000000..8ca3a2dc
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ApiKeyMissBudgetPerEndpointTests.cs
@@ -0,0 +1,184 @@
+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;
+
+///
+/// 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.
+///
+///
+/// The SDK exposes no client address on any public surface;
+/// 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.
+///
+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);
+ }
+
+ ///
+ /// 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.
+ ///
+ [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.Instance, TimeProvider.System,
+ new ApiKeyStoreGuardOptions
+ {
+ MissBurst = 100, MissRefillPerSecond = 0, MissBurstPerEndpoint = 2, MissRefillPerSecondPerEndpoint = 0,
+ });
+ var authenticator = new ApiKeyOpcUaUserAuthenticator(
+ bounded, options, NullLogger.Instance);
+ await using var rig = await Rig.StartAsync("MissBudget", authenticator, ct);
+
+ for (var i = 0; i < 4; i++)
+ {
+ var rejected = await Should.ThrowAsync(
+ () => 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 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.Instance,
+ authenticator);
+ },
+ () => new OtOpcUaSdkServer(), ct);
+ return rig;
+ }
+
+ public async Task 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 */ }
+ }
+ }
+ }
+
+ /// Admits every UserName token and records the endpoint the server handed it.
+ private sealed class RecordingAuthenticator : IOpcUaUserAuthenticator
+ {
+ public IPEndPoint? Seen { get; private set; }
+
+ public Task AuthenticateUserNameAsync(string username, string password, CancellationToken ct) =>
+ throw new InvalidOperationException("the server must call the endpoint-carrying overload");
+
+ public Task AuthenticateUserNameAsync(
+ string username, string password, IPEndPoint? remoteEndpoint, CancellationToken ct)
+ {
+ Seen = remoteEndpoint;
+ return Task.FromResult(OpcUaUserAuthResult.AllowKey("probe", [], "k-probe"));
+ }
+ }
+
+ /// Holds no keys and cannot list ids, so every lookup is a miss under the budget.
+ private sealed class UnlistingStore : IApiKeyStore
+ {
+ private int _findCalls;
+
+ public int FindCalls => Volatile.Read(ref _findCalls);
+
+ public Task FindAsync(string apiKeyId, DateTime utcNow, CancellationToken ct)
+ {
+ Interlocked.Increment(ref _findCalls);
+ return Task.FromResult(null);
+ }
+
+ public Task> FindGrantsAsync(string principal, CancellationToken ct) =>
+ Task.FromResult>([]);
+ }
+}