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)); } } }