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:
1 parent
a060204dd6
commit
611abb5857
2 files changed
+179
-10
No files matched your search
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user