using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; // Explicit: an unqualified Configuration.* inside this namespace binds to Host.Configuration, not to // the ZB.MOM.WW.OtOpcUa.Configuration assembly. using ZB.MOM.WW.OtOpcUa.Configuration.Entities; using ZB.MOM.WW.OtOpcUa.Core.Authorization; using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security; namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa; /// /// Production for the machine-to-machine data plane (#520): /// the client presents the static username apikey with an /// otua_<keyId>_<secret> credential as the password. Replaces the LDAP adapter. /// /// /// /// This runs inline on session activation. The SDK raises ImpersonateUser /// synchronously while holding a SessionManager-wide event lock, so a slow authenticator does not /// stall one request — it serializes every session activation server-wide. That is why /// verification is a single indexed lookup plus one HMAC, with no directory call and no retry — /// the main practical advantage of keys over the LDAP bind this replaces. /// /// /// ⚠️ "No network I/O" is TRUE ONLY ON A DRIVER-ONLY NODE (arch-review 03/S18). The claim /// describes , which is two dictionary probes on an in-memory /// snapshot. On an admin or fused node is /// , and a UserName activation costs two EF round trips /// (the key row, then the principal's NodeAcl rows) — inside this same lock. What bounds /// that is , which wraps it in production; the deadline it applies /// surfaces here as an ordinary throw and is denied by the catch-all below, like any other store /// failure. /// /// /// Fail closed, and say little. Every rejection returns the same opaque message. The /// reason is logged server-side but never returned, so a caller cannot distinguish "no such key" /// from "wrong secret" from "key disabled" and use the server as an oracle. /// /// public sealed class ApiKeyOpcUaUserAuthenticator : IOpcUaUserAuthenticator { /// /// Returned for every failure mode, deliberately uninformative. Public so a test can assert that /// the failure modes really are indistinguishable, rather than restating the literal and passing /// even if the implementation started differentiating them. /// public const string DenyMessage = "Invalid API key."; private readonly IApiKeyStore _store; private readonly IOptions _options; private readonly ILogger _log; private readonly TimeProvider _time; /// Initializes a new instance of the class. /// The API key source. /// The Security:ApiKeys options. /// The logger. /// Time provider, injected so expiry is testable. public ApiKeyOpcUaUserAuthenticator( IApiKeyStore store, IOptions options, ILogger log, TimeProvider? time = null) { _store = store; _options = options; _log = log; _time = time ?? TimeProvider.System; } /// public async Task AuthenticateUserNameAsync( string username, string password, CancellationToken ct) { var opts = _options.Value; // The username carries no information, but check it rather than ignore it: a client sending the // wrong username gets an explicit denial it can diagnose, instead of "any username works". if (!string.Equals(username, opts.Username, StringComparison.Ordinal)) { _log.LogInformation( "ApiKeyAuth: denied — username {Presented} is not the configured {Expected}", username, opts.Username); return OpcUaUserAuthResult.Deny(DenyMessage); } if (!ApiKeySecret.TryParse(password, out var apiKeyId, out var secret)) { _log.LogInformation("ApiKeyAuth: denied — credential is not a well-formed otua_ key"); return OpcUaUserAuthResult.Deny(DenyMessage); } var now = _time.GetUtcNow().UtcDateTime; ApiKeyLookup? lookup; try { lookup = await _store.FindAsync(apiKeyId, now, ct).ConfigureAwait(false); } catch (Exception ex) { // The contract forbids throwing: an exception here escapes into the SDK's session-activation // path and surfaces as BadInternalError. A store outage denies, it does not crash. _log.LogError(ex, "ApiKeyAuth: key store threw for key id {ApiKeyId}", apiKeyId); return OpcUaUserAuthResult.Deny(DenyMessage); } if (lookup is null) { _log.LogInformation("ApiKeyAuth: denied — no enabled, unexpired key with id {ApiKeyId}", apiKeyId); return OpcUaUserAuthResult.Deny(DenyMessage); } bool verified; try { verified = ApiKeySecret.Verify(secret, opts.Pepper, lookup.Key.SecretHash); } catch (ArgumentException ex) { // Thrown only when the pepper is unset. This is a deployment error, not a bad credential — // log it loudly, because otherwise it presents as "every key is rejected" with no clue why. _log.LogError(ex, "ApiKeyAuth: {Section}:Pepper is not configured — every key will be rejected until it is", ApiKeyOptions.SectionName); return OpcUaUserAuthResult.Deny(DenyMessage); } if (!verified) { _log.LogInformation("ApiKeyAuth: denied — secret mismatch for key id {ApiKeyId}", apiKeyId); return OpcUaUserAuthResult.Deny(DenyMessage); } if (string.Equals(lookup.Key.Principal, OpcUaPrincipals.Anonymous, StringComparison.OrdinalIgnoreCase)) { // Defence in depth against a key authored with the reserved principal (authoring blocks it). // Allowing it would let a credentialled client silently inherit whatever anonymous was // granted, and vice versa — two identities that are meant to be distinct sharing one grant. _log.LogError( "ApiKeyAuth: denied — key {ApiKeyId} uses the reserved principal '{Principal}'", apiKeyId, OpcUaPrincipals.Anonymous); return OpcUaUserAuthResult.Deny(DenyMessage); } var roles = ApiKeyInterimRoleMapper.Map(lookup.Grants, lookup.Key.Principal); _log.LogInformation( "ApiKeyAuth: granted key {Name} ({ApiKeyId}) as principal {Principal} with roles [{Roles}]", lookup.Key.Name, apiKeyId, lookup.Key.Principal, string.Join(",", roles)); // AllowKey, not Allow: the key id rides along so an open session can be re-checked against a later // deployment that revoked this key. Without it a revoked key keeps working until the client // happens to disconnect, which on a machine-to-machine data plane can be indefinitely. return OpcUaUserAuthResult.AllowKey(lookup.Key.Principal, roles, apiKeyId); } /// /// /// Resolves the anonymous principal's grants through the same mapper a key's principal /// goes through, so anonymous is authorized by ordinary ACL rows rather than by a special case. /// Whether anonymous may connect at all is the host's decision /// (OpcUa:AllowAnonymous) and is settled before this is called. /// public async Task AuthenticateAnonymousAsync(CancellationToken ct) { IReadOnlyList grants; try { grants = await _store.FindGrantsAsync(OpcUaPrincipals.Anonymous, ct).ConfigureAwait(false); } catch (Exception ex) { // Deny rather than admit-with-no-roles. Admitting would be the pre-#520 behaviour, and doing // it precisely when we could not read what anonymous is allowed to do is the wrong default: // once reads are gated (stage 6) a no-roles admission stops being harmless. _log.LogError(ex, "ApiKeyAuth: key store threw resolving anonymous grants"); return OpcUaUserAuthResult.Deny("Anonymous access is unavailable."); } var roles = ApiKeyInterimRoleMapper.Map(grants, OpcUaPrincipals.Anonymous); _log.LogInformation( "ApiKeyAuth: anonymous admitted with roles [{Roles}]", roles.Count == 0 ? "none" : string.Join(",", roles)); return OpcUaUserAuthResult.Allow(OpcUaPrincipals.Anonymous, roles); } }