feat(security): per-client pre-auth API-key miss budget; widen IOpcUaUserAuthenticator with the remote endpoint (lmxopcua#751)
The #733 miss budget (MissBurst / MissRefillPerSecond) was process-wide, because the impersonation seam did not carry the client's address, so one noisy client could drain it for everybody while the known-id set was unavailable. - IOpcUaUserAuthenticator gains AuthenticateUserNameAsync(username, password, IPEndPoint? remoteEndpoint, ct) (default: ignore it and delegate); the server calls it. IApiKeyStore gains the matching FindAsync(..., IPAddress?, ...). - The SDK (1.5.378) exposes no client address publicly. SecureChannelRemoteEndpoint reads it from the TCP listener's private channel map by reflection (through the protected TransportListeners, exposed by OtOpcUaSdkServer), keyed by the session's secure channel id. Fail-soft: null => process-wide accounting only. - BoundedApiKeyStore charges a miss to the client's own bucket (keyed by IP address) and then to the process-wide one, which stays as the backstop. A refusal by either takes from neither; a hit refunds both. New options Security:ApiKeys:MissBurstPerEndpoint (10) / MissRefillPerSecondPerEndpoint (2), 0 disables; telemetry outcome endpoint_miss_budget. At most 4096 addresses are tracked; idle buckets are pruned and an address beyond the cap is charged process-wide only. Tests: BoundedApiKeyStorePerEndpointTests (noisy client vs quiet one, process-wide backstop, refund + refill, off switch, authenticator forwards the address) and over the wire ApiKeyMissBudgetPerEndpointTests (activation hands the authenticator the loopback endpoint; real authenticator + bounded store refuse a client's 3rd and 4th unknown key without a store read). Each new behaviour test fails with its fix mutated out. Host.Tests 224/0, OpcUaServer.Tests 1019/1 (the one failure is the wall-clock NodeManagerLockTimeBenchmarkTests under suite load; passes alone). Docs: security.md. Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
+15
-4
@@ -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`)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user