Files
SocialPub/PrivaPub.Tests/Support/RemoteActor.cs
T
thepraandClaude Opus 5.5 9ab87b2779 Personas prove what goes to relays; the server says what it reads
FEP-521a and FEP-8b32. Every persona has an Ed25519 key of its own (Avatar.SigningKey; migration 014 gives the earlier
ones theirs), named in its actor's assertionMethod as a Multikey, the terms defined in the actor's own context. A
persona's activity going to a relay carries an eddsa-jcs-2022 proof (JSON canonicalised by RFC 8785, Jcs), so what
Activity-Relay forwards reaches Mastodon, which verifies it with its own code. Nothing else carries one: Mitra takes a
proof over the HTTP signature and refuses one by a key it has not read, without reading the actor again. Received: an
actor's own Multikeys are kept, and a forwarded activity whose proof one of them verifies is taken as it came instead of
being read again from its origin.

Discovery: WebFinger for the server's origin links its instance actor (FEP-d556), NodeInfo links it as the application
actor (FEP-2677), and actors name RFC 9421 under implements (FEP-844e).

Checked live: relay 16 (Activity-Relay's forward of alice's post reaches Mastodon), Mitra, GoToSocial and Mastodon
unchanged (165 in all).

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

160 lines
6.6 KiB
C#

