Inbound signatures must be fresh and cover the request line and host

S6 of the roadmap. An inbox POST is refused unless its signature covers
(request-target) and host, as well as the digest and a date or (created).
The Date or (created) may be at most an hour old and fifteen minutes ahead
(it was twelve hours either way), and an (expires) in the past is refused.
The request target is read raw from the server, so a percent-encoded path
verifies as the sender signed it.

Tests cover a Mastodon-shaped delivery and each way of tampering with it,
plus a round trip of our own outbound signature.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-01 10:51:10 +02:00
1 parent a060204dd6
commit 611abb5857
2 files changed
+179 -10

No files matched your search

+44 -10
View File
@@ -1,3 +1,5 @@
using Microsoft.AspNetCore.Http.Features;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
@@ -10,7 +12,8 @@ namespace PrivaPub.Federation.Signing
public static class HttpSignatures
{
static readonly TimeSpan AllowedClockSkew = TimeSpan.FromHours(12);
public static readonly TimeSpan MaxAge = TimeSpan.FromHours(1);
public static readonly TimeSpan MaxClockSkew = TimeSpan.FromMinutes(15);
public static string Digest(byte[] body) => "SHA-256=" + Convert.ToBase64String(SHA256.HashData(body));
@@ -105,7 +108,7 @@ namespace PrivaPub.Federation.Signing
switch (header)
{
case "(request-target)":
lines.Add($"(request-target): {request.Method.ToLowerInvariant()} {request.PathBase}{request.Path}{request.QueryString}");
lines.Add($"(request-target): {request.Method.ToLowerInvariant()} {RequestTarget(request)}");
break;
case "(created)":
lines.Add($"(created): {parameters.Created}");
@@ -126,10 +129,17 @@ namespace PrivaPub.Federation.Signing
return string.Join("\n", lines);
}
public static string CheckRequest(HttpRequest request, SignatureParameters parameters, byte[] body)
public static string CheckRequest(HttpRequest request, SignatureParameters parameters, byte[] body) =>
CheckRequest(request, parameters, body, DateTimeOffset.UtcNow);
public static string CheckRequest(HttpRequest request, SignatureParameters parameters, byte[] body, DateTimeOffset now)
{
if (parameters.Algorithm is not ("rsa-sha256" or "hs2019"))
return $"unsupported signature algorithm '{parameters.Algorithm}'";
if (!parameters.Headers.Contains("(request-target)"))
return "(request-target) is not signed";
if (!parameters.Headers.Contains("host"))
return "host is not signed";
if (body is { Length: > 0 })
{
@@ -142,24 +152,48 @@ namespace PrivaPub.Federation.Signing
return "the digest does not match the body";
}
if (parameters.Headers.Contains("date"))
var signsDate = parameters.Headers.Contains("date");
var signsCreated = parameters.Headers.Contains("(created)");
if (!signsDate && !signsCreated)
return "neither date nor (created) is signed";
if (signsDate)
{
if (!DateTimeOffset.TryParse(request.Headers["Date"].ToString(), CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal, out var date))
return "unreadable Date header";
if ((DateTimeOffset.UtcNow - date).Duration() > AllowedClockSkew)
if (!IsFresh(date, now))
return "the Date header is outside the allowed window";
}
else if (!parameters.Headers.Contains("(created)"))
return "neither date nor (created) is signed";
if (!string.IsNullOrEmpty(parameters.Expires) && long.TryParse(parameters.Expires, out var expires)
&& DateTimeOffset.FromUnixTimeSeconds(expires) < DateTimeOffset.UtcNow - AllowedClockSkew)
return "the signature has expired";
if (signsCreated)
{
if (!long.TryParse(parameters.Created, NumberStyles.Integer, CultureInfo.InvariantCulture, out var created))
return "unreadable (created)";
if (!IsFresh(DateTimeOffset.FromUnixTimeSeconds(created), now))
return "(created) is outside the allowed window";
}
if (!string.IsNullOrEmpty(parameters.Expires) || parameters.Headers.Contains("(expires)"))
{
if (!long.TryParse(parameters.Expires, NumberStyles.Integer, CultureInfo.InvariantCulture, out var expires))
return "unreadable (expires)";
if (DateTimeOffset.FromUnixTimeSeconds(expires) < now - MaxClockSkew)
return "the signature has expired";
}
return default;
}
static bool IsFresh(DateTimeOffset signedAt, DateTimeOffset now) =>
signedAt >= now - MaxAge && signedAt <= now + MaxClockSkew;
static string RequestTarget(HttpRequest request)
{
var raw = request.HttpContext.Features.Get<IHttpRequestFeature>()?.RawTarget;
return string.IsNullOrEmpty(raw) || raw[0] != '/' ? $"{request.PathBase}{request.Path}{request.QueryString}" : raw;
}
public static bool Verify(string publicKeyPem, string signingString, byte[] signature)
{
if (string.IsNullOrEmpty(publicKeyPem) || signingString == null)