fix(localdb): sync endpoint enforces the configured ApiKey (scadaproj#36)

LocalDbSyncService never read request headers, so setting
LocalDb:Replication:ApiKey on both nodes left the passive endpoint open
(LocalDb-001). It now requires Authorization: Bearer <key> (constant-time)
before the handshake and before newest-wins takeover, refusing anything else
with Unauthenticated. A blank key keeps the old accept-all behaviour and
logs a startup warning. Wire-compatible: the initiator has always sent the
header when the key is set; ScadaBridge/OtOpcUa interceptors use the same key.

LocalDb 0.3.5 -> 0.3.6 (unpublished).

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
Joseph Doherty
2026-11-09 14:49:20 -05:00
parent d549a782fd
commit 48813bba6f
8 changed files with 228 additions and 18 deletions
@@ -1,3 +1,5 @@
using System.Security.Cryptography;
using System.Text;
using Grpc.Core;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
@@ -57,6 +59,13 @@ public sealed class LocalDbSyncService : LocalDbSync.LocalDbSyncBase
_logger = loggerFactory.CreateLogger<LocalDbSyncService>();
_metrics = metrics;
_status = status as SyncStatus;
if (string.IsNullOrWhiteSpace(_options.ApiKey))
{
_logger.LogWarning(
"LocalDb sync endpoint has no LocalDb:Replication:ApiKey: it accepts any caller that can reach it " +
"unless the host adds its own authentication. Set the same key on both nodes of the pair.");
}
}
/// <summary>
@@ -66,8 +75,8 @@ public sealed class LocalDbSyncService : LocalDbSync.LocalDbSyncBase
/// <para>
/// <b>Newest wins.</b> The guard exists to SERIALIZE sessions — a node syncs with exactly one
/// peer, and two sessions against one database would fight over the same watermarks — not to
/// fence out an impostor; authentication is the interceptor's job, and an authenticated caller
/// on this key IS the peer by definition. Refusing the newcomer therefore protected nothing and
/// fence out an impostor; authentication happens before the guard (<see cref="IsAuthorized"/>, plus any
/// host interceptor), and an authenticated caller on this key IS the peer by definition. Refusing the newcomer therefore protected nothing and
/// cost everything in the case that actually happens: the dialling node crashes or is
/// partitioned, this handler stays parked forever inside <c>MoveNext</c> on a half-open stream
/// that will never produce another byte, and every reconnect from the rebooted peer is rejected
@@ -87,6 +96,14 @@ public sealed class LocalDbSyncService : LocalDbSync.LocalDbSyncBase
IServerStreamWriter<SyncMessage> responseStream,
ServerCallContext context)
{
// Authenticate BEFORE the takeover: an unauthenticated caller must not be able to displace
// the real peer's stream, nor see this node's Handshake (table names + schema digests).
if (!IsAuthorized(_options.ApiKey, context.RequestHeaders, out var reason))
{
_logger.LogWarning("Rejected a LocalDb sync stream from {Peer}: {Reason}.", context.Peer, reason);
throw new RpcException(new Status(StatusCode.Unauthenticated, "LocalDb sync authentication failed."));
}
// The linked source exists so the reader pump is deterministically torn down on EVERY exit
// path — including a session fault for a local (non-transport) reason, where the call token
// never fires and the pump would otherwise keep reading a stream whose handler has exited.
@@ -145,6 +162,50 @@ public sealed class LocalDbSyncService : LocalDbSync.LocalDbSyncBase
}
}
/// <summary>
/// The library's own sync-endpoint authentication (LocalDb-001). With no
/// <see cref="ReplicationOptions.ApiKey"/> configured (null, empty or whitespace) every caller is
/// admitted — the pre-0.3.6 behaviour, so a pair with no key keeps working unchanged. With a key
/// configured the call must carry <c>Authorization: Bearer &lt;key&gt;</c> — exactly what
/// <see cref="SyncBackgroundService"/> has always sent when the same key is configured — compared
/// in constant time over UTF-8 bytes.
/// </summary>
internal static bool IsAuthorized(string? expectedKey, Metadata headers, out string reason)
{
reason = "";
if (string.IsNullOrWhiteSpace(expectedKey))
return true;
string? presented = null;
foreach (var entry in headers)
{
// gRPC lowercases header names on the wire; compare case-insensitively anyway.
if (entry.IsBinary || !string.Equals(entry.Key, "authorization", StringComparison.OrdinalIgnoreCase))
continue;
if (entry.Value is { } value && value.StartsWith(BearerPrefix, StringComparison.OrdinalIgnoreCase))
{
presented = value[BearerPrefix.Length..];
break;
}
}
if (presented is null)
{
reason = "no bearer token presented";
return false;
}
if (!CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(presented), Encoding.UTF8.GetBytes(expectedKey)))
{
reason = "bearer token did not match";
return false;
}
return true;
}
private const string BearerPrefix = "Bearer ";
/// <summary>
/// Claims the single-stream guard for <paramref name="mine"/>, cancelling and awaiting whatever
/// held it. Throws <c>AlreadyExists</c> only when the incumbent refuses to end.
@@ -8,7 +8,15 @@ public sealed class ReplicationOptions
/// <summary>gRPC address of the peer to dial. Required for the initiator role (validated in the sync host).</summary>
public string PeerAddress { get; set; } = "";
/// <summary>API key presented to / expected from the peer; null disables key auth.</summary>
/// <summary>
/// Shared key that authenticates the pair partner. Set the SAME value on both nodes. The initiator
/// sends it as <c>Authorization: Bearer &lt;key&gt;</c>; since 0.3.6 the passive
/// <see cref="LocalDbSyncService"/> also requires it (constant-time compare) and refuses any other
/// stream with <c>Unauthenticated</c>, before the handshake and before displacing the current
/// stream. Null, empty or whitespace disables key auth on both sides: the initiator sends no
/// header and the endpoint accepts any caller (a warning is logged at startup), so protect it some
/// other way — network isolation or a host interceptor.
/// </summary>
public string? ApiKey { get; set; }
/// <summary>How often the change pump flushes pending oplog deltas to the peer.</summary>