FEP-8fcf, received. When a delivery's Collection-Synchronization header digests the sender's followers on PrivaPub
otherwise than the personas following it, a job reads the list the header names (on the sender's origin, signed by the
instance actor): a follow the list leaves out ends, only when the list is the one the digest describes; a request it
lists is taken as accepted; a persona it lists that follows nothing there sends Undo{Follow}, as Mastodon does. Each
claiming delivery is compared once.
Checked live (scenarios/followsync.sh, now 15 checks): PrivaPub ends a follow Mastodon lost and undoes one only
Mastodon remembered.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
88 lines
3.8 KiB
C#
88 lines
3.8 KiB
C#
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}\"";
|
|
|
|
// what a Collection-Synchronization header claims, written as a Signature header's parameters; default when it
|
|
// names no collection, list or digest
|
|
public static SynchronizationClaim Claim(string actorUri, string header)
|
|
{
|
|
var values = new Dictionary<string, string>(StringComparer.Ordinal);
|
|
foreach (var part in (header ?? "").Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries))
|
|
{
|
|
var equals = part.IndexOf('=');
|
|
if (equals > 0)
|
|
values[part[..equals].Trim()] = part[(equals + 1)..].Trim().Trim('"');
|
|
}
|
|
return values.TryGetValue("collectionId", out var collection) && values.TryGetValue("url", out var url) && values.TryGetValue("digest", out var digest)
|
|
&& digest.Length == 64 && digest.All(Uri.IsHexDigit)
|
|
? new SynchronizationClaim(actorUri, collection, url, digest.ToLowerInvariant())
|
|
: default;
|
|
}
|
|
|
|
// 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
|
|
});
|
|
}
|
|
}
|