using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Features;
using PrivaPub.Federation.Signing;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json.Nodes;
namespace PrivaPub.Tests.Support
{
public sealed class RemoteActor
{
readonly RSA _key = RSA.Create(2048);
readonly bool _namesSharedInbox;
readonly bool _hasWall;
readonly string _ed25519;
// sharedInbox: whether its document names its server's shared inbox (endpoints.sharedInbox), as Mastodon's do; wall:
// whether it has a wall (sm:wall, Smithereen's); ed25519: whether it has an Ed25519 key (FEP-521a) to prove what it
// sends (FEP-8b32)
public RemoteActor(Peer peer, string name, string origin = default, string type = "Person", bool sharedInbox = false, bool wall = false,
bool ed25519 = false)
{
_ed25519 = ed25519 ? PrivaPub.Federation.Signing.IntegrityProofs.NewSeed() : default;
Name = $"{name}{Guid.NewGuid():N}"[..20];
Id = $"{origin ?? peer.A}/users/{Name}";
Type = type;
_namesSharedInbox = sharedInbox;
_hasWall = wall;
peer.Serve($"/users/{Name}", Document().ToJsonString());
}
public string Type { get; }
public string Name { get; }
public string Id { get; }
public string KeyId => Id + "#main-key";
public string SharedInbox => Id.Split("/users/")[0] + "/inbox";
public string Wall => Id + "/wall";
// the activity with its proof by this actor's Ed25519 key
public JsonObject Prove(JsonObject activity)
{
activity["proof"] = PrivaPub.Federation.Signing.IntegrityProofs.Create(activity, Id + "#ed25519-key", _ed25519, DateTime.UtcNow);
return activity;
}
public JsonObject Document()
{
var document = new JsonObject
{
["id"] = Id,
["type"] = Type,
["preferredUsername"] = Name,
["inbox"] = Id + "/inbox",
["followers"] = Id + "/followers",
["outbox"] = Id + "/outbox",
["featured"] = Id + "/featured",
["publicKey"] = new JsonObject
{
["id"] = KeyId,
["owner"] = Id,
["publicKeyPem"] = _key.ExportSubjectPublicKeyInfoPem()
}
};
if (_namesSharedInbox)
document["endpoints"] = new JsonObject { ["sharedInbox"] = SharedInbox };
if (_hasWall)
document["wall"] = Wall;
if (_ed25519 != default)
document["assertionMethod"] = new JsonArray(new JsonObject
{
["id"] = Id + "#ed25519-key", ["type"] = "Multikey", ["controller"] = Id,
["publicKeyMultibase"] = PrivaPub.Federation.Signing.IntegrityProofs.Multikey(PrivaPub.Federation.Signing.IntegrityProofs.PublicKey(_ed25519))
});
return document;
}
public HttpRequestMessage SignedGet(string path, string host = "privapub.test")
{
var date = DateTimeOffset.UtcNow.ToString("r", CultureInfo.InvariantCulture);
var request = new HttpRequestMessage(HttpMethod.Get, path);
request.Headers.TryAddWithoutValidation("Date", date);
request.Headers.TryAddWithoutValidation("Accept", "application/activity+json");
request.Headers.TryAddWithoutValidation("Signature", Signature($"(request-target): get {path}\nhost: {host}\ndate: {date}", "(request-target) host date"));
return request;
}
public HttpRequestMessage SignedPost(string path, JsonNode activity, string host = "privapub.test", string date = default)
{
var body = Encoding.UTF8.GetBytes(activity.ToJsonString());
date ??= DateTimeOffset.UtcNow.ToString("r", CultureInfo.InvariantCulture);
var digest = HttpSignatures.Digest(body);
var request = new HttpRequestMessage(HttpMethod.Post, path) { Content = new ByteArrayContent(body) };
request.Content.Headers.TryAddWithoutValidation("Content-Type", "application/activity+json");
request.Headers.TryAddWithoutValidation("Date", date);
request.Headers.TryAddWithoutValidation("Digest", digest);
request.Headers.TryAddWithoutValidation("Signature",
Signature($"(request-target): post {path}\nhost: {host}\ndate: {date}\ndigest: {digest}", "(request-target) host date digest"));
return request;
}
// the same delivery signed as RFC 9421 does it (WordPress, Ghost, Fedify), for `signedFor` when it is not where the
// request goes
public HttpRequestMessage MessageSignedPost(string path, JsonNode activity, string signedFor = default, string origin = "https://privapub.test")
{
var body = Encoding.UTF8.GetBytes(activity.ToJsonString());
var request = new HttpRequestMessage(HttpMethod.Post, new Uri(origin + (signedFor ?? path))) { Content = new ByteArrayContent(body) };
request.Content.Headers.TryAddWithoutValidation("Content-Type", "application/activity+json");
MessageSignatures.Sign(request, KeyId, _key.ExportPkcs8PrivateKeyPem(), body);
request.RequestUri = new Uri(origin + path);
return request;
}
public string PrivateKeyPem => _key.ExportPkcs8PrivateKeyPem();
public string PublicKeyPem => _key.ExportSubjectPublicKeyInfoPem();
string Signature(string signingString, string headers)
{
var signature = Convert.ToBase64String(_key.SignData(Encoding.UTF8.GetBytes(signingString), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1));
return $"keyId=\"{KeyId}\",algorithm=\"rsa-sha256\",headers=\"{headers}\",signature=\"{signature}\"";
}
public HttpRequest Get(string host, string path)
{
var date = DateTimeOffset.UtcNow.ToString("r", CultureInfo.InvariantCulture);
var context = new DefaultHttpContext();
context.Request.Method = "GET";
context.Request.Host = new HostString(host);
context.Request.Path = path;
context.Features.Get<IHttpRequestFeature>().RawTarget = path;
context.Request.Headers["Date"] = date;
context.Request.Headers["Signature"] = Signature($"(request-target): get {path}\nhost: {host}\ndate: {date}", "(request-target) host date");
return context.Request;
}
public HttpRequest Post(string host, string path, JsonNode activity)
{
var body = Encoding.UTF8.GetBytes(activity.ToJsonString());
var date = DateTimeOffset.UtcNow.ToString("r", CultureInfo.InvariantCulture);
var digest = HttpSignatures.Digest(body);
var context = new DefaultHttpContext();
context.Request.Method = "POST";
context.Request.Host = new HostString(host);
context.Request.Path = path;
context.Features.Get<IHttpRequestFeature>().RawTarget = path;
context.Request.Headers["Date"] = date;
context.Request.Headers["Digest"] = digest;
context.Request.Headers["Content-Type"] = "application/activity+json";
context.Request.Headers["Signature"] = Signature($"(request-target): post {path}\nhost: {host}\ndate: {date}\ndigest: {digest}", "(request-target) host date digest");
context.Request.Body = new MemoryStream(body);
context.Request.ContentLength = body.Length;
return context.Request;
}
}
}