Flamenet Messenger · backend migration

Replacing WordPress with a focused relay

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.

A Today

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.

iOS app Android app BROWSER ORIGIN · flamenet.io · no CSP Web client IndexedDB vault ratchet state · keys crm.hitthosting/pulse.js 4 plugin scripts · jQuery theme · weather widget reads vault SERVER · one PHP process WP REST /e2e/devices · /e2e/keys · /e2e/messages · /e2e/attachments auth = WordPress cookie session + wp_rest nonce WordPress bootstrap — core + 4 plugins + theme, every request 50–200 ms before the handler runs MySQL · wp_ tables via $wpdb cookie polls inbox every few s
Two couplings, one cause. The red edge is a browser-origin problem; the vertical stack is a server-process problem. Both exist because the messenger is a WordPress plugin served from the portal.

B Target

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.

iOS app Android app OWN ORIGIN · strict CSP Web client app.flamenetmessenger.com no third-party script IDENTITY PROVIDER · interface WordPress adapter — flamenet.io built-in auth — self-host PASETO v4 Ed25519 RELAY · Swift · stateless · holds no keys /e2e/devices register · publish bundle /e2e/keys prekey bundle (rate-limited) /e2e/messages submit + WSS push /e2e/attachments sealed blobs /e2e/groups roster registry /e2e/turn short-lived credentials public key only, fetched once Bearer Bearer WSS push SQLite / Postgres opaque rows only Blob store sealed attachments PORTAL · flamenet.io forums · directory · auctions accounts · profiles · community OSCAR BRIDGE · isolated separate process, own credentials badged NOT end-to-end
What moved. The web client left the portal's origin, the relay left the PHP process, and the issuer sits off the request path, the relay verifies signatures locally and never asks WordPress anything per message. What did not move: key distribution, group rosters, and profile data are exactly where they were. See F.

C What the token has to carry

"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.

The correction that matters most

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.

D What stays identical

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.

ComponentDispositionNotes
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.

E Why a token alone isn't enough

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.

1 · token signed by the IdP 2 · registration lock IdP never holds this 3 · device accepted bundle published 4 · contacts warned safety number changed a stolen portal session stops here
Step 2 is the whole point. Without it, steps 1 and 3 are reachable by anyone who compromises the portal, which is exactly the current state.
Decide recovery before shipping the lock

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.

Design the boundary now, migrate later

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.

F What this migration does not fix

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.

ResidualState 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.

G Four gaps found while checking this

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.

  1. The prekey bundle fetch has no rate limit. 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.
  2. Classic signed-prekey rotation is not automated. The only rotation routine in the engine is rotatePQSignedPreKey(). SPEC says the SPK "SHOULD be rotated every few weeks"; a SHOULD with nothing scheduling it is a never.
  3. There is no device revocation route at all. 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.
  4. Retention purge rides wp-cron. Envelope purge, attachment purge and used-PQ-prekey purge all hang off a single daily hook. wp-cron on this install has been silently wedged before, for eight weeks, which means the retention guarantee was quietly not being kept while everything looked fine. In the relay this is a supervised background job with a heartbeat, which is most of the argument for the relay in miniature.

H Order of work

  1. Web client to its own origin, strict CSP, SRI on anything it does load, no third-party tags.
  2. Replace cookie + nonce with the token from C, device-scoped, audience-bound, key-bound, against a configurable base URL. The scoping is the part that is expensive to add later, not the token itself.
  3. Leave the sealed-submission path unauthenticated, and add a test that fails if the client starts attaching the token to it out of habit. This is a regression that would look like tidying up.
  4. Registration lock and its recovery story, decided together and shipped together.
  5. The four fixes from G, in the plugin: rate-limit the bundle fetch, add a device revocation route, schedule SPK rotation, move the purge off wp-cron. Same behaviour the relay needs; cheaper to test here.
  6. Write the relay against SPEC.md §8 and run the existing conformance suite against it. The spec is the test oracle; nothing new needs designing. Add SPK rollback protection (F) while writing the bundle route, since that is the moment it costs nothing.
  7. Point one client, the web one, since it deploys fastest, at the relay. Keep the WordPress routes live and serving the native apps.
  8. Move iOS and Android over via the server picker they already ship. Watch interop, since this is the first time three engines talk to a new server.
  9. Retire the WordPress /e2e/* routes, leaving the plugin as a token issuer and nothing more.
  10. Add the built-in auth adapter. This is the point at which self-hosting stops requiring WordPress, and the differentiator becomes real.
  11. Key transparency, before any federation work begins, not after. Federating without a verifiable key directory multiplies the number of parties who can MITM a conversation, which is the opposite of the reason for federating.