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

@@ -0,0 +1,135 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Features;
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Signing;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
namespace PrivaPub.Tests.Federation
{
public class HttpSignaturesTests
{
const string Host = "privapub.test";
const string KeyId = "https://mastodon.example/users/alice#main-key";
static readonly DateTimeOffset Now = new(2026, 10, 1, 12, 0, 0, TimeSpan.Zero);
static readonly byte[] Body = Encoding.UTF8.GetBytes("""{"type":"Follow","actor":"https://mastodon.example/users/alice"}""");
readonly RSA _key = RSA.Create(2048);
string PublicKey => _key.ExportSubjectPublicKeyInfoPem();
DefaultHttpContext MastodonRequest(string signedTarget = "/peasants/bob/mouth", DateTimeOffset? signedAt = default,
string headers = "(request-target) host date digest content-type", byte[] signedBody = default, string extra = "")
{
var date = (signedAt ?? Now).ToString("r", CultureInfo.InvariantCulture);
var digest = HttpSignatures.Digest(signedBody ?? Body);
var values = new Dictionary<string, string>
{
["(request-target)"] = $"post {signedTarget}",
["host"] = Host,
["date"] = date,
["digest"] = digest,
["content-type"] = "application/activity+json"
};
var signingString = string.Join("\n", headers.Split(' ').Select(h => $"{h}: {values[h]}"));
var signature = Convert.ToBase64String(_key.SignData(Encoding.UTF8.GetBytes(signingString), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1));
var context = new DefaultHttpContext();
context.Request.Method = "POST";
context.Request.Host = new HostString(Host);
context.Request.Path = "/peasants/bob/mouth";
context.Features.Get<IHttpRequestFeature>().RawTarget = "/peasants/bob/mouth";
context.Request.Headers["Date"] = date;
context.Request.Headers["Digest"] = digest;
context.Request.Headers["Content-Type"] = "application/activity+json";
context.Request.Headers["Signature"] = $"keyId=\"{KeyId}\",algorithm=\"rsa-sha256\",headers=\"{headers}\",signature=\"{signature}\"{extra}";
return context;
}
string Check(HttpContext context, byte[] body = default)
{
var parameters = HttpSignatures.Parse(context.Request.Headers["Signature"].ToString());
var problem = HttpSignatures.CheckRequest(context.Request, parameters, body ?? Body, Now);
if (problem != default)
return problem;
return HttpSignatures.Verify(PublicKey, HttpSignatures.SigningString(context.Request, parameters), parameters.Signature)
? default
: "does not verify";
}
[Fact]
public void Accepts_a_mastodon_style_delivery() =>
Assert.Null(Check(MastodonRequest()));
[Fact]
public void Refuses_a_tampered_body() =>
Assert.Equal("the digest does not match the body", Check(MastodonRequest(), Encoding.UTF8.GetBytes("""{"type":"Delete"}""")));
[Fact]
public void Refuses_a_signature_for_another_inbox() =>
Assert.Equal("does not verify", Check(MastodonRequest(signedTarget: "/peasants/carol/mouth")));
[Fact]
public void Refuses_a_signature_older_than_an_hour() =>
Assert.Equal("the Date header is outside the allowed window", Check(MastodonRequest(signedAt: Now.AddMinutes(-61))));
[Fact]
public void Accepts_a_clock_slightly_ahead() =>
Assert.Null(Check(MastodonRequest(signedAt: Now.AddMinutes(10))));
[Fact]
public void Refuses_a_clock_far_ahead() =>
Assert.Equal("the Date header is outside the allowed window", Check(MastodonRequest(signedAt: Now.AddMinutes(20))));
[Fact]
public void Refuses_a_signature_without_the_request_target() =>
Assert.Equal("(request-target) is not signed", Check(MastodonRequest(headers: "host date digest")));
[Fact]
public void Refuses_a_signature_without_the_host() =>
Assert.Equal("host is not signed", Check(MastodonRequest(headers: "(request-target) date digest")));
[Fact]
public void Refuses_a_body_whose_digest_is_not_signed() =>
Assert.Equal("the digest is not signed", Check(MastodonRequest(headers: "(request-target) host date")));
[Fact]
public void Refuses_an_expired_signature() =>
Assert.Equal("the signature has expired",
Check(MastodonRequest(extra: $",expires=\"{Now.AddMinutes(-20).ToUnixTimeSeconds()}\"")));
[Fact]
public void Signs_with_the_raw_request_target()
{
var context = MastodonRequest(signedTarget: "/peasants/b%6Fb/mouth");
context.Features.Get<IHttpRequestFeature>().RawTarget = "/peasants/b%6Fb/mouth";
Assert.Null(Check(context));
}
[Fact]
public void Verifies_its_own_outbound_signature()
{
var (privateKey, publicKey) = Keys.NewKeyPair();
var signer = new LocalActor { UserName = "bob", BaseAddress = "https://privapub.test", PrivateKeyPem = privateKey };
using var outbound = new HttpRequestMessage(HttpMethod.Post, "https://mastodon.example/users/alice/inbox");
HttpSignatures.Sign(outbound, signer, Body);
var context = new DefaultHttpContext();
context.Request.Method = "POST";
context.Request.Host = new HostString("mastodon.example");
context.Request.Path = "/users/alice/inbox";
context.Features.Get<IHttpRequestFeature>().RawTarget = "/users/alice/inbox";
foreach (var header in outbound.Headers)
context.Request.Headers[header.Key] = string.Join(", ", header.Value);
var parameters = HttpSignatures.Parse(context.Request.Headers["Signature"].ToString());
Assert.Equal("https://privapub.test/peasants/bob#main-key", parameters.KeyId);
Assert.Null(HttpSignatures.CheckRequest(context.Request, parameters, Body));
Assert.True(HttpSignatures.Verify(publicKey, HttpSignatures.SigningString(context.Request, parameters), parameters.Signature));
}
}
}
+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)