Files
Warp/docs/federation.md
tajniak81andClaude Opus 5.5 fc49f56339 Phase 7: E2EE support
Implements PLAN.md Phase 7: the server half of end-to-end encryption.
Devices upload identity, one-time and fallback keys; other devices query
and claim them, across servers too, and every one-time key is handed out
exactly once. Cross-signing, signature uploads, device list tracking and
server-side key backup complete the set.

internal/keyserver is the component. Keys are stored in canonical JSON
so that re-uploading the same key is a byte comparison, and signatures
uploaded later are kept apart from the key they sign and merged in on
read, so a key's own JSON stays what its owner uploaded. Device keys must
be for the caller's own device; cross-signing keys must be the
uploader's, carry the right usage and, for self- and user-signing keys,
be signed by the master key. Signatures are verified against the key as
stored. Replacing a master key asks for the password; a first upload or
an upload of the keys already stored does not, as the spec allows. A
signature of another user's master key is shown only to the user who
made it and never federated.

Every write that changes what /keys/query answers moves the device list
stream in the same transaction, one position per device, and wakes the
syncs of everyone sharing a room. The stream is the sixth field of a sync
token; older tokens still parse. The syncapi works out device_lists
changed and left from the stream and from membership moves in the
window, skipping invites and knocks, and reports
device_one_time_keys_count and device_unused_fallback_key_types, the
latter as [] when empty. GET /keys/changes uses the same logic.

Over federation: /user/keys/query, /user/keys/claim and
/user/devices/{userId}, and m.device_list_update and m.signing_key_update
both ways. Remote keys are not cached; an update about a remote user
sharing a room moves them in the stream so local clients re-query.
Device display names are not sent to other servers. With federation off,
remote users are reported in failures at once.

Deleting a device deletes its keys, through a new userapi
DeviceObserver. Key backup keeps the better copy of each session by the
spec's rule, never reuses a deleted version's number, and answers writes
to a stale version with M_WRONG_ROOM_KEYS_VERSION.

Storage is migration 0008 for sqlite and postgres, with a PocketBase
stub. The Complement target sets gain the key, device list, fallback,
cross-signing and backup tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 10:34:08 +02:00

