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
84 lines
3.9 KiB
C#
84 lines
3.9 KiB
C#
using MongoDB.Entities;
|
|
|
|
using PrivaPub.Federation.Actors;
|
|
using PrivaPub.Federation.Objects;
|
|
using PrivaPub.Models.Post;
|
|
|
|
using System.Text.Json.Nodes;
|
|
|
|
using static PrivaPub.Federation.Objects.ActivityJson;
|
|
|
|
using PostEntity = PrivaPub.Models.Post.Post;
|
|
|
|
namespace PrivaPub.Federation.Inbox
|
|
{
|
|
// Inbox forwarding (ActivityPub 7.1.2): a server passes on an activity about a thread it holds to the thread's followers,
|
|
// signed with its own key. Mastodon forwards the replies to its accounts' posts, and their deletions; Friendica every
|
|
// activity in its threads. The signature proves who passed it on, never who wrote it, so nothing in it is believed: a
|
|
// Create or an Update is taken as its object reads at the actor's origin now, a Delete once that origin says the object
|
|
// is gone, and anything else is let go (a vote or a follow cannot be checked against its origin). An LD signature
|
|
// (RsaSignature2017) would prove the author, but needs JSON-LD. An integrity proof (FEP-8b32, eddsa-jcs-2022) by one of
|
|
// the actor's Ed25519 keys does: such an activity is taken as forwarded, read again from nowhere.
|
|
public static class Forwarded
|
|
{
|
|
// whether the activity carries a proof made by one of the actor's own keys (FEP-521a)
|
|
public static bool Proven(JsonNode activity, Models.User.ForeignAvatar actor) =>
|
|
activity is JsonObject document && document["proof"] is JsonObject proof && Value(proof, "verificationMethod") is { } method
|
|
&& actor.AssertionKeys.FirstOrDefault(k => k.Id == method) is { } key
|
|
&& Signing.IntegrityProofs.Verify(document, Convert.FromBase64String(key.PublicKey));
|
|
|
|
// whether it names a key of the actor's own that we do not hold (the actor was read before it had one)
|
|
public static bool NamesUnknownKey(JsonNode activity, Models.User.ForeignAvatar actor) =>
|
|
activity is JsonObject document && document["proof"] is JsonObject proof && Value(proof, "verificationMethod") is { } method
|
|
&& method.StartsWith(actor.ActorURI + "#", StringComparison.Ordinal) && actor.AssertionKeys.All(k => k.Id != method);
|
|
|
|
// what may be taken from a forwarder: a post of the activity's actor, created, edited or deleted
|
|
public static bool Takeable(string type, JsonNode activity, string actorUri)
|
|
{
|
|
var objectUri = Id(activity["object"]);
|
|
return type is "Create" or "Update" or "Delete" && objectUri != default && objectUri != actorUri && Origin.Same(objectUri, actorUri);
|
|
}
|
|
|
|
// the activity as its origin vouches for it, or why it is dropped
|
|
public static async Task<(JsonNode Activity, string Drop)> Confirm(JsonNode activity, string type, string actorUri,
|
|
IRemoteActorService remoteActors, CancellationToken token)
|
|
{
|
|
var objectUri = Id(activity["object"]);
|
|
var trusted = new JsonObject
|
|
{
|
|
["id"] = Id(activity),
|
|
["type"] = type,
|
|
["actor"] = actorUri
|
|
};
|
|
if (type == "Delete")
|
|
{
|
|
// only a copy anyone may read: the origin answers 404 for a followers-only post to our instance actor too,
|
|
// and the author's own server tells its recipients of a deletion
|
|
var held = await DB.Default.Find<PostEntity>().Match(p => p.ObjectURI == objectUri && p.ActorURI == actorUri).ExecuteFirstAsync(token);
|
|
if (held != default && held.Visibility is not (PostVisibility.Public or PostVisibility.Unlisted))
|
|
return (default, "forwarded-private");
|
|
if (!await remoteActors.IsGone(objectUri, token))
|
|
return (default, "forwarded-not-gone");
|
|
trusted["object"] = objectUri;
|
|
return (trusted, default);
|
|
}
|
|
|
|
if (type == "Create")
|
|
{
|
|
// the Create handler reads it again from its origin, and records that it did
|
|
trusted["object"] = objectUri;
|
|
return (trusted, default);
|
|
}
|
|
|
|
using var fetched = await remoteActors.FetchObject(objectUri, token);
|
|
if (fetched == default)
|
|
return (default, "fetch-failed");
|
|
var node = JsonNode.Parse(fetched.Root.GetRawText());
|
|
if (!Origin.Same(Id(node), actorUri))
|
|
return (default, "cross-origin");
|
|
trusted["object"] = node;
|
|
return (trusted, default);
|
|
}
|
|
}
|
|
}
|