Sync the relay from flamenet-messenger-ios 5bc7258 #64
+29
-1
@@ -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
|
||||
|
||||
@@ -1,4 +1 @@
|
||||
.build/
|
||||
.env
|
||||
.DS_Store
|
||||
*.sqlite
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -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; }
|
||||
|
||||
@@ -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` | |
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
#extend("layout"):
|
||||
#export("content"):
|
||||
<article class="band doc doc-generated">
|
||||
<div class="wrap wrap-narrow">
|
||||
<h1 id="relay-api">Relay API</h1>
|
||||
<p>Every route a Flamenet relay serves, what authenticates it, and what
|
||||
it is for.</p>
|
||||
<p>Read from <code>Sources/FlamenetRelay/Entrypoint.swift</code> 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.</p>
|
||||
<p>Base URL is <code>https://<your-relay></code>. There is
|
||||
<strong>one</strong> REST prefix, <code>api</code>.</p>
|
||||
<hr />
|
||||
<h2 id="discovery">Discovery</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Route</th>
|
||||
<th>Auth</th>
|
||||
<th>Purpose</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>GET /.well-known/flamenet/relay</code></td>
|
||||
<td>none</td>
|
||||
<td>What this relay is</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>GET /api/flamenet/v1</code></td>
|
||||
<td>none</td>
|
||||
<td>The route index</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>GET /e2e/log/head</code></td>
|
||||
<td>none</td>
|
||||
<td>Signed transparency-log head</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p><code>/.well-known/flamenet/relay</code> is what a client reads
|
||||
<strong>before sending any credential</strong>:</p>
|
||||
<pre class="json"><code>{
|
||||
"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
|
||||
}</code></pre>
|
||||
<p>⚠️ <strong><code>accounts</code> decides where a passphrase
|
||||
goes.</strong> <code>local</code> means this relay is its own identity
|
||||
provider and authenticates the password itself. <code>shared</code>
|
||||
means accounts live in a separate service named by
|
||||
<code>accountsURL</code>, the passphrase is stretched on the device, and
|
||||
only a derived secret is sent — to that service, never to the relay.</p>
|
||||
<p>⚠️ <strong>A client must not believe <code>accountsURL</code>
|
||||
blindly.</strong> It should accept only account services it already
|
||||
trusts, and treat anything else as <code>local</code>. Otherwise an
|
||||
operator could name a host they control and collect passphrase-derived
|
||||
secrets. The relay's own code pins its <code>accountsURL</code> as a
|
||||
constant for the same reason.</p>
|
||||
<p>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.</p>
|
||||
<hr />
|
||||
<h2 id="accounts">Accounts</h2>
|
||||
<p>Present only when this relay runs the built-in identity provider
|
||||
(<code>FN_ISSUER_PRIVATE_KEY</code> set).</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Route</th>
|
||||
<th>Auth</th>
|
||||
<th>Purpose</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>POST /account/signup</code></td>
|
||||
<td>none</td>
|
||||
<td>Create an account</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>POST /account/signin</code></td>
|
||||
<td>none</td>
|
||||
<td>Exchange a password for a credential</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>POST /account/token</code></td>
|
||||
<td>Basic</td>
|
||||
<td>Mint a device-scoped relay token</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>POST /account/reset/request</code></td>
|
||||
<td>none</td>
|
||||
<td>Ask for a reset code</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>POST /account/reset/confirm</code></td>
|
||||
<td>none</td>
|
||||
<td>Redeem a reset code</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p>Also mounted under
|
||||
<code>/api/flamenet/v1/messenger/{signup,signin}</code>.</p>
|
||||
<p><code>signup</code> takes
|
||||
<code>{username, email, password, display_name?}</code> and answers:</p>
|
||||
<pre class="json"><code>{ "user_id": 7, "username": "alice", "display_name": "Alice",
|
||||
"app_password": "<returned once, never retrievable>" }</code></pre>
|
||||
<p>⚠️ <strong>The credential is returned once and only its hash is
|
||||
kept.</strong> 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.</p>
|
||||
<p>⚠️ <strong><code>signin</code> accepts a username <em>or</em> an
|
||||
email.</strong> 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.</p>
|
||||
<p>⚠️ <strong>Reset answers identically whatever the truth is</strong> —
|
||||
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.</p>
|
||||
<h3 id="shared-identity">Shared identity</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Route</th>
|
||||
<th>Auth</th>
|
||||
<th>Purpose</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>POST /api/flamenet/v1/messenger/session</code></td>
|
||||
<td>none</td>
|
||||
<td>Exchange an account-service token for a relay credential</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p>Present only when <code>FN_ENCLAVE_JWT_SECRET</code> or
|
||||
<code>FN_ENCLAVE_PUBLIC_KEY</code> is set. Takes
|
||||
<code>{token, handle, display_name?}</code>.</p>
|
||||
<p>⚠️ <strong>The handle travels beside the token, not inside
|
||||
it.</strong> 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.</p>
|
||||
<hr />
|
||||
<h2 id="protocol-routes-8">Protocol routes (§8)</h2>
|
||||
<p>All under <code>/api/flamenet/v1/e2e/…</code>, and also at
|
||||
<code>/e2e/…</code>.</p>
|
||||
<p>⚠️ <strong>These refuse the account credential.</strong> They take a
|
||||
short-lived token scoped to <strong>one device and one relay</strong>,
|
||||
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.</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Route</th>
|
||||
<th>Purpose</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>POST /e2e/devices</code></td>
|
||||
<td>Publish a device's identity and prekeys</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>GET /e2e/devices/{user}</code></td>
|
||||
<td>List a user's devices — costs no prekey</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>GET /e2e/keys/{user}</code></td>
|
||||
<td>Fetch bundles — <strong>pops a one-time prekey per
|
||||
device</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>POST /e2e/prekeys</code></td>
|
||||
<td>Top the pool back up</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>POST /e2e/messages</code></td>
|
||||
<td>Deposit envelopes</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>GET /e2e/messages</code></td>
|
||||
<td>Poll this device's inbox</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>POST /e2e/delivery-key</code></td>
|
||||
<td>Publish a delivery-key digest for sealed sending</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>POST /e2e/attachments</code></td>
|
||||
<td>Upload one encrypted blob</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>GET /e2e/attachments/{id}</code></td>
|
||||
<td>Download it</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>POST /e2e/groups</code></td>
|
||||
<td>Create a group</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>GET /e2e/groups</code></td>
|
||||
<td>List groups</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>POST /e2e/groups/{gid}/members</code></td>
|
||||
<td>Add or remove members</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>POST /e2e/groups/{gid}/leave</code></td>
|
||||
<td>Leave</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>GET /e2e/turn</code></td>
|
||||
<td>TURN credentials for calls</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>GET /e2e/log/{head,inclusion,consistency,device/{id}}</code></td>
|
||||
<td>Transparency log</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<h3 id="the-three-that-bite">The three that bite</h3>
|
||||
<p><strong><code>GET /e2e/keys/{user}</code> consumes a one-time prekey
|
||||
per device, every call.</strong> Use <code>/e2e/devices/{user}</code> to
|
||||
look at who somebody is; use <code>keys</code> 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.</p>
|
||||
<p><strong><code>POST /e2e/messages</code> names the recipient once, at
|
||||
the top</strong> — not per envelope:</p>
|
||||
<pre class="json"><code>{ "to": 42, "from_device": "…",
|
||||
"messages": [ { "to_device": "…", "payload": "<base64>" } ] }</code></pre>
|
||||
<p>One envelope per device of theirs. The relay does no fan-out and no
|
||||
re-encryption; it moves opaque payloads.</p>
|
||||
<p><strong><code>POST /e2e/devices</code> publishes prekeys under
|
||||
<code>prekeys</code>, not <code>opks</code>.</strong> A registration
|
||||
carrying the wrong name is accepted with a <code>200</code> and stores
|
||||
nothing — the device is published and can never be started with, which
|
||||
is a failure with no error anywhere.</p>
|
||||
<h3 id="the-per-request-proof">The per-request proof</h3>
|
||||
<p>Each authenticated request carries <code>X-FN-Timestamp</code>,
|
||||
<code>X-FN-Nonce</code> and <code>X-FN-Signature</code>, an Ed25519
|
||||
signature over:</p>
|
||||
<pre><code>METHOD \n LOGICAL_PATH \n TIMESTAMP \n NONCE \n hex(SHA-256(body))</code></pre>
|
||||
<p>⚠️ <strong><code>LOGICAL_PATH</code> is the <code>/e2e/…</code> path,
|
||||
not the URL's path.</strong> 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.</p>
|
||||
<p>⚠️ <strong>The nonce is not decoration.</strong> 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.</p>
|
||||
<hr />
|
||||
<h2 id="federation">Federation</h2>
|
||||
<p>Off by default. When <code>FN_FEDERATION</code> is not
|
||||
<code>closed</code>:</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Route</th>
|
||||
<th>Auth</th>
|
||||
<th>Purpose</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>POST /fed/v1/deposit</code></td>
|
||||
<td><code>fnfed-v1</code></td>
|
||||
<td>Accept envelopes from a peer relay</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>GET /fed/v1/keys/{fnid}</code></td>
|
||||
<td><code>fnfed-v1</code></td>
|
||||
<td>Serve bundles for a local account</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p>Signed with the relay's <strong>federation key</strong>, 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.</p>
|
||||
<p>⚠️ <strong>Federation costs metadata.</strong> 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, <code>closed</code> is
|
||||
the correct answer, not the cautious one.</p>
|
||||
|
||||
</div>
|
||||
</article>
|
||||
#endexport
|
||||
#endextend
|
||||
@@ -4,10 +4,12 @@
|
||||
<div class="wrap">
|
||||
<h1>How this compares</h1>
|
||||
<p class="lede lede-wide">
|
||||
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.
|
||||
</p>
|
||||
|
||||
<div class="compare-scroll">
|
||||
@@ -43,6 +45,27 @@
|
||||
<td><strong class="no">No</strong></td>
|
||||
<td><strong class="no">No</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Servers can talk to each other</th>
|
||||
<td class="mine"><strong class="yes">Yes</strong><br><small>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.</small></td>
|
||||
<td><strong class="no">No</strong><br><small>Deliberately. Federating spreads metadata across operators, which Signal declines to do.</small></td>
|
||||
<td><strong class="no">No</strong></td>
|
||||
<td><strong class="no">No</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Your identity survives your server</th>
|
||||
<td class="mine"><strong class="yes">Yes</strong><br><small>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.</small></td>
|
||||
<td><strong class="no">No</strong><br><small>Your account lives on Signal's servers.</small></td>
|
||||
<td><strong class="no">No</strong></td>
|
||||
<td><strong class="no">No</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Mail can wait at more than one server</th>
|
||||
<td class="mine"><strong class="yes">Yes</strong><br><small>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.</small></td>
|
||||
<td><strong class="no">No</strong></td>
|
||||
<td><strong class="no">No</strong></td>
|
||||
<td><strong class="no">No</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Source code published</th>
|
||||
<td class="mine"><strong class="part">Engine and server</strong><br><small>Under the MIT licence. The apps are not published yet.</small></td>
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
#extend("layout"):
|
||||
#export("content"):
|
||||
<article class="band doc doc-generated">
|
||||
<div class="wrap wrap-narrow">
|
||||
<h1 id="configuring-a-relay">Configuring a relay</h1>
|
||||
<p>Every variable the relay reads, what it breaks if it is wrong, and
|
||||
which of the three deployment shapes needs it.</p>
|
||||
<p>Read from <code>Sources/FlamenetRelay/Config.swift</code> and
|
||||
<code>Entrypoint.swift</code> rather than from memory. If a variable is
|
||||
not in this file it is not read.</p>
|
||||
<hr />
|
||||
<h2 id="the-three-shapes">The three shapes</h2>
|
||||
<p>A relay is one of these. Nothing needs choosing beyond the first
|
||||
column — the default is a complete, self-contained relay.</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Accounts live</th>
|
||||
<th>Federates</th>
|
||||
<th>Who runs it</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><strong>Managed</strong></td>
|
||||
<td>The shared FlameNet account service</td>
|
||||
<td>Operator's choice</td>
|
||||
<td>Us</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Self-hosted, shared identity</strong></td>
|
||||
<td>The shared FlameNet account service</td>
|
||||
<td>Operator's choice</td>
|
||||
<td>You</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Self-hosted, local</strong> <em>(default)</em></td>
|
||||
<td>This relay's own database</td>
|
||||
<td>Operator's choice</td>
|
||||
<td>You</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p><strong>A local relay is not a lesser one.</strong> 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.</p>
|
||||
<hr />
|
||||
<h2 id="required">Required</h2>
|
||||
<p>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.</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Variable</th>
|
||||
<th>Notes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>FN_RELAY_AUDIENCE</code></td>
|
||||
<td>The hostname this relay is addressed by, e.g.
|
||||
<code>relay.example.com</code>. Tokens carry it and the relay checks it,
|
||||
so a mismatch rejects <strong>every</strong> token with no other
|
||||
symptom. A relay reachable at several names still has exactly one of
|
||||
these.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>FN_ISSUER_PUBLIC_KEY</code></td>
|
||||
<td>Base64 Ed25519 public key of whatever mints this relay's tokens.
|
||||
Never fetched over the network.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>FN_AT_REST_SECRET</code></td>
|
||||
<td>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.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<h2 id="identity--minting-your-own-tokens">Identity — minting your own
|
||||
tokens</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Variable</th>
|
||||
<th>Default</th>
|
||||
<th>Notes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>FN_ISSUER_PRIVATE_KEY</code></td>
|
||||
<td>unset</td>
|
||||
<td>Base64 Ed25519 <strong>seed</strong>. Set it and this relay issues
|
||||
its own tokens and serves <code>/account/*</code>. Unset and those
|
||||
routes do not exist at all, rather than existing and refusing.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>FN_TOKEN_LIFETIME</code></td>
|
||||
<td><code>300</code></td>
|
||||
<td>Seconds. Short because there is no revocation list — expiry is the
|
||||
only thing that stops a leaked token.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>FN_CLOCK_SKEW</code></td>
|
||||
<td><code>60</code></td>
|
||||
<td>Seconds of tolerance on a proof timestamp.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p>⚠️ In one process, a relay compromise <strong>is</strong> 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.</p>
|
||||
<h2 id="storage">Storage</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Variable</th>
|
||||
<th>Default</th>
|
||||
<th>Notes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>FN_POSTGRES_URL</code></td>
|
||||
<td>unset</td>
|
||||
<td>Set it and Postgres is used.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>FN_DB</code></td>
|
||||
<td><code>relay.sqlite</code></td>
|
||||
<td>SQLite file, used when the above is unset. <strong>The schema is
|
||||
identical on both</strong> — SQLite is not a degraded mode, and is the
|
||||
right choice for a relay serving a household or a company.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>FN_ATTACHMENTS_DIR</code></td>
|
||||
<td><code>attachments</code></td>
|
||||
<td>Blobs are files, not rows, in both modes.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<h2 id="network">Network</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Variable</th>
|
||||
<th>Default</th>
|
||||
<th>Notes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>FN_HOST</code></td>
|
||||
<td><code>127.0.0.1</code></td>
|
||||
<td>Not reachable from outside the host as shipped. Terminate TLS in
|
||||
front of it.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>FN_PORT</code></td>
|
||||
<td><code>8080</code></td>
|
||||
<td></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>FN_LOG_LEVEL</code></td>
|
||||
<td><code>error</code></td>
|
||||
<td>⚠️ Never raise to <code>debug</code> on a machine with real
|
||||
accounts: it logs request URLs, and a protocol URL contains a device
|
||||
id.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p>⚠️ <strong>HTTPS is not optional in practice.</strong> 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
|
||||
<code>X-Forwarded-For</code> every request looks like the proxy.</p>
|
||||
<h2 id="federation">Federation</h2>
|
||||
<p>Off by default, and the default is load-bearing: a relay run for one
|
||||
household should stand alone without configuring anything.</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Variable</th>
|
||||
<th>Default</th>
|
||||
<th>Notes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>FN_FEDERATION</code></td>
|
||||
<td><code>closed</code></td>
|
||||
<td><code>closed</code>, <code>allowlist</code>, or <code>open</code>.
|
||||
An unrecognised value falls back to <code>closed</code> rather than
|
||||
refusing to boot — a typo in an optional variable should not stop a
|
||||
relay serving its own users.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>FN_FEDERATION_PEERS</code></td>
|
||||
<td>empty</td>
|
||||
<td>Comma-separated hostnames, used only under <code>allowlist</code>.
|
||||
Enforced <strong>outbound as well as inbound</strong>, from the same
|
||||
rule.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>FN_FEDERATION_KEY</code></td>
|
||||
<td>unset</td>
|
||||
<td>Base64 Ed25519 seed. <strong>Required</strong> when
|
||||
<code>FN_FEDERATION</code> is not <code>closed</code> — the relay
|
||||
refuses to boot otherwise, rather than starting unreachable and letting
|
||||
a stranger discover it.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p><code>FN_FEDERATION_KEY</code> is deliberately <strong>not</strong>
|
||||
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.</p>
|
||||
<p>⚠️ <strong>Federation costs metadata.</strong> 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, <code>closed</code> is the
|
||||
correct answer, not the cautious one.</p>
|
||||
<p>✅ <strong>A closed relay opens no outbound connections at
|
||||
all.</strong> 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.</p>
|
||||
<h2 id="shared-flamenet-accounts">Shared FlameNet accounts</h2>
|
||||
<p>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.</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Variable</th>
|
||||
<th>Default</th>
|
||||
<th>Notes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>FN_ENCLAVE_PUBLIC_KEY</code></td>
|
||||
<td>unset</td>
|
||||
<td>Base64 Ed25519 public key from the account service's
|
||||
<code>/.well-known/jwks.json</code> (the <code>x</code> member,
|
||||
base64url-decoded). <strong>This is the one to use.</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>FN_ENCLAVE_JWT_SECRET</code></td>
|
||||
<td>unset</td>
|
||||
<td>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.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>FN_ACCOUNTS_URL</code></td>
|
||||
<td><code>https://accounts.flamenet.io</code></td>
|
||||
<td>Which account service to advertise. Point it at your own if you run
|
||||
one.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p>⚠️ <strong>Configured, not fetched.</strong> 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.</p>
|
||||
<p>⚠️ <strong>Set the public key and HMAC tokens are refused.</strong>
|
||||
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.</p>
|
||||
<h2 id="limits">Limits</h2>
|
||||
<p>Defaults are sane; raise them only with a reason.</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Variable</th>
|
||||
<th>Default</th>
|
||||
<th>Guards</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>FN_BUNDLE_RATE</code></td>
|
||||
<td><code>120</code>/hr</td>
|
||||
<td>Prekey-bundle fetches per requester. Each pops a one-time prekey per
|
||||
device, so an unmetered version is a pool drain.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>FN_BUNDLE_RATE_TARGET</code></td>
|
||||
<td><code>6</code>/hr</td>
|
||||
<td>Per requester-target pair. Deliberately <strong>not</strong> metered
|
||||
per target alone — that would let one abuser make a popular account
|
||||
unstartable for everyone.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>FN_SEND_RATE</code></td>
|
||||
<td><code>600</code>/hr</td>
|
||||
<td>Envelopes, per sender or per recipient when sealed.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>FN_ATTACHMENT_RATE</code></td>
|
||||
<td><code>60</code>/hr</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>FN_ATTACHMENT_BYTES_RATE</code></td>
|
||||
<td><code>200 MiB</code>/hr</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>FN_ATTACHMENT_MAX_BYTES</code></td>
|
||||
<td><code>10 MiB</code></td>
|
||||
<td>Per blob.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<h2 id="retention">Retention</h2>
|
||||
<p>The relay purges on its own timer, in-process. Nothing external is
|
||||
needed.</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Variable</th>
|
||||
<th>Default</th>
|
||||
<th>Notes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>FN_DELIVERED_RETENTION_DAYS</code></td>
|
||||
<td><code>1</code></td>
|
||||
<td></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>FN_UNDELIVERED_RETENTION_DAYS</code></td>
|
||||
<td><code>30</code></td>
|
||||
<td>⚠️ Must stay below the client's retired-signed-prekey retention of
|
||||
45 days.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>FN_ATTACHMENT_RETENTION_DAYS</code></td>
|
||||
<td><code>30</code></td>
|
||||
<td></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>FN_MESSAGE_RETENTION_DAYS</code></td>
|
||||
<td><code>90</code></td>
|
||||
<td>Plaintext-mode messages only.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>FN_READ_MESSAGE_RETENTION_DAYS</code></td>
|
||||
<td><code>30</code></td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<h2 id="mail-calls-donations">Mail, calls, donations</h2>
|
||||
<p>All optional. Each is absent rather than half-enabled when
|
||||
unconfigured.</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Variable</th>
|
||||
<th>Notes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>FN_SMTP_HOST</code>, <code>FN_SMTP_PORT</code>,
|
||||
<code>FN_SMTP_FROM</code>, <code>FN_SMTP_TLS</code>,
|
||||
<code>FN_SMTP_USERNAME</code></td>
|
||||
<td>Password-reset mail. Unset disables reset entirely — a flow that
|
||||
mints a token it cannot deliver leaves the account locked out
|
||||
<em>and</em> a live token in the database.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>FN_TURN_HOST</code>, <code>FN_TURN_SECRET</code>,
|
||||
<code>FN_TURN_TTL</code></td>
|
||||
<td>Voice calls.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>FN_DONATE_PROVIDER</code>, <code>FN_DONATE_SECRET</code></td>
|
||||
<td>Off unless set. A relay somebody else runs has no business
|
||||
soliciting money for us.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
</article>
|
||||
#endexport
|
||||
#endextend
|
||||
@@ -19,6 +19,28 @@
|
||||
cannot be distributed at all.</li>
|
||||
</ul>
|
||||
|
||||
<p>
|
||||
Worth being straight about what donating here does <em>not</em> 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.
|
||||
</p>
|
||||
|
||||
#if(thanks):
|
||||
<div class="donate-thanks">
|
||||
<strong>Thank you.</strong>
|
||||
<p>
|
||||
Your receipt is on its way from #(processorName). Nothing was kept here beyond
|
||||
what they need to take the payment — there is no donor record and no list to be
|
||||
added to.
|
||||
</p>
|
||||
</div>
|
||||
#endif
|
||||
|
||||
<h2>What it does not buy</h2>
|
||||
<p>
|
||||
Not features, not priority support, and not a say in how the protocol works. There
|
||||
@@ -37,7 +59,7 @@
|
||||
<h2>Give</h2>
|
||||
|
||||
#if(donateEnabled):
|
||||
<form class="donate-form" method="POST" action="/donate/checkout">
|
||||
<form class="donate-form" id="donate-form" method="POST" action="/donate/intent">
|
||||
<fieldset class="donate-freq">
|
||||
<legend>How often</legend>
|
||||
<label><input type="radio" name="frequency" value="once" checked> One time</label>
|
||||
@@ -58,15 +80,26 @@
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<button class="btn btn-primary" type="submit">Continue to payment</button>
|
||||
<!-- Stripe mounts its own fields in here. This origin cannot read them. -->
|
||||
<div id="donate-payment" class="donate-payment" hidden></div>
|
||||
|
||||
<p id="donate-error" class="donate-error" role="alert" hidden></p>
|
||||
|
||||
<button class="btn btn-primary" id="donate-submit" type="submit">Continue</button>
|
||||
|
||||
<p class="fineprint fineprint-flush">
|
||||
This form collects an amount and nothing else. Payment happens on
|
||||
#(processorName)'s own hosted page — card details never reach this site, and this
|
||||
site never sees them. No account is created here and no newsletter exists to be
|
||||
added to.
|
||||
Payment happens here, on this page — you are not sent anywhere else. The card
|
||||
fields themselves belong to #(processorName) and run in a frame this site cannot
|
||||
read, so the card number never reaches this server and is never in its logs.
|
||||
Doing that requires loading #(processorName)'s script on this one page; every
|
||||
other page on this site still loads no third-party code at all, and the
|
||||
<a href="/privacy">privacy page</a> says so plainly. No account is created here
|
||||
and no newsletter exists to be added to.
|
||||
</p>
|
||||
</form>
|
||||
|
||||
<script src="https://js.stripe.com/v3"></script>
|
||||
<script src="/donate.js?v=#(assetVersion)" defer></script>
|
||||
#else:
|
||||
<p class="donate-pending">
|
||||
No payment processor is configured on this server, so there is nothing here to click.
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
#extend("layout"):
|
||||
#export("content"):
|
||||
<article class="band doc doc-generated">
|
||||
<div class="wrap wrap-narrow">
|
||||
<h1 id="running-your-own-relay">Running your own relay</h1>
|
||||
<p>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.</p>
|
||||
<p>⚠️ <strong>Flamenet Files is optional.</strong> 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 <em>Three shapes</em> below.</p>
|
||||
<hr />
|
||||
<h2 id="the-shortest-thing-that-works">The shortest thing that
|
||||
works</h2>
|
||||
<pre class="bash"><code>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</code></pre>
|
||||
<p>Then check it is what you think it is:</p>
|
||||
<pre class="bash"><code>curl https://relay.example.com/.well-known/flamenet/relay</code></pre>
|
||||
<p>⚠️ <strong><code>FN_RELAY_AUDIENCE</code> must be the hostname
|
||||
clients dial.</strong> Tokens carry it and the relay checks it, so a
|
||||
mismatch rejects <strong>every</strong> token with no other symptom. A
|
||||
relay reachable at several names still has exactly one of these.</p>
|
||||
<p>⚠️ <strong>Terminate TLS in front of it and forward the client
|
||||
address.</strong> 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 <code>X-Forwarded-For</code>, every
|
||||
request looks like the proxy and that limit is theatre.</p>
|
||||
<p>Every variable is in <a href="/configuration">Configuration</a>.</p>
|
||||
<hr />
|
||||
<h2 id="three-shapes">Three shapes</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Accounts live</th>
|
||||
<th>Who runs it</th>
|
||||
<th>Files needed</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><strong>Managed</strong></td>
|
||||
<td>Shared FlameNet service</td>
|
||||
<td>Us</td>
|
||||
<td>no</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Self-hosted, shared identity</strong></td>
|
||||
<td>Shared FlameNet service</td>
|
||||
<td>You</td>
|
||||
<td>no</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Self-hosted, local</strong> <em>(default)</em></td>
|
||||
<td>Your relay's database</td>
|
||||
<td>You</td>
|
||||
<td>no</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p>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 <strong>one credential across Flamenet Messenger and
|
||||
Flamenet Files</strong> — nothing else.</p>
|
||||
<p>To adopt the shared service, set <code>FN_ENCLAVE_PUBLIC_KEY</code>
|
||||
to the account service's Ed25519 key. Your relay then advertises
|
||||
<code>accounts: shared</code> in its descriptor, and clients pick that
|
||||
up automatically.</p>
|
||||
<p>⚠️ <strong>Clients accept a named account service only if they
|
||||
already trust it.</strong> 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.</p>
|
||||
<p>⚠️ <strong>Set the public key and HMAC tokens are refused.</strong> A
|
||||
fallback would let anyone who ever learned the retired shared secret
|
||||
keep minting sessions forever. Migrate the account service first, then
|
||||
the relays.</p>
|
||||
<hr />
|
||||
<h2 id="federation-if-you-want-it">Federation, if you want it</h2>
|
||||
<p>Off by default, and the default is load-bearing.</p>
|
||||
<pre><code>FN_FEDERATION=allowlist
|
||||
FN_FEDERATION_PEERS=relay.friend.example
|
||||
FN_FEDERATION_KEY=<base64 Ed25519 seed></code></pre>
|
||||
<p>⚠️ <strong>A closed relay opens no outbound connections at
|
||||
all.</strong> No telemetry, no update check, no directory to register
|
||||
with. The only outbound code is the SMTP client, itself disabled unless
|
||||
configured.</p>
|
||||
<p>⚠️ <strong>Federation costs metadata.</strong> 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,
|
||||
<code>closed</code> is the correct answer.</p>
|
||||
<p><code>FN_FEDERATION_KEY</code> is deliberately not the issuer key: a
|
||||
key that only says "this relay said this" should not also mint tokens
|
||||
for your users.</p>
|
||||
<hr />
|
||||
<h2 id="verifying-what-you-are-running">Verifying what you are
|
||||
running</h2>
|
||||
<p>A relay you cannot check is a relay you are trusting, which is the
|
||||
thing this project exists to avoid.</p>
|
||||
<pre class="bash"><code>#("#") 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>"</code></pre>
|
||||
<p>⚠️ <strong>Check a proof against a head you fetched
|
||||
separately.</strong> A server that supplies both halves of a comparison
|
||||
has proven nothing.</p>
|
||||
<hr />
|
||||
<h2 id="what-a-relay-operator-can-and-cannot-see">What a relay operator
|
||||
can and cannot see</h2>
|
||||
<p>Cannot: message contents, attachment contents, group message
|
||||
contents. Those are end-to-end encrypted and the keys never reach the
|
||||
relay.</p>
|
||||
<p>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 <a
|
||||
href="/configuration">Configuration</a>, and the relay purges on its own
|
||||
timer with nothing external required.</p>
|
||||
<p>⚠️ <strong><code>FN_AT_REST_SECRET</code> defends against a stolen
|
||||
database dump. It does not defend against you</strong> — you hold the
|
||||
key. Tell your users that plainly.</p>
|
||||
<p>⚠️ <strong>Never raise <code>FN_LOG_LEVEL</code> to
|
||||
<code>debug</code> on a machine with real accounts.</strong> It logs
|
||||
request URLs, and a protocol URL contains a device id.</p>
|
||||
|
||||
</div>
|
||||
</article>
|
||||
#endexport
|
||||
#endextend
|
||||
@@ -129,6 +129,48 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="band band-center" id="yours">
|
||||
<div class="wrap">
|
||||
<span class="eyebrow">Your name, your mailboxes</span>
|
||||
<h2>The server holds your mail, <span class="hl">not your identity</span>.</h2>
|
||||
<p class="band-lede">
|
||||
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.
|
||||
</p>
|
||||
|
||||
<div class="honest">
|
||||
<article class="honest-card">
|
||||
<h3>Your address comes from your passphrase</h3>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
</article>
|
||||
<article class="honest-card">
|
||||
<h3>You can keep a mailbox on more than one relay</h3>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
</article>
|
||||
<article class="honest-card">
|
||||
<h3>Relays can talk to each other</h3>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="band band-alt" id="honest">
|
||||
<div class="wrap">
|
||||
<div class="band-head">
|
||||
@@ -141,6 +183,16 @@
|
||||
</div>
|
||||
|
||||
<div class="honest">
|
||||
<article class="honest-card">
|
||||
<h3>Relays talking to each other means more servers see something</h3>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
</article>
|
||||
<article class="honest-card">
|
||||
<h3>Encryption covers the message, not the fact you sent one</h3>
|
||||
<p>
|
||||
|
||||
@@ -71,9 +71,17 @@
|
||||
|
||||
<h2>This website</h2>
|
||||
<p>
|
||||
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 <a href="/donate">donate</a> 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.
|
||||
</p>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
|
||||
<h2>Your choices</h2>
|
||||
|
||||
@@ -63,6 +63,15 @@ one that is down.</p>
|
||||
<td>Seconds of tolerance on a proof timestamp.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>FN_FEDERATION</code></td>
|
||||
<td><code>closed</code></td>
|
||||
<td><code>closed</code>, <code>allowlist</code> or <code>open</code>.
|
||||
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
|
||||
<code>closed</code> rather than refusing to boot — a typo in an optional
|
||||
variable should not stop a relay serving its own users.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>FN_BUNDLE_RATE</code> /
|
||||
<code>FN_BUNDLE_RATE_TARGET</code></td>
|
||||
<td><code>120</code> / <code>6</code></td>
|
||||
|
||||
@@ -6,9 +6,46 @@
|
||||
<p>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.</p>
|
||||
<h2 id="the-repositories">The repositories</h2>
|
||||
<p>Everything this page describes is published under the MIT
|
||||
licence.</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Repo</th>
|
||||
<th>What it is</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><a
|
||||
href="https://gitlab.com/paulhitt/flamenet-relay">flamenet-relay</a></td>
|
||||
<td>The server you are about to run</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a
|
||||
href="https://gitlab.com/paulhitt/flamenet-e2e">flamenet-e2e</a></td>
|
||||
<td>The protocol: JavaScript engine, specification, published test
|
||||
vectors</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a
|
||||
href="https://gitlab.com/paulhitt/flamenet-messenger-ios">flamenet-messenger-ios</a></td>
|
||||
<td>The iOS app, and the source of truth for all of the above</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a
|
||||
href="https://gitlab.com/paulhitt/flamenet-messenger-android">flamenet-messenger-android</a></td>
|
||||
<td>The Kotlin engine. Not an app yet</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p>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.</p>
|
||||
<h2 id="quick-start">Quick start</h2>
|
||||
<pre class="bash"><code>git clone https://gitlab.com/paulhitt/flamenet-e2e
|
||||
cd flamenet-e2e/server
|
||||
<pre class="bash"><code>git clone https://gitlab.com/paulhitt/flamenet-relay
|
||||
cd flamenet-relay
|
||||
cp .env.example .env</code></pre>
|
||||
<p>Generate the three secrets <code>.env</code> asks for:</p>
|
||||
<pre class="bash"><code>openssl rand -base64 32 #("#") POSTGRES_PASSWORD
|
||||
@@ -115,6 +152,67 @@ Messaging works without any of it.</p>
|
||||
running</h2>
|
||||
<pre class="bash"><code>engine/scripts/verify-build.sh #("#") digests of the engine sources
|
||||
engine/scripts/verify-build.sh --rebuild #("#") prove the vendored ML-KEM rebuilds byte-identically</code></pre>
|
||||
<h2 id="standing-alone-or-joining-the-federation">Standing alone, or
|
||||
joining the federation</h2>
|
||||
<p><strong>A relay you run is self-contained by default, and staying
|
||||
that way requires no configuration at all.</strong>
|
||||
<code>FN_FEDERATION</code> defaults to <code>closed</code>. 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.</p>
|
||||
<p>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 <code>FN_SMTP_HOST</code> unset and federation closed, the
|
||||
process makes <strong>no outbound connections at all</strong>. 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.</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th><code>FN_FEDERATION</code></th>
|
||||
<th>What it means</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>closed</code> <em>(default)</em></td>
|
||||
<td>Your relay talks to your users and to nobody else.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>allowlist</code></td>
|
||||
<td>Your relay exchanges traffic only with peers you name.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>open</code></td>
|
||||
<td>Your relay accepts peers it has not met.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p>Your relay advertises which of these it is at
|
||||
<code>/.well-known/flamenet/relay</code>, so a peer reads your terms
|
||||
before attempting anything rather than discovering them from a rejected
|
||||
request.</p>
|
||||
<p>⚠️ <strong>Federation is not free, and the cost is metadata.</strong>
|
||||
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.</p>
|
||||
<p>Choose it deliberately. If your relay exists so that one household,
|
||||
one company or one group of friends can talk without anyone else
|
||||
involved, <code>closed</code> is not the cautious option — it is the
|
||||
correct one.</p>
|
||||
<h3 id="identities-work-either-way">Identities work either way</h3>
|
||||
<p>An account key and the <code>fnid</code> 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.</p>
|
||||
<h2 id="what-self-hosting-actually-buys-you">What self-hosting actually
|
||||
buys you</h2>
|
||||
<p>It removes the operator from your threat model, because you are the
|
||||
|
||||
+14
-11
@@ -4,15 +4,22 @@
|
||||
<div class="wrap wrap-narrow">
|
||||
<h1>Source code</h1>
|
||||
<p class="lede">
|
||||
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.
|
||||
</p>
|
||||
|
||||
<h2>What is in it</h2>
|
||||
<h2>Where things are</h2>
|
||||
<ul>
|
||||
<li><strong>engine/</strong> — the JavaScript cryptographic engine, its test suite, and the published test vectors</li>
|
||||
<li><strong>engine/docs/SPEC.md</strong> — the protocol <a href="/spec">specification</a>, rendered on this site</li>
|
||||
<li><strong>server/</strong> — the Swift relay that serves this page</li>
|
||||
<li><a href="https://gitlab.com/paulhitt/flamenet-e2e"><strong>flamenet-e2e</strong></a>
|
||||
— the JavaScript engine, the <a href="/spec">specification</a>, and the published
|
||||
test vectors every implementation is held to</li>
|
||||
<li><a href="https://gitlab.com/paulhitt/flamenet-relay"><strong>flamenet-relay</strong></a>
|
||||
— the Swift relay that serves this page. What you run to
|
||||
<a href="/self-hosting">self-host</a></li>
|
||||
<li><a href="https://gitlab.com/paulhitt/flamenet-messenger-ios"><strong>flamenet-messenger-ios</strong></a>
|
||||
— the iOS app, and the source of truth for all of the above</li>
|
||||
<li><a href="https://gitlab.com/paulhitt/flamenet-messenger-android"><strong>flamenet-messenger-android</strong></a>
|
||||
— the Kotlin engine. Not an app yet, and the roadmap says so</li>
|
||||
</ul>
|
||||
|
||||
<h2>Reading it without cloning it</h2>
|
||||
@@ -24,12 +31,8 @@
|
||||
<a href="/engine">engine</a> and <a href="/relay">relay</a> overviews.
|
||||
</p>
|
||||
|
||||
<h2>The repository</h2>
|
||||
<p>
|
||||
<a href="https://gitlab.com/paulhitt/flamenet-e2e">gitlab.com/paulhitt/flamenet-e2e</a>
|
||||
</p>
|
||||
<p>
|
||||
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.
|
||||
|
||||
@@ -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).</p>
|
||||
<h3 id="69-message-ids-replies-and-reactions">6.9 Message ids, replies,
|
||||
and reactions</h3>
|
||||
<p>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.</p>
|
||||
<p><strong><code>mid</code>.</strong> A sender MAY put an id on a
|
||||
message it sends, in the content envelope:</p>
|
||||
<pre class="json"><code>{ "fnc": 1, "kind": "text", "mid": "<uuid>", "caption": "hello" }</code></pre>
|
||||
<p>It MUST be unique within the conversation; a UUID is the obvious
|
||||
choice. A message without a <code>mid</code> is still valid — plain
|
||||
UTF-8 with no envelope remains a legal message — but nothing can refer
|
||||
to it, so <strong>it cannot be reacted to</strong>. Implementations
|
||||
SHOULD put a <code>mid</code> on every message they send.</p>
|
||||
<p><strong><code>re</code> — what a message is about.</strong> Present
|
||||
on a reply and on a reaction:</p>
|
||||
<pre class="json"><code>{ "mid": "<the id being answered>", "author": 12, "preview": "what they said" }</code></pre>
|
||||
<p><code>preview</code> 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 <code>> [missing]</code> is worse than one that shows what was
|
||||
actually said. The preview is what the <em>sender</em> 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.</p>
|
||||
<p><strong>Reactions.</strong> <code>kind</code> is
|
||||
<code>"react"</code>, <code>caption</code> is the emoji, <code>re</code>
|
||||
names the target:</p>
|
||||
<pre class="json"><code>{ "fnc": 1, "kind": "react", "caption": "👍", "re": { "mid": "<target>", "author": 12, "preview": "" } }</code></pre>
|
||||
<p>An <strong>empty</strong> <code>caption</code> 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.</p>
|
||||
<p>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.</p>
|
||||
<p><code>gid</code> applies as elsewhere: a reaction on a group message
|
||||
carries the group id and routes to the group thread.</p>
|
||||
<p><strong>Status.</strong> Implemented in the iOS engine and the
|
||||
JavaScript engine, and checked against each other in both directions by
|
||||
<code>test/interop.mjs</code> — including a preview containing quotes
|
||||
and a newline, which is where two hand-ordered JSON writers drift apart.
|
||||
<strong>The Kotlin engine does not implement this section</strong>,
|
||||
along with most of the rest of the protocol.</p>
|
||||
<h2 id="7-safety-numbers-verification">7. Safety numbers
|
||||
(verification)</h2>
|
||||
<p>For a local identity <code>IK_sig_local_pub</code> 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.</p>
|
||||
<h3 id="192-leaf-contents">19.2 Leaf contents</h3>
|
||||
<pre><code>leaf = SHA-256( 0x00 || "v1|" || kind || "|" || user_id || "|" || device_id
|
||||
<pre><code>leaf = SHA-256( 0x00 || "v2|" || kind || "|" || subject || "|" || device_id
|
||||
|| "|" || ik_sig || "|" || ik_dh )</code></pre>
|
||||
<p><code>kind</code> is <code>publish</code> or <code>revoke</code>.
|
||||
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.</p>
|
||||
<p><strong><code>subject</code> replaced <code>user_id</code> in
|
||||
v2.</strong> A relay's <code>user_id</code> 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.</p>
|
||||
<p>A subject is one of two shapes, and which one it is is readable from
|
||||
the string:</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Form</th>
|
||||
<th>Meaning</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>fnid:<crockford-base32></code></td>
|
||||
<td>A global account identity: the account's Ed25519 public key.
|
||||
Portable — the same person at another relay produces the same
|
||||
subject.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>local:<audience>/<user_id></code></td>
|
||||
<td>A relay-local row number, qualified by the relay that assigned
|
||||
it.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p>The <code>local:</code> 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.</p>
|
||||
<p>⚠️ <strong>A v1 leaf must never be verified under v2 rules.</strong>
|
||||
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.</p>
|
||||
<h3 id="193-routes">19.3 Routes</h3>
|
||||
<p>All four are <strong>unauthenticated</strong>. A log only its own
|
||||
users can read is not a transparency log; the value is that anyone — a
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:<base32>` — a global account identity. Portable: a monitor can check
|
||||
/// it against entries in any other relay's log for the same person.
|
||||
/// - `local:<audience>/<user_id>` — 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)"
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 <no-reply@example.org>`.
|
||||
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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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|<method>|<path>|<origin>|<destination>|<timestamp>|<nonce>|<sha256(body)>
|
||||
/// ```
|
||||
///
|
||||
/// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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|<fnid>|<serial>|<relay1>,<relay2>,...
|
||||
/// ```
|
||||
///
|
||||
/// 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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>` → `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)..<close])
|
||||
}
|
||||
|
||||
/// One RFC 5322 message. Body is escaped for the dot-stuffing rule: a line
|
||||
/// that is a single "." would otherwise end the DATA section early, which
|
||||
/// truncates the mail rather than failing.
|
||||
static func message(from: String, to: String, subject: String, body: String) -> 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<Reply, any Error>] = []
|
||||
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..<newline])
|
||||
partial = String(partial[partial.index(after: newline)...])
|
||||
accumulated.append(raw)
|
||||
|
||||
// Continuation lines look like "250-STARTTLS"; the final one has a
|
||||
// space in the fourth position.
|
||||
let isFinal = raw.count < 4 || Array(raw)[3] == " "
|
||||
guard isFinal else { continue }
|
||||
|
||||
let code = Int(raw.prefix(3)) ?? 0
|
||||
let reply = Reply(code: code, text: accumulated.joined(separator: "\n"))
|
||||
accumulated.removeAll()
|
||||
deliver(reply)
|
||||
}
|
||||
}
|
||||
|
||||
func errorCaught(context: ChannelHandlerContext, error: any Error) {
|
||||
let waiting: [CheckedContinuation<Reply, any Error>] = 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<Reply, any Error>] = 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<Reply, any Error>? = lock.withLock {
|
||||
if pending.isEmpty {
|
||||
ready.append(reply)
|
||||
return nil
|
||||
}
|
||||
return pending.removeFirst()
|
||||
}
|
||||
waiting?.resume(returning: reply)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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?
|
||||
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
import Crypto
|
||||
import Fluent
|
||||
import Foundation
|
||||
import SQLKit
|
||||
import Vapor
|
||||
|
||||
/// A pending password reset.
|
||||
///
|
||||
/// The token is `<selector>.<secret>`, 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) }
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<SHA256>.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<SHA256>.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=<username>
|
||||
// MARK: - GET /messenger/users?slug=<username>
|
||||
|
||||
/// 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".
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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|<fnid>|<serial>|<relays>`.
|
||||
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 `<prefix>/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",
|
||||
|
||||
@@ -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:
|
||||
@@ -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:
|
||||
|
||||
+194
@@ -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://<your-relay>`. 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": "<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).
|
||||
|
||||
| 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": "<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
|
||||
|
||||
| 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": "<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`:
|
||||
|
||||
| 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.
|
||||
@@ -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. |
|
||||
+141
@@ -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=<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:
|
||||
|
||||
```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=<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.
|
||||
|
||||
```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=<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.
|
||||
Reference in New Issue
Block a user