Owner decision of 2026-10-06 (G-0009, FEP-400e). A persona's actor names its wall (…/graffiti, sm:wall) and, in
Smithereen's privacySettings, that its followers may write on it. A public post that is not a reply, sent with the wall
as its target by an account following the persona, is hosted: the persona is notified, it reaches the persona's and its
followers' homes, and the followers' servers and the author's are told with Add{Note}. The persona deletes it with
DELETE /api/v1/statuses/:id, which sends Remove{Note}; Smithereen deletes the post then. The wall lists the persona's
public posts that start a thread and what was written on it.
Elsewhere: an account's Add{Note} on its own wall shows the post to its followers here as on that wall (privapub.wall on
the status), and its Remove takes it away. An Add or Remove naming a collection of the account's own server PrivaPub
does not know has its document read again first, at most hourly: accounts kept before walls were read had none.
Checked live: Smithereen 40 checks (G-0009 closed); GoToSocial and Mastodon unchanged (121).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
143 lines
5.8 KiB
C#
143 lines
5.8 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;
|
|
|
|
// 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)
|
|
{
|
|
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";
|
|
|
|
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;
|
|
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;
|
|
}
|
|
}
|
|
}
|