diff --git a/src/ZB.MOM.WW.ScadaBridge.InboundAPI/HttpStandbyRequestForwarder.cs b/src/ZB.MOM.WW.ScadaBridge.InboundAPI/HttpStandbyRequestForwarder.cs index 4c2817ce..3b90c39f 100644 --- a/src/ZB.MOM.WW.ScadaBridge.InboundAPI/HttpStandbyRequestForwarder.cs +++ b/src/ZB.MOM.WW.ScadaBridge.InboundAPI/HttpStandbyRequestForwarder.cs @@ -98,7 +98,6 @@ public sealed class HttpStandbyRequestForwarder : IStandbyRequestForwarder // already enforced MaxRequestBodyBytes (Content-Length up front, and the // per-request Kestrel cap for chunked bodies), so this is bounded. var body = await RelayProvenance.ReadBoundedAsync(context.Request.Body, opts.MaxRequestBodyBytes, cancellationToken); - var provenance = RelayProvenance.Sign(context, body, opts.ApiKeyPepper); var client = _httpClientFactory.CreateClient(HttpClientName); var pathAndQuery = context.Request.Path.Value + context.Request.QueryString.Value; @@ -115,6 +114,8 @@ public sealed class HttpStandbyRequestForwarder : IStandbyRequestForwarder try { + // A fresh proof (own nonce) per attempt: each is single-use at the receiver. + var provenance = RelayProvenance.Sign(context, body, opts.ApiKeyPepper); using var request = BuildRequest(context, peer, pathAndQuery, body, forwardedBy, provenance); using var response = await client .SendAsync(request, HttpCompletionOption.ResponseHeadersRead, deadline.Token) diff --git a/src/ZB.MOM.WW.ScadaBridge.InboundAPI/RelayProvenance.cs b/src/ZB.MOM.WW.ScadaBridge.InboundAPI/RelayProvenance.cs index efd1aa1e..ebdfe318 100644 --- a/src/ZB.MOM.WW.ScadaBridge.InboundAPI/RelayProvenance.cs +++ b/src/ZB.MOM.WW.ScadaBridge.InboundAPI/RelayProvenance.cs @@ -12,7 +12,12 @@ internal static class RelayProvenance { internal const string Header = "X-ScadaBridge-Relay"; private static readonly object VerifiedKey = new(); - private sealed record Proof(long Time, Guid ExecutionId, string Ip, string Binding); + private sealed record Proof(long Time, Guid ExecutionId, string Ip, string Binding, string? Nonce = null); + private const int ProofWindowSeconds = 300; + private const int ReplayPruneThreshold = 4096; + private const int ReplayHardCap = 100_000; + // (ExecutionId, nonce) -> unix-seconds expiry, for replay rejection within the proof window. + private static readonly System.Collections.Concurrent.ConcurrentDictionary SeenProofs = new(StringComparer.Ordinal); private static readonly byte[] Domain = "ScadaBridge.InboundRelay.v1"u8.ToArray(); /// Whether a valid relay proof has been verified for this request. @@ -36,7 +41,8 @@ internal static class RelayProvenance var executionId = context.Items.TryGetValue(AuditWriteMiddleware.InboundExecutionIdItemKey, out var value) && value is Guid id ? id : Guid.NewGuid(); var proof = new Proof(DateTimeOffset.UtcNow.ToUnixTimeSeconds(), executionId, - context.Connection.RemoteIpAddress.ToString(), Binding(context.Request, body)); + context.Connection.RemoteIpAddress.ToString(), Binding(context.Request, body), + Convert.ToHexString(RandomNumberGenerator.GetBytes(16))); var bytes = JsonSerializer.SerializeToUtf8Bytes(proof); return Convert.ToBase64String(bytes) + "." + Convert.ToHexString(Mac(bytes, pepper)); } @@ -59,7 +65,8 @@ internal static class RelayProvenance if (!CryptographicOperations.FixedTimeEquals(signature, Mac(payload, options.ApiKeyPepper))) return; proof = JsonSerializer.Deserialize(payload); if (proof is null || proof.ExecutionId == Guid.Empty || !IPAddress.TryParse(proof.Ip, out _) - || Math.Abs((double)DateTimeOffset.UtcNow.ToUnixTimeSeconds() - proof.Time) > 300) return; + || string.IsNullOrEmpty(proof.Nonce) + || Math.Abs((double)DateTimeOffset.UtcNow.ToUnixTimeSeconds() - proof.Time) > ProofWindowSeconds) return; } catch (Exception ex) when (ex is FormatException or JsonException or ArgumentException) { return; } @@ -68,10 +75,31 @@ internal static class RelayProvenance var body = await ReadBoundedAsync(context.Request.Body, options.MaxRequestBodyBytes, context.RequestAborted); context.Request.Body.Position = 0; if (!string.Equals(proof.Binding, Binding(context.Request, body), StringComparison.Ordinal)) return; + // Replay guard (review 2026-10-21 05/O1): a proof is single-use within its window. A + // repeat is treated as unverified (the request still runs as an ordinary call from the + // connection's own IP), so a captured proof cannot re-stamp ExecutionId/Ip on a second row. + if (!TryRecordProof(proof)) return; context.Items[VerifiedKey] = proof.Ip; context.Items[AuditWriteMiddleware.InboundExecutionIdItemKey] = proof.ExecutionId; } + private static bool TryRecordProof(Proof proof) + { + var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + if (SeenProofs.Count >= ReplayPruneThreshold) + { + foreach (var kv in SeenProofs) + if (kv.Value <= now) SeenProofs.TryRemove(kv); + } + + // Still full after pruning live entries: fail closed rather than grow without bound. + if (SeenProofs.Count >= ReplayHardCap) return false; + + // The proof is valid for +/- the window around its own timestamp, so keep the record + // until the far edge of that window. + return SeenProofs.TryAdd(proof.ExecutionId.ToString("N") + ":" + proof.Nonce, proof.Time + ProofWindowSeconds + 1); + } + /// Reads a stream fully, failing if it exceeds the given byte limit. /// The stream to read. /// The maximum number of bytes allowed. diff --git a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/RelayProvenanceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/RelayProvenanceTests.cs index 48cbf892..69f1a699 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/RelayProvenanceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/RelayProvenanceTests.cs @@ -62,6 +62,29 @@ public class RelayProvenanceTests if (tamper == "none") Assert.Equal(original.Items[AuditWriteMiddleware.InboundExecutionIdItemKey], row.ExecutionId); } + /// Verifies that a captured relay proof cannot be replayed. + /// A task that represents the asynchronous operation. + [Fact] + public async Task O1_ReplayedProof_IsRejectedOnSecondUse() + { + // Review 2026-10-21 05/O1: the proof carries a nonce and the receiver remembers it. + var options = new InboundApiOptions { ApiKeyPepper = new string('p', 32) }; + var original = Context("{}", "10.4.5.6"); + original.Items[AuditWriteMiddleware.InboundExecutionIdItemKey] = Guid.NewGuid(); + var header = RelayProvenance.Sign(original, "{}"u8.ToArray(), options.ApiKeyPepper)!; + + var first = Context("{}", "10.0.0.2"); + first.Request.Headers[RelayProvenance.Header] = header; + await RelayProvenance.VerifyAsync(first, options); + var replay = Context("{}", "10.0.0.2"); + replay.Request.Headers[RelayProvenance.Header] = header; + await RelayProvenance.VerifyAsync(replay, options); + + Assert.True(RelayProvenance.IsVerified(first)); + Assert.False(RelayProvenance.IsVerified(replay)); + Assert.Equal("10.0.0.2", RelayProvenance.CallerIp(replay)); + } + /// Verifies that directly forged forwarding headers cannot change the audited caller. /// A task that represents the asynchronous operation. [Fact]