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)