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.ScadaBridge.Host; /// /// 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 the site node's gRPC port could /// stream arbitrary rows into the consolidated site database. /// /// /// /// Scoped by method path. Only calls under /// /localdb_sync.v1.LocalDbSync/ are gated; every other method — notably the /// existing SiteStream service sharing this listener — 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 site node ships with. An operator /// enabling replication must set the same key on both nodes, which is already required /// for the initiator to dial out (SyncBackgroundService sends /// Authorization: Bearer <key>). /// /// /// 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)); }