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
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-06 09:01:21 +02:00
1 parent c47b6e5533
commit 9ab87b2779
22 files changed
+702 -30

No files matched your search

+13 -1
View File
@@ -17,9 +17,21 @@ namespace PrivaPub.Federation.Inbox
// 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; integrity proofs (FEP-8b32) will.
// (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)
{
+3 -1
View File
@@ -65,7 +65,9 @@ namespace PrivaPub.Federation.Inbox
Record(job, payload, activity, type, started, new ArrivalVerdict(), Interactions.Deferred, "actor-unavailable");
return JobOutcome.Retry("the actor could not be loaded");
}
if (payload.ForwardedBy != default)
if (payload.ForwardedBy != default && Forwarded.NamesUnknownKey(activity, actor))
actor = await _remoteActors.GetActor(actor.ActorURI, refresh: true, token) ?? actor;
if (payload.ForwardedBy != default && !Forwarded.Proven(activity, actor))
{
var (confirmed, drop) = await Forwarded.Confirm(activity, type, actor.ActorURI, _remoteActors, token);
if (drop != default)