From 611abb58572a4660ba1e877c5b3d2e2d110014ef Mon Sep 17 00:00:00 2001 From: thepra Date: Thu, 1 Oct 2026 10:51:10 +0200 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB --- .../Federation/HttpSignaturesTests.cs | 135 ++++++++++++++++++ PrivaPub/Federation/Signing/HttpSignatures.cs | 54 +++++-- 2 files changed, 179 insertions(+), 10 deletions(-) create mode 100644 PrivaPub.Tests/Federation/HttpSignaturesTests.cs diff --git a/PrivaPub.Tests/Federation/HttpSignaturesTests.cs b/PrivaPub.Tests/Federation/HttpSignaturesTests.cs new file mode 100644 index 0000000..55b769d --- /dev/null +++ b/PrivaPub.Tests/Federation/HttpSignaturesTests.cs @@ -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 + { + ["(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().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().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().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)); + } + } +} diff --git a/PrivaPub/Federation/Signing/HttpSignatures.cs b/PrivaPub/Federation/Signing/HttpSignatures.cs index b15824f..4fc414c 100644 --- a/PrivaPub/Federation/Signing/HttpSignatures.cs +++ b/PrivaPub/Federation/Signing/HttpSignatures.cs @@ -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()?.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)