From d59e76dd9da6f0659b024f77a5ec4d6a5a0b586c Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 9 Nov 2026 13:46:24 -0500 Subject: [PATCH] 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 --- docs/security.md | 19 +- .../OpcUa/ApiKeyOpcUaUserAuthenticator.cs | 10 +- .../OpcUa/ApiKeyStoreGuardOptions.cs | 19 +- .../OpcUa/BoundedApiKeyStore.cs | 157 ++++++++++++--- .../OpcUa/IApiKeyStore.cs | 14 ++ .../OpcUaApplicationHost.cs | 23 ++- .../OtOpcUaSdkServer.cs | 10 + .../Security/IOpcUaUserAuthenticator.cs | 20 ++ .../Security/SecureChannelRemoteEndpoint.cs | 106 ++++++++++ .../BoundedApiKeyStorePerEndpointTests.cs | 171 ++++++++++++++++ .../ApiKeyMissBudgetPerEndpointTests.cs | 184 ++++++++++++++++++ 11 files changed, 698 insertions(+), 35 deletions(-) create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/SecureChannelRemoteEndpoint.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/OpcUa/BoundedApiKeyStorePerEndpointTests.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ApiKeyMissBudgetPerEndpointTests.cs 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>([]); + } +}