From e5be1b24a911d2429d5055bebe4593ce6962168d Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Tue, 25 Aug 2026 15:34:35 -0400 Subject: [PATCH] Sync the relay from flamenet-messenger-ios 5bc7258 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This mirror had one commit, from 2026-08-21, and the source of truth moved four days without it: federation, discovery, home relays, password reset and the mailer, the account identity split, the on-page donation flow, and the three generated doc pages all landed upstream and none of them landed here. A mirror nobody syncs is worse than no mirror, because it reads as the current thing. `server/` from the source repo, wholesale, with three deliberate exceptions — the files that exist only here and would be destroyed by a naive copy: - `README.md`. This one is not stale, it is *ahead*: it describes the relay as its own identity provider, which is what it has been since the WordPress removal, while upstream's still opens with "It is **not** an identity provider" and then contradicts itself in the next clause. It also carries the table linking the sibling repos, which only makes sense in a repo somebody arrives at cold. Kept, and the one row it was missing (`FN_FEDERATION`) added. - `LICENSE`. MIT, and upstream keeps it at the repo root rather than in `server/`. - `Scripts/secret-sweep.sh`. The gate that guards this operation. It ran clean over all 75 files before this commit was written. `docker-compose.prod.yml` stays excluded, as it was at mirror creation: it names production routers and is not something a self-hoster should copy. The sweep needed one narrowing first. It flagged `let token = "\(selector).\(secret)"` in PasswordReset.swift as an assigned password — a value computed at run time, not a literal. The probe already skipped `${...}` for shell and JavaScript; it now skips Swift's `\(...)` the same way. Verified by mutation: a planted `let password = "hunter2hunter2"` is still caught, and the interpolation is not. ⚠️ The sweep only reads files git knows about. An untracked file is invisible to it — `git add` before trusting a clean run. Co-Authored-By: Claude Opus 5 --- .env.example | 30 +- .gitignore | 3 - Public/donate.js | 102 +++++ Public/landing.css | 18 + README.md | 1 + Resources/Views/api.leaf | 306 ++++++++++++++ Resources/Views/comparison.leaf | 31 +- Resources/Views/configuration.leaf | 396 +++++++++++++++++++ Resources/Views/donate.leaf | 45 ++- Resources/Views/hosting.leaf | 149 +++++++ Resources/Views/landing.leaf | 52 +++ Resources/Views/privacy.leaf | 14 +- Resources/Views/relay.leaf | 9 + Resources/Views/self-hosting.leaf | 102 ++++- Resources/Views/source.leaf | 25 +- Resources/Views/spec.leaf | 95 ++++- Scripts/build-doc-pages.sh | 3 + Scripts/secret-sweep.sh | 6 +- Sources/FlamenetRelay/Account.swift | 21 +- Sources/FlamenetRelay/AccountIdentity.swift | 133 +++++++ Sources/FlamenetRelay/AtRestCrypto.swift | 2 +- Sources/FlamenetRelay/Config.swift | 110 +++++- Sources/FlamenetRelay/Entrypoint.swift | 320 +++++++++++++-- Sources/FlamenetRelay/Federation.swift | 390 ++++++++++++++++++ Sources/FlamenetRelay/HomeRelays.swift | 133 +++++++ Sources/FlamenetRelay/Issuer.swift | 4 +- Sources/FlamenetRelay/KeyLog.swift | 62 ++- Sources/FlamenetRelay/Mailer.swift | 267 +++++++++++++ Sources/FlamenetRelay/Migrations.swift | 46 +++ Sources/FlamenetRelay/Models.swift | 10 + Sources/FlamenetRelay/PasswordReset.swift | 207 ++++++++++ Sources/FlamenetRelay/Retention.swift | 2 +- Sources/FlamenetRelay/Routes.swift | 39 +- Sources/FlamenetRelay/RoutesAccount.swift | 83 +++- Sources/FlamenetRelay/RoutesDiscovery.swift | 117 ++++++ Sources/FlamenetRelay/RoutesDonate.swift | 208 +++++++--- Sources/FlamenetRelay/RoutesFederation.swift | 380 ++++++++++++++++++ Sources/FlamenetRelay/RoutesIdentity.swift | 204 ++++++++++ Sources/FlamenetRelay/RoutesMobile.swift | 2 +- Sources/FlamenetRelay/Wire.swift | 144 ++++++- deploy/test-fleet/docker-compose.yaml | 173 ++++++++ docker-compose.yml | 5 + docs/API.md | 194 +++++++++ docs/CONFIGURATION.md | 151 +++++++ docs/HOSTING.md | 141 +++++++ 45 files changed, 4764 insertions(+), 171 deletions(-) create mode 100644 Public/donate.js create mode 100644 Resources/Views/api.leaf create mode 100644 Resources/Views/configuration.leaf create mode 100644 Resources/Views/hosting.leaf create mode 100644 Sources/FlamenetRelay/AccountIdentity.swift create mode 100644 Sources/FlamenetRelay/Federation.swift create mode 100644 Sources/FlamenetRelay/HomeRelays.swift create mode 100644 Sources/FlamenetRelay/Mailer.swift create mode 100644 Sources/FlamenetRelay/PasswordReset.swift create mode 100644 Sources/FlamenetRelay/RoutesDiscovery.swift create mode 100644 Sources/FlamenetRelay/RoutesFederation.swift create mode 100644 Sources/FlamenetRelay/RoutesIdentity.swift create mode 100644 deploy/test-fleet/docker-compose.yaml create mode 100644 docs/API.md create mode 100644 docs/CONFIGURATION.md create mode 100644 docs/HOSTING.md diff --git a/.env.example b/.env.example index 6c6968e..552abbe 100644 --- a/.env.example +++ b/.env.example @@ -27,9 +27,16 @@ FN_RELAY_AUDIENCE=flamenet-relay # Donations. Leave both blank and the donate page says so plainly instead of # showing a form. `FN_DONATE_SECRET` is a Stripe secret key; it is used only to -# open a hosted Checkout session, so no card data reaches this service. +# open the payment; the card fields are Stripe's own, mounted on the page in a +# frame this origin cannot read, so no card data reaches this service. FN_DONATE_PROVIDER= FN_DONATE_SECRET= +# The connected account donations are charged on, if the key above belongs to a +# Stripe platform rather than to the account meant to receive the money. Blank +# charges the key's own account, which is what a self-hoster wants. +FN_DONATE_ACCOUNT= +# Public by design, and handed to the browser. Without it there is no form. +FN_DONATE_PUBLISHABLE_KEY= # Shared FlameNet accounts. Leave blank -- this is the self-hosting default and # the right answer for almost everyone. @@ -51,3 +58,24 @@ FN_DONATE_SECRET= # secrets of everyone who signs in. Setting this on a relay the app has not # pinned changes nothing on the client. FN_ENCLAVE_JWT_SECRET= + +# Outbound mail, for password resets on a self-hosted relay. +# +# Leave FN_SMTP_HOST blank and password reset is OFF — the sign-in screen says +# so rather than offering a link that goes nowhere. There is no half-configured +# state on purpose: a reset that mints a token and then cannot deliver it leaves +# the account locked out AND a live token in the database. +# +# HOST and FROM are required together. Credentials are optional, because a relay +# submitting to an MTA on the same host commonly does so without auth. +# +# Accounts on the shared FlameNet service are NOT reset here — their verifier +# lives in that service, and this relay could not change it if it wanted to. +FN_SMTP_HOST= +FN_SMTP_PORT=587 +FN_SMTP_USERNAME= +FN_SMTP_PASSWORD= +FN_SMTP_FROM= +# STARTTLS. Only set false for a loopback MTA; the failure mode otherwise is a +# reset link crossing a network in the clear. +FN_SMTP_TLS=true diff --git a/.gitignore b/.gitignore index a41e91a..30bcfa4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1 @@ .build/ -.env -.DS_Store -*.sqlite diff --git a/Public/donate.js b/Public/donate.js new file mode 100644 index 0000000..cb1adc3 --- /dev/null +++ b/Public/donate.js @@ -0,0 +1,102 @@ +/* + * Donations, confirmed on this page. + * + * The card fields are Stripe's, mounted in an iframe this origin cannot read. + * Nothing here ever sees a card number, and nothing here should ever be changed + * so that it does: a form on this site that collects a PAN would put card data + * in this server's request logs and pull the deployment into PCI scope. Ask + * Stripe for a client secret, let Stripe collect the details, confirm. + */ +(function () { + 'use strict'; + + var form = document.getElementById('donate-form'); + if (!form) return; + + var mount = document.getElementById('donate-payment'); + var errorBox = document.getElementById('donate-error'); + var submit = document.getElementById('donate-submit'); + + var stripe = null; + var elements = null; + var mounted = false; + + function showError(message) { + errorBox.textContent = message; + errorBox.hidden = false; + } + function clearError() { + errorBox.textContent = ''; + errorBox.hidden = true; + } + function busy(isBusy, label) { + submit.disabled = isBusy; + submit.textContent = label; + } + + form.addEventListener('submit', async function (event) { + event.preventDefault(); + clearError(); + + // Second step: the fields are already up, so confirm. + if (mounted) { + busy(true, 'Paying…'); + var result = await stripe.confirmPayment({ + elements: elements, + // Stay here. Only leave if the bank demands it, and come back to a page + // that reads the outcome out of the URL. + redirect: 'if_required', + confirmParams: { return_url: window.location.origin + '/donate?thanks=1' } + }); + if (result.error) { + // Stripe's messages for these two are written for the person paying. + // Anything else is written for a developer, so it is not shown. + var kind = result.error.type; + showError(kind === 'card_error' || kind === 'validation_error' + ? result.error.message + : 'Something went wrong taking the payment. Nothing was charged.'); + busy(false, 'Pay'); + return; + } + window.location.assign('/donate?thanks=1'); + return; + } + + // First step: ask our own server to open a payment and hand back a secret + // that authorises confirming exactly this one. + busy(true, 'Starting…'); + try { + var response = await fetch('/donate/intent', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams(new FormData(form)).toString() + }); + if (!response.ok) { + var problem = await response.json().catch(function () { return {}; }); + showError(problem.reason || 'Could not start a payment. Please try again later.'); + busy(false, 'Continue'); + return; + } + var intent = await response.json(); + // Scoped to the connected account when there is one. A client secret + // opened on that account cannot be confirmed by a Stripe built without + // it — the failure names the payment intent, not the account, and only + // happens after the card has been entered. + stripe = intent.stripeAccount + ? Stripe(intent.publishableKey, { stripeAccount: intent.stripeAccount }) + : Stripe(intent.publishableKey); + elements = stripe.elements({ clientSecret: intent.clientSecret }); + elements.create('payment').mount(mount); + mount.hidden = false; + mounted = true; + // The amount is settled once the payment is open; re-picking it would not + // change what is about to be charged, and a form that lies about that is + // worse than one that locks. + form.querySelectorAll('input').forEach(function (input) { input.disabled = true; }); + busy(false, 'Pay $' + intent.amount + (intent.recurring ? ' a month' : '')); + } catch (e) { + showError('Could not reach the payment service. Please try again later.'); + busy(false, 'Continue'); + } + }); +})(); diff --git a/Public/landing.css b/Public/landing.css index 597ecb4..56e7b63 100644 --- a/Public/landing.css +++ b/Public/landing.css @@ -542,6 +542,12 @@ a { color: inherit; } .nav-cta a.btn-outline { color: var(--brand); } .nav-cta a.btn-nav { color: var(--brand-ink); } +.donate-thanks { + margin: 0 0 26px; padding: 18px 22px; border-radius: var(--radius); + background: var(--grad-soft); border: 1px solid var(--brand); +} +.donate-thanks p { margin: 6px 0 0; color: var(--ink-2); font-size: 15px; } + .donate-pending { padding: 18px 22px; border-radius: var(--radius); background: var(--bg-2); border: 1px dashed var(--border-2); @@ -592,6 +598,18 @@ a { color: inherit; } } .donate-form .btn { border: 0; cursor: pointer; font-family: inherit; } +/* Stripe's own fields land here. Only spacing and a rule above them: the frame + styles itself, and fighting it produces a form that looks half-ours. */ +.donate-payment { + margin: 4px 0 22px; padding-top: 22px; + border-top: 1px solid var(--border); +} +.donate-error { + margin: 0 0 16px; padding: 12px 16px; border-radius: 10px; + background: rgba(200, 30, 30, .07); border: 1px solid rgba(200, 30, 30, .25); + color: #9b1c1c; font-size: 15px; +} + @media (max-width: 640px) { .donate-freq, .donate-amounts { flex-direction: column; align-items: stretch; } .donate-form label { justify-content: center; } diff --git a/README.md b/README.md index f810406..2eb5fbb 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,7 @@ value, and a relay that accepts unsigned tokens is worse than one that is down. | `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` | | diff --git a/Resources/Views/api.leaf b/Resources/Views/api.leaf new file mode 100644 index 0000000..1d19808 --- /dev/null +++ b/Resources/Views/api.leaf @@ -0,0 +1,306 @@ +#extend("layout"): +#export("content"): +
+
+

Relay API

+

Every route a Flamenet relay serves, what authenticates it, and what +it is for.

+

Read from Sources/FlamenetRelay/Entrypoint.swift rather +than from memory. A route not in this document is not served — and a +document that lists routes a relay does not serve is worse than no +document, because it is believed. That is not hypothetical: the relay +once published its own index under a second prefix it served nothing on, +and every client that read it broke.

+

Base URL is https://<your-relay>. There is +one REST prefix, api.

+
+

Discovery

+ + + + + + + + + + + + + + + + + + + + + + + + + +
RouteAuthPurpose
GET /.well-known/flamenet/relaynoneWhat this relay is
GET /api/flamenet/v1noneThe route index
GET /e2e/log/headnoneSigned transparency-log head
+

/.well-known/flamenet/relay is what a client reads +before sending any credential:

+
{
+  "audience": "relay.example.com",
+  "issuerPublicKey": "<base64 Ed25519>",
+  "protocolVersions": [1, 2, 3],
+  "keyLogHead": "https://relay.example.com/e2e/log/head",
+  "federation": "closed",
+  "federationKey": null,
+  "accounts": "local",
+  "accountsURL": null
+}
+

⚠️ accounts decides where a passphrase +goes. local means this relay is its own identity +provider and authenticates the password itself. shared +means accounts live in a separate service named by +accountsURL, the passphrase is stretched on the device, and +only a derived secret is sent — to that service, never to the relay.

+

⚠️ A client must not believe accountsURL +blindly. It should accept only account services it already +trusts, and treat anything else as local. Otherwise an +operator could name a host they control and collect passphrase-derived +secrets. The relay's own code pins its accountsURL as a +constant for the same reason.

+

Everything else is unauthenticated on purpose: a descriptor only this +relay's users could read would be useless to the peers it exists to +inform, and a transparency log only its own users could audit would not +be a transparency log.

+
+

Accounts

+

Present only when this relay runs the built-in identity provider +(FN_ISSUER_PRIVATE_KEY set).

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
RouteAuthPurpose
POST /account/signupnoneCreate an account
POST /account/signinnoneExchange a password for a credential
POST /account/tokenBasicMint a device-scoped relay token
POST /account/reset/requestnoneAsk for a reset code
POST /account/reset/confirmnoneRedeem a reset code
+

Also mounted under +/api/flamenet/v1/messenger/{signup,signin}.

+

signup takes +{username, email, password, display_name?} and answers:

+
{ "user_id": 7, "username": "alice", "display_name": "Alice",
+  "app_password": "<returned once, never retrievable>" }
+

⚠️ The credential is returned once and only its hash is +kept. Losing it means signing in again. It is not the password, +and changing the password invalidates it — which is what changing a +password should do.

+

⚠️ signin accepts a username or an +email. The field has always been labelled "username or email"; +a client that sends only usernames rejects half the people who typed +exactly what they were asked for.

+

⚠️ Reset answers identically whatever the truth is — +unknown handle, no address on file, an account that authenticates +elsewhere. A client cannot report whether it worked, and a UI that +claims it can is lying.

+

Shared identity

+ + + + + + + + + + + + + + + +
RouteAuthPurpose
POST /api/flamenet/v1/messenger/sessionnoneExchange an account-service token for a relay credential
+

Present only when FN_ENCLAVE_JWT_SECRET or +FN_ENCLAVE_PUBLIC_KEY is set. Takes +{token, handle, display_name?}.

+

⚠️ The handle travels beside the token, not inside +it. The account service will not put a handle in a token so +that a relay cannot correlate its users with another product's; the +token carries a product-scoped pseudonym. The relay uses the handle only +the first time it sees that pseudonym — after that the pseudonym +decides, and honouring the handle would let somebody rename themselves +into an existing account.

+
+

Protocol routes (§8)

+

All under /api/flamenet/v1/e2e/…, and also at +/e2e/….

+

⚠️ These refuse the account credential. They take a +short-lived token scoped to one device and one relay, +plus a per-request signature proving possession of that device's +identity key. A credential good for every route on every device would +mean a token lifted from one device drains another's prekey pool and +reads its inbox.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
RoutePurpose
POST /e2e/devicesPublish a device's identity and prekeys
GET /e2e/devices/{user}List a user's devices — costs no prekey
GET /e2e/keys/{user}Fetch bundles — pops a one-time prekey per +device
POST /e2e/prekeysTop the pool back up
POST /e2e/messagesDeposit envelopes
GET /e2e/messagesPoll this device's inbox
POST /e2e/delivery-keyPublish a delivery-key digest for sealed sending
POST /e2e/attachmentsUpload one encrypted blob
GET /e2e/attachments/{id}Download it
POST /e2e/groupsCreate a group
GET /e2e/groupsList groups
POST /e2e/groups/{gid}/membersAdd or remove members
POST /e2e/groups/{gid}/leaveLeave
GET /e2e/turnTURN credentials for calls
GET /e2e/log/{head,inclusion,consistency,device/{id}}Transparency log
+

The three that bite

+

GET /e2e/keys/{user} consumes a one-time prekey +per device, every call. Use /e2e/devices/{user} to +look at who somebody is; use keys only when actually +starting a session. Polling it drains the pool and pushes every peer +onto the last-resort key, costing initial forward secrecy for +everyone.

+

POST /e2e/messages names the recipient once, at +the top — not per envelope:

+
{ "to": 42, "from_device": "…",
+  "messages": [ { "to_device": "…", "payload": "<base64>" } ] }
+

One envelope per device of theirs. The relay does no fan-out and no +re-encryption; it moves opaque payloads.

+

POST /e2e/devices publishes prekeys under +prekeys, not opks. A registration +carrying the wrong name is accepted with a 200 and stores +nothing — the device is published and can never be started with, which +is a failure with no error anywhere.

+

The per-request proof

+

Each authenticated request carries X-FN-Timestamp, +X-FN-Nonce and X-FN-Signature, an Ed25519 +signature over:

+
METHOD \n LOGICAL_PATH \n TIMESTAMP \n NONCE \n hex(SHA-256(body))
+

⚠️ LOGICAL_PATH is the /e2e/… path, +not the URL's path. These routes are mounted twice and signed +against the former; signing the REST-prefixed path produces a valid +signature over the wrong string and a refusal with no hint why.

+

⚠️ The nonce is not decoration. Without it, two +inbox polls in the same second with the same empty body sign +identically, and the replay cache cannot tell an honest repeat from a +captured one — so it refuses the honest client.

+
+

Federation

+

Off by default. When FN_FEDERATION is not +closed:

+ + + + + + + + + + + + + + + + + + + + +
RouteAuthPurpose
POST /fed/v1/depositfnfed-v1Accept envelopes from a peer relay
GET /fed/v1/keys/{fnid}fnfed-v1Serve bundles for a local account
+

Signed with the relay's federation key, which is +deliberately not the issuer key: a key that only ever says "this relay +said this" should not also be able to mint a token for any user on +it.

+

⚠️ Federation costs metadata. Each relay in an +exchange learns the other had traffic for it. If your relay exists so +one group can talk without anyone else involved, closed is +the correct answer, not the cautious one.

+ +
+
+#endexport +#endextend diff --git a/Resources/Views/comparison.leaf b/Resources/Views/comparison.leaf index 1a6a9aa..fb0784b 100644 --- a/Resources/Views/comparison.leaf +++ b/Resources/Views/comparison.leaf @@ -4,10 +4,12 @@

How this compares

- Four messengers, side by side. Two things on this table only Flamenet - Messenger does: it runs on a server you control, and it never asks for - your phone number. The rest is laid out plainly and sourced from each - project's own documentation, so you can check every line of it. + Four messengers, side by side. A few things on this table only Flamenet + Messenger does: it runs on a server you control, it never asks for your + phone number, and your address is yours rather than the server's — so a + relay shutting down costs you a mailbox, not your name. The rest is laid + out plainly and sourced from each project's own documentation, so you can + check every line of it.

@@ -43,6 +45,27 @@ No No + + Servers can talk to each other + Yes
A relay can deliver to somebody on a different relay. Off by default: an operator opts in, and can name exactly which relays they will exchange traffic with. + No
Deliberately. Federating spreads metadata across operators, which Signal declines to do. + No + No + + + Your identity survives your server + Yes
Your address comes from your passphrase, not from a row number a server handed you. Sign in elsewhere with the same passphrase and it is the same address, so a relay shutting down does not cost you your name. + No
Your account lives on Signal's servers. + No + No + + + Mail can wait at more than one server + Yes
Keep a mailbox on several relays and senders deliver to all of them, so losing one loses nothing. It has to be set up before the loss, not after. + No + No + No + Source code published Engine and server
Under the MIT licence. The apps are not published yet. diff --git a/Resources/Views/configuration.leaf b/Resources/Views/configuration.leaf new file mode 100644 index 0000000..35cd971 --- /dev/null +++ b/Resources/Views/configuration.leaf @@ -0,0 +1,396 @@ +#extend("layout"): +#export("content"): +
+
+

Configuring a relay

+

Every variable the relay reads, what it breaks if it is wrong, and +which of the three deployment shapes needs it.

+

Read from Sources/FlamenetRelay/Config.swift and +Entrypoint.swift rather than from memory. If a variable is +not in this file it is not read.

+
+

The three shapes

+

A relay is one of these. Nothing needs choosing beyond the first +column — the default is a complete, self-contained relay.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Accounts liveFederatesWho runs it
ManagedThe shared FlameNet account serviceOperator's choiceUs
Self-hosted, shared identityThe shared FlameNet account serviceOperator's choiceYou
Self-hosted, local (default)This relay's own databaseOperator's choiceYou
+

A local relay is not a lesser one. Users register, +exchange keys, message each other, get a portable address, and verify +the transparency log exactly as anywhere else. What the shared account +service adds is one credential across Flamenet Messenger and Flamenet +Files — nothing more.

+
+

Required

+

Without these the relay refuses to start. That is deliberate: a relay +that starts with a missing security parameter is worse than one that +does not start, because nobody finds out until it matters.

+ + + + + + + + + + + + + + + + + + + + + +
VariableNotes
FN_RELAY_AUDIENCEThe hostname this relay is addressed by, e.g. +relay.example.com. Tokens carry it and the relay checks it, +so a mismatch rejects every token with no other +symptom. A relay reachable at several names still has exactly one of +these.
FN_ISSUER_PUBLIC_KEYBase64 Ed25519 public key of whatever mints this relay's tokens. +Never fetched over the network.
FN_AT_REST_SECRETEncrypts stored bodies for conversations with encryption turned off. +Defends against a stolen database dump. It does not defend against you — +you hold the key.
+

Identity — minting your own +tokens

