Files
SocialPub/PrivaPub.Tests/Http/MessageSignatureTests.cs
T
thepraandClaude Opus 5.5 c5a69d240a RFC 9421 signatures are verified, not refused
WordPress's ActivityPub plugin (and Ghost and Fedify) sign with RFC 9421
first and fall back to draft-cavage only after a refusal, so each first
delivery cost two requests and a 401 in our statistics. Now a request
carrying Signature-Input is verified as an HTTP message signature: its
covered components (the method and our own public target, the body's
Content-Digest), its created and expires, with the actor's RSA key under
PKCS#1 v1.5 or PSS. Deliveries and signed fetches both take it; the
ledger names the scheme (rfc9421:rsa-v1_5-sha256). What PrivaPub sends
stays draft-cavage, which every server reads. Ed25519 waits for FEP-521a
keys.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 06:48:22 +02:00

104 lines
4.9 KiB
C#

using MongoDB.Entities;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Features;
using PrivaPub.Federation.Signing;
using PrivaPub.Models.Post;
using PrivaPub.Tests.Support;
using PrivaPub.Tests.Support.Host;
using System.Net;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json.Nodes;
namespace PrivaPub.Tests.Http
{
// RFC 9421 message signatures, as WordPress's ActivityPub plugin, Ghost and Fedify send them first: a delivery so signed
// is taken in one request, and refused when its body or its target is not what was signed
[Trait("Category", "Integration")]
public sealed class MessageSignatureTests : IAsyncLifetime
{
PrivaPubHost _host;
Peer _peer;
static CancellationToken Token => TestContext.Current.CancellationToken;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_host = await PrivaPubHost.Shared();
_peer = await Peer.Start();
}
public async ValueTask DisposeAsync()
{
if (_peer != default)
await _peer.DisposeAsync();
}
[Fact]
public async Task A_delivery_signed_as_rfc9421_is_taken_and_one_whose_body_or_target_differs_is_refused()
{
var alice = await _host.Mastodon("alice");
var bob = new RemoteActor(_peer, "bob");
var create = bob.Create("<p>signed the new way</p>", new[] { alice.Uri });
var noteId = create["object"]!["id"]!.GetValue<string>();
using var client = _host.Client();
var tampered = bob.MessageSignedPost(alice.Mouth, create);
tampered.Content = new ByteArrayContent(Encoding.UTF8.GetBytes(create.ToJsonString().Replace("new way", "other way")));
tampered.Content.Headers.TryAddWithoutValidation("Content-Type", "application/activity+json");
tampered.Content.Headers.TryAddWithoutValidation("Content-Digest", bob.MessageSignedPost(alice.Mouth, create).Content!.Headers.GetValues("Content-Digest").First());
Assert.Equal(HttpStatusCode.Unauthorized, (await client.SendAsync(tampered, Token)).StatusCode);
var elsewhere = bob.MessageSignedPost(alice.Mouth, create, signedFor: "/human-centipede");
Assert.Equal(HttpStatusCode.Unauthorized, (await client.SendAsync(elsewhere, Token)).StatusCode);
Assert.Equal(HttpStatusCode.Accepted, (await client.SendAsync(bob.MessageSignedPost(alice.Mouth, create), Token)).StatusCode);
Assert.Equal(1, await _host.RunInbox(Id(create), Token));
Assert.Contains("signed the new way", (await DB.Default.Find<Post>().Match(p => p.ObjectURI == noteId).ExecuteSingleAsync(Token)).ContentHtml);
}
static string Id(JsonObject activity) => activity["id"]!.GetValue<string>();
// a signed fetch (a GET, no body) verifies the same way, and so does RSA-PSS with SHA-512
[Fact]
public void A_signed_fetch_and_an_rsa_pss_signature_verify()
{
using var key = RSA.Create(2048);
var message = new HttpRequestMessage(HttpMethod.Get, "https://privapub.test/peasants/alice/scribbles/1");
MessageSignatures.Sign(message, "https://peer.example/users/bob#main-key", key.ExportPkcs8PrivateKeyPem(), body: null);
var request = Request(message);
var signature = RequestSignature.Of(request);
Assert.Equal("rfc9421:rsa-v1_5-sha256", signature.Scheme);
Assert.Null(signature.Problem(request, body: null));
Assert.True(signature.VerifiedBy(key.ExportSubjectPublicKeyInfoPem(), signature.Signed(request, "https://privapub.test")));
Assert.False(signature.VerifiedBy(key.ExportSubjectPublicKeyInfoPem(), signature.Signed(request, "https://elsewhere.example")));
var created = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
var parameters = $"(\"@method\" \"@target-uri\");created={created};keyid=\"bob\";alg=\"rsa-pss-sha512\"";
var signatureBase = $"\"@method\": GET\n\"@target-uri\": https://privapub.test/peasants/alice/scribbles/1\n\"@signature-params\": {parameters}";
var signed = key.SignData(Encoding.UTF8.GetBytes(signatureBase), HashAlgorithmName.SHA512, RSASignaturePadding.Pss);
var pss = new HttpRequestMessage(HttpMethod.Get, "https://privapub.test/peasants/alice/scribbles/1");
pss.Headers.TryAddWithoutValidation("Signature-Input", $"pss={parameters}");
pss.Headers.TryAddWithoutValidation("Signature", $"pss=:{Convert.ToBase64String(signed)}:");
var pssRequest = Request(pss);
var pssSignature = RequestSignature.Of(pssRequest);
Assert.True(pssSignature.VerifiedBy(key.ExportSubjectPublicKeyInfoPem(), pssSignature.Signed(pssRequest, "https://privapub.test")));
}
static HttpRequest Request(HttpRequestMessage message)
{
var context = new DefaultHttpContext();
context.Request.Method = message.Method.Method;
context.Request.Host = new HostString(message.RequestUri!.Host);
context.Request.Path = message.RequestUri.AbsolutePath;
context.Features.Get<IHttpRequestFeature>()!.RawTarget = message.RequestUri.PathAndQuery;
foreach (var (name, values) in message.Headers)
context.Request.Headers[name] = values.ToArray();
return context.Request;
}
}
}