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.LocalDb.Replication; namespace ZB.MOM.WW.OtOpcUa.Host.Configuration; /// /// Gates the LocalDb passive sync endpoint. The replication library deliberately leaves inbound /// authentication to the host — its LocalDbSyncService verifies nothing — so without this /// interceptor anything that can reach a driver node's sync port could stream arbitrary rows /// into that node's deployment-artifact cache. /// /// /// /// Why that matters here specifically. The cached artifact is what a driver node /// boots from when central SQL is unreachable. An attacker able to write it could choose /// the configuration a node comes up on during exactly the outage nobody is watching. /// /// /// Scoped by method path. Only calls under /localdb_sync.v1.LocalDbSync/ are /// gated; every other method on the shared gRPC pipeline passes through untouched. /// /// /// Fail-closed. With no LocalDb:Replication:ApiKey configured, NO sync stream /// is accepted, authenticated or not. That is the deliberate choice: the alternative — /// treating "no key" as "no auth required" — would silently expose the endpoint on exactly /// the default configuration every node ships with. An operator enabling replication must /// set the same key on both nodes of the pair, which is already required for the initiator /// to dial out (SyncBackgroundService sends Authorization: Bearer <key>). /// A key typo therefore stops convergence 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 LocalDbSyncAuthInterceptor : Interceptor { private const string ServicePrefix = "/localdb_sync.v1.LocalDbSync/"; private const string AuthorizationHeader = "authorization"; private const string BearerPrefix = "Bearer "; private readonly IOptions _options; private readonly ILogger _logger; /// Creates the interceptor. /// Replication options; ApiKey is the expected bearer token. /// Logger for denial diagnostics. public LocalDbSyncAuthInterceptor( 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 sync call that does not carry the configured bearer token. Non-sync 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 LocalDb sync call to {Method}: no LocalDb:Replication:ApiKey is configured, " + "so the passive sync endpoint is closed. Configure the same key on both nodes of the pair.", context.Method); throw new RpcException(new Status( StatusCode.PermissionDenied, "LocalDb sync 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 LocalDb sync 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, "LocalDb sync 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)); }