2026-10-01 10:57:01 +02:00
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 );
2026-10-06 01:56:06 +02:00
readonly bool _namesSharedInbox ;
2026-10-06 08:17:10 +02:00
readonly bool _hasWall ;
2026-10-01 10:57:01 +02:00
2026-10-06 08:17:10 +02:00
// 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)
public RemoteActor ( Peer peer , string name , string origin = default , string type = "Person" , bool sharedInbox = false , bool wall = false )
2026-10-01 10:57:01 +02:00
{
Name = $"{name}{Guid.NewGuid():N}" [.. 20 ];
Id = $"{origin ?? peer.A}/users/{Name}" ;
2026-10-01 12:30:57 +02:00
Type = type ;
2026-10-06 01:56:06 +02:00
_namesSharedInbox = sharedInbox ;
2026-10-06 08:17:10 +02:00
_hasWall = wall ;
2026-10-01 10:57:01 +02:00
peer . Serve ( $"/users/{Name}" , Document (). ToJsonString ());
}
2026-10-01 12:30:57 +02:00
public string Type { get ; }
2026-10-01 10:57:01 +02:00
public string Name { get ; }
public string Id { get ; }
public string KeyId => Id + "#main-key" ;
2026-10-01 11:34:35 +02:00
public string SharedInbox => Id . Split ( "/users/" )[ 0 ] + "/inbox" ;
2026-10-06 08:17:10 +02:00
public string Wall => Id + "/wall" ;
2026-10-01 10:57:01 +02:00
2026-10-06 01:56:06 +02:00
public JsonObject Document ()
2026-10-01 10:57:01 +02:00
{
2026-10-06 01:56:06 +02:00
var document = new JsonObject
2026-10-01 10:57:01 +02:00
{
2026-10-06 01:56:06 +02:00
["id"] = Id ,
["type"] = Type ,
["preferredUsername"] = Name ,
["inbox"] = Id + "/inbox" ,
["followers"] = Id + "/followers" ,
2026-10-06 02:15:49 +02:00
["outbox"] = Id + "/outbox" ,
2026-10-06 01:56:06 +02:00
["featured"] = Id + "/featured" ,
["publicKey"] = new JsonObject
{
["id"] = KeyId ,
["owner"] = Id ,
["publicKeyPem"] = _key . ExportSubjectPublicKeyInfoPem ()
}
};
if ( _namesSharedInbox )
document [ "endpoints" ] = new JsonObject { [ "sharedInbox" ] = SharedInbox };
2026-10-06 08:17:10 +02:00
if ( _hasWall )
document [ "wall" ] = Wall ;
2026-10-06 01:56:06 +02:00
return document ;
}
2026-10-01 10:57:01 +02:00
2026-10-03 10:43:03 +02:00
public HttpRequestMessage SignedGet ( string path , string host = "privapub.test" )
2026-10-01 12:30:57 +02:00
{
var date = DateTimeOffset . UtcNow . ToString ( "r" , CultureInfo . InvariantCulture );
2026-10-03 10:43:03 +02:00
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 ;
}
2026-10-05 06:48:22 +02:00
// 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 ();
2026-10-03 10:43:03 +02:00
string Signature ( string signingString , string headers )
{
2026-10-01 12:30:57 +02:00
var signature = Convert . ToBase64String ( _key . SignData ( Encoding . UTF8 . GetBytes ( signingString ), HashAlgorithmName . SHA256 , RSASignaturePadding . Pkcs1 ));
2026-10-03 10:43:03 +02:00
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 );
2026-10-01 12:30:57 +02:00
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 ;
2026-10-03 10:43:03 +02:00
context . Request . Headers [ "Signature" ] = Signature ( $"(request-target): get {path}\nhost: {host}\ndate: {date}" , "(request-target) host date" );
2026-10-01 12:30:57 +02:00
return context . Request ;
}
2026-10-01 10:57:01 +02:00
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" ;
2026-10-03 10:43:03 +02:00
context . Request . Headers [ "Signature" ] = Signature ( $"(request-target): post {path}\nhost: {host}\ndate: {date}\ndigest: {digest}" , "(request-target) host date digest" );
2026-10-01 10:57:01 +02:00
context . Request . Body = new MemoryStream ( body );
context . Request . ContentLength = body . Length ;
return context . Request ;
}
}
}