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:
@@ -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 <key></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 <key></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>
|
||||
|
||||
Reference in New Issue
Block a user