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."));
}
}
}