using Grpc.Core; using Grpc.Core.Interceptors; using Microsoft.Extensions.Logging; using ZB.MOM.WW.OtOpcUa.ControlPlane.Communication; namespace ZB.MOM.WW.OtOpcUa.Host.Configuration; /// /// Gates the central-side command endpoint (mesh gRPC command plane). The /// CentralCommandService.ReportApply RPC is how a node tells central its deployment /// applied; the coordinator counts those acks, so anything able to reach central's command port /// could otherwise forge a fleet-wide "everything applied". This interceptor is the ONLY inbound /// auth on that endpoint. /// /// /// /// Scoped by method path. Only calls under /// /mesh_transport.v1.CentralCommandService/ are gated; every other method on the /// shared gRPC pipeline (config-serve, and on a fused node sync + telemetry + the node /// command service) passes through untouched, so this shares one AddGrpc registration /// with its siblings. /// /// /// Per-cluster key, not one server key. Unlike the four sibling interceptors there is /// no single expected token: central talks to N site clusters, each with its own PSK. The /// caller names its cluster in x-otopcua-cluster and the bearer is verified against /// that cluster's key via . The header is therefore a /// claim the caller must then prove, not a credential — choosing a different cluster only /// changes which key it is graded against. A single fleet-wide key would let one compromised /// site node forge acks on behalf of every other site. /// /// /// Fail-closed. A missing or blank cluster header, and a cluster whose key cannot be /// established (unseeded, blank, or a secret store that is down), are both denied. There is /// deliberately no "try every known key" fallback: it would restore exactly the fleet-wide /// key this design exists to avoid. /// /// /// This interceptor never handles a key. Verification is /// , which owns the constant-time comparison, so a /// wrong key cannot be recovered byte-by-byte from response timing and no future edit here /// can replace that comparison with == and still compile (Phase 1A security register /// item 2). Length differences are unavoidably observable and are not sensitive. /// /// /// All denials return the same status text, unlike the sibling interceptors, which /// distinguish "no key configured" from "authentication failed". Here that distinction would /// be a content oracle: the caller supplies the cluster id, so a reply that said "no /// key for that cluster" would let an unauthenticated caller read central's cluster set /// straight off the wire. The operator-facing detail goes to the log instead — and for the /// same reason the log no longer separates "no key for that cluster" from "wrong key": the /// provider answers both as one false, and collapsing them costs an operator a hint /// (restated in the one denial message) rather than costing a diagnosis. /// /// /// The timing oracle that survived Phase 0 is closed by the known-cluster gate. An /// unknown cluster used to miss the provider's cache and pay a secret-store read on every /// probe, while a known one was answered from memory after the first — a stable, remotely /// measurable distinguisher, and a pre-auth store/audit flood besides. /// CentralCommunicationActor now publishes central's cluster set to the provider on /// every contact refresh, and a cluster outside it is refused in O(1) with no store touch, /// so the two cases cost the same. What remains is a known cluster's first-ever lookup, /// which is one read per cluster per process and not attacker-triggerable. /// /// /// Exactly one public constructor. Grpc.AspNetCore silently stops invoking an /// interceptor that has more than one public ctor — see /// CentralCommandAuthInterceptorTests.HasExactlyOnePublicConstructor. /// /// public sealed class CentralCommandAuthInterceptor : Interceptor { private const string ServicePrefix = "/mesh_transport.v1.CentralCommandService/"; private const string ClusterHeader = "x-otopcua-cluster"; private readonly IMeshPskProvider _pskProvider; private readonly ILogger _logger; /// Creates the interceptor. /// Supplies the expected bearer token for a given cluster. /// Logger for denial diagnostics. public CentralCommandAuthInterceptor( IMeshPskProvider pskProvider, ILogger logger) { ArgumentNullException.ThrowIfNull(pskProvider); ArgumentNullException.ThrowIfNull(logger); _pskProvider = pskProvider; _logger = logger; } /// public override Task UnaryServerHandler( TRequest request, ServerCallContext context, UnaryServerMethod continuation) { Authorize(context); return continuation(request, context); } /// public override Task DuplexStreamingServerHandler( IAsyncStreamReader requestStream, IServerStreamWriter responseStream, ServerCallContext context, DuplexStreamingServerMethod continuation) { Authorize(context); return continuation(requestStream, responseStream, context); } /// public override Task ClientStreamingServerHandler( IAsyncStreamReader requestStream, ServerCallContext context, ClientStreamingServerMethod continuation) { Authorize(context); return continuation(requestStream, context); } /// public override Task ServerStreamingServerHandler( TRequest request, IServerStreamWriter responseStream, ServerCallContext context, ServerStreamingServerMethod continuation) { Authorize(context); return continuation(request, responseStream, context); } /// /// Throws with if this is /// a central-command call that does not name a cluster, names one with no resolvable key, or /// does not carry that cluster's bearer token. Non-command calls return immediately. /// private void Authorize(ServerCallContext context) { if (!context.Method.StartsWith(ServicePrefix, StringComparison.Ordinal)) return; var clusterId = GrpcRequestHeaders.First(context.RequestHeaders, ClusterHeader); if (string.IsNullOrWhiteSpace(clusterId)) { _logger.LogWarning( "Rejected a central command call to {Method}: no {Header} header, so there is no " + "cluster whose pre-shared key the caller could be graded against.", context.Method, ClusterHeader); throw new RpcException(new Status( StatusCode.PermissionDenied, "Central command authentication failed.")); } // Checked here rather than deferred to Matches so the log can still separate "the caller // presented nothing" — an operator misconfiguration, and the one case the provider cannot // see — from "the caller presented something that did not verify". var presented = GrpcRequestHeaders.BearerToken(context.RequestHeaders); if (presented is null) { _logger.LogWarning( "Rejected a central command call to {Method} claiming cluster {ClusterId}: no bearer " + "token presented.", context.Method, clusterId); throw new RpcException(new Status( StatusCode.PermissionDenied, "Central command authentication failed.")); } if (!_pskProvider.Matches(clusterId, presented)) { _logger.LogWarning( "Rejected a central command call to {Method} claiming cluster {ClusterId}: the " + "presented bearer token did not match that cluster's pre-shared key, no key could " + "be established for it, or it is not a cluster this node knows. If the cluster is " + "legitimate, seed the secret OT-MESH-PSK- on this node, or set " + "MeshTransport:ClusterPsks: on a rig, and check it has an enabled, " + "non-maintenance ClusterNode row.", context.Method, clusterId); throw new RpcException(new Status( StatusCode.PermissionDenied, "Central command authentication failed.")); } } }