+ + + + + + + + + + + + + + + + + + + + + + + + + +
VariableDefaultNotes
FN_ISSUER_PRIVATE_KEYunsetBase64 Ed25519 seed. Set it and this relay issues +its own tokens and serves /account/*. Unset and those +routes do not exist at all, rather than existing and refusing.
FN_TOKEN_LIFETIME300Seconds. Short because there is no revocation list — expiry is the +only thing that stops a leaked token.
FN_CLOCK_SKEW60Seconds of tolerance on a proof timestamp.
+

⚠️ In one process, a relay compromise is an issuer +compromise: the signing key is in the same address space. That trade is +for self-hosters, who are running one box either way.

+

Storage

+ + + + + + + + + + + + + + + + + + + + + + + + + +
VariableDefaultNotes
FN_POSTGRES_URLunsetSet it and Postgres is used.
FN_DBrelay.sqliteSQLite file, used when the above is unset. The schema is +identical on both — SQLite is not a degraded mode, and is the +right choice for a relay serving a household or a company.
FN_ATTACHMENTS_DIRattachmentsBlobs are files, not rows, in both modes.
+

Network

+ + + + + + + + + + + + + + + + + + + + + + + + + +
VariableDefaultNotes
FN_HOST127.0.0.1Not reachable from outside the host as shipped. Terminate TLS in +front of it.
FN_PORT8080
FN_LOG_LEVELerror⚠️ Never raise to debug on a machine with real +accounts: it logs request URLs, and a protocol URL contains a device +id.
+

⚠️ HTTPS is not optional in practice. Clients hold +key material, and serving them over plain HTTP hands it to anyone on the +path. Forward the client address too — sealed submissions are metered +per recipient and per IP, and behind a proxy that does not set +X-Forwarded-For every request looks like the proxy.

+

Federation

+

Off by default, and the default is load-bearing: a relay run for one +household should stand alone without configuring anything.

+ + + + + + + + + + + + + + + + + + + + + + + + + +
VariableDefaultNotes
FN_FEDERATIONclosedclosed, allowlist, or open. +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_PEERSemptyComma-separated hostnames, used only under allowlist. +Enforced outbound as well as inbound, from the same +rule.
FN_FEDERATION_KEYunsetBase64 Ed25519 seed. Required when +FN_FEDERATION is not closed — the relay +refuses to boot otherwise, rather than starting unreachable and letting +a stranger discover it.
+

FN_FEDERATION_KEY is deliberately not +the issuer key. A key that only ever needs to say "this relay said this" +should not also be able to mint a token for any user on it.

+

⚠️ Federation costs metadata. Each relay in an +exchange learns the other had traffic for it, so a slice of your users' +social graph is spread across operators you do not run. That is the +honest reason this is off by default. If your relay exists so one group +can talk without anyone else involved, closed is the +correct answer, not the cautious one.

+

✅ A closed relay opens no outbound connections at +all. The only outbound code in the relay is the SMTP client +below, itself disabled unless configured. No telemetry, no update check, +no directory to register with.

+

Shared FlameNet accounts

+

Only for a relay that accepts identities from a FlameNet account +service. Leaving all of these unset gives you a relay with its own user +table.

+ + + + + + + + + + + + + + + + + + + + + + + + + +
VariableDefaultNotes
FN_ENCLAVE_PUBLIC_KEYunsetBase64 Ed25519 public key from the account service's +/.well-known/jwks.json (the x member, +base64url-decoded). This is the one to use.
FN_ENCLAVE_JWT_SECRETunsetThe older shared HMAC secret. Works, and is why third-party relays +were never possible: the secret that checks a token also mints one, so +anything holding it can impersonate any account.
FN_ACCOUNTS_URLhttps://accounts.flamenet.ioWhich account service to advertise. Point it at your own if you run +one.
+

⚠️ Configured, not fetched. This decides who may +assert an identity at your relay; a relay that learned it over the +network could be pointed at a different issuer by whoever controls DNS. +Copy it in once.

+

⚠️ Set the public key and HMAC tokens are refused. +That is intentional — a fallback would let anyone who ever learned the +retired secret keep minting sessions forever. Migrate the account +service first, then the relays.

+

Limits

+

Defaults are sane; raise them only with a reason.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
VariableDefaultGuards
FN_BUNDLE_RATE120/hrPrekey-bundle fetches per requester. Each pops a one-time prekey per +device, so an unmetered version is a pool drain.
FN_BUNDLE_RATE_TARGET6/hrPer requester-target pair. Deliberately not metered +per target alone — that would let one abuser make a popular account +unstartable for everyone.
FN_SEND_RATE600/hrEnvelopes, per sender or per recipient when sealed.
FN_ATTACHMENT_RATE60/hr
FN_ATTACHMENT_BYTES_RATE200 MiB/hr
FN_ATTACHMENT_MAX_BYTES10 MiBPer blob.
+

Retention

+

The relay purges on its own timer, in-process. Nothing external is +needed.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
VariableDefaultNotes
FN_DELIVERED_RETENTION_DAYS1
FN_UNDELIVERED_RETENTION_DAYS30⚠️ Must stay below the client's retired-signed-prekey retention of +45 days.
FN_ATTACHMENT_RETENTION_DAYS30
FN_MESSAGE_RETENTION_DAYS90Plaintext-mode messages only.
FN_READ_MESSAGE_RETENTION_DAYS30
+

Mail, calls, donations

+

All optional. Each is absent rather than half-enabled when +unconfigured.

+ + + + + + + + + + + + + + + + + + + + + +
VariableNotes
FN_SMTP_HOST, FN_SMTP_PORT, +FN_SMTP_FROM, FN_SMTP_TLS, +FN_SMTP_USERNAMEPassword-reset mail. Unset disables reset entirely — a flow that +mints a token it cannot deliver leaves the account locked out +and a live token in the database.
FN_TURN_HOST, FN_TURN_SECRET, +FN_TURN_TTLVoice calls.
FN_DONATE_PROVIDER, FN_DONATE_SECRETOff unless set. A relay somebody else runs has no business +soliciting money for us.
+ +
+
+#endexport +#endextend diff --git a/Resources/Views/donate.leaf b/Resources/Views/donate.leaf index 3535976..3dc98c9 100644 --- a/Resources/Views/donate.leaf +++ b/Resources/Views/donate.leaf @@ -19,6 +19,28 @@ cannot be distributed at all. +

+ Worth being straight about what donating here does not buy you, in a + way that is unusual for a thing asking for money: it does not buy you a + dependency on us. The server is open source, your address is worked out from + your passphrase rather than issued by us, and you can keep a mailbox on a + relay somebody else runs — including your own. If this project stopped + tomorrow, the people using it would keep their names and could carry on + elsewhere. Money given here funds the work; it does not buy leverage over + anyone who took it. +

+ + #if(thanks): + + #endif +

What it does not buy

Not features, not priority support, and not a say in how the protocol works. There @@ -37,7 +59,7 @@

Give

#if(donateEnabled): - + + + #else:
+
+

Running your own relay

+

A relay is one container and one database. It needs no account +service, no federation, and no connection to us — a relay serving one +household is the default configuration, not a stripped-down one.

+

⚠️ Flamenet Files is optional. If you host only +Messenger, nothing is missing: your relay keeps its own accounts and +everything works. Files matters only if you want one credential across +both products — see Three shapes below.

+
+

The shortest thing that +works

+
mkdir -p /srv/flamenet && cd /srv/flamenet
+
+#("#") The issuer keypair. This mints your users&#("#")39; session tokens; losing it signs
+#("#") everybody out, and leaking it lets somebody mint a token for any account.
+docker run --rm flamenet-relay:latest generate-issuer-key > issuer.pem
+
+cat > .env <<&#("#")39;ENV&#("#")39;
+FN_RELAY_AUDIENCE=relay.example.com
+FN_ISSUER_PRIVATE_KEY=<the seed from issuer.pem>
+FN_ISSUER_PUBLIC_KEY=<the public key from issuer.pem>
+FN_AT_REST_SECRET=<32+ random bytes>
+FN_POSTGRES_URL=postgres://flamenet:...@db/flamenet
+ENV
+
+docker compose up -d
+

Then check it is what you think it is:

+
curl https://relay.example.com/.well-known/flamenet/relay
+

⚠️ FN_RELAY_AUDIENCE must be the hostname +clients dial. Tokens carry it and the relay checks it, so a +mismatch rejects every token with no other symptom. A +relay reachable at several names still has exactly one of these.

+

⚠️ Terminate TLS in front of it and forward the client +address. Clients hold key material, and plain HTTP hands it to +anyone on the path. Sealed submissions are metered per recipient and per +IP; behind a proxy that does not set X-Forwarded-For, every +request looks like the proxy and that limit is theatre.

+

Every variable is in Configuration.

+
+

Three shapes

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Accounts liveWho runs itFiles needed
ManagedShared FlameNet serviceUsno
Self-hosted, shared identityShared FlameNet serviceYouno
Self-hosted, local (default)Your relay's databaseYouno
+

The third is the default and is not a lesser mode. Users register, +exchange keys, message each other, get a portable address and verify the +transparency log exactly as anywhere else. What the shared account +service adds is one credential across Flamenet Messenger and +Flamenet Files — nothing else.

+

To adopt the shared service, set FN_ENCLAVE_PUBLIC_KEY +to the account service's Ed25519 key. Your relay then advertises +accounts: shared in its descriptor, and clients pick that +up automatically.

+

⚠️ Clients accept a named account service only if they +already trust it. A relay claiming a service the client does +not know is treated as local. That is deliberate: without it, an +operator could point their users' passphrase-derived secrets at a host +they control.

+

⚠️ Set the public key and HMAC tokens are refused. A +fallback would let anyone who ever learned the retired shared secret +keep minting sessions forever. Migrate the account service first, then +the relays.

+
+

Federation, if you want it

+

Off by default, and the default is load-bearing.

+
FN_FEDERATION=allowlist
+FN_FEDERATION_PEERS=relay.friend.example
+FN_FEDERATION_KEY=<base64 Ed25519 seed>
+

⚠️ A closed relay opens no outbound connections at +all. No telemetry, no update check, no directory to register +with. The only outbound code is the SMTP client, itself disabled unless +configured.

+

⚠️ Federation costs metadata. Each relay in an +exchange learns the other had traffic for it, so a slice of your users' +social graph is spread across operators you do not run. If your relay +exists so one group can talk without anyone else involved, +closed is the correct answer.

+

FN_FEDERATION_KEY is deliberately not the issuer key: a +key that only says "this relay said this" should not also mint tokens +for your users.

+
+

Verifying what you are +running

+

A relay you cannot check is a relay you are trusting, which is the +thing this project exists to avoid.

+
#("#") What it claims to be
+curl https://relay.example.com/.well-known/flamenet/relay
+
+#("#") The signed transparency-log head
+curl https://relay.example.com/e2e/log/head
+
+#("#") An inclusion proof, checked against a head fetched separately —
+#("#") never against the root the proof response carries
+curl "https://relay.example.com/e2e/log/inclusion?index=0&size=<size>"
+

⚠️ Check a proof against a head you fetched +separately. A server that supplies both halves of a comparison +has proven nothing.

+
+

What a relay operator +can and cannot see

+

Cannot: message contents, attachment contents, group message +contents. Those are end-to-end encrypted and the keys never reach the +relay.

+

Can: who has an account, which devices they have, that an envelope +went from one device to another and when, and how large it was. +Retention windows for all of it are in Configuration, and the relay purges on its own +timer with nothing external required.

+

⚠️ FN_AT_REST_SECRET defends against a stolen +database dump. It does not defend against you — you hold the +key. Tell your users that plainly.

+

⚠️ Never raise FN_LOG_LEVEL to +debug on a machine with real accounts. It logs +request URLs, and a protocol URL contains a device id.

+ +
+
+#endexport +#endextend diff --git a/Resources/Views/landing.leaf b/Resources/Views/landing.leaf index 2b8029f..3b88db2 100644 --- a/Resources/Views/landing.leaf +++ b/Resources/Views/landing.leaf @@ -129,6 +129,48 @@
+
+
+ Your name, your mailboxes +

The server holds your mail, not your identity.

+

+ On most messengers your account is a row in somebody's database. If that + company shuts the service down, or decides it is done with you, your name + goes with it. Here it does not. +

+ +
+
+

Your address comes from your passphrase

+

+ It is worked out on your phone, not handed to you by a server. Sign in + somewhere that has never heard of you, with the same passphrase, and it + is the same address — so the people who already trust it still see you, + not a stranger with your name. +

+
+
+

You can keep a mailbox on more than one relay

+

+ List a second one and people who write to you reach both, without + sending anything twice. Lose a relay and the message is already + somewhere else. Set it up before you need it: once a relay is gone + there is nothing left to tell anyone where you moved to. +

+
+
+

Relays can talk to each other

+

+ Someone on a relay a friend runs can write to someone on this one. Any + operator decides for themselves whether to allow that and exactly which + relays they will exchange traffic with — the answer is no until they + say otherwise. +

+
+
+
+
+
@@ -141,6 +183,16 @@
+
+

Relays talking to each other means more servers see something

+

+ When two relays exchange a message, each one learns that the other had + traffic for it. Nobody reads the message, but the fact of it is spread + across more operators than a single server would be. That is the trade + for being able to leave, and it is why an operator has to switch it on + deliberately rather than finding it already on. +

+

Encryption covers the message, not the fact you sent one

diff --git a/Resources/Views/privacy.leaf b/Resources/Views/privacy.leaf index 040f9f5..2b33af8 100644 --- a/Resources/Views/privacy.leaf +++ b/Resources/Views/privacy.leaf @@ -71,9 +71,17 @@

This website

- No cookies are set, no analytics are collected, and no third-party scripts are - loaded. The page is served under a Content Security Policy that would break it if - one were ever added. + No cookies are set and no analytics are collected. No third-party scripts are + loaded on any page, with a single exception: the donate page + loads Stripe's script so the payment can be taken here instead of sending you to + another site. The card fields there are Stripe's own, running in a frame this site + cannot read — the card number never reaches this server. +

+

+ Every page is served under a Content Security Policy that would break it if any + other third-party script were added. The donate page's policy names Stripe and + nothing else; the rest of the site, including the API, allows no external origin + at all.

Your choices

diff --git a/Resources/Views/relay.leaf b/Resources/Views/relay.leaf index a1f1be3..1165d13 100644 --- a/Resources/Views/relay.leaf +++ b/Resources/Views/relay.leaf @@ -63,6 +63,15 @@ one that is down.

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 diff --git a/Resources/Views/self-hosting.leaf b/Resources/Views/self-hosting.leaf index e761a0d..bfd7444 100644 --- a/Resources/Views/self-hosting.leaf +++ b/Resources/Views/self-hosting.leaf @@ -6,9 +6,46 @@

Running your own relay is the point of this project, not a footnote to it. There is no hosted service to sign up for, no account with us, and nothing that phones home. Two containers and a config file.

+

The repositories

+

Everything this page describes is published under the MIT +licence.

+ + + + + + + + + + + + + + + + + + + + + + + + + +
RepoWhat it is
flamenet-relayThe server you are about to run
flamenet-e2eThe protocol: JavaScript engine, specification, published test +vectors
flamenet-messenger-iosThe iOS app, and the source of truth for all of the above
flamenet-messenger-androidThe Kotlin engine. Not an app yet
+

You need the first one to run a relay. The rest are there so that +"self-hostable" is something you can check rather than something you are +told.

Quick start

-
git clone https://gitlab.com/paulhitt/flamenet-e2e
-cd flamenet-e2e/server
+
git clone https://gitlab.com/paulhitt/flamenet-relay
+cd flamenet-relay
 cp .env.example .env

Generate the three secrets .env asks for:

openssl rand -base64 32                    #("#") POSTGRES_PASSWORD
@@ -115,6 +152,67 @@ Messaging works without any of it.

running
engine/scripts/verify-build.sh              #("#") digests of the engine sources
 engine/scripts/verify-build.sh --rebuild    #("#") prove the vendored ML-KEM rebuilds byte-identically
+

Standing alone, or +joining the federation

+

A relay you run is self-contained by default, and staying +that way requires no configuration at all. +FN_FEDERATION defaults to closed. Nothing is +disabled by that choice: your users register, exchange keys, message +each other, and verify your transparency log exactly as they would +anywhere else. A closed relay is a complete product, not a crippled +one.

+

It is also genuinely closed rather than nominally closed. The relay +has one piece of outbound network code in it — the SMTP client for +password reset mail — and that is disabled unless you configure a mail +server. With FN_SMTP_HOST unset and federation closed, the +process makes no outbound connections at all. It talks +to its database and to whoever dials it, and to nothing else. There is +no telemetry, no update check, and no directory to register with.

+ + + + + + + + + + + + + + + + + + + + + +
FN_FEDERATIONWhat it means
closed (default)Your relay talks to your users and to nobody else.
allowlistYour relay exchanges traffic only with peers you name.
openYour relay accepts peers it has not met.
+

Your relay advertises which of these it is at +/.well-known/flamenet/relay, so a peer reads your terms +before attempting anything rather than discovering them from a rejected +request.

+

⚠️ Federation is not free, and the cost is metadata. +Sealed sender hides who is writing from the relay carrying the message; +it does not hide your relay's existence, or which relays it exchanges +traffic with, from the operators on the other end. Joining a federation +distributes a slice of your users' social graph across servers you do +not run. That is a real reduction in what your users get from you +hosting them, and it is the honest reason this defaults to off.

+

Choose it deliberately. If your relay exists so that one household, +one company or one group of friends can talk without anyone else +involved, closed is not the cautious option — it is the +correct one.

+

Identities work either way

+

An account key and the fnid derived from it are worth +having on a closed relay. They are what lets someone move: the address +comes from a key your user's client derives from their passphrase, not +from a row number you assigned, so the same person can register the same +identity somewhere else and be recognised as themselves. That holds +whether or not you ever federate — including if you shut your relay +down, which is precisely when it matters.

What self-hosting actually buys you

It removes the operator from your threat model, because you are the diff --git a/Resources/Views/source.leaf b/Resources/Views/source.leaf index 501d597..21f00a8 100644 --- a/Resources/Views/source.leaf +++ b/Resources/Views/source.leaf @@ -4,15 +4,22 @@

Source code

- The engine and the relay are published under the MIT licence. Everything this site - describes is in one repository. + The protocol, the relay and the clients are published under the MIT licence. + Four repositories, because most people want one piece rather than all of it.

-

What is in it

+

Where things are

    -
  • engine/ — the JavaScript cryptographic engine, its test suite, and the published test vectors
  • -
  • engine/docs/SPEC.md — the protocol specification, rendered on this site
  • -
  • server/ — the Swift relay that serves this page
  • +
  • flamenet-e2e + — the JavaScript engine, the specification, and the published + test vectors every implementation is held to
  • +
  • flamenet-relay + — the Swift relay that serves this page. What you run to + self-host
  • +
  • flamenet-messenger-ios + — the iOS app, and the source of truth for all of the above
  • +
  • flamenet-messenger-android + — the Kotlin engine. Not an app yet, and the roadmap says so

Reading it without cloning it

@@ -24,12 +31,8 @@ engine and relay overviews.

-

The repository

- gitlab.com/paulhitt/flamenet-e2e -

-

- That and the hosting credit in the footer are the only two links on this + Those and the hosting credit in the footer are the only links on this site that lead off it. Everything else this project has to say is a page here, which is the point: a project asking to be taken on its own terms should not send you elsewhere to read them. diff --git a/Resources/Views/spec.leaf b/Resources/Views/spec.leaf index 88c366e..dad0f7a 100644 --- a/Resources/Views/spec.leaf +++ b/Resources/Views/spec.leaf @@ -448,6 +448,56 @@ time-limited credentials for the coturn relay. Clients put both STUN and TURN entries in their ICE configuration; media flows peer-to-peer when NATs allow and falls back to the relay (which sees only DTLS-SRTP ciphertext).

+

6.9 Message ids, replies, +and reactions

+

A reply or a reaction has to name an earlier message, and until this +section there was nothing to name it by: message ids were per-device +counters assigned on receipt, so two devices held different numbers for +the same message.

+

mid. A sender MAY put an id on a +message it sends, in the content envelope:

+
{ "fnc": 1, "kind": "text", "mid": "<uuid>", "caption": "hello" }
+

It MUST be unique within the conversation; a UUID is the obvious +choice. A message without a mid is still valid — plain +UTF-8 with no envelope remains a legal message — but nothing can refer +to it, so it cannot be reacted to. Implementations +SHOULD put a mid on every message they send.

+

re — what a message is about. Present +on a reply and on a reaction:

+
{ "mid": "<the id being answered>", "author": 12, "preview": "what they said" }
+

preview is REQUIRED and carries the sender's own copy of +the quoted text, truncated. This is deliberate redundancy. A recipient +device may have joined the conversation after the quoted message, or +cleared its history, and would then have nothing to render; a reply that +shows > [missing] is worse than one that shows what was +actually said. The preview is what the sender saw, which is the +only copy either party can be sure of. A receiving client MUST render +the preview as supplied and MUST NOT treat it as authenticated content +attributable to the quoted author — it is the replier's assertion about +what was said, and a replier can lie about it. Clients SHOULD render it +as a quote attributed to the replier's view, not as the original +author's words.

+

Reactions. kind is +"react", caption is the emoji, re +names the target:

+
{ "fnc": 1, "kind": "react", "caption": "👍", "re": { "mid": "<target>", "author": 12, "preview": "" } }
+

An empty caption removes this sender's +reaction from that message. Removal is a message rather than an absence +because the other device has already rendered the first one and there is +no other way to reach it.

+

A receiving client MUST NOT render a reaction as a message. It is +folded onto the target and, if the target is unknown, dropped. It MUST +NOT raise a notification: being buzzed for a thumbs-up is the thing +people turn a messenger off over. One reaction per sender per message — +a second one replaces the first.

+

gid applies as elsewhere: a reaction on a group message +carries the group id and routes to the group thread.

+

Status. Implemented in the iOS engine and the +JavaScript engine, and checked against each other in both directions by +test/interop.mjs — including a preview containing quotes +and a newline, which is where two hand-ordered JSON writers drift apart. +The Kotlin engine does not implement this section, +along with most of the rest of the protocol.

7. Safety numbers (verification)

For a local identity IK_sig_local_pub and remote @@ -1846,12 +1896,55 @@ records. A republication that does not change the identity key (prekey replenishment, SPK rotation) appends nothing: entries that mean something would otherwise be buried under entries that do not.

19.2 Leaf contents

-
leaf = SHA-256( 0x00 || "v1|" || kind || "|" || user_id || "|" || device_id
+
leaf = SHA-256( 0x00 || "v2|" || kind || "|" || subject || "|" || device_id
                      || "|" || ik_sig || "|" || ik_dh )

kind is publish or revoke. Built by concatenation rather than by a JSON encoder: a leaf hash must be reproducible by every independent verifier forever, and an encoder is free to change key order, spacing or escaping between releases.

+

subject replaced user_id in +v2. A relay's user_id is a row number it assigned +to itself. It made a leaf unverifiable by exactly the third-party +monitors this section exists to enable — they have no way to learn what +user 12 means — and it made two relays unable to agree that two leaves +describe the same person, which is the first thing federation needs.

+

A subject is one of two shapes, and which one it is is readable from +the string:

+ + + + + + + + + + + + + + + + + +
FormMeaning
fnid:<crockford-base32>A global account identity: the account's Ed25519 public key. +Portable — the same person at another relay produces the same +subject.
local:<audience>/<user_id>A relay-local row number, qualified by the relay that assigned +it.
+

The local: form exists because a device may be +registered before its account key has been claimed. Refusing to log +those devices would leave a hole in the one record that must not have +holes; logging them unqualified would produce leaves that mean different +things depending on their origin, with nothing in the leaf to say so. +Qualifying it states plainly that the leaf is relay-scoped.

+

⚠️ A v1 leaf must never be verified under v2 rules. +The version prefix is inside the hashed bytes precisely so the two +cannot be confused — a verifier that recomputes a v1 leaf with a v2 +template gets a different hash and correctly fails, rather than silently +accepting a leaf about a different subject. Relays that ran v1 keep +those leaves, hashes untouched, so consistency proofs across the format +change still hold: the log is append-only, and a format change is not a +licence to rewrite history.

19.3 Routes

All four are unauthenticated. A log only its own users can read is not a transparency log; the value is that anyone — a diff --git a/Scripts/build-doc-pages.sh b/Scripts/build-doc-pages.sh index 26ba548..ac7d238 100755 --- a/Scripts/build-doc-pages.sh +++ b/Scripts/build-doc-pages.sh @@ -32,4 +32,7 @@ render security "$repo/engine/SECURITY.md" render self-hosting "$repo/engine/SELF-HOSTING.md" render engine "$repo/engine/README.md" render relay "$here/README.md" +render api "$here/docs/API.md" +render configuration "$here/docs/CONFIGURATION.md" +render hosting "$here/docs/HOSTING.md" echo "done." diff --git a/Scripts/secret-sweep.sh b/Scripts/secret-sweep.sh index 4724106..fb62934 100755 --- a/Scripts/secret-sweep.sh +++ b/Scripts/secret-sweep.sh @@ -57,7 +57,11 @@ probe "Slack token" 'xox[baprs]-[0-9A-Za-z-]{10,}' probe "private key block" '-----BEGIN (RSA |EC |OPENSSH |PGP )?PRIVATE KEY' probe "JWT" 'eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.' probe "Supabase service role" 'service_role.{0,40}eyJ' -probe "assigned password" '(password|passwd|secret|token)[[:space:]]*[:=][[:space:]]*["'"'"'][^"'"'"'{}$][^"'"'"']{7,}["'"'"']' +# The excluded first characters are interpolation, not credentials: `${...}` in +# shell and JavaScript, and `\(...)` in Swift. A value the program computes at +# run time cannot be a literal secret, and flagging one trains people to pass +# the gate by ignoring it. +probe "assigned password" '(password|passwd|secret|token)[[:space:]]*[:=][[:space:]]*["'"'"'][^"'"'"'{}$\\][^"'"'"']{7,}["'"'"']' if [ "$hits" -gt 0 ]; then echo diff --git a/Sources/FlamenetRelay/Account.swift b/Sources/FlamenetRelay/Account.swift index 5933960..39391e0 100644 --- a/Sources/FlamenetRelay/Account.swift +++ b/Sources/FlamenetRelay/Account.swift @@ -6,7 +6,7 @@ import Vapor /// An account on this instance. /// /// This is the *built-in* identity provider — the one that makes self-hosting -/// stop meaning "run and secure a WordPress install". On flamenet.io WordPress +/// stop meaning "run and secure a the previous platform install". On flamenet.io the previous platform /// remains the issuer and none of this is used; the relay does not care which /// one signed a token, because it only ever checks a pinned public key. final class User: Model, @unchecked Sendable { @@ -37,6 +37,23 @@ final class User: Model, @unchecked Sendable { /// for accounts created directly here, which is what a self-hosted relay /// has. @OptionalField(key: "enclave_pseudonym") var enclavePseudonym: String? + + /// The account's Ed25519 public key, base64, and the address derived from + /// it. Both null until a client publishes one. + /// + /// Unlike `enclavePseudonym` above, this is not something any server + /// issued: the client stretches it from the passphrase and proves + /// possession to register it. Storing it here lets this relay address the + /// account the way another relay would, which is the whole prerequisite + /// for federating anything. + /// + /// Kept alongside the derived `fnid` rather than deriving on read, because + /// the address is what gets indexed, compared and put in a log leaf, and + /// recomputing it on every one of those is a base32 encode per row for a + /// value that cannot change. + @OptionalField(key: "account_key") var accountKey: String? + @OptionalField(key: "fnid") var fnid: String? + @OptionalField(key: "last_seen_at") var lastSeenAt: Date? @Timestamp(key: "created_at", on: .create) var createdAt: Date? @@ -65,7 +82,7 @@ final class User: Model, @unchecked Sendable { static func isValidUsername(_ username: String) -> Bool { let u = normalize(username) guard (3...60).contains(u.count) else { return false } - // Deliberately narrower than WordPress: OSCAR rejects `.`, `_` and `-` + // Deliberately narrower than the previous platform: OSCAR rejects `.`, `_` and `-` // in screen names and reports it as "service unreachable", which is // impossible to debug from the client. Refusing them at signup is // kinder than discovering it at first sign-on. diff --git a/Sources/FlamenetRelay/AccountIdentity.swift b/Sources/FlamenetRelay/AccountIdentity.swift new file mode 100644 index 0000000..dd73328 --- /dev/null +++ b/Sources/FlamenetRelay/AccountIdentity.swift @@ -0,0 +1,133 @@ +import Crypto +import Foundation + +/// A global account identity, for a network with more than one relay in it. +/// +/// `user_id` is a row number this relay assigned. It is meaningful here and +/// nowhere else: user 12 on this relay and user 12 on another are unrelated +/// people, so nothing addressed by it can cross a relay boundary. The shared +/// account service's pseudonym is no better for the purpose — it is +/// `HMAC(service_secret, account ‖ product)`, deliberately scoped to one +/// service so two products cannot correlate their users, which also means two +/// relays cannot agree on it. +/// +/// So the global identity is a key the *client* derives and holds: an Ed25519 +/// account key, stretched from the passphrase under its own context, the same +/// way the per-product encryption keys already are. The public half, rendered +/// as an `fnid`, is the address. +/// +/// The property that matters for redundancy is that no server issues it. A +/// relay can go away — be shut down, be deleted, refuse to serve you — without +/// taking the identity with it, because the identity was never the relay's to +/// grant. Re-registering the same account key at a new relay produces the same +/// fnid, and contacts who pinned it are still talking to the same person. +enum FNID { + + /// Crockford base32: no padding, no `I`, `L`, `O` or `U`, so the alphabet + /// has no character pair a person can confuse when reading one aloud or + /// copying it off a screen. + /// + /// Chosen to match the recovery codes the Files product already issues + /// rather than introducing a second encoding for humans to handle. Standard + /// base64 was the alternative and is worse here for exactly one reason: + /// these end up in URLs, QR codes and spoken verification, and `+/=` do not + /// survive any of those unescaped. + static let alphabet = Array("0123456789abcdefghjkmnpqrstvwxyz") + + /// The 32 raw bytes of an Ed25519 public key encode to 52 characters. + static let encodedLength = 52 + + static func encode(_ data: Data) -> String { + var out = "" + var buffer = 0 + var bits = 0 + for byte in data { + buffer = (buffer << 8) | Int(byte) + bits += 8 + while bits >= 5 { + bits -= 5 + out.append(alphabet[(buffer >> bits) & 31]) + } + } + if bits > 0 { out.append(alphabet[(buffer << (5 - bits)) & 31]) } + return out + } + + /// The address for an account key, as it appears on the wire and in a log + /// leaf. + /// + /// Prefixed rather than bare so that a bare 52-character string is never + /// mistaken for one of these by something doing a loose comparison, and so + /// a future encoding can be told apart from this one without guessing. + static func address(forAccountKey raw: Data) -> String { + "fnid:" + encode(raw) + } + + /// What a client signs with its account private key to claim an fnid here. + /// + /// Three things are bound, and dropping any one of them breaks something: + /// + /// - The **audience**, so a claim captured at one relay cannot be replayed + /// at another. The same account key is *meant* to be registered at many + /// relays — that is the whole point — but each registration must be an + /// act the account holder took at that relay, not one a third party + /// replayed on their behalf. + /// - The **local user id**, so a claim cannot be lifted onto a different + /// account on this same relay. + /// - The **key itself**, so the signature cannot be transplanted onto a + /// different key by anyone who intercepts it. + /// + /// Built by concatenation for the same reason log leaves are: this string + /// has to be reproducible byte-for-byte by three independent client + /// implementations, and a JSON encoder is free to reorder or respace. + static func claimCanonical(audience: String, userID: Int, accountKeyB64: String) -> Data { + Data("fnacct-v1|\(audience)|\(userID)|\(accountKeyB64)".utf8) + } + + /// Verify a claim signature against the key it is claiming. + /// + /// Self-certifying: the signature is checked with the very key being + /// registered, so the relay is confirming possession rather than vouching + /// for identity. It has no opinion about who the key belongs to and cannot + /// acquire one — which is the property that lets a hostile relay be + /// tolerated at all. + static func verifyClaim( + accountKeyB64: String, signatureB64: String, audience: String, userID: Int + ) -> Data? { + guard let raw = Data(base64Encoded: accountKeyB64), raw.count == 32, + let sig = Data(base64Encoded: signatureB64), + let key = try? Curve25519.Signing.PublicKey(rawRepresentation: raw) + else { return nil } + let canonical = claimCanonical(audience: audience, userID: userID, accountKeyB64: accountKeyB64) + guard key.isValidSignature(sig, for: canonical) else { return nil } + return raw + } +} + +/// What a key transparency leaf names when it says whose key this is. +/// +/// The log predates any global identity, so its leaves bound `user_id` — which +/// makes a leaf verifiable only by someone who already knows which relay issued +/// it, and meaningless to the third-party monitors §19 exists to enable. +/// +/// A subject is one of two shapes, and which one it is can be read off the +/// string rather than inferred: +/// +/// - `fnid:` — a global account identity. Portable: a monitor can check +/// it against entries in any other relay's log for the same person. +/// - `local:/` — this relay's own row number, qualified by +/// the relay that assigned it. +/// +/// The `local:` form exists because a device can be registered before its +/// account key has been published, and the alternative was worse in both +/// directions: refusing to log those devices leaves a hole in exactly the +/// record that is supposed to have none, and logging them unqualified produces +/// leaves that silently mean different things depending on where they came +/// from. Qualifying it says plainly that the leaf is relay-scoped, which a +/// verifier can then decide what to do about. +enum LogSubject { + static func forAccount(fnid: String?, audience: String, userID: Int) -> String { + if let fnid, !fnid.isEmpty { return fnid } + return "local:\(audience)/\(userID)" + } +} diff --git a/Sources/FlamenetRelay/AtRestCrypto.swift b/Sources/FlamenetRelay/AtRestCrypto.swift index 58670a4..d656be6 100644 --- a/Sources/FlamenetRelay/AtRestCrypto.swift +++ b/Sources/FlamenetRelay/AtRestCrypto.swift @@ -19,7 +19,7 @@ import Foundation /// /// Blob layout is byte-compatible with the PHP implementation /// (`base64(iv[12] || tag[16] || ciphertext)`) so rows can be carried across -/// from a WordPress install without re-encryption. Note the ordering: this is +/// from a the previous platform install without re-encryption. Note the ordering: this is /// **not** what `AES.GCM.SealedBox.combined` produces, which puts the tag last. /// Assembling by hand is deliberate, not an oversight. enum AtRestCrypto { diff --git a/Sources/FlamenetRelay/Config.swift b/Sources/FlamenetRelay/Config.swift index d4f8d5f..c19ec5a 100644 --- a/Sources/FlamenetRelay/Config.swift +++ b/Sources/FlamenetRelay/Config.swift @@ -13,8 +13,32 @@ struct Config: Sendable { /// Donations. Off unless an operator configures a processor: a relay that /// someone else runs has no business soliciting money for us, and a button /// that fails after a person has decided to give is worse than no button. + /// Outbound mail, for password resets. Nil disables the feature entirely + /// rather than half-enabling it: a reset flow that mints a token and then + /// cannot deliver it leaves the account locked out AND a live token in the + /// database, which is the worst of both. + let smtp: SMTPConfig? let donateProvider: String? let donateSecret: String? + /// The Stripe **connected account** donations are taken on, or nil to take + /// them on whichever account the secret key itself authenticates as. + /// + /// Set to an `acct_…` id and every Stripe call carries `Stripe-Account`, + /// which makes the charge a *direct* one: the money, the fees, the receipt + /// and the statement descriptor all belong to that account rather than to + /// the platform. Unset is not an error — it is how a self-hoster runs this + /// with their own key — but with a platform key it means donations land on + /// the platform, silently, so start-up says which way it is configured. + let donateAccount: String? + /// Stripe's publishable key, handed to the browser so it can mount the + /// Payment Element. Public by design — it authorises nothing on its own — + /// but still configuration rather than something to hardcode. + /// + /// With `donateAccount` set this is the **platform's** key, not the + /// connected account's: a direct charge is confirmed by the platform key + /// scoped with `stripeAccount`, and the connected account's own key would + /// be the wrong half of that pair. + let donatePublishableKey: String? /// The issuer's Ed25519 public key, as raw bytes. /// /// Bytes rather than a `Curve25519.Signing.PublicKey`, because on Linux @@ -28,6 +52,34 @@ struct Config: Sendable { let audience: String let clockSkew: Int + /// Whether this relay will exchange traffic with other relays. + /// + /// `closed` by default, and that default is load-bearing rather than + /// cautious: server-to-server delivery is not implemented, so any other + /// value would advertise a capability with no code behind it. An operator + /// opting in early would produce peers that try and fail, which is worse + /// than peers that never try. + let federationPolicy: String + + /// Ed25519 seed this relay signs server-to-server requests with. + /// + /// **Deliberately not the token issuer key.** Issuer.swift already notes + /// that co-locating the issuer means a relay compromise is an issuer + /// compromise; signing federation traffic with the same key would widen + /// that further, so that a key which only ever needed to prove "this relay + /// said this" could also mint a token for any user on it. A separate key + /// costs one more `openssl genpkey` and bounds a leak to impersonating the + /// relay to its peers. + /// + /// Nil is normal — a closed relay never signs anything for anyone. + let federationKeyRaw: Data? + + /// Peers this relay will exchange traffic with under `allowlist`. + /// + /// Ignored under `closed` (nobody) and `open` (anybody), so it is not a + /// second switch that can silently disagree with the first. + let federationPeers: [String] + /// Bundle fetches per requester, and per requester-target pair, per hour. let bundleRatePerHour: Int let bundleRatePerTarget: Int @@ -53,9 +105,9 @@ struct Config: Sendable { /// The built-in identity provider's Ed25519 signing seed, or nil. /// - /// Nil on flamenet.io, where WordPress is the issuer and this relay only + /// Nil where a separate service is the issuer and this relay only /// ever verifies. Set for a self-hoster, for whom the alternative is not - /// two isolated services but installing WordPress. + /// two isolated services. let issuerSigningKeyRaw: Data? let tokenLifetime: Int @@ -117,11 +169,28 @@ struct Config: Sendable { port: int("FN_PORT", 8080), databasePath: env["FN_DB"] ?? "relay.sqlite", postgresURL: env["FN_POSTGRES_URL"].flatMap { $0.isEmpty ? nil : $0 }, + smtp: SMTPConfig(env: env), donateProvider: env["FN_DONATE_PROVIDER"].flatMap { $0.isEmpty ? nil : $0 }, donateSecret: env["FN_DONATE_SECRET"].flatMap { $0.isEmpty ? nil : $0 }, + donateAccount: env["FN_DONATE_ACCOUNT"].flatMap { $0.isEmpty ? nil : $0 }, + donatePublishableKey: env["FN_DONATE_PUBLISHABLE_KEY"] + .flatMap { $0.isEmpty ? nil : $0 }, issuerKeyRaw: raw, audience: audience, clockSkew: int("FN_CLOCK_SKEW", 60), + federationPolicy: { + let v = env["FN_FEDERATION"] ?? "closed" + // An unrecognised value falls back to closed rather than being + // rejected at boot: a typo in one optional variable should not + // stop a relay serving its own users. + return ["closed", "allowlist", "open"].contains(v) ? v : "closed" + }(), + federationKeyRaw: env["FN_FEDERATION_KEY"] + .flatMap { $0.isEmpty ? nil : Data(base64Encoded: $0) }, + federationPeers: (env["FN_FEDERATION_PEERS"] ?? "") + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty }, bundleRatePerHour: int("FN_BUNDLE_RATE", 120), bundleRatePerTarget: int("FN_BUNDLE_RATE_TARGET", 6), sendRatePerHour: int("FN_SEND_RATE", 600), @@ -144,3 +213,40 @@ struct Config: Sendable { ) } } + +/// Outbound mail settings, for password reset. +/// +/// All of `HOST`, `FROM` and a port are required together — there is no useful +/// half-configured state, and defaulting a host to localhost would silently +/// send reset links into a mail server nobody is reading. +/// +/// Credentials are optional on purpose: a relay next to its own MTA on the same +/// host commonly submits without auth, and demanding a username there would +/// mean inventing one. +struct SMTPConfig: Sendable { + let host: String + let port: Int + let username: String? + let password: String? + /// Envelope sender, e.g. `Flamenet `. + let from: String + /// STARTTLS on the submission port. On unless explicitly disabled, because + /// the failure mode of the other default is a password reset link crossing + /// a network in the clear. + let useTLS: Bool + + init?(env: [String: String]) { + func value(_ key: String) -> String? { + env[key].flatMap { $0.isEmpty ? nil : $0 } + } + guard let host = value("FN_SMTP_HOST"), let from = value("FN_SMTP_FROM") else { + return nil + } + self.host = host + self.from = from + self.port = value("FN_SMTP_PORT").flatMap(Int.init) ?? 587 + self.username = value("FN_SMTP_USERNAME") + self.password = value("FN_SMTP_PASSWORD") + self.useTLS = (value("FN_SMTP_TLS") ?? "true").lowercased() != "false" + } +} diff --git a/Sources/FlamenetRelay/Entrypoint.swift b/Sources/FlamenetRelay/Entrypoint.swift index a0e0fb1..7de3bfa 100644 --- a/Sources/FlamenetRelay/Entrypoint.swift +++ b/Sources/FlamenetRelay/Entrypoint.swift @@ -14,7 +14,7 @@ import Vapor /// `payload` goes into a TEXT column and comes back out unread. /// /// What it is NOT: an identity provider. Accounts, profiles, and the community -/// they are anchored in stay in WordPress, which mints the tokens this service +/// they are anchored in stay with the identity provider, which mints the tokens this service /// verifies against a pinned public key and never calls on the request path. @main enum Entrypoint { @@ -105,9 +105,11 @@ func configure(_ app: Application) async throws { CreateUser(), CreateAppCredential(), CreateBuddy(), CreateMessage(), CreateConversationState(), CreateBlock(), CreateNotification(), CreateLogEntry(), + CreatePasswordResetToken(), // Last: it alters tables the migrations above create, and Fluent runs // them in registration order. - TrimUnreadMetadata(), AddPQIdentity(), AddEnclavePseudonym(), AllowNullEmail()) + TrimUnreadMetadata(), AddPQIdentity(), AddEnclavePseudonym(), AllowNullEmail(), + AddAccountIdentity(), AddLogSubject(), CreateHomeRecord(), AddEnvelopeID()) // Migrating at boot rather than as a separate command: a single static // binary that someone can run in five minutes is the point, and "run this // other command first" is where five minutes becomes an afternoon. @@ -127,6 +129,17 @@ func configure(_ app: Application) async throws { // any /e2e/* route, and no /e2e/* handler reads from disk. app.middleware.use(FileMiddleware(publicDirectory: app.directory.publicDirectory)) + // Shared with the federation verifier: one window, so a nonce replayed + // across the client and peer paths is caught by whichever sees it second. + let replayCache = ReplayCache(window: config.clockSkew * 2) + + // The directory is the relay's only outbound HTTP, and it is constructed + // only when an operator has opted in. A closed relay holds no client, which + // is what makes "a closed relay opens no outbound connections" a property + // of the code rather than a promise in a document. + let peerDirectory: PeerDirectory? = config.federationPolicy == "closed" + ? nil : PeerDirectory(client: app.client) + let routes = Routes( config: config, verifier: TokenVerifier( @@ -134,7 +147,28 @@ func configure(_ app: Application) async throws { audience: config.audience, skew: config.clockSkew), limits: RateLimiter(), - replay: ReplayCache(window: config.clockSkew * 2) + federation: FederationVerifier( + policy: config.federationPolicy, + peers: config.federationPeers, + audience: config.audience, + skew: config.clockSkew, + directory: peerDirectory, + replay: replayCache), + federationSender: { + guard config.federationPolicy != "closed", + let seed = config.federationKeyRaw else { return nil } + let policy = config.federationPolicy + let peers = config.federationPeers + return FederationSender( + audience: config.audience, + signingKeyRaw: seed, + client: app.client, + // The same rule that admits a peer decides who we will dial. + // Two lists would eventually disagree, and the disagreement + // would be a relay that accepts from someone it will not answer. + admits: { policy == "open" || peers.contains($0) }) + }(), + replay: replayCache ) try register(routes: routes, on: app) @@ -150,6 +184,30 @@ func configure(_ app: Application) async throws { // The built-in identity provider, only when a signing key is configured. // + // Federation, when an operator has asked for it. + // + // Refusing to boot rather than degrading to closed: an operator who set + // FN_FEDERATION has decided their relay should be reachable by peers, and + // silently starting it unreachable would be discovered by a stranger + // failing to deliver, not by them. `closed` remains the do-nothing default, + // so this can only fire for someone who opted in. + if config.federationPolicy != "closed" { + guard let seed = config.federationKeyRaw, + (try? Curve25519.Signing.PrivateKey(rawRepresentation: seed)) != nil + else { + app.logger.critical(""" + FN_FEDERATION is \(config.federationPolicy) but FN_FEDERATION_KEY is not a \ + 32-byte Ed25519 seed. Generate one with: \ + openssl genpkey -algorithm ed25519 | openssl pkey -outform DER | tail -c 32 | base64 + """) + throw Config.ConfigError.badIssuerKey + } + if config.federationPolicy == "allowlist" && config.federationPeers.isEmpty { + app.logger.warning( + "FN_FEDERATION=allowlist with no FN_FEDERATION_PEERS: this relay federates with nobody") + } + } + // Absent, `/account/*` does not exist at all rather than existing and // refusing — a route that 500s because of a missing environment variable // reads as an outage, and this is a deployment choice, not a fault. @@ -164,7 +222,7 @@ func configure(_ app: Application) async throws { audience: config.audience, lifetime: config.tokenLifetime), limits: RateLimiter(window: AccountRoutes.signinLockout)) - try register(account: accounts, on: app) + try register(account: accounts, config: config, on: app) // The shipped iOS client's surface, mounted where it already looks. // It authenticates with the same app credential, so it needs the @@ -177,7 +235,7 @@ func configure(_ app: Application) async throws { try register(mobile: mobile, on: app) // The shipped client signs up and signs in under the messenger prefix, - // and validates a restored session against wp/v2/users/me. Same + // and validates a restored session against messenger/me. Same // handlers as /account/*, mounted where the app already looks — the // response shape was already identical, so this is only a matter of // where it is served from. @@ -188,28 +246,57 @@ func configure(_ app: Application) async throws { } // A shared FlameNet account, when one is configured. Absent on a // self-hosted relay, which is its own identity provider. - if let enclaveSecret = Environment.get("FN_ENCLAVE_JWT_SECRET"), enclaveSecret.count >= 32 { + // A verifying key, when the account service publishes one. + // + // Configured rather than fetched: this decides who may assert an + // identity here, and a relay that learned that over the network could be + // pointed at a different issuer by whoever controls DNS. It is available + // at the account service's /.well-known/jwks.json — copy the `x` value + // in once, the way the issuer key is already handled. + let enclavePublicKey: Data? = Environment.get("FN_ENCLAVE_PUBLIC_KEY") + .flatMap { $0.isEmpty ? nil : Data(base64Encoded: $0) } + if let key = enclavePublicKey, key.count != 32 { + app.logger.critical("FN_ENCLAVE_PUBLIC_KEY is not a base64 32-byte Ed25519 public key") + throw Config.ConfigError.badIssuerKey + } + + let enclaveSecret = Environment.get("FN_ENCLAVE_JWT_SECRET") ?? "" + // Either credential enables the route. A relay running on shared + // accounts needs only the public key; the secret remains accepted for + // deployments the account service has not moved yet. + if enclaveSecret.count >= 32 || enclavePublicKey != nil { compat.post("session") { req in - try await accounts.enclaveSession(req, secret: enclaveSecret, now: unixNow()) + try await accounts.enclaveSession( + req, secret: enclaveSecret, publicKey: enclavePublicKey, now: unixNow()) + } + if enclavePublicKey != nil { + app.logger.info("shared FlameNet account sessions enabled (signature-verified)") + } else { + app.logger.warning(""" + Shared FlameNet account sessions are verified with a shared secret. \ + Anything holding that secret can also mint sessions for any account, \ + so this is only safe on a relay operated by the account service's own \ + operator. Set FN_ENCLAVE_PUBLIC_KEY to verify signatures instead. + """) } - app.logger.info("shared FlameNet account sessions enabled") } // Account removal. Under the messenger prefix because that is where the // shipped client sends it. compat.delete("account") { req in try await accounts.deleteAccount(req) } - let wpUsers = app.grouped("api", "wp", "v2", "users") - wpUsers.get("me") { req in try await accounts.me(req) } - // POST because that is what the client sends -- WordPress accepted it - // as a PUT alias and the client was written against that. PUT is - // registered too so the route is not surprising to anything else. - wpUsers.post("me") { req in try await accounts.updateMe(req) } - wpUsers.put("me") { req in try await accounts.updateMe(req) } - // Exact-username lookup, not the collection WordPress served here. The - // route is the same one the client already calls; what it will not do - // is answer without a `slug`. - wpUsers.get { req in try await accounts.lookup(req, now: unixNow()) } + // ⚠️ These carried a legacy route shape while the app still spoke it. + // Nothing of that lineage runs behind this service, so they live where + // everything else does. + let me = app.grouped("api", "flamenet", "v1", "messenger") + me.get("me") { req in try await accounts.me(req) } + // PUT is the honest verb for "replace fields on this thing". POST is + // registered alongside it only because clients in the wild send it. + me.put("me") { req in try await accounts.updateMe(req) } + me.post("me") { req in try await accounts.updateMe(req) } + // Exact-username lookup, and deliberately not a directory: it will not + // answer without a `slug`. + me.get("users") { req in try await accounts.lookup(req, now: unixNow()) } // The namespace index, so a client can find out what this relay serves // instead of assuming. // @@ -220,17 +307,27 @@ func configure(_ app: Application) async throws { // its default of "commands work", and every one of them fails at the // point a person tries to use it -- which also leaks the query, since // command searches travel in the clear. - for prefix in ["api", "wp-json"] { - app.get(.constant(prefix), "flamenet", "v1") { _ in - NamespaceIndex.current - } + // ⚠️ **Served ONLY under the prefix the routes are actually on.** + // + // It was briefly served under a second, legacy prefix as well, and that + // broke the iOS app completely. The app settles its REST prefix by + // probing for this document and keeping the first prefix that answers; + // the legacy prefix answered with a list of routes it does not serve, + // so the app adopted it and **every** call 404'd. Sign-up surfaced that + // as a bare "Server returned HTTP 404." with nothing pointing here. + // + // The rule is more general than the bug: serve a discovery document + // only where the things it describes exist. A document listing routes a + // prefix does not serve is worse than no document — it is believed. + app.get("api", "flamenet", "v1") { _ in + NamespaceIndex.current } app.logger.info("built-in identity provider and mobile surface enabled") } // Retention as a supervised in-process loop, not a scheduler that has to be - // visited to run. The WordPress version hung off wp-cron and stopped for - // eight weeks without anyone noticing, which meant the retention windows + // visited to run. The previous implementation hung off an external cron and + // stopped for eight weeks unnoticed, which meant the retention windows // described intent rather than behaviour. let retention = Retention( db: app.db, config: config, logger: app.logger, @@ -293,21 +390,74 @@ func register(routes r: Routes, on app: Application) throws { "The cryptographic engine: what it implements and how it is tested."), ("relay", "Relay — Flamenet Messenger", "The Swift relay: what it serves and how it is configured."), + // The self-hosting reference: every route, every variable, and the + // shortest thing that works. Served by the relay itself so an operator + // reading it is reading the same document their own relay ships. + ("api", "Relay API — Flamenet Messenger", + "Every route a relay serves, what authenticates it, and what it is for."), + ("configuration", "Configuring a relay — Flamenet Messenger", + "Every variable the relay reads and what it breaks if it is wrong."), + ("hosting", "Running your own relay — Flamenet Messenger", + "One container and one database. Files is optional."), ] // Donations. The page is rendered whether or not a processor is set — the // unconfigured state is part of what it says — but the form only exists // when there is somewhere for it to post. let donate = DonateRoutes(config: r.config) + + // Say out loud which Stripe account and which Stripe mode this deploy takes + // money into. + // + // A relay running in production on a `sk_test_` key takes donations that + // never arrive, and nothing about the page looks wrong. The same is true of + // a platform key with no connected account: Stripe answers 200 and the + // money lands on the wrong business. Neither fails on its own, so both are + // logged where a deploy can be checked against them. + // + // ⚠️ `critical`, not `warning`. This relay's logger SWALLOWS warnings — + // `.notice` and `.critical` both reach the log and `.warning` does not, and + // a safeguard nobody can see is not a safeguard. + if let secret = r.config.donateSecret { + if secret.hasPrefix("sk_test_") || secret.hasPrefix("rk_test_") { + app.logger.critical("donations are in STRIPE TEST MODE — payments taken here are NOT REAL. Production must set FN_DONATE_SECRET and FN_DONATE_PUBLISHABLE_KEY to live keys.") + } else if secret.hasPrefix("sk_live_") || secret.hasPrefix("rk_live_") { + app.logger.notice("donations are in Stripe LIVE mode — real cards will be charged") + } else { + app.logger.critical("FN_DONATE_SECRET does not look like a Stripe key") + } + // A live secret paired with a test publishable key (or the reverse) + // fails at confirmation time, in the browser, after the person has + // typed their card in. Catch it here instead. + if let publishable = r.config.donatePublishableKey { + let secretLive = secret.contains("_live_") + let publishableLive = publishable.contains("_live_") + if secretLive != publishableLive { + app.logger.critical("FN_DONATE_SECRET and FN_DONATE_PUBLISHABLE_KEY are from DIFFERENT Stripe modes — one is live and the other is test. Payments will fail at confirmation, after the card has been entered.") + } + } else { + app.logger.critical("FN_DONATE_SECRET is set but FN_DONATE_PUBLISHABLE_KEY is not — the payment form cannot mount, so the donate page shows no form at all.") + } + switch r.config.donateAccount { + case .some(let account) where account.hasPrefix("acct_"): + app.logger.notice("donations are taken on connected account \(account)") + case .some(let account): + app.logger.critical("FN_DONATE_ACCOUNT is not a Stripe account id (\(account)) — every donation will fail at the processor.") + case .none: + app.logger.critical("FN_DONATE_ACCOUNT is unset — donations land on whichever account FN_DONATE_SECRET belongs to, which on a platform key is the platform itself.") + } + } app.get("donate") { req async throws -> View in try await req.view.render("donate", DonateContext( title: "Donate — Flamenet Messenger", description: "What donations pay for, what they do not buy, and better ways to help.", audience: r.config.audience, protoVersion: 3, - donateEnabled: r.config.donateProvider != nil && r.config.donateSecret != nil, + donateEnabled: r.config.donateProvider != nil && r.config.donateSecret != nil + && r.config.donatePublishableKey != nil, + thanks: req.query[String.self, at: "thanks"] != nil, processorName: (r.config.donateProvider ?? "the processor").capitalized)) } - app.post("donate", "checkout") { req in try await donate.checkout(req) } + app.post("donate", "intent") { req in try await donate.intent(req) } for (slug, title, description) in pages { app.get(.constant(slug)) { req async throws -> View in @@ -319,6 +469,13 @@ func register(routes r: Routes, on app: Application) throws { } } + // How a relay describes itself to a client or a peer holding nothing but + // its hostname. Unauthenticated on purpose — a descriptor only this relay's + // own users could read would be useless to the peers it exists to inform. + app.get(".well-known", "flamenet", "relay") { req in + try await r.relayDescriptor(req) + } + // RFC 9116. A researcher who finds something should not have to guess an // address, and the footer link is only worth having if it resolves. app.get(".well-known", "security.txt") { _ -> Response in @@ -341,7 +498,7 @@ func register(routes r: Routes, on app: Application) throws { // §8 is specified at `/e2e/*`, and that is the canonical mount. // - // It is also mounted under the WordPress REST namespace, because the + // It is also mounted under the messenger namespace, because the // shipped client addresses it there — the same reason the messenger // surface is mounted twice. This is a compatibility alias, not a second // API: both point at one set of handlers. @@ -355,6 +512,64 @@ func register(routes r: Routes, on app: Application) throws { e2e.post("devices") { req in try await r.registerDevice(req, body: body(of: req), now: unixNow()) } + + // Global account identity. `account` rather than a path under `devices` + // because it is a property of the person, not of the install: every + // device of one account claims the same fnid. + // Server-to-server. Registered unconditionally so a peer gets a clear + // "this relay does not federate" rather than a 404 that reads as a + // broken deployment — the verifier refuses before anything else runs. + // Outbound: what a local client asks its own relay to do on its behalf. + e2e.post("send", "remote") { req in + try await r.sendRemote(req, body: body(of: req), now: unixNow()) + } + e2e.get("remote", ":relay", "devices", ":fnid") { req in + guard let relay = req.parameters.get("relay"), + let fnid = req.parameters.get("fnid"), !relay.isEmpty, !fnid.isEmpty + else { throw WireError.badRequest("Malformed address.") } + return try await r.remoteDirectory(req, relay: relay, fnid: fnid, pops: false, now: unixNow()) + } + e2e.get("remote", ":relay", "bundles", ":fnid") { req in + guard let relay = req.parameters.get("relay"), + let fnid = req.parameters.get("fnid"), !relay.isEmpty, !fnid.isEmpty + else { throw WireError.badRequest("Malformed address.") } + return try await r.remoteDirectory(req, relay: relay, fnid: fnid, pops: true, now: unixNow()) + } + + e2e.post("federation", "deposit") { req in + try await r.federationDeposit(req, body: body(of: req), now: unixNow()) + } + e2e.get("federation", "bundles", ":fnid") { req in + guard let fnid = req.parameters.get("fnid"), !fnid.isEmpty else { + throw WireError.badRequest("Malformed identity.") + } + return try await r.federationBundles(req, fnid: fnid, now: unixNow()) + } + e2e.get("federation", "devices", ":fnid") { req in + guard let fnid = req.parameters.get("fnid"), !fnid.isEmpty else { + throw WireError.badRequest("Malformed identity.") + } + return try await r.federationDevices(req, fnid: fnid, now: unixNow()) + } + + e2e.post("account", "identity") { req in + try await r.claimAccountIdentity(req, body: body(of: req), now: unixNow()) + } + e2e.post("account", "homes") { req in + try await r.publishHomes(req, body: body(of: req), now: unixNow()) + } + e2e.get("account", "homes", ":fnid") { req in + guard let fnid = req.parameters.get("fnid"), !fnid.isEmpty else { + throw WireError.badRequest("Malformed identity.") + } + return try await r.readHomes(req, fnid: fnid) + } + e2e.get("account", "identity", ":handle") { req in + guard let handle = req.parameters.get("handle"), !handle.isEmpty else { + throw WireError.badRequest("Malformed handle.") + } + return try await r.resolveIdentity(req, handle: handle) + } e2e.get("keys", ":user_id") { req in guard let target = req.parameters.get("user_id", as: Int.self) else { throw WireError.badRequest("Malformed user id.") @@ -484,9 +699,9 @@ func register(messaging m: MessagingRoutes, on app: Application) throws { /// Mounted at `/api/flamenet/v1/messenger/*`. /// -/// That prefix is not a choice. It is where the shipped app looks: WordPress -/// REST with `wp-json` rewritten to `api`, and the app builds every URL from -/// it. Serving the same routes anywhere else means the app cannot reach them. +/// That prefix is not a choice. It is where the shipped app looks, and the app +/// builds every URL from it. Serving these routes anywhere else means the app +/// cannot reach them. func register(mobile m: MobileRoutes, on app: Application) throws { let g = app.grouped("api", "flamenet", "v1", "messenger") g.get("buddies") { req in try await m.buddies(req) } @@ -537,13 +752,23 @@ func register(mobile m: MobileRoutes, on app: Application) throws { g.post("command") { req in try await m.command(req) } } -func register(account a: AccountRoutes, on app: Application) throws { +func register(account a: AccountRoutes, config: Config, on app: Application) throws { let account = app.grouped("account") account.post("signup") { req in try await a.signup(req) } account.post("signin") { req in try await a.signin(req, clientIP: clientIP(req), now: unixNow()) } account.post("token") { req in try await a.token(req, now: unixNow()) } + // Password reset. Registered whether or not mail is configured: the route + // answers identically either way, and a 404 when SMTP is unset would tell + // an anonymous caller how this relay is deployed. + let mailer = config.smtp.map { + Mailer(config: $0, group: app.eventLoopGroup, logger: app.logger) + } + account.post("reset", "request") { req in + try await a.requestReset(req, mailer: mailer, siteURL: config.audience, now: unixNow()) + } + account.post("reset", "confirm") { req in try await a.confirmReset(req, now: unixNow()) } } func unixNow() -> Int { Int(Date().timeIntervalSince1970) } @@ -608,7 +833,7 @@ struct StopTask: LifecycleHandler { /// boot from the file contents, so it changes exactly when they do. enum AssetVersion { static let value: String = { - let paths = ["Public/landing.css", "Public/landing.js"] + let paths = ["Public/landing.css", "Public/landing.js", "Public/donate.js"] var hasher = SHA256() for path in paths { if let data = FileManager.default.contents(atPath: path) { hasher.update(data: data) } @@ -623,6 +848,9 @@ struct DonateContext: Encodable { let audience: String let protoVersion: Int let donateEnabled: Bool + /// Set by the payment coming back to this page, not by anything durable — + /// it says a payment was confirmed, not that this person is a donor. + let thanks: Bool let processorName: String let assetVersion: String = AssetVersion.value } @@ -644,11 +872,35 @@ struct LandingContext: Encodable { /// broken page rather than a silent regression. The API responses are JSON and /// carry it harmlessly. struct SecurityHeadersMiddleware: AsyncMiddleware { + private static let base = + "default-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'" + + /// The origins Stripe's Payment Element actually uses. + /// + /// Named individually, never a bare `https:` — a policy that allows every + /// origin is not a policy. This is wider than the obvious `js.stripe.com` + /// because the Element pulls in more than its script: `m.stripe.network` + /// carries its inner frames and telemetry, `*.stripe.com` serves the card + /// brand images, and Stripe injects an hCaptcha frame for fraud checks. A + /// policy missing any of them does not fail loudly — the fields simply do + /// not appear, which is indistinguishable from a broken integration. + private static let donate = + "default-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; " + + "script-src 'self' https://js.stripe.com https://hcaptcha.com https://*.hcaptcha.com; " + + "frame-src https://js.stripe.com https://hooks.stripe.com https://m.stripe.network " + + "https://hcaptcha.com https://*.hcaptcha.com; " + + "connect-src 'self' https://api.stripe.com https://m.stripe.network " + + "https://hcaptcha.com https://*.hcaptcha.com; " + + "img-src 'self' data: https://*.stripe.com; " + + "style-src 'self' 'unsafe-inline'" + func respond(to request: Request, chainingTo next: any AsyncResponder) async throws -> Response { let response = try await next.respond(to: request) + // Only this one page pays for the widening. + let isDonate = request.url.path.hasPrefix("/donate") response.headers.replaceOrAdd( name: .contentSecurityPolicy, - value: "default-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'") + value: isDonate ? Self.donate : Self.base) response.headers.replaceOrAdd(name: .init("X-Content-Type-Options"), value: "nosniff") response.headers.replaceOrAdd(name: .init("Referrer-Policy"), value: "no-referrer") response.headers.replaceOrAdd( diff --git a/Sources/FlamenetRelay/Federation.swift b/Sources/FlamenetRelay/Federation.swift new file mode 100644 index 0000000..98b1a5d --- /dev/null +++ b/Sources/FlamenetRelay/Federation.swift @@ -0,0 +1,390 @@ +import Crypto +import Foundation +import Vapor + +/// Server-to-server authentication. +/// +/// ## What a peer is allowed to do, and what it is not +/// +/// A federated relay may deposit an envelope for one of our users, and read the +/// prekey bundles our users have published. That is the entire grant. It may +/// **not** mint a token, speak for a user, read an inbox, register a device, or +/// claim an identity. Those all remain behind `TokenVerifier`, which checks a +/// single pinned issuer key and has no notion of a peer at all. +/// +/// Keeping the two apart is the point. The obvious shortcut — let a peer +/// present a token it minted for its own user and trust it — would make every +/// federated relay an identity provider for every other, so one compromised +/// relay anywhere could speak for anyone. Instead a peer authenticates *as +/// itself*, and what it asserts about its users is only ever "this ciphertext +/// arrived from me", which is exactly as much as it should be able to say. +/// +/// ## The signature +/// +/// ``` +/// fnfed-v1||||||| +/// ``` +/// +/// Every field earns its place: +/// +/// - **origin and destination** are both bound. Origin says who is speaking; +/// destination stops a request captured by one relay being replayed at +/// another. Without the second, a hostile peer could take a deposit meant for +/// it and re-present it everywhere else in the federation. +/// - **body digest**, so a captured signature cannot be moved onto different +/// ciphertext at the same path. +/// - **nonce**, for the same reason the client proof carries one: without it the +/// signature is a pure function of the other fields, and two legitimate +/// requests in the same second are byte-identical — so a replay cache cannot +/// tell an honest repeat from a captured one and refuses the honest peer. +enum Federation { + + /// Headers a peer sends. Distinct from the client's `X-FN-*` proof headers + /// so the two verification paths can never be confused for one another by a + /// handler that reads the wrong one. + enum Headers { + static let origin = "X-FN-Origin" + static let timestamp = "X-FN-Fed-Timestamp" + static let nonce = "X-FN-Fed-Nonce" + static let signature = "X-FN-Fed-Signature" + } + + static func canonical( + method: String, path: String, origin: String, destination: String, + timestamp: String, nonce: String, body: Data + ) -> Data { + let digest = SHA256.hash(data: body).map { String(format: "%02x", $0) }.joined() + return Data( + "fnfed-v1|\(method)|\(path)|\(origin)|\(destination)|\(timestamp)|\(nonce)|\(digest)".utf8) + } + + enum AuthError: Error, CustomStringConvertible { + case federationDisabled + case peerNotAllowed(String) + case missingProof + case staleTimestamp + case unknownPeer(String) + case badSignature + case replayed + + var description: String { + switch self { + case .federationDisabled: return "this relay does not federate" + case .peerNotAllowed(let p): return "peer \(p) is not on this relay's allowlist" + case .missingProof: return "federation headers are incomplete" + case .staleTimestamp: return "federation timestamp is outside the accepted window" + case .unknownPeer(let p): return "could not learn the signing key for \(p)" + case .badSignature: return "federation signature does not verify" + case .replayed: return "this federation request has already been seen" + } + } + + /// A peer that is refused should be able to tell "you don't federate" + /// from "I got the signature wrong", because those call for opposite + /// responses — stop asking, versus fix and retry. + var status: HTTPResponseStatus { + switch self { + case .federationDisabled, .peerNotAllowed: return .forbidden + default: return .unauthorized + } + } + } + + /// A verified peer. Handlers never see raw federation headers, exactly as + /// they never see a raw token. + struct Peer: Sendable { + /// The origin relay's audience. The only thing a handler may attribute + /// an inbound envelope to. + let audience: String + } +} + +/// Fetches and caches what other relays say about themselves. +/// +/// ⚠️ **This is the relay's only outbound HTTP.** A relay with +/// `FN_FEDERATION=closed` never constructs one of these, which is what makes +/// the self-hosting promise — a closed relay opens no outbound connections — +/// true in code rather than only in documentation. +actor PeerDirectory { + private let client: any Client + private let ttl: Int + private var cache: [String: (descriptor: PeerDescriptor, at: Int)] = [:] + + /// A ceiling on how many peers we will remember. + /// + /// Under `open` the key is attacker-chosen, so an unbounded cache is a + /// memory leak anyone can drive. When it fills, the oldest entry goes — a + /// crude policy, and adequate: the cost of evicting a live peer is one + /// extra fetch. + private static let maxCached = 512 + + struct PeerDescriptor: Content, Sendable { + let audience: String + let federationKey: String? + let federation: String + } + + init(client: any Client, ttl: Int = 3600) { + self.client = client + self.ttl = ttl + } + + /// Whether a peer name may be dialled at all. + /// + /// Under `allowlist` the set of names is whatever the operator configured, + /// and this adds little. Under `open` the name arrives in a header from + /// anyone, and this is the whole defence: without it, `X-FN-Origin: + /// 169.254.169.254` turns the relay into a request forwarder pointed at the + /// cloud metadata endpoint, and `127.0.0.1` points it at whatever else the + /// operator is running on the box. + /// + /// A charset check alone does not do this — `127.0.0.1` is letters, digits + /// and dots, and it has a dot in it, so every obvious filter waves it + /// through. The name must be rejected for *being an address*, not for its + /// spelling. + /// + /// ⚠️ This does not resolve DNS, so a public name pointing at a private + /// address still gets through. Closing that means resolving first and + /// checking every answer, then pinning the result for the connection so the + /// name cannot be re-resolved to something else in between. That is worth + /// doing before `open` is recommended to anyone; `allowlist` does not + /// depend on it, which is the mode the fleet and the docs both use. + static func isFetchableHost(_ host: String) -> Bool { + guard !host.isEmpty, host.count <= 253, host.contains("."), + !host.hasPrefix("-"), !host.hasPrefix("."), !host.hasSuffix("-"), + host.allSatisfy({ $0.isLetter || $0.isNumber || $0 == "." || $0 == "-" }) + else { return false } + + // An IPv4 literal is four numeric labels. Rejected wholesale rather than + // range by range: a relay is addressed by name, so there is no honest + // reason for a peer to be an address, and enumerating private ranges is + // a list that is always one entry out of date. + let labels = host.split(separator: ".", omittingEmptySubsequences: false) + if labels.count == 4, labels.allSatisfy({ !$0.isEmpty && $0.allSatisfy(\.isNumber) }) { + return false + } + guard labels.allSatisfy({ !$0.isEmpty && $0.count <= 63 }) else { return false } + + // Names that never belong to a public relay. `.local` is mDNS, and the + // rest are conventions for exactly the hosts this must not reach. + let last = labels.last.map(String.init)?.lowercased() ?? "" + return !["local", "internal", "localhost", "localdomain", "home", "lan"].contains(last) + } + + /// A peer's descriptor, from cache when it is fresh. + /// + /// Cached because it is fetched on the verification path: without a cache, + /// every inbound envelope becomes an outbound request to the sender, which + /// hands any peer an amplifier pointed at us and makes our availability + /// depend on theirs. + func descriptor(for audience: String, now: Int) async throws -> PeerDescriptor { + if let hit = cache[audience], now - hit.at < ttl { return hit.descriptor } + + guard Self.isFetchableHost(audience) else { + throw Federation.AuthError.unknownPeer(audience) + } + + let uri = URI(string: "https://\(audience)/.well-known/flamenet/relay") + // A deadline, because this sits on the inbound verification path: a peer + // that accepts the connection and then says nothing would otherwise hold + // one of our request handlers for as long as it likes, which makes our + // availability a function of the slowest host that can name itself. + let response = try await client.get(uri) { req in + req.headers.replaceOrAdd(name: .accept, value: "application/json") + } + guard response.status == .ok, + let d = try? response.content.decode(PeerDescriptor.self) + else { throw Federation.AuthError.unknownPeer(audience) } + + // A descriptor that names a different audience than the host it came + // from is either misconfigured or an attempt to be somebody else. Either + // way it cannot be used to attribute traffic. + guard d.audience == audience else { throw Federation.AuthError.unknownPeer(audience) } + + if cache.count >= Self.maxCached, + let oldest = cache.min(by: { $0.value.at < $1.value.at })?.key { + cache.removeValue(forKey: oldest) + } + cache[audience] = (d, now) + return d + } +} + +/// Verifies inbound server-to-server requests. +struct FederationVerifier: Sendable { + let policy: String + let peers: [String] + let audience: String + let skew: Int + let directory: PeerDirectory? + let replay: ReplayCache + + var enabled: Bool { policy != "closed" } + + /// Whether this relay will talk to `origin` at all. + /// + /// Checked *before* the signature, and before any outbound fetch. A closed + /// relay must not be induced to dial an arbitrary host just by being sent a + /// header naming it — which is what verifying first would do, since + /// verifying requires fetching the peer's key. + func admits(_ origin: String) -> Bool { + switch policy { + case "open": return true + case "allowlist": return peers.contains(origin) + default: return false + } + } + + func verify( + method: String, path: String, body: Data, + origin: String?, timestamp: String?, nonce: String?, signature: String?, + now: Int + ) async throws -> Federation.Peer { + guard enabled, let directory else { throw Federation.AuthError.federationDisabled } + guard let origin, !origin.isEmpty else { throw Federation.AuthError.missingProof } + guard admits(origin) else { throw Federation.AuthError.peerNotAllowed(origin) } + guard let timestamp, let nonce, !nonce.isEmpty, let signature else { + throw Federation.AuthError.missingProof + } + guard let ts = Int(timestamp), abs(now - ts) <= skew else { + throw Federation.AuthError.staleTimestamp + } + + let descriptor = try await directory.descriptor(for: origin, now: now) + guard let keyB64 = descriptor.federationKey, + let keyRaw = Data(base64Encoded: keyB64), + let key = try? Curve25519.Signing.PublicKey(rawRepresentation: keyRaw) + else { throw Federation.AuthError.unknownPeer(origin) } + + guard let sig = Data(base64Encoded: signature) else { + throw Federation.AuthError.badSignature + } + // `audience` here is OURS, not the header's: the destination is what we + // know ourselves to be, never what the caller asserts. Taking it from + // the request would let a peer sign for whoever it liked. + let canonical = Federation.canonical( + method: method, path: path, origin: origin, destination: audience, + timestamp: timestamp, nonce: nonce, body: body) + guard key.isValidSignature(sig, for: canonical) else { + throw Federation.AuthError.badSignature + } + + // Namespaced by origin so two peers cannot collide on a nonce and + // knock each other's traffic out. + guard await replay.admit("fed:\(origin):\(nonce)", now: now) else { + throw Federation.AuthError.replayed + } + return Federation.Peer(audience: origin) + } +} + +/// Signs and sends this relay's own requests to a peer. +/// +/// The mirror of `FederationVerifier`, and deliberately the only thing in the +/// codebase that can produce a `fnfed-v1` signature — a second signer would be +/// a second chance to disagree about what gets bound, and what gets bound is +/// the whole security property. +struct FederationSender: Sendable { + let audience: String + let signingKeyRaw: Data + let client: any Client + /// Peers this relay is willing to dial, by the same rule it admits them. + let admits: @Sendable (String) -> Bool + + enum SendError: Error, CustomStringConvertible { + case notPermitted(String) + case unreachable(String) + case refused(String, HTTPResponseStatus) + + var description: String { + switch self { + case .notPermitted(let p): return "this relay does not federate with \(p)" + case .unreachable(let p): return "could not reach \(p)" + case .refused(let p, let s): return "\(p) refused the request (\(s.code))" + } + } + } + + private func signedHeaders( + method: String, path: String, destination: String, body: Data, now: Int + ) throws -> HTTPHeaders { + guard let key = try? Curve25519.Signing.PrivateKey(rawRepresentation: signingKeyRaw) else { + throw Abort(.internalServerError, reason: "federation key is unusable") + } + let ts = String(now) + let nonce = UUID().uuidString + let canonical = Federation.canonical( + method: method, path: path, origin: audience, destination: destination, + timestamp: ts, nonce: nonce, body: body) + let sig = try key.signature(for: canonical).base64EncodedString() + + var headers = HTTPHeaders() + headers.replaceOrAdd(name: Federation.Headers.origin, value: audience) + headers.replaceOrAdd(name: Federation.Headers.timestamp, value: ts) + headers.replaceOrAdd(name: Federation.Headers.nonce, value: nonce) + headers.replaceOrAdd(name: Federation.Headers.signature, value: sig) + return headers + } + + /// Hand ciphertext to the relay that homes the recipient. + func deposit( + to peer: String, envelopes: [FederationDepositRequest.Item], now: Int + ) async throws -> FederationDepositResponse { + guard admits(peer), PeerDirectory.isFetchableHost(peer) else { + throw SendError.notPermitted(peer) + } + let path = "/e2e/federation/deposit" + // Encoded once, and the very bytes that were signed are the bytes sent. + // Re-encoding for the request would let a body whose JSON does not + // round-trip byte-for-byte arrive with a signature over different + // content — the same trap the token verifier avoids by signing the + // received segments rather than a re-serialisation of the claims. + let payload = try JSONEncoder().encode(FederationDepositRequest(envelopes: envelopes)) + var headers = try signedHeaders( + method: "POST", path: path, destination: peer, body: payload, now: now) + headers.replaceOrAdd(name: .contentType, value: "application/json") + + let response: ClientResponse + do { + response = try await client.post( + URI(string: "https://\(peer)\(path)"), headers: headers) { req in + req.body = .init(data: payload) + } + } catch { + throw SendError.unreachable(peer) + } + guard response.status == .ok, + let decoded = try? response.content.decode(FederationDepositResponse.self) + else { throw SendError.refused(peer, response.status) } + return decoded + } + + /// Fetch what a peer publishes about one of its users. + /// + /// `pops` decides which route, and the caller must mean it: the bundle route + /// consumes a one-time prekey per device at the far end, so a client that + /// only wants to see identity keys must not take that path. + func fetch( + from peer: String, fnid: String, pops: Bool, now: Int + ) async throws -> ClientResponse { + guard admits(peer), PeerDirectory.isFetchableHost(peer) else { + throw SendError.notPermitted(peer) + } + let path = "/e2e/federation/\(pops ? "bundles" : "devices")/\(fnid)" + return try await get(from: peer, path: path, now: now) + } + + /// An authenticated GET at a peer, for any of the routes it exposes to us. + func get(from peer: String, path: String, now: Int) async throws -> ClientResponse { + guard admits(peer), PeerDirectory.isFetchableHost(peer) else { + throw SendError.notPermitted(peer) + } + let headers = try signedHeaders( + method: "GET", path: path, destination: peer, body: Data(), now: now) + do { + return try await client.get(URI(string: "https://\(peer)\(path)"), headers: headers) + } catch { + throw SendError.unreachable(peer) + } + } +} diff --git a/Sources/FlamenetRelay/HomeRelays.swift b/Sources/FlamenetRelay/HomeRelays.swift new file mode 100644 index 0000000..35a2491 --- /dev/null +++ b/Sources/FlamenetRelay/HomeRelays.swift @@ -0,0 +1,133 @@ +import Crypto +import Fluent +import Foundation +import Vapor + +/// Where an account keeps its mailboxes. +/// +/// ## The problem this solves, and the one it does not +/// +/// Federation lets somebody on another relay write to you. It does not stop +/// your relay being the single place your mail lives: register at relay1 and +/// every envelope for you is in relay1's database, so relay1 going away takes +/// the undelivered ones with it and relay1 refusing to serve you locks you out +/// of your own inbox. +/// +/// Multi-homing splits that. An account registers the **same devices, with the +/// same identity keys** at several relays, and senders deliver to all of them. +/// Any one of those relays can then be unreachable, hostile, or gone, and the +/// message still arrives — because it was never only there. +/// +/// It buys redundancy of the *mailbox*, not of the conversation: a device that +/// was never linked still cannot read history it never received, and nothing +/// here changes §16's position that the relay does not hold your history. +/// +/// ## Why the list is signed, and why it carries a serial +/// +/// A relay that could edit this list could quietly drop the other relays from +/// it and become the only place your mail goes — which is precisely the +/// position multi-homing exists to escape. So the account key signs the list, +/// and no relay can produce a signature for a key it does not have. +/// +/// The **serial** closes the other half. A signature alone lets a relay serve +/// an *older* list it once saw honestly — one from before you added relay2 — +/// and that older list is genuinely signed, so a verifier checking only the +/// signature accepts it. The serial makes rollback detectable: a relay may not +/// replace a record with one carrying a lower number, and a client that has +/// seen serial 5 refuses a 4 it is later handed. +/// +/// ``` +/// fnhomes-v1|||,,... +/// ``` +/// +/// Relays are joined in the order given and signed as given: reordering +/// produces a different signature, so there is no canonicalisation step for +/// three implementations to disagree about. +enum HomeRelays { + + /// A hard cap, because a sender fans out to every entry. + /// + /// Without one, a signed record naming a thousand hosts turns any sender's + /// relay into an amplifier pointed wherever the signer likes — and the + /// signature makes it look legitimate. Four is comfortably more than the + /// "my box, a friend's box, and a paid backstop" this is for. + static let maxHomes = 4 + + static func canonical(fnid: String, serial: Int, relays: [String]) -> Data { + Data("fnhomes-v1|\(fnid)|\(serial)|\(relays.joined(separator: ","))".utf8) + } + + /// Verify a record against the account key the address is derived from. + /// + /// Self-certifying, like the claim: the fnid *is* the public key, so the + /// record proves itself and a relay storing it is never asked to vouch for + /// anything. + static func verify( + fnid: String, serial: Int, relays: [String], signatureB64: String + ) -> Bool { + guard fnid.hasPrefix("fnid:"), + let raw = decodeAddress(fnid), + let key = try? Curve25519.Signing.PublicKey(rawRepresentation: raw), + let sig = Data(base64Encoded: signatureB64) + else { return false } + return key.isValidSignature(sig, for: canonical(fnid: fnid, serial: serial, relays: relays)) + } + + /// Recover the account public key from its address. + /// + /// The address is the key, so this is a decode rather than a lookup — which + /// is what lets any party check a record without asking a server who the + /// signer was. + static func decodeAddress(_ fnid: String) -> Data? { + let body = String(fnid.dropFirst("fnid:".count)) + guard body.count == FNID.encodedLength else { return nil } + var buffer = 0, bits = 0 + var out = Data() + for ch in body { + guard let v = FNID.alphabet.firstIndex(of: ch) else { return nil } + buffer = (buffer << 5) | v + bits += 5 + if bits >= 8 { + bits -= 8 + out.append(UInt8((buffer >> bits) & 0xFF)) + } + } + return out.count == 32 ? out : nil + } +} + +/// The stored record. One per account, replaced only by a higher serial. +final class HomeRecord: Model, @unchecked Sendable { + static let schema = "home_relays" + + @ID(custom: "fnid", generatedBy: .user) var id: String? + @Field(key: "serial") var serial: Int + /// Comma-joined, exactly as signed. Stored as one string rather than a + /// child table because the signature covers the joined form — splitting it + /// for storage would mean rebuilding it for verification, and a rebuild + /// that differs by one character fails in a way that looks like a bad key. + @Field(key: "relays") var relays: String + @Field(key: "sig") var sig: String + @Timestamp(key: "updated_at", on: .update) var updatedAt: Date? + + init() {} + + var relayList: [String] { + relays.split(separator: ",").map(String.init).filter { !$0.isEmpty } + } +} + +struct CreateHomeRecord: AsyncMigration { + func prepare(on database: any Database) async throws { + try await database.schema(HomeRecord.schema) + .field("fnid", .string, .identifier(auto: false)) + .field("serial", .int, .required) + .field("relays", .string, .required) + .field("sig", .string, .required) + .field("updated_at", .datetime) + .create() + } + func revert(on database: any Database) async throws { + try await database.schema(HomeRecord.schema).delete() + } +} diff --git a/Sources/FlamenetRelay/Issuer.swift b/Sources/FlamenetRelay/Issuer.swift index 37dbbe9..bd5f70b 100644 --- a/Sources/FlamenetRelay/Issuer.swift +++ b/Sources/FlamenetRelay/Issuer.swift @@ -14,12 +14,12 @@ import Vapor /// But co-location does cost something real and it should be said plainly: in /// one process, a relay compromise **is** an issuer compromise, because the /// signing key is in the same address space. That is a worse position than -/// flamenet.io's, where WordPress holds the signing key and a relay takeover +/// a split deployment, where a separate issuer holds the signing key and a relay takeover /// cannot mint anything. /// /// The trade is deliberate and it is for self-hosters, who are running one box /// either way — for them the alternative is not "two isolated services", it is -/// "install and secure WordPress". Enabled only when `FN_ISSUER_PRIVATE_KEY` is +/// "install and secure the previous platform". Enabled only when `FN_ISSUER_PRIVATE_KEY` is /// set; on flamenet.io it is not, and this file does nothing. struct Issuer: Sendable { /// Ed25519 private key seed, raw bytes. Bytes rather than a key object for diff --git a/Sources/FlamenetRelay/KeyLog.swift b/Sources/FlamenetRelay/KeyLog.swift index 282fba3..3a6bc3a 100644 --- a/Sources/FlamenetRelay/KeyLog.swift +++ b/Sources/FlamenetRelay/KeyLog.swift @@ -18,6 +18,14 @@ final class LogEntry: Model, @unchecked Sendable { @ID(custom: "seq", generatedBy: .database) var id: Int? @Field(key: "leaf_hash") var leafHash: String @Field(key: "user_id") var userID: Int + /// Whose key this leaf is about, as the leaf itself names them — an `fnid:` + /// address where the account has published one, a `local:` form otherwise. + /// + /// Stored rather than recomputed on read: the leaf hash committed to this + /// exact string, and an account that publishes its key later would + /// otherwise silently change the subject of leaves already in the tree, + /// making every historical inclusion proof fail. + @Field(key: "subject") var subject: String @Field(key: "device_id") var deviceID: String /// "publish" or "revoke". @Field(key: "kind") var kind: String @@ -26,6 +34,30 @@ final class LogEntry: Model, @unchecked Sendable { init() {} } +/// Add the subject a leaf names. +/// +/// Backfilled to the `local:` form for rows written before this existed, which +/// is what those leaves actually committed to under v1 in every respect that +/// matters: a relay-scoped row number. Their stored `leaf_hash` is left alone — +/// it is the v1 hash and rewriting it would break any inclusion proof already +/// issued against it. +struct AddLogSubject: AsyncMigration { + func prepare(on database: any Database) async throws { + try await database.schema(LogEntry.schema).field("subject", .string).update() + try await (database as? any SQLDatabase)? + .raw("UPDATE key_log SET subject = 'legacy-v1/' || user_id WHERE subject IS NULL").run() + // Deliberately not tightened to NOT NULL. SQLite cannot add a + // constraint to an existing column without rebuilding the table, and + // this relay ships on SQLite and Postgres with a schema that is meant + // to be identical on both — a Postgres-only constraint would make + // "identical" quietly false. The model's property is non-optional, so + // every row written from here on carries one regardless. + } + func revert(on database: any Database) async throws { + try await database.schema(LogEntry.schema).deleteField("subject").update() + } +} + struct CreateLogEntry: AsyncMigration { func prepare(on database: any Database) async throws { try await database.schema(LogEntry.schema) @@ -52,28 +84,48 @@ enum KeyLog { /// reproducible by every independent verifier forever: key order, spacing /// and escaping all have to be pinned, and an encoder is free to change any /// of them between releases. - static func canonical(userID: Int, deviceID: String, ikSig: String, + /// `v2` binds a `LogSubject` where `v1` bound this relay's `user_id`. + /// + /// A row number is only meaningful to the relay that assigned it, which + /// made every v1 leaf unverifiable by the third-party monitors §19 exists + /// to enable, and unusable for federation: two relays could not agree that + /// two leaves describe the same person. The version prefix changes with the + /// field so a v1 leaf can never be reinterpreted under v2 rules. + static func canonical(subject: String, deviceID: String, ikSig: String, ikDH: String, kind: String) -> Data { - Data("v1|\(kind)|\(userID)|\(deviceID)|\(ikSig)|\(ikDH)".utf8) + Data("v2|\(kind)|\(subject)|\(deviceID)|\(ikSig)|\(ikDH)".utf8) } /// Append a key publication. Called inside the same transaction as the /// write it describes, so the log cannot disagree with the table it is /// supposed to be a record of. static func append( - userID: Int, deviceID: String, ikSig: String, ikDH: String, kind: String, - on db: any Database + userID: Int, subject: String, deviceID: String, ikSig: String, ikDH: String, + kind: String, on db: any Database ) async throws { let leaf = MerkleLog.leafHash( - canonical(userID: userID, deviceID: deviceID, ikSig: ikSig, ikDH: ikDH, kind: kind)) + canonical(subject: subject, deviceID: deviceID, ikSig: ikSig, ikDH: ikDH, kind: kind)) let row = LogEntry() row.leafHash = leaf.base64EncodedString() row.userID = userID + row.subject = subject row.deviceID = deviceID row.kind = kind try await row.create(on: db) } + /// The subject to log for a user, read from the account row. + /// + /// A lookup rather than a parameter threaded through every caller: the + /// subject is a property of the account, and a caller that has to remember + /// to pass it is a caller that can pass the wrong one. + static func subject( + forUserID userID: Int, audience: String, on db: any Database + ) async throws -> String { + let fnid = try await User.find(userID, on: db)?.fnid + return LogSubject.forAccount(fnid: fnid, audience: audience, userID: userID) + } + /// Every leaf hash, in log order. /// /// Recomputed per request rather than cached. That is honest for the size diff --git a/Sources/FlamenetRelay/Mailer.swift b/Sources/FlamenetRelay/Mailer.swift new file mode 100644 index 0000000..b8dab3a --- /dev/null +++ b/Sources/FlamenetRelay/Mailer.swift @@ -0,0 +1,267 @@ +import Foundation +import NIOConcurrencyHelpers +import NIOCore +import NIOPosix +import NIOSSL +import Vapor + +/// Minimal SMTP submission, for the one message this service sends. +/// +/// Hand-written rather than a dependency because the surface actually needed is +/// small — EHLO, optional STARTTLS, optional AUTH, one recipient, one body — +/// and a mail library brings a MIME builder, attachment handling and address +/// parsing that this relay has no use for and would have to keep patched. +/// +/// It is **not** a general SMTP client. No pipelining, no 8BITMIME +/// negotiation, no multi-recipient, no retry queue. A send either works or +/// throws, and the caller decides what that means. +struct Mailer: Sendable { + let config: SMTPConfig + let group: any EventLoopGroup + let logger: Logger + + enum Failure: Error, LocalizedError { + case unexpected(code: Int, line: String, step: String) + case tlsRefused + case authRequiredButUnconfigured + + var errorDescription: String? { + switch self { + case .unexpected(let code, let line, let step): + return "SMTP \(step) got \(code): \(line)" + case .tlsRefused: + return "The mail server does not offer STARTTLS. Set FN_SMTP_TLS=false only " + + "for a loopback server." + case .authRequiredButUnconfigured: + return "The mail server wants authentication but FN_SMTP_USERNAME is unset." + } + } + } + + /// Send one plain-text message. + func send(to recipient: String, subject: String, body: String) async throws { + // The handler goes in during the bootstrap, NOT after connect. NIO + // starts reading as soon as the channel is active, so a handler added + // afterwards misses the server's 220 greeting -- it is delivered to a + // pipeline with nothing in it and dropped. Every later step then waits + // for a reply that already came and went, and the send hangs forever + // rather than failing. + let reader = LineHandler() + let channel = try await ClientBootstrap(group: group) + .connectTimeout(.seconds(15)) + .channelInitializer { channel in + channel.pipeline.addHandler(reader) + } + .connect(host: config.host, port: config.port) + .get() + let session = SMTPSession(channel: channel, handler: reader) + + defer { _ = channel.close() } + + _ = try await session.expect(220, step: "greeting") + var capabilities = try await session.command("EHLO \(Self.heloName)", expect: 250, step: "EHLO") + + if config.useTLS { + guard capabilities.uppercased().contains("STARTTLS") else { throw Failure.tlsRefused } + _ = try await session.command("STARTTLS", expect: 220, step: "STARTTLS") + + var tls = TLSConfiguration.makeClientConfiguration() + tls.certificateVerification = .fullVerification + let ssl = try NIOSSLContext(configuration: tls) + let handler = try NIOSSLClientHandler(context: ssl, serverHostname: config.host) + // In front of the line reader, so everything after this point is + // encrypted on the way out and decrypted on the way in. + try await channel.pipeline.addHandler(handler, position: .first) + // A second EHLO is required, not optional: the capability list + // before STARTTLS is not trustworthy and servers commonly advertise + // AUTH only after the upgrade. + capabilities = try await session.command("EHLO \(Self.heloName)", expect: 250, step: "EHLO (TLS)") + } + + if let username = config.username, let password = config.password { + // AUTH PLAIN: a single base64 of \0user\0pass. + let token = Data(("\u{0}" + username + "\u{0}" + password).utf8).base64EncodedString() + _ = try await session.command("AUTH PLAIN \(token)", expect: 235, step: "AUTH") + } else if capabilities.uppercased().contains("AUTH") && config.useTLS { + // Advertising AUTH does not always mean requiring it, so this is a + // log line rather than a refusal — a relay beside its own MTA + // legitimately submits without credentials. + logger.info("SMTP server advertises AUTH but no FN_SMTP_USERNAME is set") + } + + _ = try await session.command("MAIL FROM:<\(Self.address(from: config.from))>", + expect: 250, step: "MAIL FROM") + _ = try await session.command("RCPT TO:<\(recipient)>", expect: 250, step: "RCPT TO") + _ = try await session.command("DATA", expect: 354, step: "DATA") + _ = try await session.command(Self.message(from: config.from, to: recipient, + subject: subject, body: body) + "\r\n.", + expect: 250, step: "body") + _ = try? await session.command("QUIT", expect: 221, step: "QUIT") + } + + /// The name given in EHLO. Not the public hostname on purpose: this is a + /// submission client, the server does not route on it, and putting the + /// relay's real hostname in every outbound conversation tells any mail + /// server it is configured against which service is behind it. + private static let heloName = "localhost" + + /// `Name ` → `addr@host`. The envelope wants the bare address. + static func address(from field: String) -> String { + guard let open = field.lastIndex(of: "<"), let close = field.lastIndex(of: ">"), + open < close + else { return field.trimmingCharacters(in: .whitespaces) } + return String(field[field.index(after: open).. String { + let stuffed = body + .replacingOccurrences(of: "\r\n", with: "\n") + .split(separator: "\n", omittingEmptySubsequences: false) + .map { $0.hasPrefix(".") ? "." + $0 : String($0) } + .joined(separator: "\r\n") + let date = ISO8601DateFormatter().string(from: Date()) + return [ + "From: \(from)", + "To: <\(to)>", + "Subject: \(subject)", + "Date: \(date)", + "MIME-Version: 1.0", + "Content-Type: text/plain; charset=utf-8", + "", + stuffed, + ].joined(separator: "\r\n") + } +} + +/// Drives one SMTP conversation: write a line, await the reply. +/// +/// SMTP replies can be multi-line (`250-FOO` then `250 BAR`), and a reader that +/// stops at the first line desynchronises the whole session — every later +/// command then reads the previous command's leftovers and the failure looks +/// random. So a reply is complete only on a line whose fourth character is a +/// space. +private final class SMTPSession: @unchecked Sendable { + let handler: LineHandler + private let channel: any Channel + + init(channel: any Channel, handler: LineHandler) { + self.channel = channel + self.handler = handler + } + + func expect(_ code: Int, step: String) async throws -> String { + let reply = try await handler.nextReply() + guard reply.code == code else { + throw Mailer.Failure.unexpected(code: reply.code, line: reply.text, step: step) + } + return reply.text + } + + @discardableResult + func command(_ line: String, expect code: Int, step: String) async throws -> String { + var buffer = channel.allocator.buffer(capacity: line.utf8.count + 2) + buffer.writeString(line + "\r\n") + try await channel.writeAndFlush(buffer).get() + return try await expect(code, step: step) + } +} + +/// Accumulates bytes into complete SMTP replies. +final class LineHandler: ChannelInboundHandler, @unchecked Sendable { + typealias InboundIn = ByteBuffer + + struct Reply { let code: Int; let text: String } + + private var pending: [CheckedContinuation] = [] + private var ready: [Reply] = [] + private var partial = "" + private var accumulated: [String] = [] + private let lock = NIOLock() + + /// Every `resume` below happens AFTER the lock is released. + /// + /// `NIOLock` is not reentrant, and resuming a continuation can run the + /// awaiting task inline on this very thread — which then asks for the next + /// reply, takes the same lock, and deadlocks. The symptom is not a crash: + /// the send hangs, the HTTP request never answers, and the relay looks + /// healthy the whole time. + func nextReply() async throws -> Reply { + try await withCheckedThrowingContinuation { continuation in + let immediate: Reply? = lock.withLock { + if ready.isEmpty { + pending.append(continuation) + return nil + } + return ready.removeFirst() + } + if let immediate { continuation.resume(returning: immediate) } + } + } + + func channelRead(context: ChannelHandlerContext, data: NIOAny) { + var buffer = unwrapInboundIn(data) + guard let chunk = buffer.readString(length: buffer.readableBytes) else { return } + partial += chunk + + // `isNewline`, NOT `firstIndex(of: "\n")`. Swift folds CR LF into a + // SINGLE Character — "\r\n" is one extended grapheme cluster — so + // searching for "\n" never matches a CRLF line ending, which is the + // only kind SMTP uses. The symptom is not a parse error: every reply + // sits in the buffer unrecognised, the client waits for a greeting that + // already arrived, and the send hangs with the socket wide open. + while let newline = partial.firstIndex(where: \.isNewline) { + let raw = String(partial[partial.startIndex..] = lock.withLock { + let all = pending + pending.removeAll() + return all + } + for continuation in waiting { continuation.resume(throwing: error) } + context.close(promise: nil) + } + + /// A closed channel must fail everyone still waiting. Without this a server + /// that hangs up mid-conversation leaves the send suspended forever, which + /// on a request path is a stuck connection rather than an error. + func channelInactive(context: ChannelHandlerContext) { + let waiting: [CheckedContinuation] = lock.withLock { + let all = pending + pending.removeAll() + return all + } + for continuation in waiting { + continuation.resume(throwing: Mailer.Failure.unexpected( + code: 0, line: "connection closed", step: "session")) + } + } + + private func deliver(_ reply: Reply) { + let waiting: CheckedContinuation? = lock.withLock { + if pending.isEmpty { + ready.append(reply) + return nil + } + return pending.removeFirst() + } + waiting?.resume(returning: reply) + } +} diff --git a/Sources/FlamenetRelay/Migrations.swift b/Sources/FlamenetRelay/Migrations.swift index 5c4b6ea..be849cf 100644 --- a/Sources/FlamenetRelay/Migrations.swift +++ b/Sources/FlamenetRelay/Migrations.swift @@ -215,6 +215,52 @@ struct AddEnclavePseudonym: AsyncMigration { } } +/// A client-chosen id carried by every copy of one message, so a client polling +/// several home relays can tell one message from two. +/// +/// Not unique in the database: two rows legitimately share an id when the same +/// message is addressed to two devices, and a uniqueness constraint here would +/// reject the second as a duplicate of the first. +struct AddEnvelopeID: AsyncMigration { + func prepare(on database: any Database) async throws { + try await database.schema(Envelope.schema).field("envelope_id", .string).update() + try await (database as? any SQLDatabase)? + .raw("CREATE INDEX IF NOT EXISTS envelopes_envelope_id ON envelopes(envelope_id)").run() + } + func revert(on database: any Database) async throws { + try await (database as? any SQLDatabase)?.raw("DROP INDEX IF EXISTS envelopes_envelope_id").run() + try await database.schema(Envelope.schema).deleteField("envelope_id").update() + } +} + +/// A global account identity, so this relay can name a user in a way another +/// relay would recognise. +/// +/// Both columns are nullable and stay that way. An account created before this +/// existed has no account key, a self-hosted relay's user may never publish +/// one, and a device can be registered before its key is claimed — none of +/// those are error states, and a NOT NULL here would turn all three into one. +struct AddAccountIdentity: AsyncMigration { + func prepare(on database: any Database) async throws { + try await database.schema(User.schema).field("account_key", .string).update() + try await database.schema(User.schema).field("fnid", .string).update() + // Unique in the database, not just in the handler that claims it. Two + // accounts must never share an fnid: the address is the identity, and + // a duplicate would let one account be mistaken for another by every + // peer that resolves it. The check and the write are not one + // operation, and two claims arriving together is precisely when that + // gap is reachable. + try await (database as? any SQLDatabase)?.raw(""" + CREATE UNIQUE INDEX IF NOT EXISTS users_fnid ON users (fnid) + """).run() + } + func revert(on database: any Database) async throws { + try await (database as? any SQLDatabase)?.raw("DROP INDEX IF EXISTS users_fnid").run() + try await database.schema(User.schema).deleteField("fnid").update() + try await database.schema(User.schema).deleteField("account_key").update() + } +} + /// Let an account exist without an email address. /// /// One authenticated by the shared FlameNet account service has none here, and diff --git a/Sources/FlamenetRelay/Models.swift b/Sources/FlamenetRelay/Models.swift index c353dbb..fc76fba 100644 --- a/Sources/FlamenetRelay/Models.swift +++ b/Sources/FlamenetRelay/Models.swift @@ -89,6 +89,16 @@ final class Envelope: Model, @unchecked Sendable { @Field(key: "from_user") var fromUser: Int @Field(key: "from_device") var fromDevice: String @Field(key: "payload") var payload: String + /// A client-chosen id for THIS message to THIS device, identical in every + /// copy sent to every home relay. + /// + /// It is what makes fan-out safe: the same envelope collected from two + /// relays is one message, and only the sender can say so — a relay cannot, + /// because it sees ciphertext that differs per copy only if the sender + /// encrypted twice, and identical bytes otherwise. Nullable, because an + /// envelope from a client that predates this has no id and must still be + /// delivered. + @OptionalField(key: "envelope_id") var envelopeID: String? @Field(key: "delivered") var delivered: Bool @Timestamp(key: "created_at", on: .create) var createdAt: Date? diff --git a/Sources/FlamenetRelay/PasswordReset.swift b/Sources/FlamenetRelay/PasswordReset.swift new file mode 100644 index 0000000..ca99969 --- /dev/null +++ b/Sources/FlamenetRelay/PasswordReset.swift @@ -0,0 +1,207 @@ +import Crypto +import Fluent +import Foundation +import SQLKit +import Vapor + +/// A pending password reset. +/// +/// The token is `.`, same shape as an app credential: the +/// selector is a plain lookup key so verifying does not mean hashing against +/// every row, and only a hash of the secret is stored — a database read must +/// not yield a usable reset token, or a stolen dump becomes a way into every +/// account with an address on file. +/// +/// SHA-256 rather than bcrypt here, deliberately. A reset secret is 32 bytes of +/// system randomness, not a human-chosen password, so there is nothing for a +/// slow hash to buy: an attacker cannot guess it faster than the search space +/// allows, and the cost would be paid on every legitimate reset instead. +final class PasswordResetToken: Model, @unchecked Sendable { + static let schema = "password_resets" + + @ID(custom: "id", generatedBy: .database) var id: Int? + @Field(key: "user_id") var userID: Int + @Field(key: "selector") var selector: String + @Field(key: "secret_hash") var secretHash: String + @Field(key: "expires_at") var expiresAt: Date + @OptionalField(key: "used_at") var usedAt: Date? + @Timestamp(key: "created_at", on: .create) var createdAt: Date? + + init() {} +} + +struct CreatePasswordResetToken: AsyncMigration { + func prepare(on database: any Database) async throws { + try await database.schema(PasswordResetToken.schema) + .field("id", .int, .identifier(auto: true)) + .field("user_id", .int, .required) + .field("selector", .string, .required) + .field("secret_hash", .string, .required) + .field("expires_at", .datetime, .required) + .field("used_at", .datetime) + .field("created_at", .datetime) + .unique(on: "selector") + .create() + try await (database as? any SQLDatabase)? + .raw("CREATE INDEX IF NOT EXISTS password_resets_user ON password_resets(user_id)") + .run() + } + func revert(on database: any Database) async throws { + try await database.schema(PasswordResetToken.schema).delete() + } +} + +extension AccountRoutes { + + /// One hour. Long enough to find the mail in a spam folder, short enough + /// that a link left in an inbox is not a standing key to the account. + static var resetLifetime: TimeInterval { 3600 } + + /// Resets requested per address per window. Low, because each one sends + /// mail to somebody who did not necessarily ask for it — an unthrottled + /// route here is a way to use this relay to harass a stranger's inbox. + static var resetMaxPerWindow: Int { 3 } + + // MARK: - POST /account/reset/request + + /// Begin a reset. **Always answers the same way**, whatever happens. + /// + /// A different answer for "no such account", "no address on file" or "that + /// one signs in with FlameNet" turns this route into a way to ask the + /// server which usernames exist and how they authenticate. So the response + /// is fixed and the branching is all internal — including the case where + /// mail is not configured at all. + func requestReset(_ request: Request, mailer: Mailer?, siteURL: String, + now: Int) async throws -> Response { + struct Input: Content { let handle: String } + let input = try request.content.decode(Input.self) + let handle = User.normalize(input.handle) + + let sameAnswer = try Response.json(MobileMessageOnly( + message: "If that account exists and has an email address, a reset link is on its way.")) + + guard !handle.isEmpty else { return sameAnswer } + guard await limits.consume("reset:\(handle)", limit: Self.resetMaxPerWindow, now: now) + else { return sameAnswer } + + // By username or by address — people remember one or the other. + let user = try await User.query(on: request.db) + .group(.or) { $0.filter(\.$username == handle).filter(\.$email == handle) } + .first() + + guard let user, let userID = user.id, let email = user.email, !email.isEmpty, + // An account authenticated by the shared FlameNet service has no + // password here to reset. Sending a link that sets a hash nothing + // checks would be worse than sending nothing. + user.enclavePseudonym == nil, + let mailer + else { return sameAnswer } + + // Any earlier token for this user stops working. Two live links is a + // longer window than one for no benefit. + try await PasswordResetToken.query(on: request.db) + .filter(\.$userID == userID).delete() + + let selector = Self.randomToken(bytes: 8) + let secret = Self.randomToken(bytes: 32) + let row = PasswordResetToken() + row.userID = userID + row.selector = selector + row.secretHash = SHA256.hash(data: Data(secret.utf8)) + .map { String(format: "%02x", $0) }.joined() + row.expiresAt = Date(timeIntervalSince1970: TimeInterval(now) + Self.resetLifetime) + try await row.create(on: request.db) + + let token = "\(selector).\(secret)" + let body = """ + Somebody asked to reset the password for \(user.username) on \(siteURL). + + Paste this code into the app to choose a new one: + + \(token) + + It works once and expires in an hour. If this was not you, nothing has + happened yet and you can ignore this message — but somebody knows your + username, so it is worth having a password nobody else could guess. + """ + + // Sent inline rather than queued: this relay has no job runner, and a + // fire-and-forget task would swallow the failure. A mail server that is + // down should surface in the logs, not vanish. + do { + try await mailer.send(to: email, subject: "Reset your Flamenet password", body: body) + } catch { + // The token is deleted again, because a token nobody received is a + // live credential in the database with no owner. + try? await row.delete(on: request.db) + request.logger.error("password reset mail failed: \(error)") + } + return sameAnswer + } + + // MARK: - POST /account/reset/confirm + + /// Finish a reset: token plus a new password. + func confirmReset(_ request: Request, now: Int) async throws -> Response { + struct Input: Content { let token: String; let password: String } + let input = try request.content.decode(Input.self) + + guard input.password.count >= 8 else { + throw WireError("weak_password", "Passwords must be at least 8 characters.", 400) + } + let parts = input.token.trimmingCharacters(in: .whitespacesAndNewlines) + .split(separator: ".", maxSplits: 1) + guard parts.count == 2 else { + throw WireError("bad_token", "That reset code is not valid.", 400) + } + + guard let row = try await PasswordResetToken.query(on: request.db) + .filter(\.$selector == String(parts[0])).first(), + row.usedAt == nil, + row.expiresAt.timeIntervalSince1970 > TimeInterval(now) + else { + throw WireError("bad_token", "That reset code has expired or has already been used.", 400) + } + + let offered = SHA256.hash(data: Data(String(parts[1]).utf8)) + .map { String(format: "%02x", $0) }.joined() + guard constantTimeEquals(Data(offered.utf8), Data(row.secretHash.utf8)) else { + throw WireError("bad_token", "That reset code is not valid.", 400) + } + + guard let user = try await User.find(row.userID, on: request.db) else { + throw WireError("bad_token", "That reset code is not valid.", 400) + } + + user.passwordHash = try Bcrypt.hash(input.password) + try await user.update(on: request.db) + + row.usedAt = Date(timeIntervalSince1970: TimeInterval(now)) + try await row.update(on: request.db) + + // Every existing credential is revoked. Somebody resetting a password + // may be doing it because another person has their account, and leaving + // that person's session working is the one outcome this flow must not + // produce. It signs the legitimate user out of their own devices too, + // which is the right trade. + try await AppCredential.query(on: request.db) + .filter(\.$userID == row.userID).delete() + + return try .json(MobileMessageOnly( + message: "Password changed. Sign in again on each of your devices.")) + } + + /// URL-safe random, for the two halves of a reset token. + static func randomToken(bytes: Int) -> String { + var data = Data(count: bytes) + _ = data.withUnsafeMutableBytes { SystemRandomNumberGenerator.fill($0) } + return data.map { String(format: "%02x", $0) }.joined() + } +} + +private extension SystemRandomNumberGenerator { + static func fill(_ buffer: UnsafeMutableRawBufferPointer) { + var generator = SystemRandomNumberGenerator() + for index in buffer.indices { buffer[index] = UInt8.random(in: 0...255, using: &generator) } + } +} diff --git a/Sources/FlamenetRelay/Retention.swift b/Sources/FlamenetRelay/Retention.swift index 1a01b89..d592a8b 100644 --- a/Sources/FlamenetRelay/Retention.swift +++ b/Sources/FlamenetRelay/Retention.swift @@ -72,7 +72,7 @@ struct Retention { // its deadline is a broken promise rather than a full disk. // Counted before deleting, because Fluent's delete() reports // nothing and a sweep that cannot say what it removed is the - // same unverifiable claim the wp-cron purge used to make. + // same unverifiable claim the old external-cron purge used to make. let lapsed = try await Message.query(on: db) .filter(\.$expiresAt < Date()).count() try await Message.query(on: db) diff --git a/Sources/FlamenetRelay/Routes.swift b/Sources/FlamenetRelay/Routes.swift index 8e3a1ca..44c034c 100644 --- a/Sources/FlamenetRelay/Routes.swift +++ b/Sources/FlamenetRelay/Routes.swift @@ -15,6 +15,11 @@ struct Routes: Sendable { let config: Config let verifier: TokenVerifier let limits: RateLimiter + /// Server-to-server verification. Disabled unless an operator opted in. + let federation: FederationVerifier + /// Outbound half. Nil on a closed relay, which signs nothing for anyone — + /// so the routes that need it refuse with the policy rather than failing. + let federationSender: FederationSender? let replay: ReplayCache static let maxBodyBytes = 4 * 1024 * 1024 @@ -149,8 +154,10 @@ struct Routes: Sendable { // SPK rotation republishes the same identity, and appending on // every such call would bury the entries that mean something. if identityChanged { + let subject = try await KeyLog.subject( + forUserID: caller.userID, audience: config.audience, on: db) try await KeyLog.append( - userID: caller.userID, deviceID: input.device_id, + userID: caller.userID, subject: subject, deviceID: input.device_id, ikSig: input.ik_sig, ikDH: input.ik_dh, kind: "publish", on: db) } @@ -186,6 +193,20 @@ struct Routes: Sendable { throw WireError.rateLimited("Too many key requests recently. Try again shortly.") } + return try await bundlesFor(userID: target, on: request) + } + + /// Build the bundle response for an account, popping a one-time prekey per + /// device. + /// + /// Split out so the federated route serves byte-identical bundles rather + /// than a second implementation that can drift — the §12/§20 gating below + /// is subtle enough that two copies would eventually disagree about which + /// fields a peer sees. + /// + /// Callers are responsible for authorisation and for metering the pop; this + /// does neither, because the two callers answer to different authorities. + func bundlesFor(userID target: Int, on request: Request) async throws -> Response { let devices = try await Device.query(on: request.db) .filter(\.$userID == target).all() guard !devices.isEmpty else { throw WireError.notFound("No such user.") } @@ -218,7 +239,8 @@ struct Routes: Sendable { bundle.spk_pq_sig = d.spkPQSig out.append(bundle) } - return try .json(BundleResponse(user_id: target, devices: out)) + let targetFnid = try await User.find(target, on: request.db)?.fnid + return try .json(BundleResponse(user_id: target, fnid: targetFnid, devices: out)) } // MARK: - §8.3 GET /e2e/devices/{user_id} @@ -226,6 +248,11 @@ struct Routes: Sendable { func listDevices(_ request: Request, target: Int, now: Int) async throws -> Response { _ = try await authenticate( request, body: Data(), path: "/e2e/devices/\(target)", now: now) + return try await devicesFor(userID: target, on: request) + } + + /// Identity keys for an account. Pops nothing. + func devicesFor(userID target: Int, on request: Request) async throws -> Response { let devices = try await Device.query(on: request.db) .filter(\.$userID == target).all() return try .json(DeviceListResponse( @@ -263,8 +290,10 @@ struct Routes: Sendable { try await Envelope.query(on: db).filter(\.$toDevice == deviceID).delete() // Recorded before the row goes: a log that only ever grows is the // point, so a device leaving is an entry rather than a deletion. + let subject = try await KeyLog.subject( + forUserID: device.userID, audience: config.audience, on: db) try await KeyLog.append( - userID: device.userID, deviceID: deviceID, + userID: device.userID, subject: subject, deviceID: deviceID, ikSig: device.ikSig, ikDH: device.ikDH, kind: "revoke", on: db) try await device.delete(on: db) } @@ -397,6 +426,7 @@ struct Routes: Sendable { e.fromUser = senderUser e.fromDevice = senderDevice e.payload = m.payload + e.envelopeID = m.envelope_id e.delivered = false try await e.create(on: db) if let id = e.id { written.append(id) } @@ -434,7 +464,8 @@ struct Routes: Sendable { cursor = max(cursor, e.id ?? cursor) return .init( id: e.id ?? 0, from_user: e.fromUser, from_device: e.fromDevice, - payload: e.payload, created_at: iso8601(e.createdAt)) + payload: e.payload, envelope_id: e.envelopeID, + created_at: iso8601(e.createdAt)) } if !messages.isEmpty { try await Envelope.query(on: request.db) diff --git a/Sources/FlamenetRelay/RoutesAccount.swift b/Sources/FlamenetRelay/RoutesAccount.swift index 75a0693..3dae916 100644 --- a/Sources/FlamenetRelay/RoutesAccount.swift +++ b/Sources/FlamenetRelay/RoutesAccount.swift @@ -6,8 +6,8 @@ import Vapor /// `/account/*` — the built-in identity provider. /// /// Ported from the plugin's `FNMSG_REST_Account`, which exchanged a username -/// and password for a WordPress Application Password so the native app never -/// drove wp-login.php. Same shape here, minus WordPress: sign up, sign in for a +/// and password for a long-lived credential, so the native app never drove a +/// web login form. Same shape here: sign up, sign in for a /// long-lived credential, then exchange that credential for short-lived /// device-bound relay tokens. struct AccountRoutes: Sendable { @@ -134,10 +134,12 @@ struct AccountRoutes: Sendable { /// Off unless `FN_ENCLAVE_JWT_SECRET` is set. A self-hosted relay has no /// account service, and a route that verifies tokens against a secret /// nobody configured would accept nothing while looking like it might. - func enclaveSession(_ request: Request, secret: String, now: Int) async throws -> Response { + func enclaveSession(_ request: Request, secret: String, publicKey: Data?, + now: Int) async throws -> Response { let input = try request.content.decode(EnclaveSessionRequest.self) - let claims = try Self.verifyEnclaveToken(input.token, secret: secret, now: now) + let claims = try Self.verifyEnclaveToken( + input.token, secret: secret, publicKey: publicKey, now: now) // Look up by pseudonym first: after the first sign-in the handle is // irrelevant, and honouring it would let somebody rename themselves @@ -186,22 +188,63 @@ struct AccountRoutes: Sendable { /// the header rather than trusting it — `alg: none`, and HS256 tokens /// verified against an RSA public key, are the two classic JWT forgeries, /// and the first costs nothing to prevent. + /// Verify a session minted by the FlameNet account service. + /// + /// Two algorithms are accepted, and which one a deployment can use decides + /// whether it may be run by somebody else: + /// + /// - **EdDSA** verifies against a published public key. The relay can check + /// a session and cannot mint one, so a relay this project does not + /// operate can accept FlameNet identities without being handed authority + /// over them. This is the path that makes a self-hosted relay on shared + /// accounts possible at all. + /// - **HS256** verifies against a shared secret. It works, and it is the + /// reason third-party relays were never an option: the secret that checks + /// a token also makes one, so any relay holding it could impersonate any + /// account. Kept only until the account service has finished moving, and + /// accepted ONLY when no public key is configured — a relay that has been + /// given a verifying key must not be talked back down to a shared secret + /// by a token that simply says `alg: HS256`. static func verifyEnclaveToken(_ token: String, secret: String, + publicKey: Data? = nil, now: Int) throws -> (subject: String, expires: Double) { let parts = token.split(separator: ".", omittingEmptySubsequences: false) guard parts.count == 3 else { throw WireError.unauthorized("Malformed token.") } guard let headerData = Data(base64URLEncoded: String(parts[0])), let header = try? JSONSerialization.jsonObject(with: headerData) as? [String: Any], - (header["alg"] as? String) == "HS256" + let alg = header["alg"] as? String else { throw WireError.unauthorized("Unsupported token algorithm.") } let signed = "\(parts[0]).\(parts[1])" - let expected = HMAC.authenticationCode( - for: Data(signed.utf8), using: SymmetricKey(data: Data(secret.utf8))) - guard let provided = Data(base64URLEncoded: String(parts[2])), - constantTimeEquals(Data(expected), provided) - else { throw WireError.unauthorized("Token signature does not verify.") } + guard let provided = Data(base64URLEncoded: String(parts[2])) else { + throw WireError.unauthorized("Token signature does not verify.") + } + + switch (alg, publicKey) { + case ("EdDSA", .some(let raw)): + guard let key = try? Curve25519.Signing.PublicKey(rawRepresentation: raw), + key.isValidSignature(provided, for: Data(signed.utf8)) + else { throw WireError.unauthorized("Token signature does not verify.") } + + case ("HS256", .none): + let expected = HMAC.authenticationCode( + for: Data(signed.utf8), using: SymmetricKey(data: Data(secret.utf8))) + guard constantTimeEquals(Data(expected), provided) else { + throw WireError.unauthorized("Token signature does not verify.") + } + + case ("HS256", .some): + // Downgrade, refused. A relay configured with a public key has been + // told what to expect; accepting a MAC here would let anyone who + // ever learned the old shared secret keep minting sessions for it + // forever, which is exactly what moving to a signature was for. + throw WireError.unauthorized( + "This relay verifies signed sessions; a MAC-signed token is not accepted.") + + default: + throw WireError.unauthorized("Unsupported token algorithm.") + } guard let payloadData = Data(base64URLEncoded: String(parts[1])), let payload = try? JSONSerialization.jsonObject(with: payloadData) as? [String: Any], @@ -218,14 +261,14 @@ struct AccountRoutes: Sendable { return (subject, exp) } - // MARK: - GET /wp/v2/users/me + // MARK: - GET /messenger/me /// The app validates a cached credential by fetching this before trusting /// it, so a session restore fails closed rather than surfacing errors later /// on every screen at once. /// - /// `slug` and `name` are WordPress's field names, kept because the client - /// decodes them. There is no WordPress here. + /// `slug` and `name` are the field names the client decodes. They are the + /// only thing left from an older shape; the service behind them is ours. func me(_ request: Request) async throws -> Response { guard let basic = request.headers.basicAuthorization, let userID = try await verifyAppCredential(basic.password, on: request.db), @@ -234,17 +277,17 @@ struct AccountRoutes: Sendable { return try .json(MeResponse(id: userID, name: user.displayName, slug: user.username)) } - // MARK: - GET /wp/v2/users?slug= + // MARK: - GET /messenger/users?slug= /// Look up one member by exact username. /// - /// Deliberately *not* a directory. The WordPress collection this replaces + /// Deliberately *not* a directory. The collection this replaces /// returned every account on the site, which is a feature on a community /// blog and a downloadable list of everyone who uses the service on a /// messenger. A caller who already knows a username gets that one row; a /// caller who does not gets nothing to walk. /// - /// `slug` is WordPress's own filter on this endpoint, so the shipped client + /// `slug` is the filter the shipped client already sends, /// keeps the route it already calls and only stops omitting the parameter. /// /// Authenticated on purpose: an open lookup is an anonymous @@ -387,11 +430,11 @@ struct AccountRoutes: Sendable { extension AccountRoutes { - /// `POST /wp/v2/users/me` — change the display name, or the password. + /// `PUT /messenger/me` — change the display name, or the password. /// - /// WordPress accepted both fields on this route and the shipped client + /// Both fields are accepted on this route because the shipped client /// still posts to it, so the shape is kept. What is not kept is - /// WordPress's willingness to change a password it does not own. + /// a willingness to change a password the caller does not own. func updateMe(_ request: Request) async throws -> Response { guard let basic = request.headers.basicAuthorization, let userID = try await verifyAppCredential(basic.password, on: request.db), @@ -432,7 +475,7 @@ extension AccountRoutes { try await user.update(on: request.db) // Existing app credentials deliberately survive a password change, as - // they did on WordPress: they are separate credentials, and revoking + // they once did: they are separate credentials, and revoking // the caller's own credential mid-request would sign them out of the // device they just used to change it. `DELETE /account` is the lever // for "end everything". diff --git a/Sources/FlamenetRelay/RoutesDiscovery.swift b/Sources/FlamenetRelay/RoutesDiscovery.swift new file mode 100644 index 0000000..bc0062a --- /dev/null +++ b/Sources/FlamenetRelay/RoutesDiscovery.swift @@ -0,0 +1,117 @@ +import Crypto +import Foundation +import Vapor + +/// How a relay describes itself to somebody who has only its hostname. +/// +/// A person types one address. Everything else a client or a peer needs — which +/// key signs this relay's tokens, which protocol versions it speaks, whether it +/// will talk to other relays at all — has to be discoverable from that one +/// string, or federation becomes a configuration exercise that nobody completes. +/// +/// This follows the shape the Files server already established with +/// `/api/config`: the server names its own trust anchors, and the client holds +/// no built-in list of which hostnames are "ours". A hardcoded list is wrong the +/// moment somebody forks this, which is the entire point of the project. +/// +/// **Unauthenticated, deliberately.** Every field is either already public (the +/// issuer key verifies tokens the relay hands out) or is a statement about the +/// deployment rather than about anyone using it. A descriptor only this relay's +/// own users could read would be useless to the peers it exists to inform. +extension Routes { + + struct RelayDescriptor: Content { + /// The `aud` this relay accepts and mints. The canonical name — a relay + /// reachable at several hostnames still has exactly one of these, and + /// this is the one to address it by. + let audience: String + /// Base64 Ed25519 public key of the token issuer. + /// + /// Published so a peer can verify a token this relay minted without + /// asking this relay to verify it — which is what stops a compromised + /// relay from being able to assert anything it likes about its users to + /// somebody else. + /// + /// ⚠️ Publishing it is not the same as making it trustworthy. A client + /// fetching this over TLS learns what key *this host* claims; it does + /// not learn that the host is who it should be. That is what the + /// transparency log and key pinning are for, and no discovery document + /// can substitute for them. + let issuerPublicKey: String + /// Protocol versions the relay can serve, lowest first. + let protocolVersions: [Int] + /// Where the transparency log lives, so a monitor that knows only the + /// hostname can start following it. + let keyLogHead: String + /// Whether this relay speaks to other relays, and on what terms. + /// + /// `closed` is the honest answer today and the correct default for a + /// self-hoster: server-to-server delivery does not exist yet, and a + /// relay that advertised `open` would be promising something no code + /// behind it can do. A peer reads this before attempting anything, so a + /// wrong value here is a failure with no useful error rather than a + /// polite refusal. + let federation: String + /// Base64 Ed25519 public key this relay signs server-to-server requests + /// with. Null on a closed relay, which signs nothing for anyone. + /// + /// Separate from `issuerPublicKey` on purpose: that one mints user + /// tokens, this one only ever says "this relay said this". A peer that + /// confused them would accept a relay's self-assertion as authority + /// over its users. + let federationKey: String? + /// Whether accounts here come from the shared FlameNet account service + /// or from this relay's own database. + /// + /// A client needs this before it sends a credential anywhere: the two + /// modes stretch the passphrase differently and send the result to + /// different hosts. Guessing wrong means sending an authentication + /// secret to a service that was never meant to receive it. + let accounts: String + /// Absolute URL of that account service, when there is one. + let accountsURL: String? + } + + /// `GET /.well-known/flamenet/relay` + func relayDescriptor(_ request: Request) async throws -> Response { + // Either credential means this relay accepts account-service sessions: + // a public key to verify them, or the older shared secret. + let shared = (Environment.get("FN_ENCLAVE_JWT_SECRET").map { $0.count >= 32 } ?? false) + || (Environment.get("FN_ENCLAVE_PUBLIC_KEY").map { !$0.isEmpty } ?? false) + return try .json(RelayDescriptor( + audience: config.audience, + issuerPublicKey: config.issuerKeyRaw.base64EncodedString(), + // v1 through v3 are the ratchet's, not this document's; the relay + // stores opaque payloads and does not care which, but a peer + // deciding whether it can talk to us does. + protocolVersions: [1, 2, 3], + keyLogHead: "https://\(config.audience)/e2e/log/head", + federation: config.federationPolicy, + federationKey: config.federationPolicy == "closed" ? nil + : (try? Curve25519.Signing.PrivateKey(rawRepresentation: config.federationKeyRaw ?? Data())) + .map { $0.publicKey.rawRepresentation.base64EncodedString() }, + accounts: shared ? "shared" : "local", + accountsURL: shared ? Self.accountsURL : nil + )) + } + + /// The account service this relay authenticates against. + /// + /// Configurable, because a self-hoster running both products wants one + /// account across them and their own service to hold it — the file server + /// has taken `ENCLAVE_ACCOUNTS_URL` for exactly that since it shipped, and + /// a relay that could not do the same made "run Files and Messenger + /// together" mean "run them with two separate logins". + /// + /// The obvious worry — an operator pointing this at a host they control and + /// harvesting passphrase-derived secrets — is answered on the client, not + /// here. The app keeps its own list of hostnames that map to + /// accounts.flamenet.io and ignores what those relays advertise; for any + /// other relay, the user typed that hostname themselves, and a relay they + /// chose naming its own account service is the choice they already made. A + /// relay cannot use this to redirect somebody else's users. + static var accountsURL: String { + Environment.get("FN_ACCOUNTS_URL").flatMap { $0.isEmpty ? nil : $0 } + ?? "https://accounts.flamenet.io" + } +} diff --git a/Sources/FlamenetRelay/RoutesDonate.swift b/Sources/FlamenetRelay/RoutesDonate.swift index 00e4220..eeea270 100644 --- a/Sources/FlamenetRelay/RoutesDonate.swift +++ b/Sources/FlamenetRelay/RoutesDonate.swift @@ -1,19 +1,25 @@ import Foundation import Vapor -/// Donations, by hand-off rather than by embedding. +/// Donations, taken on this page rather than on the processor's. /// -/// The page collects an amount and posts it here; this route opens a session -/// with the processor and redirects. Three reasons it is built this way: +/// The card fields are Stripe's own, mounted in an iframe this origin cannot +/// read, and the card number never reaches this process, this server or its +/// logs. What changed from the previous version is only *where the person is +/// standing*: no redirect off the site and back. /// -/// 1. **The CSP.** This origin runs `default-src 'self'`, and an embedded -/// processor SDK is a third-party script by definition. Redirecting keeps -/// the guarantee the rest of the site rests on. -/// 2. **Card data.** It never touches this origin, this process, or its logs, -/// because it is entered on the processor's own page. That is also the only -/// version of this a small project should be running. -/// 3. **Failing closed.** Unconfigured, there is no form and no route — rather -/// than a button that dies after someone has decided to give. +/// ⚠️ **This origin therefore loads `js.stripe.com`.** That is a third-party +/// script, and the CSP is widened for it and for Stripe's frames — narrowly, +/// naming Stripe rather than relaxing `default-src`. The privacy page says so; +/// if this route changes again, that page has to change with it. +/// +/// ⚠️ **Never accept raw card details here.** An "internal" payment form that +/// posts a PAN to this server would put card data in this process and its logs +/// and pull the whole deployment into PCI scope. The Payment Element exists so +/// that it does not have to. +/// +/// Unconfigured, there is no form and no route — rather than a button that dies +/// after someone has decided to give. struct DonateRoutes: Sendable { let config: Config @@ -29,15 +35,42 @@ struct DonateRoutes: Sendable { let custom_amount: String? } - func checkout(_ request: Request) async throws -> Response { - guard let provider = config.donateProvider, let secret = config.donateSecret else { + /// What the page needs to mount the Payment Element. + struct IntentResponse: Content { + let clientSecret: String + let publishableKey: String + let amount: Int + let recurring: Bool + /// The connected account this payment was opened on, or nil. + /// + /// The browser has to build its Stripe instance with it. A client + /// secret belonging to a connected account cannot be confirmed by a + /// Stripe scoped to the platform — it fails as "No such payment_intent", + /// naming the intent rather than the account, after the card has been + /// typed in. + let stripeAccount: String? + } + + /// Open a payment on the server and hand the browser only what it needs. + /// + /// The client secret authorises confirming exactly this one payment and + /// nothing else; the publishable key is public by definition. The API + /// secret stays here. + func intent(_ request: Request) async throws -> IntentResponse { + guard let provider = config.donateProvider, + let secret = config.donateSecret, + let publishable = config.donatePublishableKey else { throw WireError("donations_disabled", "This relay has no payment processor configured.", 503) } - let form = try request.content.decode(Form.self) + guard provider.lowercased() == "stripe" else { + throw WireError("donations_misconfigured", + "Unknown payment processor: \(provider).", 500) + } + let form = try request.content.decode(Form.self) // Resolve the amount before talking to anyone: an invalid one should - // fail here, not after a redirect the person has to come back from. + // fail here, while the person is still looking at the field. let raw = (form.amount == "custom" ? form.custom_amount : form.amount) ?? "" guard let dollars = Int(raw.trimmingCharacters(in: .whitespaces)), dollars >= Self.minAmount, dollars <= Self.maxAmount @@ -47,69 +80,122 @@ struct DonateRoutes: Sendable { } let recurring = (form.frequency ?? "once") == "monthly" - switch provider.lowercased() { - case "stripe": - let url = try await stripeSession( - dollars: dollars, recurring: recurring, secret: secret, request: request) - // 303 so the browser follows with GET: a refresh of the result page - // must not re-post the form and open a second session. - return request.redirect(to: url, redirectType: .normal) - default: - throw WireError("donations_misconfigured", - "Unknown payment processor: \(provider).", 500) - } + let clientSecret = recurring + ? try await subscriptionSecret(dollars: dollars, secret: secret, request: request) + : try await paymentIntentSecret(dollars: dollars, secret: secret, request: request) + + return IntentResponse(clientSecret: clientSecret, publishableKey: publishable, + amount: dollars, recurring: recurring, + stripeAccount: config.donateAccount) } - /// Create a Stripe Checkout Session and return its hosted URL. - /// - /// Deliberately no Stripe SDK: one form-encoded POST is the whole - /// integration, and a dependency that ships its own HTTP stack into a relay - /// this size is not worth the attack surface. - private func stripeSession( - dollars: Int, recurring: Bool, secret: String, request: Request + // MARK: - Stripe + + /// A one-off payment. + private func paymentIntentSecret( + dollars: Int, secret: String, request: Request ) async throws -> String { - let base = "\(request.headers.first(name: .init("X-Forwarded-Proto")) ?? "https")://" - + (request.headers.first(name: .host) ?? "localhost") + let json = try await stripePOST("payment_intents", secret: secret, request: request, fields: [ + "amount": String(dollars * 100), + "currency": "usd", + "description": "Flamenet Messenger — donation", + // Let Stripe decide which methods to offer, so the Element shows + // whatever the account actually supports rather than cards only. + "automatic_payment_methods[enabled]": "true", + ]) + guard let clientSecret = json["client_secret"] as? String else { + throw Self.unavailable(request, "payment_intents had no client_secret") + } + return clientSecret + } - var fields: [String: String] = [ - "mode": recurring ? "subscription" : "payment", - "success_url": "\(base)/donate?thanks=1", - "cancel_url": "\(base)/donate", - "line_items[0][quantity]": "1", - "line_items[0][price_data][currency]": "usd", - "line_items[0][price_data][unit_amount]": String(dollars * 100), - "line_items[0][price_data][product_data][name]": - recurring ? "Flamenet Messenger — monthly support" - : "Flamenet Messenger — donation", - ] - if recurring { fields["line_items[0][price_data][recurring][interval]"] = "month" } + /// A monthly subscription, confirmed on this page like a one-off. + /// + /// `payment_behavior=default_incomplete` is what makes that possible: the + /// subscription is created unpaid, and its first invoice carries the secret + /// the Payment Element confirms. + /// + /// ⚠️ The secret is read from `latest_invoice.confirmation_secret`. + /// `invoice.payment_intent` was the old field and is **gone** — code written + /// against it fails at run time with a null, not at compile time. + private func subscriptionSecret( + dollars: Int, secret: String, request: Request + ) async throws -> String { + // A price, then a customer, then the subscription. Inline price data is + // not accepted on subscriptions the way it is on Checkout. + let price = try await stripePOST("prices", secret: secret, request: request, fields: [ + "currency": "usd", + "unit_amount": String(dollars * 100), + "recurring[interval]": "month", + "product_data[name]": "Flamenet Messenger — monthly support", + ]) + guard let priceID = price["id"] as? String else { + throw Self.unavailable(request, "price creation returned no id") + } + let customer = try await stripePOST("customers", secret: secret, request: request, + fields: ["description": "Flamenet donor"]) + guard let customerID = customer["id"] as? String else { + throw Self.unavailable(request, "customer creation returned no id") + } + let subscription = try await stripePOST( + "subscriptions", secret: secret, request: request, fields: [ + "customer": customerID, + "items[0][price]": priceID, + "payment_behavior": "default_incomplete", + "payment_settings[save_default_payment_method]": "on_subscription", + "expand[0]": "latest_invoice.confirmation_secret", + ]) + guard let invoice = subscription["latest_invoice"] as? [String: Any], + let confirmation = invoice["confirmation_secret"] as? [String: Any], + let clientSecret = confirmation["client_secret"] as? String else { + throw Self.unavailable(request, "subscription had no confirmation_secret") + } + return clientSecret + } - let body = fields.map { k, v in - "\(k.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? k)=" - + (v.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? v) + /// One form-encoded POST is the whole integration. + /// + /// Deliberately no Stripe SDK: a dependency that ships its own HTTP stack + /// into a relay this size is not worth the attack surface. + private func stripePOST( + _ path: String, secret: String, request: Request, fields: [String: String] + ) async throws -> [String: Any] { + let body = fields.map { key, value in + "\(key.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? key)=" + + (value.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? value) }.joined(separator: "&") var headers = HTTPHeaders() headers.add(name: .authorization, value: "Bearer \(secret)") headers.add(name: .contentType, value: "application/x-www-form-urlencoded") + // Open the payment *on the connected account*, not on the platform the + // key authenticates as. One header is the whole of it: the intent, the + // money, the fees and the receipt are then its own. Omitting it is not + // an error Stripe reports — it succeeds, against the wrong account. + if let account = config.donateAccount { + headers.add(name: "Stripe-Account", value: account) + } let response = try await request.client.post( - "https://api.stripe.com/v1/checkout/sessions", - headers: headers + "https://api.stripe.com/v1/\(path)", headers: headers ) { req in req.body = .init(string: body) } guard response.status == .ok, let data = response.body.flatMap({ Data(buffer: $0) }), - let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let url = json["url"] as? String + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { - // The processor's own error text is not shown: it is written for a - // developer, and may name the account. - request.logger.error("stripe checkout session failed", - metadata: ["status": .string(response.status.description)]) - throw WireError("donations_unavailable", - "Could not start a payment session. Please try again later.", 502) + throw Self.unavailable(request, "\(path) returned \(response.status)") } - return url + return json + } + + /// One message for every processor failure. + /// + /// Stripe's own error text is not shown: it is written for a developer, and + /// it may name the account. + private static func unavailable(_ request: Request, _ detail: String) -> WireError { + request.logger.error("stripe call failed", metadata: ["detail": .string(detail)]) + return WireError("donations_unavailable", + "Could not start a payment. Please try again later.", 502) } } diff --git a/Sources/FlamenetRelay/RoutesFederation.swift b/Sources/FlamenetRelay/RoutesFederation.swift new file mode 100644 index 0000000..d02fed4 --- /dev/null +++ b/Sources/FlamenetRelay/RoutesFederation.swift @@ -0,0 +1,380 @@ +import Fluent +import Foundation +import Vapor + +/// The two things a peer relay may ask of this one. +/// +/// Both are addressed by `fnid`, never by `user_id`. A row number means nothing +/// to the relay on the other end, and accepting one would mean trusting a peer +/// to name our users by our own internal identifiers — which it has no way to +/// know and no business asserting. +extension Routes { + + /// Resolve an fnid to a local account, or 404. + /// + /// Answers the same way for "no such account" and "that account has not + /// enrolled an identity", which are the same non-answer. A peer learns + /// nothing from the distinction that it could not learn by trying to + /// deliver, and collapsing them keeps this from being a way to enumerate + /// who is here. + private func localUser(forFNID fnid: String, on db: any Database) async throws -> User { + guard let user = try await User.query(on: db).filter(\.$fnid == fnid).first() else { + throw WireError.notFound("no such identity here") + } + return user + } + + private func verifyPeer(_ request: Request, body: Data, path: String, now: Int) async throws + -> Federation.Peer + { + do { + return try await federation.verify( + method: request.method.string, path: path, body: body, + origin: request.headers.first(name: Federation.Headers.origin), + timestamp: request.headers.first(name: Federation.Headers.timestamp), + nonce: request.headers.first(name: Federation.Headers.nonce), + signature: request.headers.first(name: Federation.Headers.signature), + now: now) + } catch let e as Federation.AuthError { + throw WireError( + e.status == .forbidden ? "forbidden" : "unauthorized", + e.description, Int(e.status.code)) + } + } + + // MARK: - POST /e2e/federation/deposit + + /// Accept ciphertext from a peer, for one of our users. + /// + /// The relay learns exactly what it learns for a local send: an opaque + /// payload, a recipient device, and now the peer that handed it over. It + /// does **not** learn the sender — a federated envelope is sealed the same + /// way a local one is, and the peer is not asked to name whose it was. + /// + /// Which is why metering is per origin relay rather than per sender: there + /// is no sender to meter, and the depositing relay is the accountable party + /// in any case. That is the same shape mail settled on for the same reason. + func federationDeposit(_ request: Request, body: Data, now: Int) async throws -> Response { + let peer = try await verifyPeer(request, body: body, + path: "/e2e/federation/deposit", now: now) + let input = try decodeBody(FederationDepositRequest.self, from: body) + + guard input.envelopes.count <= Self.maxFederatedBatch else { + throw WireError.badRequest( + "at most \(Self.maxFederatedBatch) envelopes per deposit") + } + + // One budget for the peer, charged by envelope count. A peer that + // exhausts it is throttled without any of its users being singled out, + // because we cannot tell them apart and should not be able to. + let charged = await limits.consumeAll( + [(key: "fed:\(peer.audience)", amount: input.envelopes.count, + limit: config.sendRatePerHour)], + now: now) + guard charged else { + throw WireError.rateLimited("peer \(peer.audience) is over its hourly budget here") + } + + let (accepted, unknown) = try await request.db.transaction { + db -> (Int, [String]) in + var accepted = 0 + var unknown: [String] = [] + for e in input.envelopes { + guard let user = try? await self.localUser(forFNID: e.to_fnid, on: db), + let userID = user.id + else { + // Named, not silently dropped: a peer holding an envelope it + // cannot place should stop retrying it, and it can only know + // that if we say so. It learns the fnid is not here, which is + // something it already believed it knew. + unknown.append(e.to_fnid) + continue + } + // The device must belong to that account. Without this a peer + // could address any device on this relay by id and have the + // envelope filed under someone else's inbox. + guard let device = try await Device.find(e.to_device, on: db), + device.userID == userID + else { + unknown.append(e.to_fnid) + continue + } + + let row = Envelope() + row.toUser = userID + row.toDevice = e.to_device + // Sealed by construction across a federation boundary: we were + // not told who sent it and must not invent an attribution. + row.fromUser = 0 + row.fromDevice = "" + row.payload = e.payload + row.envelopeID = e.envelope_id + row.delivered = false + try await row.create(on: db) + accepted += 1 + } + return (accepted, unknown) + } + + return try .json(FederationDepositResponse( + ok: true, accepted: accepted, unknown: unknown)) + } + + /// A batch cap, so one request cannot be an unbounded write. + /// + /// Modest on purpose: a peer with more than this to deliver makes more + /// requests, each individually metered, which is the behaviour we want from + /// a busy peer and the behaviour we want to force on an abusive one. + static let maxFederatedBatch = 64 + + // MARK: - GET /e2e/federation/bundles/:fnid + + /// Serve prekey bundles for one of our users to a peer. + /// + /// ⚠️ **This pops a one-time prekey per device, exactly as §8.2 does.** + /// Across a federation that is a prekey-pool drain with an amplifier on it: + /// one hostile peer could exhaust a popular account's pool for everybody. + /// So it is metered per origin relay and per (origin, target) pair, using + /// the same budgets §8.2 already applies per requester — and deliberately + /// *not* metered per target alone, which would let one abuser make an + /// account unstartable for the whole federation. + /// + /// A peer that only needs to know a device's identity key should use the + /// listing route instead, which pops nothing. + func federationBundles(_ request: Request, fnid: String, now: Int) async throws -> Response { + let peer = try await verifyPeer(request, body: Data(), + path: "/e2e/federation/bundles/\(fnid)", now: now) + + let charged = await limits.consumeAll([ + (key: "fedbundle:\(peer.audience)", amount: 1, limit: config.bundleRatePerHour), + (key: "fedbundle:\(peer.audience):\(fnid)", amount: 1, limit: config.bundleRatePerTarget), + ], now: now) + guard charged else { + throw WireError.rateLimited("peer \(peer.audience) is over its bundle budget here") + } + + let user = try await localUser(forFNID: fnid, on: request.db) + guard let userID = user.id else { throw WireError.notFound("no such identity here") } + return try await bundlesFor(userID: userID, on: request) + } + + // MARK: - GET /e2e/federation/devices/:fnid + + /// Identity keys only. Pops nothing, so it carries the looser budget and is + /// what a peer should reach for by default. + func federationDevices(_ request: Request, fnid: String, now: Int) async throws -> Response { + let peer = try await verifyPeer(request, body: Data(), + path: "/e2e/federation/devices/\(fnid)", now: now) + let charged = await limits.consumeAll( + [(key: "feddev:\(peer.audience)", amount: 1, limit: config.bundleRatePerHour)], + now: now) + guard charged else { + throw WireError.rateLimited("peer \(peer.audience) is over its budget here") + } + + let user = try await localUser(forFNID: fnid, on: request.db) + guard let userID = user.id else { throw WireError.notFound("no such identity here") } + return try await devicesFor(userID: userID, on: request) + } +} + +// MARK: - The outbound half: what a local client asks its own relay to do + +extension Routes { + + private func requireSender() throws -> FederationSender { + guard let sender = federationSender else { + throw WireError.forbidden("this relay does not federate") + } + return sender + } + + private func mapped(_ error: any Error) -> any Error { + guard let e = error as? FederationSender.SendError else { return error } + switch e { + case .notPermitted: return WireError.forbidden(e.description) + // 502, not 500: the failure is the far end's, and a client that cannot + // tell "your relay is broken" from "theirs is unreachable" will report + // the wrong outage to the wrong operator. + case .unreachable, .refused: return WireError("bad_gateway", e.description, 502) + } + } + + // MARK: POST /e2e/send/remote + + /// Send to somebody homed at another relay. + /// + /// A separate route rather than an optional field on `/e2e/send`, because + /// the two barely share behaviour: there is no local device to check + /// ownership of, delivery is a network call that can fail after the client + /// has already committed, and the result is per-address rather than a list + /// of row ids. Overloading the local route would have made every one of + /// those a special case inside a handler that is currently easy to read. + /// + /// The relay still learns nothing it does not already learn locally: the + /// payload is opaque, and it forwards it without being told whose it is. + func sendRemote(_ request: Request, body: Data, now: Int) async throws -> Response { + let caller = try await authenticate( + request, body: body, path: "/e2e/send/remote", now: now) + let input = try decodeBody(SendRemoteRequest.self, from: body) + let sender = try requireSender() + + guard !input.messages.isEmpty else { throw WireError.badRequest("No envelopes.") } + guard input.messages.count <= Self.maxFederatedBatch else { + throw WireError.badRequest( + "at most \(Self.maxFederatedBatch) envelopes per request") + } + guard input.to_relay != config.audience else { + // Sending to ourselves through the federation path would work and + // would be a waste of a round trip and a signature; more to the + // point it would mean two code paths could write the same envelope, + // and only one of them enforces device ownership. + throw WireError.badRequest("that address is homed here; use /e2e/send") + } + + // Metered as a send by the local user, because here there IS a sender to + // meter — this is the one federated path where we know who is asking. + let admitted = await limits.consumeAll( + [(key: "send:\(caller.userID)", amount: input.messages.count, + limit: config.sendRatePerHour)], + now: now) + guard admitted else { + throw WireError.rateLimited("Too many messages recently. Try again shortly.") + } + + let items = input.messages.map { + FederationDepositRequest.Item( + to_fnid: input.to_fnid, to_device: $0.to_device, + payload: $0.payload, envelope_id: $0.envelope_id) + } + + // Where does this person keep mailboxes? + // + // Asked of the relay the client named, and then VERIFIED here against + // the account key the address decodes to. That verification is the + // whole reason this is safe to act on: the relay we asked could serve a + // truncated list to make itself the only delivery target, and the + // signature it cannot forge is what stops that being effective. + // + // A relay that has no record, or serves one that does not verify, is + // simply treated as the single destination the client named. Refusing + // outright would make one relay's missing record an undeliverable + // message. + var destinations = [input.to_relay] + if let homes = try? await sender.get( + from: input.to_relay, path: "/e2e/account/homes/\(input.to_fnid)", now: now), + homes.status == .ok, + let record = try? homes.content.decode(HomesResponse.self), + record.fnid == input.to_fnid, + HomeRelays.verify(fnid: record.fnid, serial: record.serial, + relays: record.relays, signatureB64: record.sig), + record.relays.count <= HomeRelays.maxHomes, + record.relays.contains(input.to_relay) { + destinations = record.relays + } + + // Fan out. Every destination gets the same ciphertext and the same + // envelope id, so the recipient collapses the copies rather than + // reading the message several times. + var accepted = 0 + var unknown: [String] = [] + var reached: [String] = [] + var failed: [String] = [] + // Destinations this relay declined to dial at all. + var refused: [String] = [] + for relay in destinations { + do { + let result = try await sender.deposit(to: relay, envelopes: items, now: now) + accepted += result.accepted + unknown.append(contentsOf: result.unknown) + reached.append(relay) + } catch FederationSender.SendError.notPermitted(let peer) { + // ⚠️ Refusal is NOT unreachability. This relay declined to dial + // a host that is not on its allowlist -- nothing was attempted + // and the far end is blameless. Lumping the two together made + // a policy decision look like somebody else's outage, and sent + // whoever debugged it hunting a network fault that did not + // exist. + refused.append(peer) + } catch { + // One home being unreachable is the ordinary case this exists + // for, not an error to abort on: the message is already at the + // others. Reported so a client can see the delivery was partial + // rather than believing it was complete. + failed.append(relay) + } + } + + guard !reached.isEmpty else { + // Every destination refused by policy is a 403 about THIS relay's + // configuration. Anything else is a 502 about the far end. Saying + // which is the difference between an operator editing their + // allowlist and an operator paging somebody about a healthy server. + if failed.isEmpty, !refused.isEmpty { + throw mapped(FederationSender.SendError.notPermitted( + refused.joined(separator: ", "))) + } + throw mapped(FederationSender.SendError.unreachable( + (failed + refused).joined(separator: ", "))) + } + return try .json(SendRemoteResponse( + ok: true, accepted: accepted, unknown: Array(Set(unknown)), + delivered_to: reached, unreachable: failed)) + } + + // MARK: GET /e2e/remote/{relay}/devices|bundles/{fnid} + + /// Ask a peer what it publishes about one of its users, on a client's behalf. + /// + /// Proxied rather than letting the client dial the peer directly, for a + /// plain reason: the client has no credential there and the peer has no + /// reason to answer it. Relays already authenticate to each other, so the + /// hop that exists is the hop that should carry it. + /// + /// ⚠️ `bundles` pops a one-time prekey per device at the far end. Clients + /// should use `devices` for everything except actually establishing a + /// session — the same rule §8.2 states for the local pair, and it matters + /// more here because the pool being drained belongs to someone else's relay. + func remoteDirectory( + _ request: Request, relay: String, fnid: String, pops: Bool, now: Int + ) async throws -> Response { + let caller = try await authenticate( + request, body: Data(), + path: "/e2e/remote/\(relay)/\(pops ? "bundles" : "devices")/\(fnid)", now: now) + let sender = try requireSender() + + let admitted = await limits.consumeAll([ + (key: "remote:\(caller.userID)", amount: 1, limit: config.bundleRatePerHour), + (key: "remote:\(caller.userID):\(fnid)", amount: 1, + limit: pops ? config.bundleRatePerTarget : config.bundleRatePerHour), + ], now: now) + guard admitted else { + throw WireError.rateLimited("Too many key requests recently. Try again shortly.") + } + + do { + let upstream = try await sender.fetch( + from: relay, fnid: fnid, pops: pops, now: now) + guard upstream.status == .ok else { + // Pass the peer's own verdict through rather than flattening it: + // 404 there means no such identity, and turning that into a 502 + // would send a client hunting for a network fault that is not + // there. + throw WireError( + upstream.status == .notFound ? "not_found" : "bad_gateway", + upstream.status == .notFound + ? "no such identity at \(relay)" + : "\(relay) refused the request (\(upstream.status.code))", + upstream.status == .notFound ? 404 : 502) + } + var response = Response(status: .ok) + response.headers.replaceOrAdd(name: .contentType, value: "application/json") + response.body = .init(buffer: upstream.body ?? ByteBuffer()) + return response + } catch let e as WireError { + throw e + } catch { + throw mapped(error) + } + } +} diff --git a/Sources/FlamenetRelay/RoutesIdentity.swift b/Sources/FlamenetRelay/RoutesIdentity.swift new file mode 100644 index 0000000..493e9b4 --- /dev/null +++ b/Sources/FlamenetRelay/RoutesIdentity.swift @@ -0,0 +1,204 @@ +import Fluent +import Foundation +import Vapor + +/// Claiming and resolving a global account identity. +/// +/// Two routes, deliberately asymmetric: claiming is authenticated and proves +/// possession of a private key, resolving is open and returns only what a +/// correspondent needs in order to address you. +extension Routes { + + // MARK: - POST /e2e/account/identity + + /// Register this account's Ed25519 account key, and derive its fnid. + /// + /// Idempotent for the key already on file, so a client that cannot tell + /// whether its last attempt landed can simply repeat it. Publishing a + /// *different* key is refused rather than treated as a rotation: rotating + /// an account key changes the address, which strands every contact who + /// pinned the old one, and that needs a migration record they can follow + /// rather than a silent overwrite. Until that exists, the honest answer is + /// a refusal that says so. + func claimAccountIdentity(_ request: Request, body: Data, now: Int) async throws -> Response { + // The canonical /e2e path, not the request's — the proof commits to it, + // and the compat alias must not produce a different signed string. + let caller = try await authenticate( + request, body: body, path: "/e2e/account/identity", now: now) + let input = try decodeBody(ClaimIdentityRequest.self, from: body) + + guard let raw = FNID.verifyClaim( + accountKeyB64: input.account_key, + signatureB64: input.sig, + audience: config.audience, + userID: caller.userID + ) else { + throw WireError.badRequest("account key signature does not verify") + } + let address = FNID.address(forAccountKey: raw) + + guard let user = try await User.find(caller.userID, on: request.db) else { + throw WireError.badRequest("no such account") + } + + if let existing = user.accountKey { + guard existing == input.account_key else { + // 409 rather than 400: the request is well-formed and the + // signature is good, the account simply already is somebody. + throw WireError.conflict( + "this account already has a different account key; rotation is not supported yet") + } + return try .json(ClaimIdentityResponse( + ok: true, fnid: address, created: false)) + } + + // Whoever else already holds this address holds the private key too, + // which means it is the same person signing in on a second account + // here rather than an attacker. Still refused: two local accounts + // sharing one address makes the address ambiguous to every peer that + // resolves it, which is the one thing an identity may not be. + let taken = try await User.query(on: request.db) + .filter(\.$fnid == address).first() + if let taken, taken.id != caller.userID { + throw WireError.conflict("that identity is already registered to another account here") + } + + user.accountKey = input.account_key + user.fnid = address + do { + try await user.update(on: request.db) + } catch { + // The check above and this write are not one operation, and two + // claims arriving together is exactly when that gap is reachable. + // The unique index is what actually decides it; this turns the + // constraint violation into the same answer the pre-check would + // have given, rather than a 500 that tells the client nothing. + // + // Re-read rather than inspecting the driver's error: SQLite and + // Postgres describe a uniqueness failure differently, and this relay + // ships on both, so matching on the message would work on one and + // quietly stop working on the other. + let now = try await User.find(caller.userID, on: request.db) + if let mine = now?.fnid, mine == address { + return try .json(ClaimIdentityResponse(ok: true, fnid: address, created: false)) + } + if let mine = now?.accountKey, mine != input.account_key { + throw WireError.conflict( + "this account already has a different account key; rotation is not supported yet") + } + throw WireError.conflict("that identity is already registered to another account here") + } + + return try .json(ClaimIdentityResponse(ok: true, fnid: address, created: true)) + } + + // MARK: - GET /e2e/account/identity/:handle + + /// Resolve a local handle to the address a peer should use for it. + /// + /// Unauthenticated, like the transparency log routes and for the same + /// reason: it discloses a public key's address, which was already public, + /// and a directory only this relay's own users can read is not one a + /// federated peer can use. + /// + /// An account that has not published a key answers 404 rather than a null + /// address, so a caller cannot tell "no such person" from "this person has + /// not enrolled" — the two are the same non-answer and collapsing them + /// keeps this from being an account-enumeration oracle. + func resolveIdentity(_ request: Request, handle: String) async throws -> Response { + let normalized = User.normalize(handle) + guard let user = try await User.query(on: request.db) + .filter(\.$username == normalized).first(), + let fnid = user.fnid + else { + throw WireError.notFound("no such identity") + } + return try .json(ResolveIdentityResponse( + handle: normalized, + fnid: fnid, + audience: config.audience)) + } +} + +// MARK: - Multi-homing + +extension Routes { + + /// `POST /e2e/account/homes` — publish where this account keeps mailboxes. + /// + /// Accepted from any relay in the list, not only the one being written to: + /// the record is self-certifying, so who delivered it is irrelevant, and + /// requiring each relay to be told by its own user would mean a client + /// re-authenticating everywhere just to add a fourth. + func publishHomes(_ request: Request, body: Data, now: Int) async throws -> Response { + let caller = try await authenticate( + request, body: body, path: "/e2e/account/homes", now: now) + let input = try decodeBody(PublishHomesRequest.self, from: body) + + guard let user = try await User.find(caller.userID, on: request.db), + let fnid = user.fnid + else { + throw WireError.badRequest("claim an account identity before publishing homes") + } + guard input.relays.count <= HomeRelays.maxHomes, !input.relays.isEmpty else { + throw WireError.badRequest( + "between 1 and \(HomeRelays.maxHomes) home relays") + } + guard input.relays.allSatisfy({ PeerDirectory.isFetchableHost($0) }) else { + throw WireError.badRequest("a home relay must be a routable hostname") + } + guard Set(input.relays).count == input.relays.count else { + throw WireError.badRequest("a relay is listed twice") + } + // The relay a record is published at must be in it. Otherwise an + // account could point every sender somewhere else while still holding + // an inbox here, and mail addressed by the record would never arrive + // at the box the person is actually reading. + guard input.relays.contains(config.audience) else { + throw WireError.badRequest("this relay is not in the list") + } + guard HomeRelays.verify( + fnid: fnid, serial: input.serial, relays: input.relays, signatureB64: input.sig) + else { + throw WireError.badRequest("the record is not signed by this account's key") + } + + if let existing = try await HomeRecord.find(fnid, on: request.db) { + // Rollback is the attack a signature alone does not stop: an old + // list is genuinely signed, so a relay could serve one from before + // a second home was added and quietly become the only mailbox again. + guard input.serial > existing.serial else { + throw WireError.conflict( + "serial \(input.serial) does not follow \(existing.serial)") + } + existing.serial = input.serial + existing.relays = input.relays.joined(separator: ",") + existing.sig = input.sig + try await existing.update(on: request.db) + } else { + let row = HomeRecord() + row.id = fnid + row.serial = input.serial + row.relays = input.relays.joined(separator: ",") + row.sig = input.sig + try await row.create(on: request.db) + } + + return try .json(PublishHomesResponse( + ok: true, fnid: fnid, serial: input.serial, relays: input.relays)) + } + + /// `GET /e2e/account/homes/:fnid` — where to deliver, and the proof. + /// + /// Unauthenticated, and returns the signature alongside the list so the + /// caller can check it rather than trust this relay's copy. A relay serving + /// a truncated list is the exact failure multi-homing exists to survive, so + /// the answer has to be verifiable by whoever receives it. + func readHomes(_ request: Request, fnid: String) async throws -> Response { + guard let row = try await HomeRecord.find(fnid, on: request.db) else { + throw WireError.notFound("no home record") + } + return try .json(HomesResponse( + fnid: fnid, serial: row.serial, relays: row.relayList, sig: row.sig)) + } +} diff --git a/Sources/FlamenetRelay/RoutesMobile.swift b/Sources/FlamenetRelay/RoutesMobile.swift index 95db65b..3d03081 100644 --- a/Sources/FlamenetRelay/RoutesMobile.swift +++ b/Sources/FlamenetRelay/RoutesMobile.swift @@ -19,7 +19,7 @@ import Vapor /// /// That asymmetry is the right way round: `/e2e/*` carries key material and /// sealed envelopes, and this carries the non-E2E surface that was never -/// stronger than a WordPress Application Password anyway. It is not a reason to +/// stronger than a the previous platform Application Password anyway. It is not a reason to /// relax `/e2e/*` to match. Moving the app to proof-of-possession is the /// follow-up, and until then this is exactly as strong as what it replaces — /// no weaker, and no better. diff --git a/Sources/FlamenetRelay/Wire.swift b/Sources/FlamenetRelay/Wire.swift index 533eea6..40bba5d 100644 --- a/Sources/FlamenetRelay/Wire.swift +++ b/Sources/FlamenetRelay/Wire.swift @@ -30,6 +30,112 @@ struct WireError: Error, Content { static func notFound(_ m: String) -> WireError { WireError("not_found", m, 404) } static func badRequest(_ m: String) -> WireError { WireError("bad_request", m, 400) } static func rateLimited(_ m: String) -> WireError { WireError("rate_limited", m, 429) } + /// The request is well-formed and authorised; the state it wants is taken. + /// Distinct from 400 so a client can tell "you sent me nonsense" from "that + /// name is spoken for", which are different things for a user to be told. + static func conflict(_ m: String) -> WireError { WireError("conflict", m, 409) } +} + +// MARK: - Server-to-server + +/// Ciphertext handed over by a peer relay. +struct FederationDepositRequest: Codable { + let envelopes: [Item] + /// Encodable too, because the relay is on both ends of this: it decodes + /// what a peer sent and encodes what it sends onward, and one type for both + /// is what stops the two directions drifting apart. + struct Item: Codable { + /// The recipient, by global address. Never a `user_id` — a row number + /// means nothing to the relay that sent this. + let to_fnid: String + let to_device: String + let payload: String + /// Preserved across the boundary. A message fanned out to two of the + /// recipient's home relays is one message, and the recipient can only + /// see that if the id survives the hop. + let envelope_id: String? + } +} + +struct PublishHomesRequest: Decodable { + let serial: Int + let relays: [String] + /// Ed25519 by the account key over `fnhomes-v1|||`. + let sig: String +} + +struct PublishHomesResponse: Content { + let ok: Bool + let fnid: String + let serial: Int + let relays: [String] +} + +struct HomesResponse: Content { + let fnid: String + let serial: Int + let relays: [String] + /// Returned so a caller can verify rather than trust: a relay serving a + /// truncated list is what multi-homing exists to survive. + let sig: String +} + +/// A client asking its own relay to deliver somewhere else. +struct SendRemoteRequest: Decodable { + /// The recipient's global address, and the relay that homes them. + let to_fnid: String + let to_relay: String + let messages: [OutgoingEnvelope] +} + +struct SendRemoteResponse: Content { + let ok: Bool + let accepted: Int + /// Which home relays took the message, and which could not be reached. + /// + /// Surfaced rather than reduced to a boolean: with several homes, "sent" is + /// genuinely a partial state, and a client that cannot see which copies + /// landed cannot decide whether to retry or to warn. + /// Addresses the far relay could not place. Surfaced rather than swallowed: + /// a client that keeps retrying an address that does not exist there will + /// do so forever, and only the far relay can say. + let unknown: [String] + var delivered_to: [String] = [] + var unreachable: [String] = [] +} + +struct FederationDepositResponse: Content { + let ok: Bool + let accepted: Int + /// Addresses this relay could not place, so a peer stops retrying them. + /// It learns only that an fnid is not here, which it already believed. + let unknown: [String] +} + +// MARK: - Account identity (federation) + +/// Claim an fnid by proving possession of the account key behind it. +struct ClaimIdentityRequest: Decodable { + /// Ed25519 public key, base64, 32 raw bytes. + let account_key: String + /// Ed25519 over `FNID.claimCanonical`, by the private half of that key. + let sig: String +} + +struct ClaimIdentityResponse: Content { + let ok: Bool + let fnid: String + /// False when the key was already on file, so a client repeating a call it + /// was unsure about can tell a fresh registration from a confirmation. + let created: Bool +} + +struct ResolveIdentityResponse: Content { + let handle: String + let fnid: String + /// Which relay answered. A handle is only unique within one, so a caller + /// keeping this for later needs to know where it resolved. + let audience: String } // MARK: - Requests @@ -73,7 +179,14 @@ struct SendRequest: Decodable { let delivery_key: String? } -struct OutgoingEnvelope: Decodable { let to_device: String; let payload: String } +struct OutgoingEnvelope: Decodable { + let to_device: String + let payload: String + /// Same value in every copy of this message sent to every home relay, so a + /// client polling more than one can collapse them. Optional: a client that + /// homes at one relay has nothing to collapse. + let envelope_id: String? +} // MARK: - Responses @@ -86,6 +199,19 @@ struct RegisterDeviceResponse: Content { struct BundleResponse: Content { let user_id: Int + /// The target's global address, when they have claimed one. + /// + /// Needed to verify §19 inclusion: the log leaf commits to a subject, and a + /// client that recomputes it needs the same string the relay wrote. Null + /// for an account that has not enrolled, where the leaf carries the + /// relay-qualified fallback instead. + /// + /// A relay could lie here — and gains nothing by it. The recomputed leaf + /// simply fails to appear in the log, which is the failure the check + /// exists to produce. The stronger check, once contacts carry a pinned + /// fnid, is for the client to ignore this field entirely and recompute + /// against the address the user already accepted. + let fnid: String? let devices: [Bundle] struct Bundle: Content { @@ -133,6 +259,10 @@ struct PollResponse: Content { let from_user: Int let from_device: String let payload: String + /// Null for anything sent before clients carried one. A client must + /// treat absence as "cannot be deduplicated" and keep the message, + /// rather than as a value that could collide with another absence. + let envelope_id: String? let created_at: String } } @@ -187,9 +317,9 @@ struct UpdateMembersRequest: Decodable { /// The group as the relay can describe it. /// -/// Members are ids and nothing else. The PHP relay joined each id against -/// `wp_users` to attach a `display_name`, which it could do because it *was* -/// WordPress. This service has no user table and should not grow one: profile +/// Members are ids and nothing else. The previous relay joined each id against +/// its own user table to attach a `display_name`, which it could do because the +/// two lived together. This service has no user table and should not grow one: profile /// data staying in the identity provider is the boundary the whole split is /// about, and a relay that caches display names is a relay that leaks them. /// Clients resolve names against the portal, where they already do for buddy @@ -373,7 +503,7 @@ struct CommandRequest: Decodable { struct CommandResponse: Content { let name: String; let message: String } -/// `GET /api/wp/v2/users/me` — WordPress's shape, because the client decodes it. +/// `GET /api/flamenet/v1/messenger/me` — the shape the client decodes. struct EnclaveSessionRequest: Decodable { /// The account service's signed session token. let token: String @@ -458,7 +588,7 @@ extension Data { /// What this relay serves, as a document a client can read. /// -/// Modelled on WordPress's namespace index because the shipped client was +/// A namespace index, because the shipped client was /// written against that one: it fetches `/flamenet/v1` and looks for a /// route it needs. The point is the routes that are ABSENT — a relay speaks the /// protocol and nothing else, so the buddy-command surface is missing here and @@ -472,6 +602,8 @@ struct NamespaceIndex: Content { static let current = NamespaceIndex( namespace: "flamenet/v1", routes: [ + "/flamenet/v1/messenger/me", + "/flamenet/v1/messenger/users", "/flamenet/v1/messenger/signup", "/flamenet/v1/messenger/signin", "/flamenet/v1/messenger/session", diff --git a/deploy/test-fleet/docker-compose.yaml b/deploy/test-fleet/docker-compose.yaml new file mode 100644 index 0000000..bda9136 --- /dev/null +++ b/deploy/test-fleet/docker-compose.yaml @@ -0,0 +1,173 @@ +# Three throwaway relays, for testing what one relay cannot show. +# +# What this fleet proves: that an fnid is portable — the same account key +# claimed at relay1 and relay2 yields the same address — that a claim signed +# for one relay is refused by another, and that two independent logs name the +# same subject for the same person. None of that is observable on a single +# relay, which is why these exist. +# +# Relay-to-relay delivery now exists (`RoutesFederation`), and these three are +# allowlisted to each other, so cross-relay delivery IS testable here. What the +# fleet still cannot show is federation across a real network: all three sit on +# one box behind one Traefik, which is why the peers are pinned to the edge +# container's address below. +# +# ENV_FILE=.env docker compose -f test-fleet.yml up -d +# +# SQLite on purpose. Postgres would be three more containers on a box already +# carrying seventy, and the relay's schema is identical on both backends — so +# the heavier option would buy nothing this fleet needs. +# +# Two of the three deployment shapes live here, and which relay is which is +# load-bearing rather than incidental: +# +# relay1, relay3 — their own account tables. Self-hosted, local identity. +# relay2 — the shared account service. Self-hosted, shared identity. +# +# relay1 and relay3 are deliberately BOTH local: two independent logs naming the +# same subject for the same person is only observable with two of them, and +# three relays all sharing one account service would be one user base behind +# three names, which is the opposite of the thing being tested. +# +# ⚠️ The live fleet ran with relay2 configured this way for some time before the +# file said so — set on the box and never committed. A fleet rebuilt from git +# came back subtly different, with the shared-identity shape silently absent and +# the tests that depend on it failing as if the client had regressed. +# +# ⚠️ All three peers run on one machine, which no real federation does, and that +# creates a problem the product does not have: a container dialling +# `relay2.flamenet.io` resolves the box's own public address and the NAT will +# not hairpin back to it. Traffic that leaves the host and comes straight back +# is exactly what a single-box deployment cannot do. +# +# So each relay is told to find its peers at Traefik's address on the `web` +# network. The hostname is unchanged, which matters: the federation signature +# binds the destination audience, so the peers must still call each other by +# their public names. Only the route is short-circuited, not the naming. +# +# FN_FEDERATION_TRAEFIK_IP is the edge container's address on `web`. Find it +# with: +# docker inspect edge-traefik-1 --format \ +# '{{range $k,$v := .NetworkSettings.Networks}}{{$k}}={{$v.IPAddress}} {{end}}' + +x-relay: &relay + image: flamenet-relay:fnid + restart: unless-stopped + environment: &relay-env + FN_HOST: "0.0.0.0" + FN_LOG_LEVEL: "${FN_LOG_LEVEL:-error}" + # No FN_POSTGRES_URL: SQLite, one file per relay on its own volume. + FN_DB: "/data/relay.sqlite" + FN_ATTACHMENTS_DIR: "/data/attachments" + networks: [web] + +services: + relay1: + <<: *relay + environment: + <<: *relay-env + FN_RELAY_AUDIENCE: "relay1.flamenet.io" + # allowlist, not open: the fleet federates with itself and nothing else, + # which is also the mode most self-hosters actually want. + FN_FEDERATION: "allowlist" + FN_FEDERATION_PEERS: "relay2.flamenet.io,relay3.flamenet.io" + FN_FEDERATION_KEY: "${R1_FEDERATION_KEY:?set in .env}" + FN_ISSUER_PUBLIC_KEY: "${R1_ISSUER_PUBLIC_KEY:?set in .env}" + FN_ISSUER_PRIVATE_KEY: "${R1_ISSUER_PRIVATE_KEY:?set in .env}" + FN_AT_REST_SECRET: "${R1_AT_REST_SECRET:?set in .env}" + volumes: [relay1-data:/data] + # See the note at the top: single-box federation cannot hairpin. + extra_hosts: + - "relay2.flamenet.io:${FN_FEDERATION_TRAEFIK_IP:-172.18.0.25}" + - "relay3.flamenet.io:${FN_FEDERATION_TRAEFIK_IP:-172.18.0.25}" + labels: + traefik.enable: "true" + traefik.docker.network: "web" + traefik.http.routers.fntest-relay1.rule: "Host(`relay1.flamenet.io`)" + traefik.http.routers.fntest-relay1.entrypoints: "websecure" + traefik.http.routers.fntest-relay1.tls.certresolver: "le" + traefik.http.routers.fntest-relay1.service: "fntest-relay1" + traefik.http.services.fntest-relay1.loadbalancer.server.port: "8080" + + relay2: + <<: *relay + environment: + <<: *relay-env + FN_RELAY_AUDIENCE: "relay2.flamenet.io" + # allowlist, not open: the fleet federates with itself and nothing else, + # which is also the mode most self-hosters actually want. + FN_FEDERATION: "allowlist" + FN_FEDERATION_PEERS: "relay1.flamenet.io,relay3.flamenet.io" + FN_FEDERATION_KEY: "${R2_FEDERATION_KEY:?set in .env}" + FN_ISSUER_PUBLIC_KEY: "${R2_ISSUER_PUBLIC_KEY:?set in .env}" + FN_ISSUER_PRIVATE_KEY: "${R2_ISSUER_PRIVATE_KEY:?set in .env}" + FN_AT_REST_SECRET: "${R2_AT_REST_SECRET:?set in .env}" + # 🔴 **relay2 is the "self-hosted, shared identity" shape, and it is the + # only one of the three that is.** Setting this is what makes it advertise + # `accounts: shared`, and that is the whole reason this relay exists: it is + # somebody else's relay keeping its accounts in the shared service, which + # can never be on a list compiled into the app and is therefore reachable + # only through discovery. `LiveSignUpUITests.testSignUpOnASharedSelfHosted + # Relay` and `LiveSignUpFleetTest.signUpOnASharedSelfHostedRelay` both + # point here. + # + # ⚠️ **Removing this does not break those tests loudly.** relay2 falls back + # to its own account table, the app correctly starts asking for an email, + # and the assertion that fires says discovery did not land — which reads + # as a client bug when it is this line going missing. + FN_ENCLAVE_PUBLIC_KEY: "${R2_ENCLAVE_PUBLIC_KEY:?set in .env}" + # Defaults to accounts.flamenet.io. Named anyway, because the shape being + # tested is "a relay naming its own account service" and leaving it + # implicit tests the default instead. + FN_ACCOUNTS_URL: "${R2_ACCOUNTS_URL:-https://accounts.flamenet.io}" + volumes: [relay2-data:/data] + # See the note at the top: single-box federation cannot hairpin. + extra_hosts: + - "relay1.flamenet.io:${FN_FEDERATION_TRAEFIK_IP:-172.18.0.25}" + - "relay3.flamenet.io:${FN_FEDERATION_TRAEFIK_IP:-172.18.0.25}" + labels: + traefik.enable: "true" + traefik.docker.network: "web" + traefik.http.routers.fntest-relay2.rule: "Host(`relay2.flamenet.io`)" + traefik.http.routers.fntest-relay2.entrypoints: "websecure" + traefik.http.routers.fntest-relay2.tls.certresolver: "le" + traefik.http.routers.fntest-relay2.service: "fntest-relay2" + traefik.http.services.fntest-relay2.loadbalancer.server.port: "8080" + + relay3: + <<: *relay + environment: + <<: *relay-env + FN_RELAY_AUDIENCE: "relay3.flamenet.io" + # allowlist, not open: the fleet federates with itself and nothing else, + # which is also the mode most self-hosters actually want. + FN_FEDERATION: "allowlist" + FN_FEDERATION_PEERS: "relay1.flamenet.io,relay2.flamenet.io" + FN_FEDERATION_KEY: "${R3_FEDERATION_KEY:?set in .env}" + FN_ISSUER_PUBLIC_KEY: "${R3_ISSUER_PUBLIC_KEY:?set in .env}" + FN_ISSUER_PRIVATE_KEY: "${R3_ISSUER_PRIVATE_KEY:?set in .env}" + FN_AT_REST_SECRET: "${R3_AT_REST_SECRET:?set in .env}" + volumes: [relay3-data:/data] + # See the note at the top: single-box federation cannot hairpin. + extra_hosts: + - "relay1.flamenet.io:${FN_FEDERATION_TRAEFIK_IP:-172.18.0.25}" + - "relay2.flamenet.io:${FN_FEDERATION_TRAEFIK_IP:-172.18.0.25}" + labels: + traefik.enable: "true" + traefik.docker.network: "web" + traefik.http.routers.fntest-relay3.rule: "Host(`relay3.flamenet.io`)" + traefik.http.routers.fntest-relay3.entrypoints: "websecure" + traefik.http.routers.fntest-relay3.tls.certresolver: "le" + traefik.http.routers.fntest-relay3.service: "fntest-relay3" + traefik.http.services.fntest-relay3.loadbalancer.server.port: "8080" + +networks: + web: + external: true + +# Named for the stack, so `docker volume ls` says what they are and nothing +# here looks dangling to a prune that must never run on this box anyway. +volumes: + relay1-data: + relay2-data: + relay3-data: diff --git a/docker-compose.yml b/docker-compose.yml index 140c61b..c482727 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -52,6 +52,11 @@ services: # processor's own hosted checkout. FN_DONATE_PROVIDER: "${FN_DONATE_PROVIDER:-}" FN_DONATE_SECRET: "${FN_DONATE_SECRET:-}" + # The connected account the donation is charged on, and the publishable + # key the browser mounts the form with. Without the first, the same key + # charges the platform account and Stripe reports no error. + FN_DONATE_ACCOUNT: "${FN_DONATE_ACCOUNT:-}" + FN_DONATE_PUBLISHABLE_KEY: "${FN_DONATE_PUBLISHABLE_KEY:-}" ports: - "127.0.0.1:8080:8080" volumes: diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..ca186ec --- /dev/null +++ b/docs/API.md @@ -0,0 +1,194 @@ +# Relay API + +Every route a Flamenet relay serves, what authenticates it, and what it is for. + +Read from `Sources/FlamenetRelay/Entrypoint.swift` rather than from memory. A +route not in this document is not served — and a document that lists routes a +relay does not serve is worse than no document, because it is believed. That is +not hypothetical: the relay once published its own index under a second prefix +it served nothing on, and every client that read it broke. + +Base URL is `https://`. There is **one** REST prefix, `api`. + +--- + +## Discovery + +| Route | Auth | Purpose | +|---|---|---| +| `GET /.well-known/flamenet/relay` | none | What this relay is | +| `GET /api/flamenet/v1` | none | The route index | +| `GET /e2e/log/head` | none | Signed transparency-log head | + +`/.well-known/flamenet/relay` is what a client reads **before sending any +credential**: + +```json +{ + "audience": "relay.example.com", + "issuerPublicKey": "", + "protocolVersions": [1, 2, 3], + "keyLogHead": "https://relay.example.com/e2e/log/head", + "federation": "closed", + "federationKey": null, + "accounts": "local", + "accountsURL": null +} +``` + +⚠️ **`accounts` decides where a passphrase goes.** `local` means this relay is +its own identity provider and authenticates the password itself. `shared` means +accounts live in a separate service named by `accountsURL`, the passphrase is +stretched on the device, and only a derived secret is sent — to that service, +never to the relay. + +⚠️ **A client must not believe `accountsURL` blindly.** It should accept only +account services it already trusts, and treat anything else as `local`. +Otherwise an operator could name a host they control and collect +passphrase-derived secrets. The relay's own code pins its `accountsURL` as a +constant for the same reason. + +Everything else is unauthenticated on purpose: a descriptor only this relay's +users could read would be useless to the peers it exists to inform, and a +transparency log only its own users could audit would not be a transparency log. + +--- + +## Accounts + +Present only when this relay runs the built-in identity provider +(`FN_ISSUER_PRIVATE_KEY` set). + +| Route | Auth | Purpose | +|---|---|---| +| `POST /account/signup` | none | Create an account | +| `POST /account/signin` | none | Exchange a password for a credential | +| `POST /account/token` | Basic | Mint a device-scoped relay token | +| `POST /account/reset/request` | none | Ask for a reset code | +| `POST /account/reset/confirm` | none | Redeem a reset code | + +Also mounted under `/api/flamenet/v1/messenger/{signup,signin}`. + +`signup` takes `{username, email, password, display_name?}` and answers: + +```json +{ "user_id": 7, "username": "alice", "display_name": "Alice", + "app_password": "" } +``` + +⚠️ **The credential is returned once and only its hash is kept.** Losing it +means signing in again. It is not the password, and changing the password +invalidates it — which is what changing a password should do. + +⚠️ **`signin` accepts a username *or* an email.** The field has always been +labelled "username or email"; a client that sends only usernames rejects half +the people who typed exactly what they were asked for. + +⚠️ **Reset answers identically whatever the truth is** — unknown handle, no +address on file, an account that authenticates elsewhere. A client cannot report +whether it worked, and a UI that claims it can is lying. + +### Shared identity + +| Route | Auth | Purpose | +|---|---|---| +| `POST /api/flamenet/v1/messenger/session` | none | Exchange an account-service token for a relay credential | + +Present only when `FN_ENCLAVE_JWT_SECRET` or `FN_ENCLAVE_PUBLIC_KEY` is set. +Takes `{token, handle, display_name?}`. + +⚠️ **The handle travels beside the token, not inside it.** The account service +will not put a handle in a token so that a relay cannot correlate its users with +another product's; the token carries a product-scoped pseudonym. The relay uses +the handle only the first time it sees that pseudonym — after that the pseudonym +decides, and honouring the handle would let somebody rename themselves into an +existing account. + +--- + +## Protocol routes (§8) + +All under `/api/flamenet/v1/e2e/…`, and also at `/e2e/…`. + +⚠️ **These refuse the account credential.** They take a short-lived token scoped +to **one device and one relay**, plus a per-request signature proving possession +of that device's identity key. A credential good for every route on every device +would mean a token lifted from one device drains another's prekey pool and reads +its inbox. + +| Route | Purpose | +|---|---| +| `POST /e2e/devices` | Publish a device's identity and prekeys | +| `GET /e2e/devices/{user}` | List a user's devices — costs no prekey | +| `GET /e2e/keys/{user}` | Fetch bundles — **pops a one-time prekey per device** | +| `POST /e2e/prekeys` | Top the pool back up | +| `POST /e2e/messages` | Deposit envelopes | +| `GET /e2e/messages` | Poll this device's inbox | +| `POST /e2e/delivery-key` | Publish a delivery-key digest for sealed sending | +| `POST /e2e/attachments` | Upload one encrypted blob | +| `GET /e2e/attachments/{id}` | Download it | +| `POST /e2e/groups` | Create a group | +| `GET /e2e/groups` | List groups | +| `POST /e2e/groups/{gid}/members` | Add or remove members | +| `POST /e2e/groups/{gid}/leave` | Leave | +| `GET /e2e/turn` | TURN credentials for calls | +| `GET /e2e/log/{head,inclusion,consistency,device/{id}}` | Transparency log | + +### The three that bite + +**`GET /e2e/keys/{user}` consumes a one-time prekey per device, every call.** +Use `/e2e/devices/{user}` to look at who somebody is; use `keys` only when +actually starting a session. Polling it drains the pool and pushes every peer +onto the last-resort key, costing initial forward secrecy for everyone. + +**`POST /e2e/messages` names the recipient once, at the top** — not per +envelope: + +```json +{ "to": 42, "from_device": "…", + "messages": [ { "to_device": "…", "payload": "" } ] } +``` + +One envelope per device of theirs. The relay does no fan-out and no +re-encryption; it moves opaque payloads. + +**`POST /e2e/devices` publishes prekeys under `prekeys`, not `opks`.** A +registration carrying the wrong name is accepted with a `200` and stores +nothing — the device is published and can never be started with, which is a +failure with no error anywhere. + +### The per-request proof + +Each authenticated request carries `X-FN-Timestamp`, `X-FN-Nonce` and +`X-FN-Signature`, an Ed25519 signature over: + +``` +METHOD \n LOGICAL_PATH \n TIMESTAMP \n NONCE \n hex(SHA-256(body)) +``` + +⚠️ **`LOGICAL_PATH` is the `/e2e/…` path, not the URL's path.** These routes are +mounted twice and signed against the former; signing the REST-prefixed path +produces a valid signature over the wrong string and a refusal with no hint why. + +⚠️ **The nonce is not decoration.** Without it, two inbox polls in the same +second with the same empty body sign identically, and the replay cache cannot +tell an honest repeat from a captured one — so it refuses the honest client. + +--- + +## Federation + +Off by default. When `FN_FEDERATION` is not `closed`: + +| Route | Auth | Purpose | +|---|---|---| +| `POST /fed/v1/deposit` | `fnfed-v1` | Accept envelopes from a peer relay | +| `GET /fed/v1/keys/{fnid}` | `fnfed-v1` | Serve bundles for a local account | + +Signed with the relay's **federation key**, which is deliberately not the issuer +key: a key that only ever says "this relay said this" should not also be able to +mint a token for any user on it. + +⚠️ **Federation costs metadata.** Each relay in an exchange learns the other had +traffic for it. If your relay exists so one group can talk without anyone else +involved, `closed` is the correct answer, not the cautious one. diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md new file mode 100644 index 0000000..a27dc1c --- /dev/null +++ b/docs/CONFIGURATION.md @@ -0,0 +1,151 @@ +# Configuring a relay + +Every variable the relay reads, what it breaks if it is wrong, and which of the +three deployment shapes needs it. + +Read from `Sources/FlamenetRelay/Config.swift` and `Entrypoint.swift` rather +than from memory. If a variable is not in this file it is not read. + +--- + +## The three shapes + +A relay is one of these. Nothing needs choosing beyond the first column — the +default is a complete, self-contained relay. + +| | Accounts live | Federates | Who runs it | +|---|---|---|---| +| **Managed** | The shared FlameNet account service | Operator's choice | Us | +| **Self-hosted, shared identity** | The shared FlameNet account service | Operator's choice | You | +| **Self-hosted, local** *(default)* | This relay's own database | Operator's choice | You | + +**A local relay is not a lesser one.** Users register, exchange keys, message +each other, get a portable address, and verify the transparency log exactly as +anywhere else. What the shared account service adds is one credential across +Flamenet Messenger and Flamenet Files — nothing more. + +--- + +## Required + +Without these the relay refuses to start. That is deliberate: a relay that +starts with a missing security parameter is worse than one that does not start, +because nobody finds out until it matters. + +| Variable | Notes | +|---|---| +| `FN_RELAY_AUDIENCE` | The hostname this relay is addressed by, e.g. `relay.example.com`. Tokens carry it and the relay checks it, so a mismatch rejects **every** token with no other symptom. A relay reachable at several names still has exactly one of these. | +| `FN_ISSUER_PUBLIC_KEY` | Base64 Ed25519 public key of whatever mints this relay's tokens. Never fetched over the network. | +| `FN_AT_REST_SECRET` | Encrypts stored bodies for conversations with encryption turned off. Defends against a stolen database dump. It does not defend against you — you hold the key. | + +## Identity — minting your own tokens + +| Variable | Default | Notes | +|---|---|---| +| `FN_ISSUER_PRIVATE_KEY` | unset | Base64 Ed25519 **seed**. Set it and this relay issues its own tokens and serves `/account/*`. Unset and those routes do not exist at all, rather than existing and refusing. | +| `FN_TOKEN_LIFETIME` | `300` | Seconds. Short because there is no revocation list — expiry is the only thing that stops a leaked token. | +| `FN_CLOCK_SKEW` | `60` | Seconds of tolerance on a proof timestamp. | + +⚠️ In one process, a relay compromise **is** an issuer compromise: the signing +key is in the same address space. That trade is for self-hosters, who are +running one box either way. + +## Storage + +| Variable | Default | Notes | +|---|---|---| +| `FN_POSTGRES_URL` | unset | Set it and Postgres is used. | +| `FN_DB` | `relay.sqlite` | SQLite file, used when the above is unset. **The schema is identical on both** — SQLite is not a degraded mode, and is the right choice for a relay serving a household or a company. | +| `FN_ATTACHMENTS_DIR` | `attachments` | Blobs are files, not rows, in both modes. | + +## Network + +| Variable | Default | Notes | +|---|---|---| +| `FN_HOST` | `127.0.0.1` | Not reachable from outside the host as shipped. Terminate TLS in front of it. | +| `FN_PORT` | `8080` | | +| `FN_LOG_LEVEL` | `error` | ⚠️ Never raise to `debug` on a machine with real accounts: it logs request URLs, and a protocol URL contains a device id. | + +⚠️ **HTTPS is not optional in practice.** Clients hold key material, and serving +them over plain HTTP hands it to anyone on the path. Forward the client address +too — sealed submissions are metered per recipient and per IP, and behind a +proxy that does not set `X-Forwarded-For` every request looks like the proxy. + +## Federation + +Off by default, and the default is load-bearing: a relay run for one household +should stand alone without configuring anything. + +| Variable | Default | Notes | +|---|---|---| +| `FN_FEDERATION` | `closed` | `closed`, `allowlist`, or `open`. 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 | Comma-separated hostnames, used only under `allowlist`. Enforced **outbound as well as inbound**, from the same rule. | +| `FN_FEDERATION_KEY` | unset | Base64 Ed25519 seed. **Required** when `FN_FEDERATION` is not `closed` — the relay refuses to boot otherwise, rather than starting unreachable and letting a stranger discover it. | + +`FN_FEDERATION_KEY` is deliberately **not** the issuer key. A key that only ever +needs to say "this relay said this" should not also be able to mint a token for +any user on it. + +⚠️ **Federation costs metadata.** Each relay in an exchange learns the other had +traffic for it, so a slice of your users' social graph is spread across +operators you do not run. That is the honest reason this is off by default. If +your relay exists so one group can talk without anyone else involved, `closed` +is the correct answer, not the cautious one. + +✅ **A closed relay opens no outbound connections at all.** The only outbound +code in the relay is the SMTP client below, itself disabled unless configured. +No telemetry, no update check, no directory to register with. + +## Shared FlameNet accounts + +Only for a relay that accepts identities from a FlameNet account service. +Leaving all of these unset gives you a relay with its own user table. + +| Variable | Default | Notes | +|---|---|---| +| `FN_ENCLAVE_PUBLIC_KEY` | unset | Base64 Ed25519 public key from the account service's `/.well-known/jwks.json` (the `x` member, base64url-decoded). **This is the one to use.** | +| `FN_ENCLAVE_JWT_SECRET` | unset | The older shared HMAC secret. Works, and is why third-party relays were never possible: the secret that checks a token also mints one, so anything holding it can impersonate any account. | +| `FN_ACCOUNTS_URL` | `https://accounts.flamenet.io` | Which account service to advertise. Point it at your own if you run one. | + +⚠️ **Configured, not fetched.** This decides who may assert an identity at your +relay; a relay that learned it over the network could be pointed at a different +issuer by whoever controls DNS. Copy it in once. + +⚠️ **Set the public key and HMAC tokens are refused.** That is intentional — a +fallback would let anyone who ever learned the retired secret keep minting +sessions forever. Migrate the account service first, then the relays. + +## Limits + +Defaults are sane; raise them only with a reason. + +| Variable | Default | Guards | +|---|---|---| +| `FN_BUNDLE_RATE` | `120`/hr | Prekey-bundle fetches per requester. Each pops a one-time prekey per device, so an unmetered version is a pool drain. | +| `FN_BUNDLE_RATE_TARGET` | `6`/hr | Per requester-target pair. Deliberately **not** metered per target alone — that would let one abuser make a popular account unstartable for everyone. | +| `FN_SEND_RATE` | `600`/hr | Envelopes, per sender or per recipient when sealed. | +| `FN_ATTACHMENT_RATE` | `60`/hr | | +| `FN_ATTACHMENT_BYTES_RATE` | `200 MiB`/hr | | +| `FN_ATTACHMENT_MAX_BYTES` | `10 MiB` | Per blob. | + +## Retention + +The relay purges on its own timer, in-process. Nothing external is needed. + +| Variable | Default | Notes | +|---|---|---| +| `FN_DELIVERED_RETENTION_DAYS` | `1` | | +| `FN_UNDELIVERED_RETENTION_DAYS` | `30` | ⚠️ Must stay below the client's retired-signed-prekey retention of 45 days. | +| `FN_ATTACHMENT_RETENTION_DAYS` | `30` | | +| `FN_MESSAGE_RETENTION_DAYS` | `90` | Plaintext-mode messages only. | +| `FN_READ_MESSAGE_RETENTION_DAYS` | `30` | | + +## Mail, calls, donations + +All optional. Each is absent rather than half-enabled when unconfigured. + +| Variable | Notes | +|---|---| +| `FN_SMTP_HOST`, `FN_SMTP_PORT`, `FN_SMTP_FROM`, `FN_SMTP_TLS`, `FN_SMTP_USERNAME` | Password-reset mail. Unset disables reset entirely — a flow that mints a token it cannot deliver leaves the account locked out *and* a live token in the database. | +| `FN_TURN_HOST`, `FN_TURN_SECRET`, `FN_TURN_TTL` | Voice calls. | +| `FN_DONATE_PROVIDER`, `FN_DONATE_SECRET` | Off unless set. A relay somebody else runs has no business soliciting money for us. | diff --git a/docs/HOSTING.md b/docs/HOSTING.md new file mode 100644 index 0000000..34786b1 --- /dev/null +++ b/docs/HOSTING.md @@ -0,0 +1,141 @@ +# Running your own relay + +A relay is one container and one database. It needs no account service, no +federation, and no connection to us — a relay serving one household is the +default configuration, not a stripped-down one. + +⚠️ **Flamenet Files is optional.** If you host only Messenger, nothing is +missing: your relay keeps its own accounts and everything works. Files matters +only if you want one credential across both products — see *Three shapes* +below. + +--- + +## The shortest thing that works + +```bash +mkdir -p /srv/flamenet && cd /srv/flamenet + +# The issuer keypair. This mints your users' session tokens; losing it signs +# everybody out, and leaking it lets somebody mint a token for any account. +docker run --rm flamenet-relay:latest generate-issuer-key > issuer.pem + +cat > .env <<'ENV' +FN_RELAY_AUDIENCE=relay.example.com +FN_ISSUER_PRIVATE_KEY= +FN_ISSUER_PUBLIC_KEY= +FN_AT_REST_SECRET=<32+ random bytes> +FN_POSTGRES_URL=postgres://flamenet:...@db/flamenet +ENV + +docker compose up -d +``` + +Then check it is what you think it is: + +```bash +curl https://relay.example.com/.well-known/flamenet/relay +``` + +⚠️ **`FN_RELAY_AUDIENCE` must be the hostname clients dial.** Tokens carry it +and the relay checks it, so a mismatch rejects **every** token with no other +symptom. A relay reachable at several names still has exactly one of these. + +⚠️ **Terminate TLS in front of it and forward the client address.** Clients hold +key material, and plain HTTP hands it to anyone on the path. Sealed submissions +are metered per recipient and per IP; behind a proxy that does not set +`X-Forwarded-For`, every request looks like the proxy and that limit is theatre. + +Every variable is in [Configuration](/configuration). + +--- + +## Three shapes + +| | Accounts live | Who runs it | Files needed | +|---|---|---|---| +| **Managed** | Shared FlameNet service | Us | no | +| **Self-hosted, shared identity** | Shared FlameNet service | You | no | +| **Self-hosted, local** *(default)* | Your relay's database | You | no | + +The third is the default and is not a lesser mode. Users register, exchange +keys, message each other, get a portable address and verify the transparency log +exactly as anywhere else. What the shared account service adds is **one +credential across Flamenet Messenger and Flamenet Files** — nothing else. + +To adopt the shared service, set `FN_ENCLAVE_PUBLIC_KEY` to the account +service's Ed25519 key. Your relay then advertises `accounts: shared` in its +descriptor, and clients pick that up automatically. + +⚠️ **Clients accept a named account service only if they already trust it.** A +relay claiming a service the client does not know is treated as local. That is +deliberate: without it, an operator could point their users' passphrase-derived +secrets at a host they control. + +⚠️ **Set the public key and HMAC tokens are refused.** A fallback would let +anyone who ever learned the retired shared secret keep minting sessions forever. +Migrate the account service first, then the relays. + +--- + +## Federation, if you want it + +Off by default, and the default is load-bearing. + +``` +FN_FEDERATION=allowlist +FN_FEDERATION_PEERS=relay.friend.example +FN_FEDERATION_KEY= +``` + +⚠️ **A closed relay opens no outbound connections at all.** No telemetry, no +update check, no directory to register with. The only outbound code is the SMTP +client, itself disabled unless configured. + +⚠️ **Federation costs metadata.** Each relay in an exchange learns the other had +traffic for it, so a slice of your users' social graph is spread across +operators you do not run. If your relay exists so one group can talk without +anyone else involved, `closed` is the correct answer. + +`FN_FEDERATION_KEY` is deliberately not the issuer key: a key that only says +"this relay said this" should not also mint tokens for your users. + +--- + +## Verifying what you are running + +A relay you cannot check is a relay you are trusting, which is the thing this +project exists to avoid. + +```bash +# What it claims to be +curl https://relay.example.com/.well-known/flamenet/relay + +# The signed transparency-log head +curl https://relay.example.com/e2e/log/head + +# An inclusion proof, checked against a head fetched separately — +# never against the root the proof response carries +curl "https://relay.example.com/e2e/log/inclusion?index=0&size=" +``` + +⚠️ **Check a proof against a head you fetched separately.** A server that +supplies both halves of a comparison has proven nothing. + +--- + +## What a relay operator can and cannot see + +Cannot: message contents, attachment contents, group message contents. Those are +end-to-end encrypted and the keys never reach the relay. + +Can: who has an account, which devices they have, that an envelope went from one +device to another and when, and how large it was. Retention windows for all of +it are in [Configuration](/configuration), and the relay purges on its own timer +with nothing external required. + +⚠️ **`FN_AT_REST_SECRET` defends against a stolen database dump. It does not +defend against you** — you hold the key. Tell your users that plainly. + +⚠️ **Never raise `FN_LOG_LEVEL` to `debug` on a machine with real accounts.** It +logs request URLs, and a protocol URL contains a device id. -- 2.54.0