fix(inbound): single-use nonce on relay provenance proofs (review 2026-10-21 05/O1)
This commit is contained in:
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user