swicg / swicg/activitypub-e2ee
Limiting group membership metadata exposure
Nobody has claimed this yet.
- Dominant language
- HTML
- Stars
- 78
- Forks
- 2
- PR merge metrics
- No merged PRs in 30d
Description
(Discussion thread: https://activitypub.space/topic/421/limiting-group-membership-metadata-exposure)
This issue is intended as a brainstorming discussions based on several comments by people such as @ThisIsMissEm from the Trust and Safety task force such as "E2EE protocol should not allow a server to know who is in a conversation". I don't pretend to have a good answer, but its worth trying to think about some options...
Problem
The current spec sends group messages with all recipients in the to field:
{
"type": "PrivateMessage",
"to": ["https://server-a/users/alice", "https://server-b/users/bob", "https://server-c/users/charlie"],
...
}
Every server that receives this message sees the full member list. For a group with members across N servers, every server learns the identities of all members on all other servers, a significant metadata leak independent of the E2E encryption of message content.
Some possible approaches
Option A: Use bto instead of to
ActivityPub already defines bto (blind carbon copy) semantics: the origin server strips bto recipients before delivery, and each destination server receives only the recipients relevant to it.
Bonfire's AP server already implements this: with in the AP publisher code the function preserve_privacy_of_outgoing groups recipients by destination server and filters bto/bcc to only that server's actors before each S2S HTTP POST.
Proposed change: Send group messages with recipients in bto rather than to:
{
"type": "PrivateMessage",
"actor": "https://server-a/users/alice",
"bto": ["https://server-a/users/alice", "https://server-b/users/bob", "https://server-c/users/charlie"],
...
}
Each server receives the activity with bto filtered to only its own local members. No server other than the sender's sees the full member list.
What each server learns:
| Server | Learns |
|---|---|
| Alice's server (origin) | Full bto list (needed to do the fanout) |
| Bob's server | Only that bob received this message |
| Charlie's server | Only that charlie received this message |
No new infrastructure needed. This is simple to do with existing AP semantics.
Option B: bto with shared inbox URLs
Applying bto semantics (Option A) to shared inbox addresses rather than individual actors, with a single Create activity addressed to each participating server's shared inbox:
{
"type": "Create",
"actor": "https://server-a/users/alice",
"bto": ["https://server-b/shared_inbox", "https://server-c/shared_inbox"],
"object": {
"type": "PrivateMessage",
"mediaType": "message/mls",
"encoding": "base64",
"content": "<mls ciphertext>"
}
}
What each server learns:
| Server | Learns |
|---|---|
| Alice's server (origin) | Which servers have at least one group member (shared inbox URLs only) |
| Bob's server | A PrivateMessage arrived; with bto stripped, sees only itself as a recipient |
| Charlie's server | Same |
Local delivery gap: the receiving server has no individual recipient named, so it needs a way to know which local users to deliver to. This option is not workable as decribed.
Option C: Central group actor as address target
A Group actor (group@server-a, hosted on Alice's server) acts as the routing hub. Alice, Bob (bob@server-b), and Charlie (charlie@server-c) all follow it as members.
When Bob sends a message to the group:
- Bob submits
Create { PrivateMessage, to: [group@server-a] }via C2S - Bob's server delivers to exactly one destination:
server-a's inbox - Bob's server never sees the follower list
- Alice's server (hosting the group actor) looks up followers and fans out to
server-bandserver-c
What each server learns:
| Server | Learns |
|---|---|
| Alice's server (group host) | Full follower list (since it does the fanout) |
| Bob's server | Bob sent to group@server-a |
| Charlie's server | Charlie received a message from the group |
The privacy gain is for senders on other servers, their servers only learn the group actor's address. The group host (Alice's server here) is the single point that knows all members.
This requires servers to support a Group actor type, follower management, and a trusted server to host it (eg. the group creator's server). That server becomes the single point holding the full member list.
Option D1: Hub-and-spoke satellite groups
An extension of Option C. Rather than individual users following the origin group actor directly, each participating server also hosts a local satellite group actor. The origin group actor's followers are these per-server satellite actors, rather than individual users. Each satellite actor is in turn followed by that server's local members.
This would use only standard C2S and FEP-1b12 Group actor behaviour, just nesting it with groups following groups.
Structure:
group@server-a: origin group actor (Alice's server, acts as hub)group-satellite@server-b: satellite group actor on Bob's server, followed by Bobgroup-satellite@server-c: satellite group actor on Charlie's server, followed by Charlie
Provisioning via C2S:
- Alice's client via C2S
Create { type: "Group" }→ hubgroup@server-acreated on server-a - Alice invites Bob to the group (method TBD)
- Bob's client via C2S
Create { type: "Group", context: "https://server-a/group" }on server-b → satellitegroup-satellite@server-bcreated (contextindicates group affiliation, exact field TBD) - Bob's client via C2S
Follow { actor: "group-satellite@server-b", object: "group@server-a" }→ server sends S2S Follow on behalf of the satellite; hub accepts (server should accept Bob acting on behalf of the group here, as its creator) - Bob's client via C2S
Follow { object: "group-satellite@server-b" }→ Bob subscribes locally for inbound delivery - Charlie's client follows the same steps on server-c
Message delivery:
All members (including Bob and Charlie) send directly to the hub:
- Bob submits
Create { PrivateMessage, to: [group@server-a] }via C2S; his server delivers S2S toserver-a - The hub Announces to its followers:
group-satellite@server-bandgroup-satellite@server-c - Each satellite Announces to its local followers: Bob and Charlie respectively
Satellites are delivery-only nodes: each satellite follows the hub so the hub can fan out to them, the hub does not follow the satellites and satellites do not follow each other.
What each server learns:
| Server | Learns |
|---|---|
| Alice's server (hub) | Which servers have a satellite (server-level only, no individual identities) |
| Bob's server | Bob is a member; group@server-a is the hub |
| Charlie's server | Charlie received a message from the group |
Privacy gain over Option C: The hub never learns individual member identities on other servers, only which servers have a satellite. Alice's server remains structurally central as the hub.
Option D2: Fully decentralised peer group actors
No origin or hub. Each server hosts a peer group actor; all peer group actors follow each other (mesh topology). Members address their local peer group actor.
Structure:
group@server-a: peer group actor, followed by Alice; followsgroup@server-bandgroup@server-cgroup@server-b: peer group actor, followed by Bob; followsgroup@server-aandgroup@server-cgroup@server-c: peer group actor, followed by Charlie; followsgroup@server-aandgroup@server-b
Provisioning via C2S:
- Alice C2S
Create { type: "Group" }→group@server-acreated; Alice C2SFollows it locally - Alice invites Bob to the group (method TBD)
- Bob C2S
Create { type: "Group", context: "https://server-a/group" }on server-b →group@server-bcreated (contextindicates peer affiliation, exact field TBD) - Bob C2S
Follow { actor: "group@server-b", object: "group@server-a" }→ server sends S2S Follow;group@server-aaccepts and sends a reciprocal S2SFollowback (mutual follow for mesh) - Bob C2S
Follow { object: "group@server-b" }→ Bob subscribes locally - When Charlie joins, the same steps repeat against all existing peers (O(n) explicit C2S Follow activities at join time)
Message delivery:
When Bob sends a message:
- Bob submits
Create { PrivateMessage, to: [group@server-b] }via C2S group@server-bAnnounces to its followers: Bob gets a local copy, fans out togroup@server-aandgroup@server-c- Each peer delivers to its local followers: Alice and Charlie respectively
What each server learns:
| Server | Learns |
|---|---|
| Alice's server | Alice is a member; peer group actors exist on server-b and server-c |
| Bob's server | Bob is a member; peer group actors exist on server-a and server-c |
| Charlie's server | Charlie is a member; peer group actors exist on server-a and server-b |
Privacy gain: No server is privileged. Each server knows only its own members and the existence of peer group actors on other server, not who those peers serve individually.
Additional requirement over D1: Loop prevention, so that when group@server-b fans out to group@server-a, server-a must not Announce it again to group@server-b (creating an infinite loop). Servers must deduplicate by activity ID: if a group actor has already processed a given activity ID, it silently drops it rather than re-announcing.
Contributor guide
No contributing guide indexed for this repository
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with the linked discussion thread and the ActivityPub recipient-delivery semantics described here. Inspect the publisher path around preserve_privacy_of_outgoing, then compare Options A-D2 for what each server learns and how delivery works. The issue is not done until one approach is selected and its protocol requirements are specified.
Written by the indexing model from the issue text.
Assessment
- Domain
- backend-api-design, distributed-systems, security
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Active
- Clarity
- Needs clarification
- Newbie friendliness
- 30/100