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("

signed the new way

", new[] { alice.Uri }); var noteId = create["object"]!["id"]!.GetValue(); 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().Match(p => p.ObjectURI == noteId).ExecuteSingleAsync(Token)).ContentHtml); } static string Id(JsonObject activity) => activity["id"]!.GetValue(); // 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()!.RawTarget = message.RequestUri.PathAndQuery; foreach (var (name, values) in message.Headers) context.Request.Headers[name] = values.ToArray(); return context.Request; } } }