Deliveries to followers carry a digest of them, per server
Owner decision of 2026-10-06 (FEP-8fcf). A persona's delivery addressed to its followers carries a signed
Collection-Synchronization header naming its followers, its roll-call (…/groupies/roll-call) and the digest of its
accepted followers on the receiving server only. The roll-call answers a signed request with the persona's followers
on the signer's server and nobody else's.
Mastodon gives every Undo{Follow} it sends after reading a roll-call the same id (…#follows//undo), so a second one
looked like a copy: an Undo of a Follow that comes again while the follow it ends exists again is now kept once per
follow.
Checked live (scenarios/followsync.sh): Mastodon drops a follow PrivaPub lost, and undoes one it lost itself.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
This commit is contained in:
1 parent
bf37223849
commit
bd18de35a6
10 files changed
+385
-4
No files matched your search
@@ -0,0 +1,70 @@
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Models.Federation;
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace PrivaPub.Federation.Actors
|
||||
{
|
||||
// FEP-8fcf (owner decision 2026-10-06): a delivery addressed to a persona's followers carries a digest of its followers on
|
||||
// the receiving server, and that server reads which of its own accounts they are at the persona's roll-call, signed. A
|
||||
// server whose view differs (a Follow or an Undo lost on the way) mends it from there. No server learns of followers
|
||||
// anywhere else.
|
||||
public static class FollowersSynchronization
|
||||
{
|
||||
public const string Header = "Collection-Synchronization";
|
||||
|
||||
public static bool Synchronizes(LocalActor actor) => actor is { Kind: LocalActorKind.Person, IsCircle: false };
|
||||
|
||||
public static string RollCall(LocalActor actor) => actor.Followers + "/roll-call";
|
||||
|
||||
// a server, as Mastodon cuts a URL to tell where its accounts live: scheme and authority
|
||||
public static string Origin(string uri) =>
|
||||
Uri.TryCreate(uri, UriKind.Absolute, out var parsed) && parsed.Scheme is "https" or "http" ? $"{parsed.Scheme}://{parsed.Authority}" : default;
|
||||
|
||||
// the actor's accepted followers whose ids live under origin
|
||||
public static async Task<List<string>> On(LocalActor actor, string origin, CancellationToken token)
|
||||
{
|
||||
if (origin == default)
|
||||
return [];
|
||||
var under = new BsonRegularExpression("^" + Regex.Escape(origin + "/"));
|
||||
return await DB.Default.Find<Follower, string>()
|
||||
.Match(f => f.LocalActorId == actor.Id && f.LocalActorKind == actor.Kind && f.IsAccepted)
|
||||
.Match(f => f.Regex(x => x.ActorURI, under))
|
||||
.Project(f => f.ActorURI)
|
||||
.ExecuteAsync(token);
|
||||
}
|
||||
|
||||
// the XOR of each id's SHA-256, in lower-case hex: the same set gives the same digest in any order
|
||||
public static string Digest(IEnumerable<string> ids)
|
||||
{
|
||||
var digest = new byte[SHA256.HashSizeInBytes];
|
||||
foreach (var id in ids)
|
||||
{
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(id));
|
||||
for (var i = 0; i < digest.Length; i++)
|
||||
digest[i] ^= hash[i];
|
||||
}
|
||||
return Convert.ToHexStringLower(digest);
|
||||
}
|
||||
|
||||
public static string HeaderValue(LocalActor actor, string digest) =>
|
||||
$"collectionId=\"{actor.Followers}\", url=\"{RollCall(actor)}\", digest=\"{digest}\"";
|
||||
|
||||
// whether the activity, or the object it carries, is addressed to the actor's followers
|
||||
public static bool ForFollowers(JsonObject activity, LocalActor actor) =>
|
||||
Addressed(activity, actor.Followers) || activity["object"] is JsonObject inner && Addressed(inner, actor.Followers);
|
||||
|
||||
static bool Addressed(JsonObject node, string followers) =>
|
||||
new[] { "to", "cc", "bto", "bcc" }.Any(field => node[field] switch
|
||||
{
|
||||
JsonArray many => many.Any(a => a is JsonValue value && value.TryGetValue<string>(out var text) && text == followers),
|
||||
JsonValue one => one.TryGetValue<string>(out var text) && text == followers,
|
||||
_ => false
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -143,6 +143,20 @@ namespace PrivaPub.Federation.Controllers
|
||||
return Activity(ActivityPubRenderer.OrderedCollection(local.Followers, (int)count, default));
|
||||
}
|
||||
|
||||
// FEP-8fcf: the persona's followers on the server that signs the request, and nobody else's (FollowersSynchronization)
|
||||
[HttpGet, Route("{actor}/groupies/roll-call")]
|
||||
public async Task<IActionResult> RollCall(string actor, CancellationToken token)
|
||||
{
|
||||
var local = await _localActors.FindByUserName(actor, token);
|
||||
if (local is not { IsFederated: true } || !FollowersSynchronization.Synchronizes(local))
|
||||
return NotFound();
|
||||
var requester = await _fetches.Requester(Request, token);
|
||||
if (requester == default)
|
||||
return StatusCode(StatusCodes.Status401Unauthorized);
|
||||
var there = await FollowersSynchronization.On(local, FollowersSynchronization.Origin(requester.ActorURI), token);
|
||||
return Activity(ActivityPubRenderer.OrderedCollection(FollowersSynchronization.RollCall(local), there.Count, there.Select(f => (JsonNode)f)));
|
||||
}
|
||||
|
||||
[HttpGet, Route("{actor}/stalking")]
|
||||
public async Task<IActionResult> Following(string actor, CancellationToken token)
|
||||
{
|
||||
|
||||
@@ -199,10 +199,27 @@ namespace PrivaPub.Federation.Inbox
|
||||
// true copy carries the same
|
||||
if (!queued && dedupe != default && CarriesOther(await _queue.Payload(dedupe, token), activity))
|
||||
queued = await _queue.Enqueue(JobKind.ProcessInbox, queuedPayload, host, $"{dedupe}|{Digest(activity)}", token);
|
||||
// an Undo of a Follow that comes again while the follow it ends exists again is meant for that follow: Mastodon
|
||||
// gives every Undo it sends after reading a roll-call (FEP-8fcf) one id, `{actor}#follows//undo`. It is kept
|
||||
// once per follow, so a retry still counts as a copy
|
||||
if (!queued && dedupe != default && type == "Undo" && await FollowAgain(activity, actorUri, token) is { } follow)
|
||||
queued = await _queue.Enqueue(JobKind.ProcessInbox, queuedPayload, host, $"{dedupe}|{Digest(activity)}|{follow}", token);
|
||||
_logger.LogInformation("Inbox {Recipient}: {Type} from {Actor} queued", recipient?.Handle ?? "shared", type, actorUri);
|
||||
return new(StatusCodes.Status202Accepted, Reason: !queued ? "duplicate" : forwardedBy != default ? "forwarded" : "queued");
|
||||
}
|
||||
|
||||
// the follow an Undo of a Follow would end, when there is one: its record's id
|
||||
async Task<string> FollowAgain(JsonNode activity, string actorUri, CancellationToken token)
|
||||
{
|
||||
if (activity["object"] is not JsonObject inner || Value(inner, "type") != "Follow" || Id(inner["object"]) is not { } targetUri
|
||||
|| await _localActors.FindByUri(targetUri, token) is not { } target)
|
||||
return default;
|
||||
var follower = await MongoDB.Entities.DB.Default.Find<Follower>()
|
||||
.Match(f => f.ActorURI == actorUri && f.LocalActorId == target.Id && f.LocalActorKind == target.Kind)
|
||||
.ExecuteFirstAsync(token);
|
||||
return follower?.ID;
|
||||
}
|
||||
|
||||
static string HostOf(string uri) => Uri.TryCreate(uri, UriKind.Absolute, out var parsed) ? parsed.Host.ToLowerInvariant() : default;
|
||||
|
||||
static bool CarriesOther(string earlierPayload, JsonNode activity)
|
||||
|
||||
@@ -170,6 +170,15 @@ namespace PrivaPub.Federation.Outbox
|
||||
});
|
||||
}
|
||||
|
||||
// FEP-8fcf: what a delivery to the persona's followers tells the receiving server of its followers there
|
||||
static async Task<(string, string)[]> Synchronization(LocalActor signer, string body, Uri inbox, CancellationToken token)
|
||||
{
|
||||
if (!FollowersSynchronization.Synchronizes(signer) || JsonNode.Parse(body) is not JsonObject activity || !FollowersSynchronization.ForFollowers(activity, signer))
|
||||
return [];
|
||||
var there = await FollowersSynchronization.On(signer, FollowersSynchronization.Origin(inbox.AbsoluteUri), token);
|
||||
return [(FollowersSynchronization.Header, FollowersSynchronization.HeaderValue(signer, FollowersSynchronization.Digest(there)))];
|
||||
}
|
||||
|
||||
async Task<JobOutcome> Deliver(Job job, Attempt attempt, CancellationToken token)
|
||||
{
|
||||
var payload = JsonSerializer.Deserialize<DeliveryPayload>(job.Payload);
|
||||
@@ -198,7 +207,7 @@ namespace PrivaPub.Federation.Outbox
|
||||
var body = Encoding.UTF8.GetBytes(payload.Body);
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, inbox) { Content = new ByteArrayContent(body) };
|
||||
request.Content.Headers.ContentType = MediaTypeHeaderValue.Parse(RemoteActorService.ActivityJson);
|
||||
HttpSignatures.Sign(request, signer, body);
|
||||
HttpSignatures.Sign(request, signer, body, await Synchronization(signer, payload.Body, inbox, token));
|
||||
|
||||
var started = Stopwatch.GetTimestamp();
|
||||
try
|
||||
|
||||
@@ -17,7 +17,8 @@ namespace PrivaPub.Federation.Signing
|
||||
|
||||
public static string Digest(byte[] body) => "SHA-256=" + Convert.ToBase64String(SHA256.HashData(body));
|
||||
|
||||
public static void Sign(HttpRequestMessage request, LocalActor signer, byte[] body)
|
||||
// extra: further headers the request carries, signed with the rest (FEP-8fcf's Collection-Synchronization)
|
||||
public static void Sign(HttpRequestMessage request, LocalActor signer, byte[] body, params (string Name, string Value)[] extra)
|
||||
{
|
||||
var date = DateTime.UtcNow.ToString("r", CultureInfo.InvariantCulture);
|
||||
var signed = new List<(string Name, string Value)>
|
||||
@@ -35,6 +36,12 @@ namespace PrivaPub.Federation.Signing
|
||||
signed.Add(("digest", digest));
|
||||
}
|
||||
|
||||
foreach (var (name, value) in extra)
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation(name, value);
|
||||
signed.Add((name.ToLowerInvariant(), value));
|
||||
}
|
||||
|
||||
var signingString = string.Join("\n", signed.Select(h => $"{h.Name}: {h.Value}"));
|
||||
using var rsa = RSA.Create();
|
||||
rsa.ImportFromPem(signer.PrivateKeyPem);
|
||||
|
||||
Reference in new issue
Block a user