feat(mesh): known-cluster gate + Matches on the PSK provider
Phase 1A security register items 1 and 2, landed ahead of the command fan-out because they are independently testable and the fan-out is the first caller of TryGetPsk. Item 1 — known-cluster gate. MeshPskProvider gains SetKnownClusters, an atomic whole-set swap consulted by TryGetPsk BEFORE the config map and before the secret store. A cluster id outside the set is refused in O(1) with no store read and no audit write, which closes two things at once: the pre-auth store/audit flood an unauthenticated caller could drive with a forged x-otopcua-cluster header, and the timing oracle that defeated CentralCommandAuthInterceptor's uniform denial text (unknown clusters are never cached, so they paid a store read on every probe while known ones answered from memory). Empty or never-published leaves the gate inactive — central publishes the set from its DB contact refresh, and 'the DB told me nothing' must not become a fleet-wide authentication outage during admin boot. Item 2 — bool Matches(clusterId, presented) on IMeshPskProvider, with FixedTimeEquals inside the provider. CentralCommandAuthInterceptor now calls it and never holds key material; its two provider-side denial logs collapse into one (the provider answers both as false) and the operator hint is restated there. No production caller publishes a cluster set yet, so behaviour is unchanged until the fan-out commit wires the contact refresh. ControlPlane.Tests 190 -> 205, Host.Tests 91 -> 92.
This commit is contained in:
@@ -1,5 +1,3 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Grpc.Core;
|
||||
using Grpc.Core.Interceptors;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -38,25 +36,31 @@ namespace ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||
/// key this design exists to avoid.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Comparison is <see cref="CryptographicOperations.FixedTimeEquals"/> 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.
|
||||
/// <b>This interceptor never handles a key.</b> Verification is
|
||||
/// <see cref="IMeshPskProvider.Matches"/>, 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 <c>==</c> and still compile (Phase 1A security register
|
||||
/// item 2). Length differences are unavoidably observable and are not sensitive.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>All three denials return the same status text</b>, unlike the sibling interceptors,
|
||||
/// which distinguish "no key configured" from "authentication failed". Here that distinction
|
||||
/// would be a <i>content</i> 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.
|
||||
/// <b>All denials return the same status text</b>, unlike the sibling interceptors, which
|
||||
/// distinguish "no key configured" from "authentication failed". Here that distinction would
|
||||
/// be a <i>content</i> 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 <c>false</c>, and collapsing them costs an operator a hint
|
||||
/// (restated in the one denial message) rather than costing a diagnosis.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>That is not yet full enumeration resistance, and should not be read as such.</b> A
|
||||
/// timing oracle survives it: an unknown cluster misses the provider's cache and pays a store
|
||||
/// read on every probe, while a known one is answered from memory after the first. Closing
|
||||
/// that needs the lookup to be refused before it reaches the store for any cluster central
|
||||
/// does not know — which is the Phase 1A known-cluster-set gate, where central learns its
|
||||
/// cluster set from the DB. Until then, uniform text buys content-level indistinguishability
|
||||
/// only.
|
||||
/// <b>The timing oracle that survived Phase 0 is closed by the known-cluster gate.</b> 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.
|
||||
/// <c>CentralCommunicationActor</c> 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Exactly one public constructor.</b> <c>Grpc.AspNetCore</c> silently stops invoking an
|
||||
@@ -151,13 +155,15 @@ public sealed class CentralCommandAuthInterceptor : Interceptor
|
||||
"Central command authentication failed."));
|
||||
}
|
||||
|
||||
if (!_pskProvider.TryGetPsk(clusterId, out var expected))
|
||||
// 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 "
|
||||
+ "pre-shared key could be established for it. Seed the secret "
|
||||
+ "OT-MESH-PSK-<clusterId> on this node, or set "
|
||||
+ "MeshTransport:ClusterPsks:<clusterId> on a rig.",
|
||||
"Rejected a central command call to {Method} claiming cluster {ClusterId}: no bearer "
|
||||
+ "token presented.",
|
||||
context.Method,
|
||||
clusterId);
|
||||
throw new RpcException(new Status(
|
||||
@@ -165,21 +171,20 @@ public sealed class CentralCommandAuthInterceptor : Interceptor
|
||||
"Central command authentication failed."));
|
||||
}
|
||||
|
||||
var presented = GrpcRequestHeaders.BearerToken(context.RequestHeaders);
|
||||
if (presented is null || !FixedTimeEquals(presented, expected))
|
||||
if (!_pskProvider.Matches(clusterId, presented))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Rejected a central command call to {Method} claiming cluster {ClusterId}: {Reason}.",
|
||||
"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-<clusterId> on this node, or set "
|
||||
+ "MeshTransport:ClusterPsks:<clusterId> on a rig, and check it has an enabled, "
|
||||
+ "non-maintenance ClusterNode row.",
|
||||
context.Method,
|
||||
clusterId,
|
||||
presented is null ? "no bearer token presented" : "bearer token did not match");
|
||||
clusterId);
|
||||
throw new RpcException(new Status(
|
||||
StatusCode.PermissionDenied,
|
||||
"Central command authentication failed."));
|
||||
}
|
||||
}
|
||||
|
||||
private static bool FixedTimeEquals(string presented, string expected)
|
||||
=> CryptographicOperations.FixedTimeEquals(
|
||||
Encoding.UTF8.GetBytes(presented), Encoding.UTF8.GetBytes(expected));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user