Same REST contract, same crypto engine, same community-anchored identity, but the message relay stops being a WordPress plugin and the web client stops sharing an origin with one. WordPress becomes one implementation of an identity interface rather than the whole backend. Revised after adversarial review: the split is right, but three things drawn here the first time were wrong, and four gaps turned up in the code while checking.
Two separate couplings, often confused for one. In the browser, the web client
shares an origin with every plugin script and a third-party tag. On the server,
every /e2e/* request boots the entire WordPress stack before it can run a single
indexed query.
The relay becomes a standalone service that owns /e2e/* and holds no keys. Identity
moves behind an interface: WordPress satisfies it on flamenet.io, a built-in auth service satisfies
it for anyone self-hosting. The relay verifies tokens against an issuer public key pinned in
its own config, it never fetches that key from the issuer, and never calls the issuer on the
request path.
POST /e2e/messages. Requiring a token there would hand the relay the sender's identity at request time and make sealed sender worse than it is today. This was drawn wrong the first time, see C."A portal-minted bearer token" is not a design, it's a placeholder. Four properties decide whether the split actually moves trust or just moves a hostname, and all four are cheaper to specify now than to retrofit onto a token format already in the wild.
dev = device_id and check it on every route that names a device.aud to the specific relay origin. Without it, a token minted by one instance is valid at another, which costs nothing today and costs everything the moment step 10 ships a second instance.localStorage falls to any XSS in your own app code, and the origin split removes the third-party scripts that were the other XSS source, which promotes your own code to the main one. Bind the token to IK_sig, which the relay already holds, and require a signed challenge per connection. A stolen token is then inert. This is the highest-value single change in the whole design.
The first draft of this document said auth becomes a token, full stop. Applied to
POST /e2e/messages that is a regression, not a fix. Sealed submission is
unauthenticated today by design, the sender presents the recipient's delivery key,
the handler stores from_user = 0 and from_device = '', and every sealed row
is byte-identical to every other. Attach a token to that route and the relay learns the sender at
request time; sealing would then hide the sender from the stored row and from nobody else.
So the relay keeps two auth modes, exactly as the plugin does now: device-bound token for identified routes, delivery-key capability for sealed submission, metered by recipient and by IP because there is no sender to meter. Signal's answer to the abuse problem this creates is delivery certificates, the server verifies a sender is permitted without learning who they are. Worth knowing that's the eventual shape; not worth blocking the migration on.
This is a port, not a redesign. docs/SPEC.md §8 already specifies the wire contract
precisely enough that the relay can be written against it and validated by the existing conformance
suite, which is the reason this is tractable at all.
| Component | Disposition | Notes |
|---|---|---|
| src/e2e.js | unchanged | X3DH, Double Ratchet, PQ layers. No protocol change anywhere in this migration. |
| SPEC.md §8 | unchanged | Same routes, same payloads. The relay is a second implementation of a contract that already exists. |
| iOS / Android engines | unchanged | Server picker already exists, they point at a different base URL and nothing else moves. |
| Portal accounts | unchanged | Community-anchored identity is the differentiator. It stays exactly where it is. |
| Relay service | new | Swift, Vapor, Fluent, Leaf. Owns all of §8, groups, attachments and TURN included. Built: e2e/relay/, driven over real HTTP by test/relay.mjs (60 assertions). |
| Token issuer | new | Small WP endpoint that signs a token scoped to one device and one relay, bound to IK_sig (§C). The relay holds only the public key, pinned in config. |
| Registration lock | new | Client-held secret gating device registration, so a stolen session is not a new device. |
| Cookie + nonce auth | retired | The coupling behind the sharpest attack path in the current design. |
| Delivery-key path | unchanged | Sealed submission stays unauthenticated. The relay must not require a token on this route (§C). |
| Inbox polling | kept | A held connection is an option, not the replacement, it leaks precise presence (§F). Polling stays the default until that tradeoff is decided deliberately. |
Moving to tokens fixes the origin problem but not the takeover problem: whoever controls the identity provider can still mint a valid token and register a device, and device registration is the machine-in-the-middle primitive. The registration lock is what closes it.
A client-held secret that gates device registration is also a client-held secret the user will lose. There will be a recovery path, and the recovery path is the new attack surface, so it is part of the design, not an operational detail discovered later. If recovery is "email a reset," the lock is worth nothing, because the IdP controls email and the IdP is the thing the lock exists to survive. Signal spent enclaves on exactly this problem and still finds it hard.
Acceptable answers include a printed recovery code the portal never sees, or accepting that loss means a new identity, which the product already accepts everywhere else, since a new device is a new identity with no history by design. Unacceptable is shipping the lock first and discovering the bypass afterwards.
Today's work, moving the web client to its own origin and swapping cookie auth for a portal-minted token, is the first half of this migration. If the client already authenticates against a configurable base URL with a token that is already device-scoped, audience-bound and key-bound (§C), then standing up the relay later is a change of hostname. Do today's auth work without that in mind and you will do it twice, and the second time you will be migrating a token format that real clients already hold.
Stated plainly, because a redesign document implies it fixed everything it touches. These are unchanged on the day the relay ships, and three of them get worse at step 10.
| Residual | State after migration |
|---|---|
| Key transparency | Absent, and it becomes a precondition. The relay is still the sole distributor of prekey bundles, and the only defence against substitution is safety numbers, which approximately nobody compares. Federation multiplies the number of parties who can silently hand out a wrong key, every instance admin your contacts chose. An auditable append-only key directory is what makes "self-host it, don't trust the relay" structural rather than aspirational. It belongs before federation, not after. |
| Web client delivery | Untouched, and easy to mistake for fixed. A separate origin removes third-party scripts; it does not change who ships the code. On the web the relay still serves the client, so "the engine defends against a compromised relay reading message contents" is achievable on native and not achievable in the browser. That asterisk belongs in SECURITY.md, which is the document researchers actually read. The only real answer is signed web builds with a verification path, pinned hashes, published transparency log, which is unsolved industry-wide and the hardest item anywhere on this list. |
| Group rosters | Ported, and still server-side and authoritative (SPEC §8.9). The relay knows exactly who is in every group. Largest remaining metadata leak; moving it to Swift changed nothing about that. Signal's answer is anonymous credentials. The one thing the port did remove is member display names, the relay has no user table and should not grow one. |
| Profiles | Still in WordPress, still server-visible. Awkward given buddy lists and profiles are the stated differentiator, the differentiating feature is currently the unencrypted one. |
| IdP compromise | The registration lock stops device registration. It does not stop envelope deletion (DoS), prekey pool drain, group roster manipulation, or traffic observation. Worth enumerating so the lock isn't mistaken for a fix to the whole class. |
| SPK rollback | Bundles are signed, so the relay cannot forge one, but it can serve an old, validly signed one. Fix is cheap: a monotonic counter inside the signed payload, and clients pin the highest value seen. |
| Presence | Only if the held connection ships. Jittered reconnects, batching and cover traffic are the mitigations; they have to be designed in, not bolted on, and they eat part of the performance win that motivated the connection. |
| OSCAR bridge | Drawn as "isolated," but it has to inject messages, so it holds a service credential. Constrain it to its own bridge-user identities or a bridge compromise is an impersonation primitive against every user. |
| Attachment reads | Possession of a 64-hex id is the whole capability. Capability-in-URL leaks through referrers and access logs. Authenticated reads, or short expiry, or both. |
Verified in class-fnmsg-e2e.php today, not inferred. All four are fixable in the
WordPress plugin now, and all four have to be built in the relay anyway, so doing them here is not
throwaway work, it's writing the behaviour once while the reference implementation is still the
easier place to test it.
within_send_rate() and its per-IP twin guard message submission, and attachments have rolling hourly caps, route_get_bundles() has neither, and it pops a one-time prekey per device per call. Repeated fetches drain a target's pool and force every subsequent correspondent onto the no-OPK path: degraded initial forward secrecy, silently, until replenishment catches up. Cheap fix, real attack.rotatePQSignedPreKey(). SPEC says the SPK "SHOULD be rotated every few weeks"; a SHOULD with nothing scheduling it is a never.GET /e2e/devices/{user_id} lists identity keys; nothing deletes one. With per-device identity, a stolen or retired linked device stays a valid ratchet endpoint permanently. This needs a route and a UI, and it is the counterpart to the registration lock, one stops devices being added, the other stops them lingering./e2e/* routes, leaving the plugin as a token issuer and nothing more.