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 central config-serve artifact endpoint (per-cluster mesh Phase 3). The /// DeploymentArtifactService streams a driver node the exact configuration it will boot /// on; anything able to reach central's serve port could otherwise pull the whole deployed config /// out-of-band. This interceptor is the ONLY inbound auth on that endpoint. /// /// /// /// Scoped by method path. Only calls under /// /deployment_artifact.v1.DeploymentArtifactService/ are gated; every other method on /// the shared gRPC pipeline (notably the LocalDb sync service) passes through untouched, so /// this can share one AddGrpc registration with . /// /// /// Fail-closed. With no ConfigServe:ApiKey configured, NO fetch is accepted, /// authenticated or not. Treating "no key" as "no auth required" would expose the deployed /// configuration on exactly the default shape most nodes ship with. The node dialing in must /// present the same ConfigSource:ApiKey (the shared node key), so a key typo stops /// config delivery outright rather than degrading to unauthenticated. /// /// /// 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. /// /// public sealed class ConfigServeAuthInterceptor : Interceptor { private const string ServicePrefix = "/deployment_artifact.v1.DeploymentArtifactService/"; private const string AuthorizationHeader = "authorization"; private const string BearerPrefix = "Bearer "; private readonly IOptions _options; private readonly ILogger _logger; /// Creates the interceptor. /// Config-serve options; ApiKey is the expected bearer token. /// Logger for denial diagnostics. public ConfigServeAuthInterceptor( 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 config-serve call that does not carry the configured bearer token. Non-serve calls return /// immediately. /// private void Authorize(ServerCallContext context) { if (!context.Method.StartsWith(ServicePrefix, StringComparison.Ordinal)) return; var expected = _options.Value.ApiKey; if (string.IsNullOrEmpty(expected)) { _logger.LogWarning( "Rejected a config-serve call to {Method}: no ConfigServe:ApiKey is configured, so the " + "artifact endpoint is closed. Configure the shared node key on central and every node.", context.Method); throw new RpcException(new Status( StatusCode.Unauthenticated, "Config-serve is not accepting connections: no API key is configured on this node.")); } var presented = ExtractBearerToken(context.RequestHeaders); if (presented is null || !FixedTimeEquals(presented, expected)) { _logger.LogWarning( "Rejected a config-serve call to {Method}: {Reason}.", context.Method, presented is null ? "no bearer token presented" : "bearer token did not match"); throw new RpcException(new Status( StatusCode.Unauthenticated, "Config-serve 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)); }