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
@@ -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>