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>([]); + } +}