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:
+43
-1
@@ -242,14 +242,56 @@ public sealed class CentralCommandAuthInterceptorTests
|
||||
publicCtors.Length.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Verification_GoesThroughMatches_AndTheInterceptorNeverHandlesTheKeyItself()
|
||||
{
|
||||
// Phase 1A security register item 2. The interceptor must not fetch the raw key and compare
|
||||
// it — that is how a constant-time comparison eventually becomes '=='. Matches owns the
|
||||
// comparison; TryGetPsk survives only for the DIAL side, which has to send the key.
|
||||
// (The provider's own default Matches calls TryGetPsk internally; this fake overrides it, so
|
||||
// a zero TryGetPsk count here means the INTERCEPTOR never asked for the key.)
|
||||
var provider = new FakeMeshPskProvider(("SITE-A", "psk-site-a"));
|
||||
var interceptor = new CentralCommandAuthInterceptor(
|
||||
provider, NullLogger<CentralCommandAuthInterceptor>.Instance);
|
||||
|
||||
await Invoke(interceptor, CreateContext(ReportApplyMethod, "Bearer psk-site-a", "SITE-A"));
|
||||
|
||||
provider.MatchesCalls.ShouldBe(1);
|
||||
provider.TryGetPskCalls.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>An <see cref="IMeshPskProvider"/> over a fixed key set; unknown clusters fail closed.</summary>
|
||||
private sealed class FakeMeshPskProvider(params (string Cluster, string Psk)[] keys) : IMeshPskProvider
|
||||
{
|
||||
private readonly Dictionary<string, string> _keys =
|
||||
keys.ToDictionary(k => k.Cluster, k => k.Psk, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>How many times the raw key was asked for — must stay 0 on the verify path.</summary>
|
||||
public int TryGetPskCalls { get; private set; }
|
||||
|
||||
/// <summary>How many times verification went through the provider's comparison.</summary>
|
||||
public int MatchesCalls { get; private set; }
|
||||
|
||||
public bool TryGetPsk(string clusterId, [NotNullWhen(true)] out string? psk)
|
||||
=> _keys.TryGetValue(clusterId, out psk);
|
||||
{
|
||||
TryGetPskCalls++;
|
||||
return _keys.TryGetValue(clusterId, out psk);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Overrides the interface's default so the two calls are separately countable. The
|
||||
/// semantics are the default's: no key, or a key that is not exactly the presented token.
|
||||
/// </summary>
|
||||
public bool Matches(string clusterId, string presented)
|
||||
{
|
||||
MatchesCalls++;
|
||||
return !string.IsNullOrWhiteSpace(presented)
|
||||
&& _keys.TryGetValue(clusterId, out var expected)
|
||||
&& string.Equals(expected, presented, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>No-op: the gate is the production provider's behaviour, pinned in its own tests.</summary>
|
||||
public void SetKnownClusters(IReadOnlyCollection<string> clusterIds) { }
|
||||
|
||||
public void Invalidate(string clusterId) { }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user