using System.Security.Cryptography; using System.Text; using Grpc.Core; using Grpc.Core.Interceptors; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using ZB.MOM.WW.OtOpcUa.Cluster; namespace ZB.MOM.WW.OtOpcUa.Host.Configuration; /// /// Gates the node-side command endpoint (ClusterClient→gRPC migration). The /// NodeCommandService.Execute RPC is how central drives this node — restart a driver, /// acknowledge an alarm, dispatch a deployment — so anything able to reach a node's command port /// could otherwise operate the node. This interceptor is the ONLY inbound auth on that endpoint. /// /// /// /// Scoped by method path. Only calls under /// /mesh_transport.v1.NodeCommandService/ are gated; every other method on the shared /// gRPC pipeline (LocalDb sync, config-serve, telemetry) passes through untouched, so this can /// share one AddGrpc registration with , /// and . /// /// /// Fail-closed. With no MeshTransport:GrpcPsk configured, NO command is /// accepted, authenticated or not. Treating "no key" as "no auth required" would expose the /// command plane on exactly the default shape every node ships with — and unlike the three /// sibling endpoints, this one is a write surface, not a read one. Central must present the /// same per-cluster key, so a key typo stops commands outright rather than degrading to /// unauthenticated. /// /// /// No x-otopcua-cluster requirement here, deliberately. That header is a /// central-side routing hint — the caller's own claim about which cluster it is addressing. /// Requiring a node to check it would gate on a value the caller chooses, which proves /// nothing the key has not already proven; the key IS the cluster boundary, so a caller /// holding another cluster's PSK is rejected by the comparison below. /// /// /// Comparison is over UTF-8 bytes, so a /// wrong key cannot be recovered byte-by-byte from response timing. Length differences are /// unavoidably observable and are not sensitive. /// /// /// Exactly one public constructor. Grpc.AspNetCore silently stops invoking an /// interceptor that has more than one public ctor — see /// MeshCommandAuthInterceptorTests.HasExactlyOnePublicConstructor. /// /// public sealed class MeshCommandAuthInterceptor : Interceptor { private const string ServicePrefix = "/mesh_transport.v1.NodeCommandService/"; private const string AuthorizationHeader = "authorization"; private const string BearerPrefix = "Bearer "; private readonly IOptions _options; private readonly ILogger _logger; /// Creates the interceptor. /// Mesh transport options; GrpcPsk is the expected bearer token. /// Logger for denial diagnostics. public MeshCommandAuthInterceptor( IOptions options, ILogger logger) { ArgumentNullException.ThrowIfNull(options); ArgumentNullException.ThrowIfNull(logger); _options = options; _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 node-command call that does not carry the configured bearer token. Non-command calls /// return immediately. /// private void Authorize(ServerCallContext context) { if (!context.Method.StartsWith(ServicePrefix, StringComparison.Ordinal)) return; var expected = _options.Value.GrpcPsk; if (string.IsNullOrWhiteSpace(expected)) { _logger.LogWarning( "Rejected a mesh command call to {Method}: no MeshTransport:GrpcPsk is configured, so " + "the node command endpoint is closed. Set this node's per-cluster pre-shared key " + "(MeshTransport__GrpcPsk), ideally as a ${{secret:OT-MESH-PSK-}} reference, " + "and give central the same key for this cluster.", context.Method); throw new RpcException(new Status( StatusCode.PermissionDenied, "Node command service is not accepting calls: no pre-shared key is configured on this node.")); } var presented = ExtractBearerToken(context.RequestHeaders); if (presented is null || !FixedTimeEquals(presented, expected)) { _logger.LogWarning( "Rejected a mesh command call to {Method}: {Reason}.", context.Method, presented is null ? "no bearer token presented" : "bearer token did not match"); throw new RpcException(new Status( StatusCode.PermissionDenied, "Node command authentication failed.")); } } private static string? ExtractBearerToken(Metadata headers) { // gRPC lowercases header keys on the wire; compare case-insensitively anyway so a hand-built // Metadata in a test behaves the same as a real request. foreach (var entry in headers) { if (!string.Equals(entry.Key, AuthorizationHeader, StringComparison.OrdinalIgnoreCase)) continue; var value = entry.Value; if (value is not null && value.StartsWith(BearerPrefix, StringComparison.OrdinalIgnoreCase)) return value[BearerPrefix.Length..]; } return null; } private static bool FixedTimeEquals(string presented, string expected) => CryptographicOperations.FixedTimeEquals( Encoding.UTF8.GetBytes(presented), Encoding.UTF8.GetBytes(expected)); }