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
136 lines
5.6 KiB
C#
136 lines
5.6 KiB
C#
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));
|
|
}
|
|
}
|
|
}
|