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
104 lines
4.9 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|