fix(inbound): single-use nonce on relay provenance proofs (review 2026-10-21 05/O1)

This commit is contained in:
Joseph Doherty
2026-10-22 08:31:54 -04:00
parent 26baa5a170
commit e93af5a21f
3 changed files with 56 additions and 4 deletions
@@ -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)
@@ -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<string, long> SeenProofs = new(StringComparer.Ordinal);
private static readonly byte[] Domain = "ScadaBridge.InboundRelay.v1"u8.ToArray();
/// <summary>Whether a valid relay proof has been verified for this request.</summary>
@@ -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<Proof>(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);
}
/// <summary>Reads a stream fully, failing if it exceeds the given byte limit.</summary>
/// <param name="body">The stream to read.</param>
/// <param name="limit">The maximum number of bytes allowed.</param>
@@ -62,6 +62,29 @@ public class RelayProvenanceTests
if (tamper == "none") Assert.Equal(original.Items[AuditWriteMiddleware.InboundExecutionIdItemKey], row.ExecutionId);
}
/// <summary>Verifies that a captured relay proof cannot be replayed.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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));
}
/// <summary>Verifies that directly forged forwarding headers cannot change the audited caller.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]