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 carriesX-FN-Timestamp,X-FN-NonceandX-FN-Signatureovermethod \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
- CONTRIBUTING.md — building, testing, and the traps worth knowing before your first patch.
- SECURITY.md — what this relay is and is not trusted with, and where to send a vulnerability report. Not an issue tracker matter: security@flamenetmessenger.com.