382 lines
21 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Federation
PLAN.md Phase 5: the Server-Server API. Warp can now join rooms on other
servers, have their users join its rooms, exchange events and typing,
receipts and to-device messages with them, and do all of it behind a switch
that turns it off at runtime — cleanly, with nothing left talking to
another server — and back on again, picking up where it left off.
Federation is still off in a fresh install. Nothing here changes what a
private homeserver does.
## Where things live
| Package | What |
|---|---|
| `internal/federation` | The protocol, with no knowledge of rooms: X-Matrix request signing, server discovery, the signed HTTP client, the key ring, the gate and the allow/deny policy. |
| `internal/roomserver` | Rooms across servers: taking in events from elsewhere (`federated.go`), joining, leaving and inviting across servers in both directions (`federated_membership.go`), answering other servers' reads (`federated_query.go`), remote aliases and directories, server ACLs (`acl.go`). |
| `internal/federationapi` | Federation as a running component: the inbound endpoints, the outbound queue, the EDU adapter and the `Module` the switch starts and stops. |
The split follows the dependencies. The roomserver uses the protocol — it
fetches events and asks for joins — and the component uses the roomserver,
so the component cannot live in the protocol package. PLAN.md calls it
`federation.Module`; it is `federationapi.Module`, for that reason.
## The switch
Three things answer to `federation.enabled`, and each reads it at the
moment it matters rather than being told:
- **The gate.** Every route under `/_matrix/federation`, `/_matrix/key`
and `/.well-known/matrix/server` is wrapped by it, and answers
`404 M_UNRECOGNIZED` while the switch is off — a server with federation
off looks exactly like a server without a federation API.
- **The client.** Every outbound request checks the switch, the allow and
deny lists, and that it is not addressed to this server, before it
resolves anything or opens a connection. A component that somehow missed
the toggle still cannot reach another server.
- **The module.** The one thing that talks to other servers on its own
initiative is the outbound queue, and the module starts and stops it.
The settings service calls the module when the switch moves; stopping
cancels in-flight sends and waits for them, and leaves whatever was not
sent in the database.
Events persisted while the switch is off are not queued. Holding them would
turn switching federation back on after a month into a month of events
delivered in one burst; a remote server that cares about a room fetches
what it missed with `/get_missing_events` the next time it hears from this
one. What was queued before the switch went off goes out when it comes
back on.
## Finding other servers
`federation.Resolver` implements the spec's discovery algorithm, in its
order: an IP literal or an explicit port is used as is; otherwise
`/.well-known/matrix/server` may delegate to another name, which is then
resolved the same way; then the `_matrix-fed._tcp` SRV record, then the
deprecated `_matrix._tcp`, then port 8448.
The result has two halves and the client keeps them apart. The address to
dial can be an SRV target; the name the TLS certificate must be valid for —
also the `Host` header — is never the SRV target, only the server name or
the name the well-known document delegated to. The client carries the
dial addresses down to its dialer in the request context and builds the URL
from the other half, so Go's TLS verification checks the right name
without any custom verification code.
Well-known answers are cached for their `Cache-Control` max-age, between
the spec's bounds (24 hours by default, 48 at most); failures for an hour,
so that a server with no document is not asked for one on every request.
## Keys
`GET /_matrix/key/v2/server` serves this server's key, valid for
`federation.key_validity_period` (24 hours by default).
Everybody else's keys go through the key ring. A key response is accepted
only if it is about the server asked for and every signature the server
made on it is by a key it lists and verifies — a response citing a key it
does not publish is broken or forged. Keys are stored in
`federation_server_keys` and cached in memory, and are believed for as long
as the server said, but never for more than a week: the spec's cap.
When a server's own key endpoint cannot be reached, the notaries in
`federation.trusted_key_servers` are asked, in order; a notary's answer
must carry the notary's signature as well. The default is no notaries:
keys come from the server that owns them or not at all.
Room versions 5 and later require a key to have been valid when the event
was sent. A cached key that is not known to cover an event's timestamp is
fetched again before the event is refused — the server may have extended
it — and a fetch that fails is not retried for a minute, so that a server
whose key endpoint is down is not asked on every event it sends.
As a notary, Warp answers for its own keys only. It keeps other servers'
keys, not the signed documents they came in, and a notary has to hand out
the documents.
## Authenticating requests
Every federation endpoint but the key and version endpoints checks the
`X-Matrix` Authorization header: the signature covers the method, the
request target exactly as it appeared on the request line, the origin, the
destination and the JSON body. The origin is then checked against the
allow and deny lists, and — for anything about a room — against the room's
server ACL.
The parser is fuzzed (`FuzzParseXMatrix`): a header that parses must
survive being written back out and parsed again unchanged.
## Taking in events from other servers
An event created here is built on the room's current state and can only
fail the authorisation rules. An event from somewhere else arrives with
none of that settled. The roomserver follows the spec's procedure, in
`federated.go`:
1. **It is what it says it is.** It parses under the room's version, it is
signed by its sender's server, and its content matches its hash. A hash
mismatch with a good signature means somebody redacted it on the way;
the redacted form is kept rather than the event rejected.
2. **It passes the rules against its own auth events.** Missing auth
events are fetched with `/event_auth` and checked themselves, oldest
first.
3. **It passes them against the state before it** — the state after each
of its prev_events, resolved together. A prev_event this server does not
have is asked for with `/get_missing_events` (ten events, between this
server's forward extremities and the new event). A gap wider than that
is closed by asking for the state at the missing event with
`/state_ids`, fetching whatever of it is missing — one at a time with
`/event`, or all at once with `/state` past fifty — and checking all of
it.
4. **It passes them against the current state.** An event that fails only
here is *soft-failed*: kept, because other events may cite it, but never
shown to a client and never a forward extremity. This is what stops a
banned user's server from sending events built on the state before the
ban.
An event that fails 1 to 3 is rejected and not stored. The `/send`
response says why, per event.
### Outliers
Some events have to be held without the room's state at them: the state
and auth chain a join is handed, auth events fetched to judge something
else, the state fetched across a gap, an invite to a room nobody here is
in, a soft-failed event. These are *outliers* — a column on
`roomserver_events` rather than a table of their own, because every read
of a room's current state joins the events table, and the current state of
a room joined over federation is almost all outliers.
An outlier can be cited and read by ID. It has no state snapshot, is never
a forward extremity, and never appears in `/sync`, `/messages` or
`/context` — the timeline queries filter it out. An outlier that arrives
again as an ordinary event stays an outlier: this server used it as state
without its history, and putting the room's head on it now could put the
head behind events that are already here.
### The room's head
A Phase 3 room had one forward extremity, because every event was a child
of the one before. Federation forks rooms: two servers send at the same
moment, each without the other's event. The new extremities are the old
ones the event does not descend from, plus the event.
The current state then has to be worked out again. In the common case —
the event follows the room's single extremity — it is one tuple more than
before. Otherwise it is resolved across the new extremities and written
whole. The next event created here cites every extremity, and the fork
closes; `TestFederatedJoinAndConversation` checks that both servers end up
with one extremity and the same state.
### History: backfill
A room joined over federation starts, on this server, at the join: the
join is handed the room's state, not the events that led to it. When a
client pages `/messages` backwards and the page comes back short at the
bottom of what this server holds, the roomserver asks the servers in the
room for what came before it (`GET /backfill`) before answering, in
`backfill.go`:
- **Where to start.** The prev_events of the room's oldest timeline events
(the oldest fifty) that are not themselves in the timeline. A room whose
oldest event is its create event has nothing to ask for.
- **Whom to ask.** The servers of the room's members, joined ones first,
in turn, until one sends something that passes. A page asks at most
three times before it is returned short.
- **What is checked.** Steps 1 to 3 of the intake above. The state before
each event comes from the events before it in the same batch, or —
for the oldest ones — from `/state_ids` at its prev_events. Step 4 does
not apply: soft failure refuses events built on a state the room has
moved past, which history always is. An event already held as an
outlier is promoted into the timeline.
- **Where it goes.** Below everything. The stream ordering counter only
goes up, so backfilled events are numbered by a second counter that runs
downwards from -1 (`roomserver_sequences.backfill_ordering`). Each batch
sorts below the one before it, and nothing about the room's head, its
current state or its stream top changes: a live `/sync` reads "after my
token" and never reaches below zero, so history reaches clients only
through `/messages`, `/context` and the state at a point in it. Zero is
never a position, so it can go on meaning "unbounded" in every window;
the position just below the event at 1 is -1 (`storage.PositionBefore`).
Backfill is only asked for when the user may see before their join — a
room whose history visibility is `joined` or `invited` has nothing to
show them there — and only by `/messages`, never by `/sync` or `/context`,
which do not wait on another server. With federation off, or with nobody
reachable, the page is what this server holds.
## Membership across servers
A server can only sign as itself, and a room's state lives with the
servers in it, so each membership change that crosses a server boundary is
a short protocol:
- **Joining a room this server is not in** — not held at all, held only as
an invite, or held from before every local user left — asks a resident
server for a template (`make_join`), fills it in, signs it, and hands it
back (`send_join`, v2). The answer is the room's state and auth chain,
every event of which is verified and checked against its own auth events
before any of it is believed. Candidates are tried in order: the
client's `server_name` hints, the server in the room ID (before version
12), the servers of the room's members as last known, and whoever sent
the invite. A restricted join comes back countersigned by the server that
authorised it.
- **Leaving a room this server is not in** — rejecting an invite, in
practice — is the same with `make_leave` and `send_leave`. When no server
in the room can be reached the invite is still rejected locally: a leave
is built and stored here and nowhere else. An invite a user cannot get
rid of because its server is gone is the worse failure.
- **Inviting a user of another server** goes through the ordinary
pipeline, with one extra stage under the room lock: the invite is sent
to the invitee's server to countersign (`/invite`, v2), and the copy with
both signatures is the one persisted.
And the other side of each:
- `make_join` builds the template from the room's current state and runs
the rules over a trial build first, so that a banned user hears so before
signing anything. Under the restricted join rule it names a local user to
authorise the join, and `send_join` countersigns it.
- `send_join` and `send_leave` take the event through the same intake as
any other, and mark it to be **relayed**: the joining server is not in
the room yet and relies on this one to tell everybody else.
- `/invite` countersigns an invite to a local user. A room this server is
in takes the invite in as an ordinary event. One it is not in gets it as
an out-of-band membership — an outlier that is nonetheless the invitee's
membership in the room's current state, which is all `/sync` needs — and
the stripped state the inviter sent along is kept in
`roomserver_invite_state` for the invite list, until the invite is
answered.
## The outbound queue
`federationapi.Queue` sends this server's events and EDUs to everybody who
should have them.
The roomserver's emit stage calls it with the room lock held, so that call
only appends to an in-memory list. A dispatcher drains the list, works out
each event's destinations — the servers with a user joined to the room,
plus, for a membership event, the server of the user it is about, so that
a server whose last user was just kicked hears of it — and writes one row
per destination to `federation_queue`. An event received from another
server is not sent on, because its origin sends it to everybody itself;
the exception is a relayed join or leave, which goes to everybody but its
origin.
A worker per destination sends the rows in transactions of at most 50 PDUs
and 100 EDUs, and deletes them once the destination has answered. A
failure backs the destination off — ten seconds, doubling to six hours,
with a fifth of jitter — in `federation_destinations`, which the admin API
reports and can reset. A destination later denied by the allow and deny
lists has its backlog dropped rather than sent.
Rows are deleted only once delivered, so a restart loses nothing that had
reached the database: the queue starts a worker for every destination with
a backlog. Transaction IDs carry the queue's start time, because a
receiving server deduplicates by transaction ID and an ID reused after a
restart with different contents would be answered from its cache.
Received transactions are recorded with their response in
`federation_inbound_txns`, so that a retransmission is answered the same
way rather than processed twice.
## EDUs
| EDU | Out | In |
|---|---|---|
| `m.typing` | a local user's typing, to the room's servers | into the typing tracker, if the user belongs to the sending server |
| `m.receipt` | a local user's public read receipts — never private ones | into the receipts stream, likewise |
| `m.direct_to_device` | to-device messages for remote users, one EDU per server | delivered to local devices |
| `m.device_list_update`, `m.signing_key_update` | a local user's key changes, to every server sharing a room with them | the remote user is marked changed for local syncs; see [e2ee.md](e2ee.md) |
| `m.presence` | — | ignored: presence is off by default |
Nothing in an EDU is signed, so the one check there is — the user must
belong to the server that sent it — is what keeps a server from making
anybody appear to type. EDUs about a room are subject to its server ACL.
The sync API hands EDUs over through `syncapi.Outbound`, and only for what
a local user did: the same calls are how EDUs from other servers come in,
and passing those on would echo them back.
## Server ACLs
`m.room.server_acl` is enforced against the origin of everything about a
room: its PDUs, its typing and receipts, its joins and leaves, and its
reads of the room's history. Globs match case-insensitively, without the
port; deny wins over allow; an empty allow list allows nobody; and
`allow_ip_literals: false` refuses servers named by an IP address. An ACL
never applies to the server enforcing it.
## What other servers may read
`/event`, `/state`, `/state_ids`, `/event_auth`, `/backfill` and
`/get_missing_events` are answered for a server with a user joined to the
room, or for any server if the room is world-readable — and only if the
room's ACL allows it. Anybody else gets `403` whether or not the event
exists.
`/state_ids` and `/state` answer with the state *before* the event asked
about, which is what every implementation's client side expects; the
asker adds the event itself if it is a state event. An outlier has no
snapshot, and asking for the state at one is a `404`.
## Endpoints
| | |
|---|---|
| `GET /_matrix/key/v2/server` · `POST /_matrix/key/v2/query` · `GET /_matrix/key/v2/query/{server}` | keys; the notary answers for this server only |
| `GET /_matrix/federation/v1/version` | |
| `PUT /v1/send/{txnId}` | transactions of PDUs and EDUs |
| `GET /v1/make_join/{room}/{user}` · `PUT /v1/send_join`, `/v2/send_join` | joins |
| `GET /v1/make_leave/{room}/{user}` · `PUT /v1/send_leave`, `/v2/send_leave` | leaves |
| `PUT /v2/invite/{room}/{event}` | invites |
| `GET /v1/event/{event}` · `/v1/state/{room}` · `/v1/state_ids/{room}` · `/v1/event_auth/{room}/{event}` | reads |
| `GET /v1/backfill/{room}` · `POST /v1/get_missing_events/{room}` | history |
| `GET /v1/query/directory` · `/v1/query/profile` | queries |
| `GET`, `POST /v1/publicRooms` | the public room directory |
Client-Server endpoints that now reach other servers: `/join` and
`/rooms/{id}/join` for a room this server is not in (by ID or by remote
alias), `/invite` for a remote user, `/leave` to reject a remote invite,
`GET /directory/room/{alias}` for a remote alias, `/publicRooms?server=`,
and `GET /profile/{user}` for a remote user.
## Known gaps
- **Backfill only fills in history below the room.** A gap higher up — a
live event whose prev_events were closed with `/state_ids` rather than
fetched — stays a gap. And the end of a server's history is not
remembered: a room whose servers cannot supply anything before its
oldest event is asked again each time a client scrolls to the top.
- **Backfilled redactions are not applied** to earlier backfilled events;
a server sends a redacted event in its redacted form anyway, but
`unsigned.redacted_because` is not filled in.
- **No partial-state joins** (MSC3706). Joining a room of tens of
thousands of members means receiving, verifying and checking its whole
state before the join completes, which takes minutes.
- **No key rotation.** The key file holds one key; old keys are not
served as `old_verify_keys`, so rotating the key would invalidate this
server's old events for anybody who has not cached the old key.
- **Rejected events are not remembered.** A rejected event is not stored,
so if it is sent again it is fetched and judged again.
- **Soft-failed events are never re-evaluated**, and a redaction whose
target arrives later is not applied when the target arrives.
- **Room versions 1 and 2** — event IDs carried in the event — are not
supported, so the oldest rooms on the network cannot be joined.
- **Presence EDUs** are ignored on the way in and not sent (presence is
off by default).
- **Third-party invites** across servers (`exchange_third_party_invite`)
and **knocking** over federation (`make_knock`, `send_knock`) are not
implemented.
## Testing
| Where | What |
|---|---|
| `internal/federation` | X-Matrix parsing (fuzzed) and signing, discovery against fake DNS and a fake well-known server, the key ring against a fake key server, an authenticated request end to end over TLS, the client refusing while disabled or denied. |
| `internal/roomserver/federated_test.go` | Two and three roomservers in one process, wired to each other's federation methods: joins at versions 10–12, conversation and fork merging, fail-over, refused joins, invites with countersignatures, rejection with and without the inviter reachable, missing events, a gap closed with state, forged and soft-failed events, ACLs, remote aliases and directories, the switch. `backfill_test.go`: a server that joined late scrolling back through the whole room at versions 10–12, the state inside the history, backfill with the switch off and under `joined` visibility. |
| `internal/federationapi` | The queue: fan-out and relay rules, backoff and retry, transactions within the limits, a restart with a backlog, the policy, EDUs. |
| `internal/setup/phase5_test.go` | Two complete servers over HTTPS: a join by remote alias, messages both ways through the queues, a remote profile, federation switched off with a message held and delivered after it is switched back on, the admin view of it; the TLS listener and the module following the switch. |
| Complement | `make complement-federation` — see [server/test/complement](../server/test/complement/README.md). |