Open Free and open source — read the code

flamenet-relay

The Flamenet Messenger relay: the /e2e/* protocol routes, the account service, and this project's public site, as one standalone Swift service.

It stores public keys and opaque ciphertext. It holds no private key, and there is no path in it that could decrypt an envelope if it wanted to: payload goes into a column and comes back out unread.

It is its own identity provider, and that is what makes self-hosting one service rather than two. /account/signup and /account/signin write to this relay's own database; an account here means nothing anywhere else. It mints the short-lived, device-scoped tokens it later verifies against a pinned public key it never fetches on the request path.

Optionally it will also accept a signed session from a shared FlameNet account service, so one passphrase works across FlameNet products. That is off unless FN_ENCLAVE_JWT_SECRET is set, and off is the right answer for almost everyone — see .env.example.

Running it

swift build -c release
FN_ISSUER_PUBLIC_KEY=<base64 Ed25519 public key> \
FN_RELAY_AUDIENCE=https://relay.flamenet.io \
FN_DB=/var/lib/flamenet/relay.sqlite \
  .build/release/flamenet-relay

It refuses to start without an issuer key. There is no safe default for that value, and a relay that accepts unsigned tokens is worse than one that is down.

Variable Default Notes
FN_ISSUER_PUBLIC_KEY Required. Base64 Ed25519 public key of the token issuer.
FN_RELAY_AUDIENCE Required. The aud this relay accepts.
FN_DB relay.sqlite SQLite file.
FN_HOST / FN_PORT 127.0.0.1 / 8080 Bind address.
FN_WORKDIR inferred Directory containing Resources/ and Public/. Set it in a unit file.
FN_CLOCK_SKEW 60 Seconds of tolerance on a proof timestamp.
FN_FEDERATION closed closed, allowlist or open. Closed is a complete relay, not a limited one, and is what a self-hoster gets without configuring anything. An unrecognised value falls back to closed rather than refusing to boot — a typo in an optional variable should not stop a relay serving its own users.
FN_BUNDLE_RATE / FN_BUNDLE_RATE_TARGET 120 / 6 Bundle fetches per hour, per requester and per pair.
FN_SEND_RATE 600 Envelopes per hour.
FN_DELIVERED_RETENTION_DAYS 1
FN_UNDELIVERED_RETENTION_DAYS 30 Must stay below the client's retired-SPK retention (45 days).
FN_ATTACHMENTS_DIR attachments Blob directory. Relative paths resolve under FN_WORKDIR.
FN_ATTACHMENT_MAX_BYTES 10485760 10 MiB per blob.
FN_ATTACHMENT_RATE / FN_ATTACHMENT_BYTES_RATE 60 / 200 MiB Per uploader, per hour.
FN_ATTACHMENT_RETENTION_DAYS 30
FN_TURN_SECRET / FN_TURN_HOST unset Unset means GET /e2e/turn answers 503 — calling is off on this instance, which is a different thing from not permitted.
FN_TURN_TTL 7200

Auth

Two modes, deliberately.

Identified routes take Authorization: Bearer <token> plus a proof-of-possession signature. The token is not a bearer credential:

  • scoped to one device (dev), so one stolen from a browser cannot drain the phone's prekey pool or read its inbox;
  • bound to one relay (aud), so a token minted by one instance is not valid at another;
  • bound to the device's IK_sig (cnf), and every request carries X-FN-Timestamp, X-FN-Nonce and X-FN-Signature over method \n path \n timestamp \n nonce \n sha256(body). A stolen token is inert without the key. Proofs are single-use within the skew window.

The issuer's verification key is configuration, never fetched. Fetched over HTTPS, a compromised identity provider could serve a key of its own and mint tokens this relay accepts — which is the exact compromise the split exists to survive. Rotation is a deploy, on purpose.

POST /e2e/messages is different. A sealed submission presents the recipient's delivery key and is otherwise unauthenticated, exactly as the earlier relay had it. Requiring a token there would hand the relay the sender's identity at request time, so sealing would hide the sender from the stored row and from nobody else. Sealed traffic is metered by recipient and by IP, because there is no sender to meter.

Routes

All of SPEC.md §8: devices (§8.1), bundles (§8.2), device list (§8.3), revocation (§8.3a), send and poll (§8.4/§8.5), prekey replenishment (§8.6), sealed attachments (§8.7/§8.8), the group registry (§8.9), and TURN credentials (§8.10).

Two deliberate deviations from what the earlier implementation did, both forced by the same thing — this service is not the identity provider:

  • Group members are ids only, with no name. The earlier server joined a user table for a display name. A relay that caches display names is a relay that leaks them; clients resolve names against the portal, as they already do for buddy lists.
  • Group membership requires a reachable device, rather than get_userdata(). The relay cannot answer "is this a real account". It can answer "can this member receive a single group envelope", which is stricter: fan-out is per device, so a member with none is a silent black hole.

What this does not fix

Stated here because a rewrite implies it fixed everything it touched.

  • Key transparency is still absent. This service is still the sole distributor of prekey bundles; the split reduced its power over key distribution by exactly zero. It is a precondition for federation, not a follow-on.
  • Group rosters are not implemented here yet, and remain server-visible where they are.
  • Profiles are server-visible.
  • IdP compromise still buys envelope deletion, prekey drain and traffic observation. A registration lock stops device registration, not everything.
  • Web client delivery. 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 "defends against a compromised relay" holds on native and not in the browser.

Tests

The conformance harness lives in flamenet-e2e, because it carries the spec it is testing against:

swift build                    # the harness drives the BINARY — build it first
git clone --depth 1 https://gitlab.com/paulhitt/flamenet-e2e.git /tmp/e2e
cd /tmp/e2e && FN_RELAY_DIR=/path/to/flamenet-relay node test/relay.mjs

It boots this binary and drives it over real HTTP against SPEC.md §8 — including the negative cases, which are the point: a forged proof, a replayed proof, a token for another relay, a token signed by an unpinned issuer, and a token used against a device it was not scoped to.

It skips rather than fails when the binary is not built, so the JS suite still runs without a Swift toolchain. 60 assertions, including a path-traversal probe against the attachment id and a cross-implementation check that the coturn REST credential this mints matches one computed independently in Node.

Contributing and security