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 serves https://flamenetmessenger.com, so a fresh install explains itself rather than greeting you with an error.
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_POSTGRES_URL=postgres://flamenet:<password>@localhost:5432/relay \
.build/release/flamenet-relay
Self-hosting requires Postgres. Without FN_POSTGRES_URL the relay uses a
SQLite file, which is for local development only and receives no new features.
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 |
n/a | Required. Base64 Ed25519 public key of the token issuer. |
FN_RELAY_AUDIENCE |
n/a | Required. The aud this relay accepts. |
FN_POSTGRES_URL |
unset | Postgres. Required for self-hosting. |
FN_DB |
relay.sqlite |
SQLite file, used without FN_POSTGRES_URL. Local development only. |
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_FEDERATION_PEERS |
empty | host, host=key or host=key1|key2. A pinned peer publishing another key is refused. See docs/CONFIGURATION.md for rotation. |
FN_METRICS_PORT |
unset | Prometheus /metrics on a second port, loopback by default. Off unless set. |
FN_BUNDLE_RATE / FN_BUNDLE_RATE_TARGET |
600 / 60 |
Bundle fetches per hour, per requester and per pair. |
FN_SEND_RATE |
3000 |
Envelopes per hour. |
FN_DELIVERED_RETENTION_DAYS |
1 |
The backstop for a collected envelope the app never confirms. |
FN_DELETE_ON_ACK |
on | Delete an envelope when the app's next poll confirms it. Postgres only; 0 turns it off. |
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/extant2000/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.