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