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