Second pass with the avoid-ai-writing detector

The skill's own detector (conorbronsdon/avoid-ai-writing) scored every
README, doc, page and the extracted comment prose. Scores were already
0 to 3 out of 100; what it still flagged is fixed here: hollow
intensifiers (genuinely, genuine), "in order to", "additionally",
"the whole point" and "the entire point", bold sentences of seven words
or more in the docs, and bold that had wrapped across comment lines.

Comment prose now scores a maximum of 1 out of 100 across the repo.

One rule in the sweep went wrong and was caught by reading the diff:
"which is the entire point of X" lost its clause and left "of X"
dangling in four comments. Repaired by hand; the rule now keeps the
clause when it is followed by "of".

Comments and strings only. 694 tests pass.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-09-08 19:19:52 -04:00
co-authored by Claude Fable 5.1
parent 0cccf91689
commit e15beaf9a9
100 changed files with 964 additions and 995 deletions
+1 -1
View File
@@ -31,7 +31,7 @@ One MR, all three parts together:
2. `Scripts/build-doc-pages.sh` and commit the regenerated pages.
3. Make whatever source change the new conformance suite expects.
**Do not do 1 without 2 and 3.** The point of the pin is that the bump and
Do not do 1 without 2 and 3. The point of the pin is that the bump and
the work it implies land as one reviewable change. Bumping alone just moves
the breakage to `main`, which is what this file exists to prevent.
+8 -8
View File
@@ -1,7 +1,7 @@
# flamenet-relay, the server, standing on its own.
#
# **This repository was an rsync extract of the iOS app's `server/` subtree
# until the 2026-08-28 split.** It is standalone now, with its own history, and
# This repository was an rsync extract of the iOS app's `server/` subtree
# until the 2026-08-28 split. It is standalone now, with its own history, and
# patches land here directly. Comments elsewhere still calling it a mirror are
# stale; this one is the correction.
#
@@ -53,12 +53,12 @@ build:
- swift build
timeout: 30 minutes
# **These are swift-testing tests, and that is worth stating in the file
# somebody reads when the number looks wrong.** `swift test` reports
# These are swift-testing tests, and that is worth stating in the file
# somebody reads when the number looks wrong. `swift test` reports
# "Executed 0 tests" for the XCTest half and the swift-testing results appear
# separately, which reads as an empty suite and has already been misread as
# one. The grep below fails the job if the run does not report a passing suite,
# so a genuinely empty result cannot pass as a green tick.
# so a empty result cannot pass as a green tick.
test:
<<: *rules
stage: test
@@ -83,7 +83,7 @@ test:
# privilege, so this is a real gate rather than a manual button.
#
# What kaniko cannot do is RUN the result, so the boot smoke-test the dind
# version attempted is genuinely lost, not hidden: this job proves the image
# version attempted is lost, not hidden: this job proves the image
# builds, and whether the relay boots is proven by the deploy, which starts it
# and waits on its healthcheck. `--no-push` because publishing an image is a
# deployment decision, not a CI side effect.
@@ -165,8 +165,8 @@ image-build:
# a claim. The harness already discovers the server via FN_RELAY_DIR, so it
# clones flamenet-e2e at HEAD and points back at this checkout.
#
# **Pinned, and the previous comment here argued the opposite, so it is
# worth saying why it changed.** It read: "Coupled to flamenet-e2e's main on
# Pinned, and the previous comment here argued the opposite, so it is
# worth saying why it changed. It read: "Coupled to flamenet-e2e's main on
# purpose: the harness and the spec live there, and a relay tested against a
# pinned old harness is tested against a pinned old spec." That concern is
# real and is NOT abandoned; see `conformance-head` below, which still runs at
+6 -6
View File
@@ -38,7 +38,7 @@ request opens with **`Closes #N`** on its own line, which links the two and shut
the issue on merge. Without it the issue stays open after the fix ships and
somebody re-does the work.
`Closes #N` is resolved **in the repository the branch is pushed to**. Getting
`Closes #N` is resolved in the repository the branch is pushed to. Getting
the repository wrong does not error, it silently points at whatever issue holds
that number there, or at nothing. Check `git remote get-url origin` before you
commit, not after you push.
@@ -73,7 +73,7 @@ which it clones when the job runs.
**That means a cross-repo doc change makes this repository's `main` red for a
while, and the guard is right to say so.** A regenerated page can only match the
new hash after the source is on e2e main, so between "source lands" and
"regeneration lands" the served page genuinely is stale. Sequence that works:
"regeneration lands" the served page is stale. Sequence that works:
1. Merge the source change in `flamenet-e2e`.
2. Immediately run `Scripts/build-doc-pages.sh` here and merge the result.
@@ -84,7 +84,7 @@ Do not shorten step 2 by making the job `allow_failure` or skipping it on
command that did not work; the guard exists because of that, and a green that
means nothing is how it comes back.
**The same failure has a second cause, and the fix is the opposite one.** If
The same failure has a second cause, and the fix is the opposite one. If
your branch was cut before somebody else's regeneration landed, it carries the
old pages while the source has moved, identical message, but running the
generator produces no diff and leaves you stuck. Merge `main` in instead. Tell
@@ -121,8 +121,8 @@ swift build
swift test
```
**`swift test` prints "Executed 0 tests" and that is not an empty suite.**
These are swift-testing tests; the XCTest half is genuinely empty and the real
`swift test` prints "Executed 0 tests" and that is not an empty suite.
These are swift-testing tests; the XCTest half is empty and the real
results appear separately as `Test run with N tests ... passed`. This has been
misread as a broken suite before, which is why CI greps for that line and fails
the job without it.
@@ -158,7 +158,7 @@ belongs there, and CI fails if the file is missing.
*right*, bounded by `FN_TRUSTED_PROXIES`. Counting from the left lets a client
choose its own bucket. Sign-in and quota limiters fail *closed* to a local
fallback when Redis is unreachable; keep it that way.
- **Adding a claim to a public page or to `/metadata`?** It has to be true of the
- Adding a claim to a public page or to `/metadata`? It has to be true of the
running configuration, not of the code in the abstract. `/metadata` renders
from the instance's own config for exactly that reason.
+1 -1
View File
@@ -10,7 +10,7 @@ redirect domain stops working when they do.
| `flamenet-donate.svg` / `.png` | `https://flamenetmessenger.com/donate` |
| `*-card.png` | the same codes, captioned |
**The download code points at the page, not at the `.apk`.** A raw APK URL
The download code points at the page, not at the `.apk`. A raw APK URL
scanned on an iPhone downloads a file the phone cannot open, and on Android it
skips the walkthrough of the three prompts it is about to show. `/get` puts the
download one tap away with that context, and works on both phones.
+47 -49
View File
@@ -3,7 +3,7 @@
<!-- GENERATED by Scripts/build-doc-pages.sh from docs/API.md.
Edits made HERE are deleted the next time that script runs.
Change the markdown source and re-run it.
source-sha256: cce1e400e12a56bb -->
source-sha256: 68ba3c5d6b3caad8 -->
<article class="band doc doc-generated">
<div class="wrap wrap-narrow">
<h1 id="relay-api">Relay API</h1>
@@ -63,12 +63,11 @@ 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>A client must not believe <code>accountsURL</code> blindly. 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
@@ -119,18 +118,18 @@ be a transparency log.</p>
<code>{username, email, password, display_name?}</code> and answers:</p>
<pre class="json"><code>{ &quot;user_id&quot;: 7, &quot;username&quot;: &quot;alice&quot;, &quot;display_name&quot;: &quot;Alice&quot;,
&quot;app_password&quot;: &quot;&lt;returned once, never retrievable&gt;&quot; }</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>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.</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>
<p>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.</p>
<h3 id="shared-identity">Shared identity</h3>
<table>
<thead>
@@ -151,9 +150,9 @@ claims it can is lying.</p>
<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
<p>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
@@ -251,48 +250,47 @@ Range: bytes=-200 the last 200 bytes</code></pre>
relay will not serve, several ranges in one header, or anything
malformed, is <strong>ignored</strong> rather than refused, and you get
<code>200</code> and the whole blob.</p>
<p><strong><code>416</code> means the blob is not the length you think
it is.</strong> A resume asks for <code>bytes=900-</code> because the
client already holds 900 bytes; if the blob was replaced or is shorter
than that, the answer is <code>416 Range Not Satisfiable</code> carrying
<p><code>416</code> means the blob is not the length you think it is. A
resume asks for <code>bytes=900-</code> because the client already holds
900 bytes; if the blob was replaced or is shorter than that, the answer
is <code>416 Range Not Satisfiable</code> carrying
<code>Content-Range: bytes */&lt;real length&gt;</code>. That is not
"your request was malformed", it is the length you need in order to
start again from zero. Do not retry the same range.</p>
<p><strong>What a range costs you in privacy.</strong> Asking for part
of a file tells the relay which part you wanted: that somebody stopped
listening to a voice note after three seconds, which a whole-file fetch
cannot say. There is no way to offer resumable downloads without the
server seeing the offset. This relay does not log it, does not count it
per blob, and does not store it on the attachment row, but a relay you
do not run could, so a client that would rather not say should fetch
whole blobs.</p>
"your request was malformed", it is the length you need to start again
from zero. Do not retry the same range.</p>
<p>What a range costs you in privacy. Asking for part of a file tells
the relay which part you wanted: that somebody stopped listening to a
voice note after three seconds, which a whole-file fetch cannot say.
There is no way to offer resumable downloads without the server seeing
the offset. This relay does not log it, does not count it per blob, and
does not store it on the attachment row, but a relay you do not run
could, so a client that would rather not say should fetch whole
blobs.</p>
<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>
<p><code>GET /e2e/keys/{user}</code> consumes a one-time prekey per
device, every call. 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><code>POST /e2e/messages</code> names the recipient once, at the top:
not per envelope:</p>
<pre class="json"><code>{ &quot;to&quot;: 42, &quot;from_device&quot;: &quot;…&quot;,
&quot;messages&quot;: [ { &quot;to_device&quot;: &quot;…&quot;, &quot;payload&quot;: &quot;&lt;base64&gt;&quot; } ] }</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>
<p><code>POST /e2e/devices</code> publishes prekeys under
<code>prekeys</code>, not <code>opks</code>. 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><code>LOGICAL_PATH</code> is the <code>/e2e/…</code> 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.</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
+45 -45
View File
@@ -3,7 +3,7 @@
<!-- GENERATED by Scripts/build-doc-pages.sh from docs/CONFIGURATION.md.
Edits made HERE are deleted the next time that script runs.
Change the markdown source and re-run it.
source-sha256: 8f23f2fa27187e1a -->
source-sha256: ca90c6ecc7ee4e29 -->
<article class="band doc doc-generated">
<div class="wrap wrap-narrow">
<h1 id="configuring-a-relay">Configuring a relay</h1>
@@ -46,11 +46,11 @@ column, the default is a complete, self-contained relay.</p>
</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>
<p>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.</p>
<hr />
<h2 id="required">Required</h2>
<p>Without these the relay refuses to start. That is deliberate: a relay
@@ -222,10 +222,10 @@ 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>
<p>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.</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
@@ -265,10 +265,10 @@ one.</td>
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>
<p>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.</p>
<h2 id="limits">Limits</h2>
<p>Defaults are sane; raise them only with a reason.</p>
<table>
@@ -328,17 +328,17 @@ as an app that hangs on the sign-up button.</td>
<td><code>FN_SIGNUP_MAX_PER_HOUR_WITHOUT_POW</code></td>
<td><code>5</code>/hr</td>
<td>What a client too old to answer the challenge gets instead, per
address. <strong>This is what makes <code>FN_SIGNUP_POW_BITS</code>
switchable on at all.</strong> Refusing an unanswered sign-up outright
would break every build already in somebody's hand, and break it
invisibly, the APK in the field is Android 0.6 (2026-08-31) and the
client-side solver landed 2026-09-01, one day later. An old build now
drops to this strict allowance rather than hitting a wall, so the
opt-out costs something without stranding anyone. Per-address is
trivially rotated, so this bounds a lazy attacker, not a determined one;
the gain is that the strong thing can be enabled at all.
<strong><code>0</code> restores the wall</strong>: the right setting
once both shipped clients solve challenges.</td>
address. This is what makes <code>FN_SIGNUP_POW_BITS</code> switchable
on at all. Refusing an unanswered sign-up outright would break every
build already in somebody's hand, and break it invisibly, the APK in the
field is Android 0.6 (2026-08-31) and the client-side solver landed
2026-09-01, one day later. An old build now drops to this strict
allowance rather than hitting a wall, so the opt-out costs something
without stranding anyone. Per-address is trivially rotated, so this
bounds a lazy attacker, not a determined one; the gain is that the
strong thing can be enabled at all. <strong><code>0</code> restores the
wall</strong>: the right setting once both shipped clients solve
challenges.</td>
</tr>
</tbody>
</table>
@@ -349,7 +349,7 @@ engages, deliberately: an invite-only server nobody can join is worse
than an open one. | | <code>FN_VOUCH_UNTIL</code> | unset | Unix seconds
after which vouching stops being required. <strong>The gate turns itself
off.</strong> Vouching makes bulk sign-ups costly and gives every
account a provenance; it also slows genuine growth and creates an
account a provenance; it also slows growth and creates an
in-group. That is a reasonable trade for a phase and a bad one for a
policy, so it cannot be switched on without saying when it stops. | |
<code>FN_SEND_RATE</code> | <code>600</code>/hr | Envelopes, per sender
@@ -451,21 +451,21 @@ including a typo, leaves it off.</td>
the switch above, <strong>and</strong> SMTP being configured. A relay
that cannot send mail can never answer the question it is asking, so it
does not ask, the same rule the key-log enforcement follows.</p>
<p><strong>Before you turn this on, check what your users are
running.</strong> The gate sits on the token route, which is the door to
everything: an account that cannot mint a token cannot poll, register a
device, or fetch a prekey bundle. A client with no screen for entering
the code has no way through that door and no way to tell the person why.
The Android build released on 2026-08-31 (v8) is one of these. Turning
this on for a relay it talks to would let somebody create an account and
then strand them.</p>
<p>Before you turn this on, check what your users are running. The gate
sits on the token route, which is the door to everything: an account
that cannot mint a token cannot poll, register a device, or fetch a
prekey bundle. A client with no screen for entering the code has no way
through that door and no way to tell the person why. The Android build
released on 2026-08-31 (v8) is one of these. Turning this on for a relay
it talks to would let somebody create an account and then strand
them.</p>
<p>Two things this deliberately does <strong>not</strong> do:</p>
<ul>
<li><strong>It never touches an account that already exists.</strong>
The migration marks every account present at upgrade as verified.
Switching this on does not lock out your current users.</li>
<li><strong>It never gates an account with no email of its own</strong>:
those authenticated by the shared service have nothing to prove.</li>
<li>It never touches an account that already exists. The migration marks
every account present at upgrade as verified. Switching this on does not
lock out your current users.</li>
<li>It never gates an account with no email of its own: those
authenticated by the shared service have nothing to prove.</li>
</ul>
<p>Codes last 24 hours and one is live at a time; asking for another
replaces the first. If the mail fails to send, the code is deleted
@@ -578,12 +578,12 @@ entry published under one account, publications and retirements, with
the device each names. It is unauthenticated, like the rest of §19: a
log only its own users can read is not a transparency log, and a monitor
is by definition somebody else.</p>
<p><strong>That has a cost, and it is stated here rather than left to be
discovered.</strong> Anyone holding somebody's fnid, the address they
hand to contacts, can see how many devices they have published and
roughly when. This is inherent to key transparency rather than an
oversight: a substitution cannot be detected in a log nobody may read.
What it is not is a directory; you need the identifier first.</p>
<p>That has a cost, and it is stated here rather than left to be
discovered. Anyone holding somebody's fnid, the address they hand to
contacts, can see how many devices they have published and roughly when.
This is inherent to key transparency rather than an oversight: a
substitution cannot be detected in a log nobody may read. What it is not
is a directory; you need the identifier first.</p>
<p><code>Scripts/watch-key-log.py</code> is a self-hostable monitor,
forty lines of stdlib. Run it somewhere you control, a hosted service
everybody is enrolled in would learn which accounts are worth watching,
+1 -2
View File
@@ -113,8 +113,7 @@
<span class="eyebrow">Sending</span>
<h2>To a person, <span class="hl">not to a link</span>.</h2>
<p class="band-lede band-lede-spaced">
We used to make share links. They worked, and the server genuinely
couldn't read them, but a link is a key anybody can forward, and it has
We used to make share links. They worked, and the server couldn't read them, but a link is a key anybody can forward, and it has
no idea what it's carrying. So we took them out.
</p>
<p class="band-lede band-lede-spaced">
+1 -1
View File
@@ -115,7 +115,7 @@
<li>
<strong>&ldquo;Allow installs from this source&rdquo;</strong>, tap
through to the setting and turn it on for your browser. This is the one
genuinely worth understanding: it is the permission that lets anything
worth understanding: it is the permission that lets anything
outside the Play Store be installed at all, and it is reasonable that
Android makes you say so on purpose.
</li>
+39 -43
View File
@@ -3,7 +3,7 @@
<!-- GENERATED by Scripts/build-doc-pages.sh from docs/HOSTING.md.
Edits made HERE are deleted the next time that script runs.
Change the markdown source and re-run it.
source-sha256: bdd84088e6e81762 -->
source-sha256: b29369e9ea11d4cd -->
<article class="band doc doc-generated">
<div class="wrap wrap-narrow">
<h1 id="running-your-own-relay">Running your own relay</h1>
@@ -33,15 +33,15 @@ 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><code>FN_RELAY_AUDIENCE</code> must be the hostname clients dial.
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>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 <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="voice-and-video-calls">Voice and video calls</h2>
@@ -50,13 +50,12 @@ request looks like the proxy and that limit is theatre.</p>
<code>coturn</code> service alongside the relay, so deploying with that
file gets you working calls rather than calls that work for some
people.</p>
<p><strong>This is not the optional extra it sounds like.</strong> STUN
alone connects two peers only when at least one NAT is permissive.
Symmetric NAT and CGNAT, most mobile networks, need a relay in the
middle, so without TURN a large share of real calls fail while messaging
keeps working perfectly. That is a miserable thing to diagnose from a
bug report, and a relay set up without TURN answers 503 on
<code>/e2e/turn</code>.</p>
<p>This is not the optional extra it sounds like. STUN alone connects
two peers only when at least one NAT is permissive. Symmetric NAT and
CGNAT, most mobile networks, need a relay in the middle, so without TURN
a large share of real calls fail while messaging keeps working
perfectly. That is a miserable thing to diagnose from a bug report, and
a relay set up without TURN answers 503 on <code>/e2e/turn</code>.</p>
<p>Three variables, all required by that file, all in
<code>.env.example</code>:</p>
<pre class="bash"><code>FN_TURN_SECRET=$(openssl rand -hex 32) #("#") shared with coturn; must match exactly
@@ -66,13 +65,13 @@ FN_TURN_EXTERNAL_IP=203.0.113.10 #("#") the host&#("#")39;s PUBLIC addre
<code>49160-49200/udp</code></strong> on the host firewall. A closed
relay range fails exactly like a wrong secret: the call connects and
then carries no audio.</p>
<p><strong>Keep the TURN record unproxied in DNS.</strong> Cloudflare
does not proxy UDP, so a proxied record makes every relayed call fail
while the record looks perfectly healthy.</p>
<p><strong><code>FN_TURN_EXTERNAL_IP</code> must be the address the
outside world sees</strong>, not the one on the interface. Behind NAT,
coturn that does not know its public address hands out candidates nobody
can reach, and it presents as packet loss.</p>
<p>Keep the TURN record unproxied in DNS. Cloudflare does not proxy UDP,
so a proxied record makes every relayed call fail while the record looks
perfectly healthy.</p>
<p><code>FN_TURN_EXTERNAL_IP</code> must be the address the outside
world sees, not the one on the interface. Behind NAT, coturn that does
not know its public address hands out candidates nobody can reach, and
it presents as packet loss.</p>
<p>To run without calls, remove the <code>coturn</code> service from the
compose file. Leaving the variables blank is not the way to choose that,
the stack refuses to start, on purpose, so that "calls are broken"
@@ -118,24 +117,22 @@ Flamenet Files</strong>, nothing else.</p>
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>
<p>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.</p>
<p>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.</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=&lt;base64 Ed25519 seed&gt;</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>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.</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
@@ -158,9 +155,8 @@ 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 &quot;https://relay.example.com/e2e/log/inclusion?index=0&amp;size=&lt;size&gt;&quot;</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>
<p>Check a proof against a head you fetched separately. 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>
@@ -172,16 +168,16 @@ 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>This relay holds no key to any message.</strong> It used to:
<p>This relay holds no key to any message. It used to:
<code>FN_AT_REST_SECRET</code> sealed bodies on the plaintext path,
which defended against a stolen database dump and not against the
operator. That path was removed on 2026-09-04 along with the key, so
there is no longer an "it does not defend against you" caveat to pass on
to your users. If the variable is still in your <code>.env</code>,
nothing reads it, delete it.</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>
<p>Never raise <code>FN_LOG_LEVEL</code> to <code>debug</code> on a
machine with real accounts. It logs request URLs, and a protocol URL
contains a device id.</p>
</div>
</article>
+1 -1
View File
@@ -6,7 +6,7 @@
<p class="lede">
If somebody is using Flamenet Messenger to harass, threaten or hurt you, we want to
know. This page says how to tell us, what happens next, and, because it matters more
than the promise, what we genuinely cannot do.
than the promise, what we cannot do.
</p>
<h2>Reporting from inside the app</h2>
+22 -22
View File
@@ -3,7 +3,7 @@
<!-- GENERATED by Scripts/build-doc-pages.sh from flamenet-e2e/SECURITY.md.
Edits made HERE are deleted the next time that script runs.
Change the markdown source and re-run it.
source-sha256: cbe60e9127b3e572 -->
source-sha256: ea694afaa36a330b -->
<article class="band doc doc-generated">
<div class="wrap wrap-narrow">
<h1 id="security">Security</h1>
@@ -30,9 +30,9 @@ hours.</p>
<h2 id="threat-model">Threat model</h2>
<p>The full model is <code>docs/SPEC.md</code> §11 and §12.1. In brief,
this engine defends against a <strong>malicious or compromised
relay</strong> reading message contents, and (in v2) against a
<strong>passive adversary recording traffic today to decrypt after a
quantum computer exists</strong>.</p>
relay</strong> reading message contents, and (in v2) against a passive
adversary recording traffic today to decrypt after a quantum computer
exists.</p>
<p>It does <strong>not</strong> defend against:</p>
<ul>
<li><strong>A compromised endpoint.</strong> Device keys live on the
@@ -42,16 +42,16 @@ whom, when, and roughly how much. Sealed sender hides the
<em>sender</em> from the stored row (§14), but not from the connection
that delivered it, and the first message to a new contact is necessarily
unsealed.</li>
<li><strong>A quantum adversary present during the exchange.</strong> v2
makes key agreement hybrid, but identity keys are still Ed25519, so an
adversary who can forge signatures in real time can still mount an
active MITM. Post-quantum <em>authentication</em> is unimplemented.</li>
<li><strong>A hostile server operator, when the client is a web page
served by that operator.</strong> This is structural, not a bug: the
relay ships the JavaScript that holds the keys, so it can ship different
JavaScript to one user. Native clients do not have this property. Any
deployment making confidentiality claims to users must be explicit about
which client those claims apply to.</li>
<li>A quantum adversary present during the exchange. v2 makes key
agreement hybrid, but identity keys are still Ed25519, so an adversary
who can forge signatures in real time can still mount an active MITM.
Post-quantum <em>authentication</em> is unimplemented.</li>
<li>A hostile server operator, when the client is a web page served by
that operator. This is structural, not a bug: the relay ships the
JavaScript that holds the keys, so it can ship different JavaScript to
one user. Native clients do not have this property. Any deployment
making confidentiality claims to users must be explicit about which
client those claims apply to.</li>
</ul>
<h2 id="deployment-notes">Deployment notes</h2>
<ul>
@@ -59,9 +59,9 @@ which client those claims apply to.</li>
<code>DeviceIdentity.archive()</code> and
<code>E2ESession.archive()</code> both contain private key material.
<code>localStorage</code> is not an acceptable store.</li>
<li><strong>Keep the two prekey pools the same size.</strong> A PQ pool
smaller than the classical pool drains first and silently downgrades new
sessions to the last-resort key. §12.3.</li>
<li>Keep the two prekey pools the same size. A PQ pool smaller than the
classical pool drains first and silently downgrades new sessions to the
last-resort key. §12.3.</li>
<li><strong>Pin <code>minProto</code> per device.</strong> Without it, a
relay can strip the v2 fields from a bundle and both honest parties will
negotiate v1. Pinning is what makes downgrade detectable; first contact
@@ -71,11 +71,11 @@ remains vulnerable until <code>pqMode</code> is <code>required</code>.
<code>GET /e2e/keys</code> consumes a one-time prekey per device per
call, so an attacker can drain both pools cheaply. This is unfixed at
the protocol layer.</li>
<li><strong>Do not claim more than you can show.</strong> The spec's
§12.10 lists what must actually be run before the corresponding claims
are made. An earlier version of this project asserted cross-platform
interop in its spec that had never been performed and was, at the time,
impossible. Write claims after the runs, not before.</li>
<li>Do not claim more than you can show. The spec's §12.10 lists what
must actually be run before the corresponding claims are made. An
earlier version of this project asserted cross-platform interop in its
spec that had never been performed and was, at the time, impossible.
Write claims after the runs, not before.</li>
</ul>
</div>
+22 -22
View File
@@ -3,7 +3,7 @@
<!-- GENERATED by Scripts/build-doc-pages.sh from flamenet-e2e/SELF-HOSTING.md.
Edits made HERE are deleted the next time that script runs.
Change the markdown source and re-run it.
source-sha256: 93fa087f92659503 -->
source-sha256: 4173d817c93eb9bf -->
<article class="band doc doc-generated">
<div class="wrap wrap-narrow">
<h1 id="self-hosting">Self-hosting</h1>
@@ -74,12 +74,12 @@ usually failed.</p>
start:</p>
<pre class="bash"><code>docker network create web
docker compose up -d</code></pre>
<p><strong><code>docker network create web</code> is not
optional.</strong> The compose file declares that network as external,
it is where a reverse proxy would find the relay, so without it
<code>up -d</code> stops with "network web declared as external, but
could not be found", after building the image. Nothing else is needed if
you are not running a proxy; an empty network costs nothing.</p>
<p><code>docker network create web</code> is not optional. The compose
file declares that network as external, it is where a reverse proxy
would find the relay, so without it <code>up -d</code> stops with
"network web declared as external, but could not be found", after
building the image. Nothing else is needed if you are not running a
proxy; an empty network costs nothing.</p>
<p>That pulls a prebuilt image, so there is no Swift toolchain to
install and nothing to compile. <strong>The images are amd64
only</strong>: they are built by this project's CI, which runs on an
@@ -192,7 +192,7 @@ is a supported deployment rather than a fault.</p>
<p>Running the published image: <code>docker compose pull</code> then
<code>docker compose up -d</code>.</p>
<p>Building from source: <code>git pull</code> then
<strong><code>docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build</code></strong>.</p>
<code>docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build</code>.</p>
<p>The <code>--build</code> matters when you are building from source.
Compose reuses an existing image tag rather than rebuilding it, so
without it a <code>git pull</code> brings down new source and leaves you
@@ -237,13 +237,13 @@ 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>
<p>It is also 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>
@@ -270,13 +270,13 @@ no telemetry, no update check, and no directory to register with.</p>
<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>Federation is not free, and the cost is metadata. Sealed sender hides
who is writing from the relay carrying the message; it does not hide
your relay's existence, or which relays it exchanges traffic with, from
the operators on the other end. Joining a federation distributes a slice
of your users' social graph across servers you do not run. That is a
real reduction in what your users get from you hosting them, and it is
the honest reason this defaults to off.</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
+478 -500
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -8,7 +8,7 @@
<p class="lede lede-wide">
Twenty-four words, written on paper, that will open your wallet in any
other wallet on earth. We don't hold them, we can't recover them, and we
can't freeze anything. That's the whole point, and it's also the risk, so
can't freeze anything. That's the point, and it's also the risk, so
the app says it out loud the first time you see those words.
</p>
+3 -3
View File
@@ -28,9 +28,9 @@ body = body.replace('href="https://flamenetmessenger.com/', 'href="/')
# literal. Single pass, so the `#` this inserts is not itself re-scanned.
body = body.replace("#", '#("#")')
# **A banner, because this file has been hand-edited before and the edits
# were silently destroyed.** `self-hosting.leaf` grew a hundred lines of
# genuinely useful prose, the amd64 note, the update instructions, written
# A banner, because this file has been hand-edited before and the edits
# were silently destroyed. `self-hosting.leaf` grew a hundred lines of
# useful prose, the amd64 note, the update instructions, written
# straight into the generated output. Nobody re-ran this script for a month, so
# nothing complained; the next run would have deleted all of it, with no error
# and no diff anybody was looking at.
@@ -2,8 +2,8 @@
# Weekly: re-measure the security and privacy properties that were established
# on 2026-09-04, and say so when one stops being true.
#
# **Every check here is a property that was TRUE ONCE and could quietly stop
# being true.** That is not hypothetical, it is what the day this was written
# Every check here is a property that was TRUE ONCE and could quietly stop
# being true. That is not hypothetical, it is what the day this was written
# actually found:
#
# * `/metadata` promised that group rosters were held in the clear. The apps
+1 -1
View File
@@ -42,7 +42,7 @@ fi
# A gate that fails on a clean tree is worse than no gate: it teaches you to run
# it and ignore the answer, and the one time it catches something real the habit
# is already to shrug. This happened, `token: "deadbeef"`, an attachment id in
# a truncation test, is genuinely the shape of an assigned credential.
# a truncation test, is the shape of an assigned credential.
#
# The two obvious fixes are both worse than this one. Narrowing the
# pattern until `deadbeef` slips through puts a hole in the SHAPE, and the next
+5 -5
View File
@@ -19,8 +19,8 @@
# sudo ./staging-gate.sh --no-deploy (gate only, leave prod alone)
set -euo pipefail
# **Where the SOURCE tree is, captured before the cd below moves us out of
# it.** The script lives in `<source>/Scripts/`, and the cd that follows lands
# Where the SOURCE tree is, captured before the cd below moves us out of
# it. The script lives in `<source>/Scripts/`, and the cd that follows lands
# on `/srv/prod/messenger`, a directory that holds the compose file and NOT the
# Swift sources. Anything that wants to read the source has to have remembered
# where it was.
@@ -61,7 +61,7 @@ docker run -d --name ${STACK}-redis --network ${NET} redis:7-alpine >/dev/null
# connection. The gate failed here on 2026-08-31 with "staging postgres never
# became ready", a green build reported as a deploy failure, purely because
# the gate ran out of patience faster than the machine ran out of work. It
# still fails if postgres is genuinely broken; it just no longer calls a busy
# still fails if postgres is broken; it just no longer calls a busy
# machine a broken one. Waiting longer can only ever delay a failure, never
# hide one, so this loop is the safe place to be generous.
echo "GATE: waiting for staging postgres"
@@ -142,8 +142,8 @@ PAGES=$(grep -oE '^ \("[a-z-]+", "' \
# that has to be verified rather than read. That is the opposite of a gate.
ALL_PAGES="donate releases $PAGES"
echo "GATE: rendering $(echo "$ALL_PAGES" | wc -w | tr -d ' ') public pages"
# **`releases` is named here for the same reason `donate` is: it is NOT in
# the table above.** Its route is deliberately outside `pages` because its
# `releases` is named here for the same reason `donate` is: it is NOT in
# the table above. Its route is deliberately outside `pages` because its
# content is data read from disk per request rather than a template, and the
# consequence is that the derived list cannot see it. The deploy that first
# shipped that page was, without this line, a deploy whose ONLY new page was
+4 -4
View File
@@ -55,7 +55,7 @@ struct APNs: Sendable {
/// sandbox, so the failure mode at a real release is: every push refused,
/// no iPhone ever rings, and a log line that reads like a bad token.
///
/// Says "almost certainly" rather than "is". A genuinely malformed token
/// Says "almost certainly" rather than "is". A malformed token
/// produces the same reason, and a diagnosis stated as fact would send
/// somebody to change a working configuration.
static func diagnose(status: UInt, reason: String, sandbox: Bool) -> String {
@@ -141,7 +141,7 @@ struct APNs: Sendable {
/// A silent wake still WAKES: `content-available` at priority 5 with no
/// alert, so a backgrounded app fetches the envelope and acts on it,
/// which is what makes a call answer arrive in time, and shows the
/// person nothing. Anything genuinely worth announcing, the app announces
/// person nothing. Anything worth announcing, the app announces
/// itself after decrypting, where it knows who sent it and what it is.
@discardableResult
func wake(token: String, badge: Int? = nil, silent: Bool = false) async -> Bool {
@@ -215,8 +215,8 @@ struct APNs: Sendable {
/// them at all. So this is sent ONLY for an envelope the sender marked
/// urgent, and the client reports a call the moment it arrives.
///
/// **Three things differ from an ordinary wake-up, and all three are
/// required.** The topic gains `.voip`; `apns-push-type` is `voip`; and the
/// Three things differ from an ordinary wake-up, and all three are
/// required. The topic gains `.voip`; `apns-push-type` is `voip`; and the
/// payload carries nothing to display, because a VoIP push is not shown,
/// CallKit draws the call UI from what the app reports after it decrypts.
///
+1 -1
View File
@@ -69,7 +69,7 @@ enum FNID {
///
/// - 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
/// relays, that is the 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
@@ -2,15 +2,15 @@ import Foundation
/// Checking that what is served is what was stored (relay#26).
///
/// **The relay cannot check what an attachment IS, only that it is still
/// the same size it was.** The blob is sealed before it arrives, so its content
/// The relay cannot check what an attachment IS, only that it is still
/// the same size it was. The blob is sealed before it arrives, so its content
/// is noise here and no amount of inspection would tell a photograph from a
/// probe. What the relay does know is the byte count it recorded at upload, and
/// a served blob whose length disagrees with that is not the file somebody
/// uploaded, whatever else it might be.
///
/// **This is not a substitute for the client's own check, and must not be
/// read as one.** The sender seals with AES-GCM and the recipient's tag check
/// This is not a substitute for the client's own check, and must not be
/// read as one. The sender seals with AES-GCM and the recipient's tag check
/// is what actually proves the bytes are unmodified, a relay could serve a
/// wrong blob of exactly the right length and only the recipient would catch
/// it. What a length check adds is a clear answer for the far commoner case:
@@ -46,7 +46,7 @@ enum AttachmentIntegrity {
///
/// Refuses on either mismatch. Serving a blob that is not what was
/// stored hands the client a decryption failure and no way to tell that
/// from a genuine one, and for the `longer` case, serving extra bytes the
/// from a one, and for the `longer` case, serving extra bytes the
/// uploader never sent is the worse of the two.
///
/// Serves when there is nothing to compare against. A row written
+3 -3
View File
@@ -3,8 +3,8 @@ import Vapor
/// Serving part of a blob instead of all of it (relay#37).
///
/// **Half of this already worked and no client could ever have discovered
/// it.** Vapor's `streamFile` reads the `Range` header itself and answers 206
/// Half of this already worked and no client could ever have discovered
/// it. Vapor's `streamFile` reads the `Range` header itself and answers 206
/// with a `Content-Range`, so the directory store has been capable of partial
/// responses the whole time, but nothing ever sent `Accept-Ranges: bytes`, and
/// a client that is not told ranges are available does not speculatively try
@@ -20,7 +20,7 @@ import Vapor
/// `bytes=900-` because it already holds 900 bytes; if the blob was replaced or
/// truncated, 400 tells it *your request was malformed*, which is not true and
/// gives it nothing to do. 416 with `Content-Range: bytes */<total>` tells it
/// the file is now a different length, which is the fact it needs in order to
/// the file is now a different length, which is the fact it needs to
/// start again from zero instead of retrying the same broken resume forever.
///
/// The privacy cost is real and is not the one the issue names. Filed as
+3 -3
View File
@@ -131,8 +131,8 @@ public enum CapacityPolicy {
case atCapacity = "at capacity"
}
/// **`ceiling` is a configured soft limit, not a measured hard one, and
/// nil when nobody has configured one.**
/// `ceiling` is a configured soft limit, not a measured hard one, and
/// nil when nobody has configured one.
///
/// A held poll does not pin a database connection, it borrows one from the
/// pool for a few milliseconds per cursor query, so the Postgres
@@ -228,7 +228,7 @@ public struct StatusReport: Sendable, Codable, Content {
/// `ok`, `draining`, or `degraded`.
///
/// Derived from the checks every time it is asked, never stored. There
/// is no operator switch that sets this green, and that is the point:
/// is no operator switch that sets this green:
/// switches get flipped to green during a calm week and stay there through
/// the outage. A page that can only report what it just measured cannot be
/// left stale by someone being busy.
+3 -3
View File
@@ -84,8 +84,8 @@ struct Config: Sendable {
/// it on is lines in a log.
/// Whether the iPhone app is publicly available yet.
///
/// **A switch, because the alternative is editing seven sentences under
/// time pressure on approval day** (relay#3). Seven clauses across
/// A switch, because the alternative is editing seven sentences under
/// time pressure on approval day (relay#3). Seven clauses across
/// `comparison.leaf` and `metadata.leaf` are written for "not approved
/// yet", and every one of them is ACCURATE while this is false. Flipping
/// them early would make the site claim availability that does not exist,
@@ -181,7 +181,7 @@ struct Config: Sendable {
///
/// Off by default, and inert without `vouchUntil`. Vouching makes
/// bulk sign-ups costly and gives every account a provenance; it also slows
/// genuine growth and creates an in-group. That is a reasonable trade for a
/// growth and creates an in-group. That is a reasonable trade for a
/// phase and a bad one for a policy, so this gate cannot be switched on
/// without also saying when it stops.
let vouchRequired: Bool
+4 -4
View File
@@ -3,8 +3,8 @@ import Vapor
/// Letting a deploy finish rather than cutting it off (relay#33).
///
/// **The problem is the long polls, and it is subtler than "requests get
/// killed".** A phone waiting for messages holds a connection open for up to
/// The problem is the long polls, and it is subtler than "requests get
/// killed". A phone waiting for messages holds a connection open for up to
/// half a minute. When a deploy recreates the container, every one of those is
/// severed at the socket, and from the client's side a severed socket is
/// indistinguishable from a network failure, so it backs off before retrying.
@@ -87,8 +87,8 @@ public enum DrainPolicy {
/// What `/health` answers, as a status code and a body.
///
/// **A relay that cannot reach its database is not healthy, and this
/// endpoint said it was.** On 2026-09-08 the disk filled, Postgres
/// A relay that cannot reach its database is not healthy, and this
/// endpoint said it was. On 2026-09-08 the disk filled, Postgres
/// crash-looped in recovery for hours, every request failed, and
/// `/health` returned `200 ok` throughout, because it read one in-memory
/// flag and nothing else. The outage was found by eye. (relay#60)
+6 -6
View File
@@ -1068,8 +1068,8 @@ func register(routes r: Routes, on app: Application) throws {
client: app.client, logger: app.logger)
app.logger.notice("APNs wake-ups enabled for \(topic) (\(sandbox ? "sandbox" : "production"))")
if sandbox {
// **A warning, not a notice, because sandbox is the DEFAULT
// and production is the deliberate state.** Shipping to
// A warning, not a notice, because sandbox is the DEFAULT
// and production is the deliberate state. Shipping to
// TestFlight without flipping this means every push is refused
// with BadDeviceToken and no iPhone ever rings, a total,
// silent failure of the feature people notice first. A `notice`
@@ -1451,8 +1451,8 @@ struct WireErrorMiddleware: AsyncMiddleware {
/// Marks the process as draining the moment shutdown begins (relay#33).
///
/// **Registered FIRST, so it runs before anything that tears services
/// down.** Vapor calls `shutdown` on handlers in reverse order of
/// Registered FIRST, so it runs before anything that tears services
/// down. Vapor calls `shutdown` on handlers in reverse order of
/// registration, and a drain flag set after the database pool has closed is a
/// flag nobody can act on, the long polls it is meant to release have already
/// failed on a dead connection.
@@ -1552,8 +1552,8 @@ struct LandingContext: Encodable {
/// The words the shared layout owns, navigation, the call to action and
/// the whole footer.
///
/// **A translated page with an English footer is not a translated
/// page.** `layout.leaf` is one template for every language, so these ~40
/// A translated page with an English footer is not a translated
/// page. `layout.leaf` is one template for every language, so these ~40
/// strings belong to the context or they stay in English forever. The
/// Spanish `/es/get` rendered with an entirely English nav until this
/// existed.
+4 -4
View File
@@ -4,8 +4,8 @@ import Vapor
/// What is still in the envelopes table after a sweep (relay#28).
///
/// **The sweep reported what it deleted and never what remained, which is
/// the number the question was actually about.** relay#28 was filed on
/// The sweep reported what it deleted and never what remained, which is
/// the number the question was actually about. relay#28 was filed on
/// "delivered envelopes stay in the same table serving live polls, forever",
/// and that is not true, because retention deletes them after a day. But
/// nobody could have known either way from the outside: the hourly log said how
@@ -17,8 +17,8 @@ import Vapor
/// whether the table is growing can now read it off one line an hour instead of
/// reasoning about it.
///
/// **Two counts and one indexed lookup, once an hour, off the request
/// path.** A `COUNT(*)` over a large table is a scan on Postgres, so this is
/// Two counts and one indexed lookup, once an hour, off the request
/// path. A `COUNT(*)` over a large table is a scan on Postgres, so this is
/// deliberately not something a route or the status page does, it runs inside
/// the retention sweeper, which is already the slowest thing the relay does on
/// purpose and is already leader-elected so only one replica pays it.
+2 -2
View File
@@ -192,8 +192,8 @@ actor PeerDirectory {
throw Federation.AuthError.unknownPeer(audience)
}
// **Resolve before fetching, and refuse anything not on the public
// internet** (relay#55). The spelling check above cannot see where a
// Resolve before fetching, and refuse anything not on the public
// internet (relay#55). The spelling check above cannot see where a
// name points, and `internal.attacker.example` resolving to
// `169.254.169.254` is a perfectly ordinary hostname.
//
+3 -3
View File
@@ -13,8 +13,8 @@ import Vapor
/// 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.
/// 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.
///
@@ -31,7 +31,7 @@ import Vapor
///
/// 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
/// and that older list is 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.
+5 -6
View File
@@ -3,12 +3,12 @@ import Vapor
/// Running more than one relay against one database (relay#31).
///
/// **The claim under test was "horizontal scaling needs no protocol
/// The claim under test was "horizontal scaling needs no protocol
/// change", and it is true, with one silent condition nobody had written
/// down.** Wake tokens are per device in Redis, envelopes are rows, and the key
/// down. Wake tokens are per device in Redis, envelopes are rows, and the key
/// log is a table, so any instance really can serve any waiter. What does *not*
/// survive being split across processes is everything the relay keeps **in
/// memory**, and two of those are not caches. They are the only thing standing
/// survive being split across processes is everything the relay keeps in
/// memory, and two of those are not caches. They are the only thing standing
/// between an attacker and unlimited attempts:
///
/// - Replay protection. A proof-of-possession signature is single-use, and
@@ -29,8 +29,7 @@ import Vapor
///
/// So the guard is narrow: declaring replicas without a shared store is refused
/// at boot, naming the two properties that would silently stop holding. It is
/// opt-in by declaration rather than detected, because a process genuinely
/// cannot tell how many of itself are running, and a check that guessed would
/// opt-in by declaration rather than detected, because a process /// cannot tell how many of itself are running, and a check that guessed would
/// either refuse a single instance or miss the case it exists for.
enum Horizontal {
+2 -2
View File
@@ -119,8 +119,8 @@ enum KeyLog {
/// 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.
/// **The `fnid:` form once the account has one, and that is what the
/// shipped client expects.** The Android build in the field resolves every
/// The `fnid:` form once the account has one, and that is what the
/// shipped client expects. The Android build in the field resolves every
/// contact's address whenever it loads the buddy list, pins it, and then
/// recomputes leaves under `fnid:`, so a relay that logged `local:` for a
/// claimed account would be refused by exactly the clients that had done
+2 -2
View File
@@ -74,8 +74,8 @@ enum MerkleLog {
/// RFC 6962 specifies, as `inclusionProof` emits, and as the iOS and
/// Android verifiers already read it.
///
/// **This read it top-down and was wrong for 15 of the 36 leaf
/// positions in a tree of eight.** It verified fine for index 0 at every
/// This read it top-down and was wrong for 15 of the 36 leaf
/// positions in a tree of eight. It verified fine for index 0 at every
/// size, because the left spine is the one path where the two orderings
/// coincide, so any test that checked "does a proof verify?" with a single
/// leaf would have passed. Nothing called it, in production or in tests,
+3 -3
View File
@@ -105,8 +105,8 @@ enum ChosenStatus: String, Codable, CaseIterable {
/// Parse what a client sent, accepting the names clients actually use.
///
/// **`offline` is an alias for `invisible`, and that is a bug fix rather
/// than a courtesy.** The Android app has always offered an `offline` chip;
/// `offline` is an alias for `invisible`, and that is a bug fix rather
/// than a courtesy. The Android app has always offered an `offline` chip;
/// `ChosenStatus(rawValue:)` returned nil for it, the route's `if let`
/// quietly skipped the write, and the call still answered 200. So a user
/// who chose "offline" was told it worked, stayed visible to every contact,
@@ -147,7 +147,7 @@ extension ChosenStatus {
}
}
/// What a viewer sees. There is no `invisible` here on purpose: the whole point
/// What a viewer sees. There is no `invisible` here on purpose: the point
/// is that the choice is not observable.
///
/// `busy` is newer than some deployed clients. An older one parses an unknown
+7 -7
View File
@@ -331,8 +331,8 @@ struct AddVoipToken: AsyncMigration {
}
/// Everyone already in somebody's contacts counts as accepted.
///
/// **Without this, the feature it supports would libel every existing
/// conversation.** `accepted` is derived from `conversation_state.accepted_at`,
/// Without this, the feature it supports would libel every existing
/// conversation. `accepted` is derived from `conversation_state.accepted_at`,
/// a column that has existed since the port and that nothing ever wrote, so
/// every row in the database has it NULL, including contacts people chose and
/// have been talking to for weeks. Shipping the label without a backfill would
@@ -343,14 +343,14 @@ struct AddVoipToken: AsyncMigration {
/// Backfills from the BUDDY list rather than from message history: being in
/// someone's contacts is the durable statement, and it is the one that predates
/// auto-adoption. Anyone auto-added from here on is added with `auto: true` and
/// is deliberately left unaccepted, which is the whole point.
/// is deliberately left unaccepted.
///
/// Insert-or-update rather than a blanket INSERT: `conversation_state` rows
/// already exist wherever anyone starred, archived, cleared or set a timer.
struct AcceptExistingContacts: AsyncMigration {
/// Fluent, not raw SQL, and that is not a style choice. The first
/// version of this used `NOW()`, which Postgres has and **SQLite does
/// not**, so prod would have migrated cleanly while every self-hosted
/// version of this used `NOW()`, which Postgres has and SQLite does
/// not, so prod would have migrated cleanly while every self-hosted
/// relay on the default database refused to boot at all. The conformance
/// suite runs on SQLite and caught it; nothing in a Postgres-only test
/// would have.
@@ -602,8 +602,8 @@ struct AddUsernameSkeleton: AsyncMigration {
/// Give every device already registered a leaf in the transparency log.
///
/// **Without this, turning on automatic verification strands the existing
/// fleet.** The log started being written when §19 shipped, so it records only
/// Without this, turning on automatic verification strands the existing
/// fleet. The log started being written when §19 shipped, so it records only
/// devices registered after that date. A client that now checks every key it is
/// served would find nothing for anybody older and refuse to message them, the
/// protection would land as an outage, which is the surest way to have it
+4 -4
View File
@@ -170,7 +170,7 @@ final class Group: Model, @unchecked Sendable {
@Field(key: "roster_blob") var rosterBlob: String?
/// Monotonic, and the reason is replay. Without it a captured older
/// roster could be posted back to re-add somebody who had been removed,
/// the signature on it is still valid, because it was genuinely signed.
/// the signature on it is still valid, because it was signed.
@Field(key: "roster_version") var rosterVersion: Int?
@Timestamp(key: "created_at", on: .create) var createdAt: Date?
@@ -200,7 +200,7 @@ final class Group: Model, @unchecked Sendable {
/// One member's opaque claim on a group.
///
/// A random token, not a user id, and that is the whole point. The
/// A random token, not a user id. The
/// membership rows used to map `gid -> user_id`, which is a readable social
/// graph sitting in a table: seven rows on production said exactly who was in
/// which group. A tag says "somebody holding this may read this group's roster"
@@ -297,8 +297,8 @@ final class DeliveryKey: Model, @unchecked Sendable {
/// messages from one sender are no more linkable than messages from different
/// ones.
///
/// **This is not the KVAC construction of §21.3 and does not claim its
/// properties.** A relay that records when each row was inserted can group a
/// This is not the KVAC construction of §21.3 and does not claim its
/// properties. A relay that records when each row was inserted can group a
/// batch, and a batch is one sender. What it buys over the shared key of §14.4
/// is per-contact revocation and per-message unlinkability; what it does not
/// buy is unlinkability against a relay that keeps insertion metadata.
@@ -9,8 +9,8 @@ import Darwin
/// An IP address, as bytes rather than as text.
///
/// **Bytes, because every string-matching version of this check has a hole
/// in it.** `127.0.0.1` is not the only spelling of loopback: `127.1`,
/// Bytes, because every string-matching version of this check has a hole
/// in it. `127.0.0.1` is not the only spelling of loopback: `127.1`,
/// `0177.0.0.1`, `2130706433` and `::ffff:127.0.0.1` all reach the same place,
/// and a prefix test on the presentation form waves at least one of them
/// through. Parsing with `inet_pton` and comparing numerically removes the
+2 -2
View File
@@ -3,8 +3,8 @@ import Vapor
/// Noticing when a device's one-time prekeys run out (relay#35).
///
/// **An empty pool is a silent security downgrade, not an inventory
/// problem.** X3DH uses a one-time prekey so that the first message of a
/// An empty pool is a silent security downgrade, not an inventory
/// problem. X3DH uses a one-time prekey so that the first message of a
/// session has forward secrecy even against a later compromise of the signed
/// prekey. With the pool drained the handshake still succeeds, it falls back
/// to the signed prekey, and nothing anywhere says so. Both parties believe
+8 -8
View File
@@ -3,8 +3,8 @@ import Vapor
/// What changed, in the words of somebody using the app.
///
/// **One page, written for people, with the operator's part at the bottom of
/// each release rather than on a page of its own.** The audience for this site
/// One page, written for people, with the operator's part at the bottom of
/// each release rather than on a page of its own. The audience for this site
/// is somebody deciding whether to install a messenger; a self-hoster is the
/// smaller group and will read further. Splitting them makes the user-facing
/// page thinner and the operator page nobody's job to update.
@@ -18,8 +18,8 @@ struct Release: Codable, Content {
let date: String
/// Which build of which app this release is.
///
/// **Per-platform versions, because one release is several artifacts
/// with numbers of their own.** A single `version` on the release was wrong
/// Per-platform versions, because one release is several artifacts
/// with numbers of their own. A single `version` on the release was wrong
/// the day it was written: this repository called a release "0.9" while the
/// Android build in it was 0.10. A reader checking whether they have a fix
/// compares the number in their app against this page, so a number that
@@ -47,8 +47,8 @@ struct Release: Codable, Content {
self.selfHosting = selfHosting
}
/// **Written out because Swift's synthesized decoding ignores default
/// values.** `selfHosting` reads as optional and is documented as optional,
/// Written out because Swift's synthesized decoding ignores default
/// values. `selfHosting` reads as optional and is documented as optional,
/// but with the synthesized initialiser a release that omitted it threw,
/// and `all(in:)` fails open, so ONE release without operator notes emptied
/// the entire page rather than losing one section. Most releases have no
@@ -107,8 +107,8 @@ struct Release: Codable, Content {
/// What the page prints before the note.
///
/// **A STORED property, derived on decode, and that is not a style
/// choice.** Leaf renders the ENCODED object, and Swift does not encode
/// A STORED property, derived on decode, and that is not a style
/// choice. Leaf renders the ENCODED object, and Swift does not encode
/// computed properties, so as a `var label: String { ... }` this
/// rendered as nothing at all, silently stripping "New" / "Fixed" /
/// "Changed" from every note in public. Unit tests that read `.label`
+1 -1
View File
@@ -6,7 +6,7 @@ import Redis
/// Deletes what the retention windows say should be gone, on a loop, and says
/// so at a level someone can alert on.
///
/// `.notice`, not `.info`, and that is the whole point. This was logged
/// `.notice`, not `.info`. This was logged
/// at `.info` while production runs `FN_LOG_LEVEL=error`, so the line the
/// comment calls "a level someone can alert on" never appeared: the relay had
/// emitted exactly two log lines since its last restart, both of them Vapor's
+8 -8
View File
@@ -125,8 +125,8 @@ struct Routes: Sendable {
throw AuthError.deviceMismatch
}
} else if try await KeyLog.isRetired(deviceID: caller.deviceID, on: request.db) {
// **A revoked device does not get to finish its token's
// lifetime (audit M2).** With the row gone the binding check
// A revoked device does not get to finish its token's
// lifetime (audit M2). With the row gone the binding check
// above cannot run, so without this a token minted moments
// before a revocation kept authenticating, and could
// re-register the same device id, resurrecting a device the
@@ -165,8 +165,8 @@ struct Routes: Sendable {
/// Record that this account is alive, coarsened to the online window.
///
/// **The §8 mount had no heartbeat at all, and it is where the app
/// actually lives.** `MobileRoutes.caller` has done this since presence was
/// The §8 mount had no heartbeat at all, and it is where the app
/// actually lives. `MobileRoutes.caller` has done this since presence was
/// written, so liveness was recorded only by the legacy `/messenger/*`
/// routes, a buddy-list load, a status change. The client's real sign of
/// life is the long-poll on `/e2e/messages`, which authenticates here and
@@ -609,8 +609,8 @@ struct Routes: Sendable {
guard isValidDeviceID(fromDevice) else {
throw WireError.badRequest("Malformed from_device.")
}
// **Every other device-scoped route checks this and send did
// not.** registerDevice, poll, replenish and pushToken all refuse a
// Every other device-scoped route checks this and send did
// not. registerDevice, poll, replenish and pushToken all refuse a
// device id the token is not scoped to; here the field was only
// format-checked, so a caller could stamp an envelope with any
// device id, another of their own, or a stranger's.
@@ -873,8 +873,8 @@ struct Routes: Sendable {
try? await device.save(on: request.db)
}
// **An advancing cursor is an acknowledgement, so drop what it has
// acknowledged.** A client only moves `after` past a message it has
// An advancing cursor is an acknowledgement, so drop what it has
// acknowledged. A client only moves `after` past a message it has
// received, decrypted and stored, so everything at or below it is
// provably delivered and the relay has no reason to keep holding the
// ciphertext until the retention sweep gets to it.
+9 -10
View File
@@ -19,8 +19,8 @@ struct AccountRoutes: Sendable {
let signupMaxPerHour: Int
/// New accounts per NETWORK per hour, a /24 for IPv4, a /48 for IPv6.
///
/// **The per-address limit above counts a rented subnet as separate
/// strangers.** Fifty accounts from one address is refused; fifty each from
/// The per-address limit above counts a rented subnet as separate
/// strangers. Fifty accounts from one address is refused; fifty each from
/// two hundred addresses in one /24 is ten thousand accounts and looks, one
/// request at a time, exactly like a busy afternoon. The cheapest thing an
/// abuser buys is more addresses, so a limit keyed on a single address is
@@ -63,8 +63,7 @@ struct AccountRoutes: Sendable {
/// sign-up for every build already in somebody's hand, and it would have
/// presented as the app not working, with nothing the person could do. The
/// APK in the field is Android 0.6, released 2026-08-31; the client-side
/// solver landed 2026-09-01, one day later. So the shipped floor genuinely
/// cannot answer a challenge today, and "just turn it on" was never
/// solver landed 2026-09-01, one day later. So the shipped floor /// cannot answer a challenge today, and "just turn it on" was never
/// available.
///
/// The opt-out costs something, which is the whole design. A client
@@ -153,8 +152,8 @@ struct AccountRoutes: Sendable {
/// is already an env var and this one was not, which is the whole of the
/// mistake. `FN_SIGNUP_MAX_PER_HOUR`.
///
/// **Raised from 5 to 50 on 2026-08-30, because 5 locks out real
/// first-time users.** The key is the IP and nothing else, and on the real
/// Raised from 5 to 50 on 2026-08-30, because 5 locks out real
/// first-time users. The key is the IP and nothing else, and on the real
/// internet an IP is not a person: mobile carriers put thousands of
/// subscribers behind one CGNAT address, and an office, a campus or a cafe
/// is one address too. At five, the sixth person on a shared connection was
@@ -440,8 +439,8 @@ struct AccountRoutes: Sendable {
throw WireError("username_taken",
"Somebody already uses that name on this server. Pick another.", 409)
}
// **The same look-alike check as `signup`, and it has to be here
// too.** This path creates accounts for people authenticated
// The same look-alike check as `signup`, and it has to be here
// too. This path creates accounts for people authenticated
// elsewhere, so a check that lived only on the password path would be
// one an impersonator simply walks around.
let skeleton = User.confusableSkeleton(username)
@@ -648,8 +647,8 @@ struct AccountRoutes: Sendable {
throw WireError.unauthorized("Credential not accepted.")
}
// **The one gate that covers everything, and the only place it can
// go.** "Verify before you can send" cannot be implemented on the send
// The one gate that covers everything, and the only place it can
// go. "Verify before you can send" cannot be implemented on the send
// path: a sealed send is authorised by a delivery key and deliberately
// records NO sender (§14), so the relay does not know whose account to
// check. Gating the send would either break sealed sending or only
@@ -36,8 +36,8 @@ import Vapor
///
/// ## What it is not
///
/// **This is not the KVAC construction of §21.3 and must not be described as
/// it.** Two residual leaks, both stated rather than implied away:
/// This is not the KVAC construction of §21.3 and must not be described as
/// it. Two residual leaks, both stated rather than implied away:
///
/// 1. A relay that records *when* rows were inserted can group a batch, and a
/// batch is one sender. Nothing here prevents that; it requires the relay to
+1 -1
View File
@@ -12,7 +12,7 @@ import Vapor
/// 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.
/// moment somebody forks this, which is the 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
+1 -1
View File
@@ -30,7 +30,7 @@ struct DonateRoutes: Sendable {
/// then a customer, then a subscription, three writes on the real account
/// per call. Without a limit a script leaves the operator with a Stripe
/// account full of orphaned customers and incomplete subscriptions, burns
/// the API rate budget that genuine donors need, and does it for free.
/// the API rate budget that donors need, and does it for free.
let limits: any RateLimiting
/// Generous for a person, useless for a script. Somebody correcting a typo
+2 -2
View File
@@ -6,7 +6,7 @@ import Vapor
///
/// 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.
/// correspondent needs to address you.
extension Routes {
// MARK: - POST /e2e/account/identity
@@ -179,7 +179,7 @@ extension Routes {
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
// list is 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(
+3 -3
View File
@@ -57,7 +57,7 @@ extension Routes {
}
// Defaults to the current head, but an older size is accepted so a
// client can verify against a head it already holds rather than one it
// is being handed now, which is the whole point of pinning a head.
// is being handed now, which is the point of pinning a head.
let size = request.query[Int.self, at: "size"] ?? leaves.count
guard size > 0, size <= leaves.count else {
throw WireError.badRequest("size is beyond the log.")
@@ -148,8 +148,8 @@ extension Routes {
/// that contains a slash, a path route would silently 404 for exactly the
/// accounts least able to notice.
///
/// **Unauthenticated, like the rest of §19, and that has a cost worth
/// naming.** Anyone holding somebody's fnid, which is the address they
/// Unauthenticated, like the rest of §19, and that has a cost worth
/// naming. Anyone holding somebody's fnid, which is the address they
/// hand to contacts, can see how many devices they have published and
/// when. That is inherent to key transparency rather than a slip: a
/// substitution cannot be detected in a log nobody may read. It is stated
+3 -3
View File
@@ -126,7 +126,7 @@ struct MessagingRoutes: Sendable {
.filter(\.$userID == subject).filter(\.$buddyID == viewer).first() != nil
guard visible else { return .offline }
}
// Invisible reads as offline to everyone, which is the entire point of
// Invisible reads as offline to everyone, which is the point of
// choosing it, including here, before any freshness check.
guard let chosen = ChosenStatus(rawValue: user.chosenStatus), chosen != .invisible else {
return .offline
@@ -157,8 +157,8 @@ struct MessagingRoutes: Sendable {
/// beat landing late in a bucket is recorded as though it happened at the
/// START of that bucket, so its apparent age is up to 299 seconds more than
/// its real one. The five-minute tolerance then collapsed toward zero as
/// each bucket filled, and at the moment a bucket rolled over, **somebody
/// who had checked in one second earlier read as offline**, to everyone,
/// each bucket filled, and at the moment a bucket rolled over, somebody
/// who had checked in one second earlier read as offline, to everyone,
/// at once, every five minutes. It looked like presence randomly breaking
/// because from the outside that is exactly what it was.
///
+2 -2
View File
@@ -5,8 +5,8 @@ import Vapor
/// A report the operator can actually act on (spec §6.25).
///
/// **This row is the one place the operator can read user content, and it
/// is here because a reporter chose to put it here.** The relay stores
/// This row is the one place the operator can read user content, and it
/// is here because a reporter chose to put it here. The relay stores
/// ciphertext it cannot open, so it cannot examine a reported story on its own
/// account. A report carrying only an accusation would give an operator nothing
/// but the assertion, which makes moderation either credulous or useless, so
@@ -5,8 +5,8 @@ import Vapor
/// §8.9b, group rosters the relay cannot read.
///
/// **The relay does not route group messages, and that is what makes this
/// possible.** Fan-out is client-side and pairwise (§6.7), so the roster was
/// The relay does not route group messages, and that is what makes this
/// possible. Fan-out is client-side and pairwise (§6.7), so the roster was
/// never a routing table the server had to read, it was a document the members
/// happened to keep here. Nothing below parses a member list; the blob is
/// opaque and the access control is a random token.
@@ -159,7 +159,7 @@ extension Routes {
// Strictly greater, not merely different. A roster is signed, so
// an OLD one stays valid for ever, replaying it would re-add somebody
// the owner had just removed, with a signature that genuinely verifies.
// the owner had just removed, with a signature that verifies.
guard input.roster_version > (group.rosterVersion ?? 0) else {
throw WireError.badRequest("Roster version must increase.")
}
+2 -2
View File
@@ -9,8 +9,8 @@ import Vapor
///
/// - Poll the database on an interval. What the relay does without Redis.
/// Correct, and costs a query per waiter per second.
/// - Postgres `LISTEN`. Wakes instantly and **pins a backend connection per
/// waiter**. `max_connections` on this database is 100, so it caps
/// - Postgres `LISTEN`. Wakes instantly and pins a backend connection per
/// waiter. `max_connections` on this database is 100, so it caps
/// simultaneous waiters at about 86, not a distant ceiling, roughly 86
/// people with the app open.
/// - Redis `BRPOP`. Thousands of blocked clients on one process, and the
+2 -2
View File
@@ -11,8 +11,8 @@ import Vapor
/// couple of seconds once and costs a farm of registrations real money, which is
/// exactly the asymmetry wanted.
///
/// **It is a tax on the slowest phone, and that is the whole difficulty
/// question.** Tuned for the average device it is invisible to the average
/// It is a tax on the slowest phone, and that is the whole difficulty
/// question. Tuned for the average device it is invisible to the average
/// person and punishing on an old one, so the number is a deployment choice
/// with a conservative default, not a constant to be raised because a laptop
/// found it easy.
+2 -2
View File
@@ -10,7 +10,7 @@ import Vapor
/// somebody who already has one, which makes bulk sign-ups costly and gives
/// every account a provenance.
///
/// It also slows genuine growth and creates an in-group, which is why
/// It also slows growth and creates an in-group, which is why
/// the ticket calls for a phase with a stated end date rather than a permanent
/// policy, and why `Config.vouchUntil` is not optional when vouching is on.
/// A gate with no expiry is one somebody has to remember to remove, and nobody
@@ -35,7 +35,7 @@ final class VouchCode: Model, @unchecked Sendable {
/// mechanism defeated. Worse, a sign-up that failed after redeeming left a
/// code that looked unspent for ever.
///
/// Kept rather than deleted on use: the provenance is the entire point
/// Kept rather than deleted on use: the provenance is the point
/// of the scheme, and a row removed on redemption throws away the only
/// record of who vouched for whom.
@OptionalField(key: "used_by") var usedBy: Int?
+6 -6
View File
@@ -106,7 +106,7 @@ struct SendRemoteResponse: Content {
/// 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
/// 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
@@ -206,16 +206,16 @@ struct SendRequest: Decodable {
let delivery_token: String?
/// Time-critical: deliver with a VoIP push where the platform has one (§6.14).
///
/// **This is the one thing a sender tells the relay about the CONTENT of
/// an envelope**, and it exists only because a locked iPhone cannot be rung
/// This is the one thing a sender tells the relay about the CONTENT of
/// an envelope, and it exists only because a locked iPhone cannot be rung
/// any other way. The relay learns that this envelope is time-critical,
/// in practice, "probably a call". Advisory and unauthenticated; absent
/// means false.
let urgent: Bool?
/// Deliver without announcing: protocol traffic, not a message (§6.14).
///
/// **The second and last thing a sender tells the relay about content,
/// and it exists because the first one was not enough.** Call answers, call
/// The second and last thing a sender tells the relay about content,
/// and it exists because the first one was not enough. Call answers, call
/// ends, delivery keys and key-log gossip are envelopes like any other, so
/// every one of them raised "New message", one call produced a burst of
/// banners for things nobody sent, and a locked phone lit up to report mail
@@ -405,7 +405,7 @@ struct SealGroupRequest: Decodable {
/// §8.9b, "give me the groups these tags open".
///
/// The client asks by TAG, never "what groups am I in". The relay cannot
/// answer the second question about a sealed group, which is the point.
/// answer the second question about a sealed group.
struct FetchGroupsRequest: Decodable {
let tags: [String]
}
+3 -3
View File
@@ -13,8 +13,8 @@ import Foundation
///
/// A witness is an independent party that remembers what it has already signed.
/// Ask it to co-sign a head of size 100 with root A and it will; ask it later
/// to co-sign size 100 with root B and an honest witness **refuses, because it
/// has already made a statement about size 100**. So a relay wanting an
/// to co-sign size 100 with root B and an honest witness refuses, because it
/// has already made a statement about size 100. So a relay wanting an
/// undetected fork now needs the witness's cooperation as well as its own key.
///
/// What this does not do, said plainly. It does not prevent a fork. It
@@ -52,7 +52,7 @@ public enum Witness {
public enum Decision: Equatable {
case sign
/// A different root at a size already signed. This is the fork, and
/// refusing is the entire point of the witness existing.
/// refusing is the point of the witness existing.
case refuseFork(previous: String, offered: String, size: Int)
/// A head SMALLER than one already signed. An append-only log cannot
/// shrink, so this is either a rollback or a different log wearing the
@@ -31,7 +31,7 @@ struct APNsEnvironmentTests {
#expect(message.contains("production host"))
}
/// Says "almost certainly", never "is". A genuinely malformed token
/// Says "almost certainly", never "is". A malformed token
/// produces the same reason, and a diagnosis stated as fact would send
/// somebody to change a configuration that was right.
@Test("the diagnosis is hedged, because APNs does not actually say")
@@ -3,8 +3,8 @@ import XCTest
/// Checking that what is served is what was stored (relay#26).
///
/// **The relay cannot check what an attachment IS, only that it is still
/// the same size.** The blob is sealed before it arrives, so its content is
/// The relay cannot check what an attachment IS, only that it is still
/// the same size. The blob is sealed before it arrives, so its content is
/// noise here. What the relay knows is the byte count it recorded at upload,
/// and a blob of a different length is not the file somebody uploaded.
///
@@ -183,7 +183,7 @@ struct AttachmentRangeTests {
}
}
/// 416 rather than Vapor's 400, and the difference is the whole point.
/// 416 rather than Vapor's 400, and the difference is the point.
/// A phone resuming a download asks for `bytes=900-` because it holds 900
/// bytes; if the blob is now shorter, "your request was malformed" is untrue
/// and leaves it retrying the same broken resume. The 416 carries the real
@@ -218,8 +218,8 @@ struct AttachmentRangeTests {
}
}
/// **The integrity check (relay#26) still runs, and a range must not
/// slip past it.** The check compares the bytes on disk against the length
/// The integrity check (relay#26) still runs, and a range must not
/// slip past it. The check compares the bytes on disk against the length
/// recorded at upload, a truncated blob is refused whole, and it has to be
/// refused in pieces too, or ranges would become the way around it.
@Test("a corrupted blob is refused for a range exactly as it is refused whole")
@@ -9,8 +9,8 @@ import Vapor
/// Giving every already-registered device a leaf in the transparency log.
///
/// **Without this, turning on automatic key checking strands the existing
/// fleet.** The log started being written when §19 shipped, so it records only
/// Without this, turning on automatic key checking strands the existing
/// fleet. The log started being written when §19 shipped, so it records only
/// devices registered after that date. A client that now checks every key it is
/// served finds nothing for anybody older and refuses to message them, the
/// protection lands as an outage, which is the surest way to have a security
@@ -86,8 +86,8 @@ struct BackfillKeyLogTests {
#expect(leaves.allSatisfy { $0.kind == "publish" })
}
/// **A device belonging to somebody who has already claimed an address
/// is logged under that address**, because that is what the shipped client
/// A device belonging to somebody who has already claimed an address
/// is logged under that address, because that is what the shipped client
/// recomputes: it resolves every contact's address when it loads the buddy
/// list, pins it, and derives the leaf from it. Backfilling under `local:`
/// would produce a leaf the most careful clients cannot match.
@@ -298,8 +298,8 @@ struct ClaimLogsExistingDevicesTests {
}
}
/// **Through the ROUTE, because the wiring is the part that was
/// missing.** The three above exercise `logExistingDevices` directly and
/// Through the ROUTE, because the wiring is the part that was
/// missing. The three above exercise `logExistingDevices` directly and
/// pass whether or not anything calls it, which is exactly how a fix gets
/// believed without being connected. This one claims an address the way a
/// client does and asserts the leaf exists afterwards.
+4 -4
View File
@@ -7,8 +7,8 @@ import Vapor
/// Publishing what this relay is carrying, honestly (relay#34).
///
/// **The failure this suite is really about is a status page that lies while
/// every individual line of it is defensible.** A gauge that leaks upward on
/// The failure this suite is really about is a status page that lies while
/// every individual line of it is defensible. A gauge that leaks upward on
/// cancelled polls, a green tick for a dependency nobody probed, a state field
/// that outranks an outage with a routine deploy, none of those look like
/// dishonesty in the diff. All of them produce a page that says the wrong thing
@@ -308,8 +308,8 @@ struct HeldConnections {
/// on this path or the published figure climbs forever while real
/// concurrency stays flat.
///
/// **This one does not discriminate between `defer` and a trailing
/// `leave()`, and saying so matters.** Cancellation leaves the wait by
/// This one does not discriminate between `defer` and a trailing
/// `leave()`, and saying so matters. Cancellation leaves the wait by
/// `break`ing the loop rather than by throwing, so both spellings balance
/// here and this passes either way, checked by removing the `defer` and
/// watching it stay green. It earns its place as coverage of the common
@@ -8,8 +8,8 @@ import Vapor
/// Reporting content (spec §6.25).
///
/// **A report is the one place the operator can read user content, and it is
/// readable because the reporter chose to send it.** The relay holds ciphertext
/// A report is the one place the operator can read user content, and it is
/// readable because the reporter chose to send it. The relay holds ciphertext
/// it cannot open, so a report carrying only an accusation would give an
/// operator nothing but the assertion. Everything here is about that row being
/// exactly what the reporter meant to file, and nothing more.
@@ -9,8 +9,8 @@ import VaporTesting
/// The seven things that must never stop working.
///
/// **This suite exists because the unit tests stayed green through a
/// two-day outage.** On 2 September twenty-eight merges left sign-in,
/// This suite exists because the unit tests stayed green through a
/// two-day outage. On 2 September twenty-eight merges left sign-in,
/// messages, calls and stories broken on both clients while 489 Android unit
/// tests and the relay's own suite reported success throughout, because they
/// test units. "assemble produces this JSON" is a unit test; "an account can
@@ -286,8 +286,8 @@ struct CorePaths {
}
}
/// **The wallet does not touch the relay, and this test exists to keep
/// that true.** Keys are derived on the device from a BIP-39 phrase and
/// The wallet does not touch the relay, and this test exists to keep
/// that true. Keys are derived on the device from a BIP-39 phrase and
/// balances come from a Solana node the person chooses, nothing in this
/// server is on that path. So the guarantee worth pinning is a negative
/// one: no backend change can break the wallet, because there is no
@@ -308,7 +308,7 @@ struct CorePaths {
}
/// Call signalling rides the ratchet as ordinary content envelopes, so the
/// relay cannot tell an offer from a photo, which is the point. What it
/// relay cannot tell an offer from a photo. What it
/// can break is the envelope path and the TURN credential route.
@Test("calls still signal, and TURN answers honestly")
func calls() async throws {
+8 -8
View File
@@ -56,8 +56,8 @@ struct Cost {
/// Counts every database round trip a route makes, by turning Fluent's own
/// query history on for each request and adding up what it recorded.
///
/// **`app.fluent.history` does not see route queries, and that trap ate
/// the first version of this suite.** Handlers use `req.db`, and `req.db`
/// `app.fluent.history` does not see route queries, and that trap ate
/// the first version of this suite. Handlers use `req.db`, and `req.db`
/// reads the history flag out of the *request's* storage, which is fresh
/// per request, so enabling history on the application records nothing at
/// all. The measurement came back as zero queries for every flow and every
@@ -285,8 +285,8 @@ struct Cost {
}
}
/// **A finding for relay#27, which is filed on a premise this
/// contradicts.** That issue says a group message is "sent once per member
/// A finding for relay#27, which is filed on a premise this
/// contradicts. That issue says a group message is "sent once per member
/// per device, from the phone, a ten-person group with two devices each is
/// twenty uploads of the same blob".
///
@@ -431,8 +431,8 @@ struct Cost {
/// index-and-disk argument rather than a round-trip one, a real argument,
/// and a smaller one than the issue assumes.
///
/// **Both samples are taken with history already behind them, and the
/// reason is a fault this test found in its own first version.** Comparing
/// Both samples are taken with history already behind them, and the
/// reason is a fault this test found in its own first version. Comparing
/// a cold relay against a warm one showed ELEVEN queries cold and TEN warm
/// fewer with more history, which is not a thing storage growth can do.
/// The breakdown named it: the presence heartbeat (`noteAlive`) writes
@@ -485,8 +485,8 @@ struct Cost {
}
}
/// **Round trips are flat against history; BYTES are not, and the client
/// decides which.** A poll with no `after` asks for everything from zero
/// Round trips are flat against history; BYTES are not, and the client
/// decides which. A poll with no `after` asks for everything from zero
/// and gets a page of up to a hundred retained envelopes, 36 KB behind a
/// hundred and twenty messages, against 700 bytes for the one that is
/// actually new. Carrying the cursor collapses it back.
@@ -148,7 +148,7 @@ struct DeliveryTokenTests {
}
}
// MARK: - Revocation, which is the point
// MARK: - Revocation
/// The problem §21 exists to solve. With the shared key of §14.4,
/// cutting one sender off means rotating for everybody and redistributing
@@ -13,13 +13,13 @@ import Vapor
/// least a payment intent, and a monthly one creates a price, then a customer,
/// then a subscription, three writes on the real account per call. Without a
/// limit a script leaves the operator with a Stripe account full of orphaned
/// customers and incomplete subscriptions, burns the API budget genuine donors
/// customers and incomplete subscriptions, burns the API budget donors
/// need, and does it for free.
///
/// It was 0% covered, including the limit itself, which was added during the
/// August audit.
///
/// Everything asserted here happens BEFORE any network call, which is the point:
/// Everything asserted here happens BEFORE any network call:
/// a request that will be refused must be refused without touching Stripe.
@Suite("donate")
struct DonateRoutesTests {
@@ -378,8 +378,8 @@ struct E2ERoutesTests {
}
}
/// **The same thing, through the door people actually use, and this is
/// the one that was broken.**
/// The same thing, through the door people actually use, and this is
/// the one that was broken.
///
/// The test above deletes the device ROW and gets its 410. A real
/// revocation goes through `revokeDevice`, which also appends a `revoke`
@@ -8,8 +8,8 @@ import Vapor
/// Proving somebody can read the address they signed up with.
///
/// **The gate is on the TOKEN route, and that is a design decision rather
/// than a convenience.** "Verify before you can send" cannot be implemented on
/// The gate is on the TOKEN route, and that is a design decision rather
/// than a convenience. "Verify before you can send" cannot be implemented on
/// the send path: a sealed send is authorised by a delivery key and records no
/// sender at all (§14), so the relay does not know whose account to check.
/// Gating there would either break sealed sending or catch only the unsealed
@@ -14,8 +14,8 @@ import VaporTesting
/// and the half that catches a route mounted at a path the client never calls
/// or behind a middleware that rejects it.
///
/// **Nested inside `EndToEndTests` so it inherits that suite's
/// `.serialized` trait.** Marking this suite serialized on its own is not
/// Nested inside `EndToEndTests` so it inherits that suite's
/// `.serialized` trait. Marking this suite serialized on its own is not
/// enough: two top-level suites still run in PARALLEL with each other, and both
/// set FN_DB in the process environment, so one suite's relay booted against
/// the other's database and failed with "table devices already exists" and
@@ -323,8 +323,8 @@ struct Journey {
#expect(res.body.string.contains("\"screen_name\":\"bob\""))
}
// **The plaintext send is gone, and this journey proves it over
// HTTP rather than by reading the router.** A well-formed, fully
// The plaintext send is gone, and this journey proves it over
// HTTP rather than by reading the router. A well-formed, fully
// authenticated request that would have stored a readable message
// now finds nothing there (relay#50).
try await app.testing().test(
@@ -8,8 +8,8 @@ import Vapor
/// Cold storage for delivered envelopes (relay#28), the spike.
///
/// **The issue is filed on "delivered envelopes stay in the same table
/// serving live polls, forever", and they do not.** Retention deletes them
/// The issue is filed on "delivered envelopes stay in the same table
/// serving live polls, forever", and they do not. Retention deletes them
/// after `FN_DELIVERED_RETENTION_DAYS`, which defaults to one day, on an
/// hourly sweep. What the table holds is not a growing archive; it is about a
/// day of collected mail plus whatever is still uncollected.
@@ -59,8 +59,8 @@ struct EnvelopeBacklogTests {
}
}
/// **Uncollected mail is NOT waste, and cold storage must never treat it
/// as such.** These are the rows for somebody whose phone is off. They stay
/// Uncollected mail is NOT waste, and cold storage must never treat it
/// as such. These are the rows for somebody whose phone is off. They stay
/// for thirty days by design, and moving them somewhere slower would make
/// the first poll after a holiday the slowest request the relay serves.
@Test("uncollected envelopes are kept for the full undelivered window")
@@ -60,8 +60,8 @@ struct FederationTests {
#expect(v.enabled)
}
/// **The admission check runs BEFORE the signature and before any
/// outbound fetch.** Verifying first would require fetching the peer's key,
/// The admission check runs BEFORE the signature and before any
/// outbound fetch. Verifying first would require fetching the peer's key,
/// which means a closed relay could be induced to dial an arbitrary host
/// just by being sent a header naming it, a request-forgery primitive
/// handed out for free.
@@ -8,8 +8,8 @@ import Vapor
/// What a group message actually costs the sender's phone (relay#27).
///
/// **The issue is filed on "twenty uploads of the same blob", and the blob
/// is not what gets uploaded twenty times.** A picture, a voice note or a file
/// The issue is filed on "twenty uploads of the same blob", and the blob
/// is not what gets uploaded twenty times. A picture, a voice note or a file
/// is an *attachment*: uploaded once, and named from inside each recipient's
/// sealed envelope by a token any authenticated member can fetch with. What is
/// sent per recipient is the envelope, a few hundred bytes of ciphertext
@@ -294,7 +294,7 @@ struct IdentityRoutesTests {
}
/// 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
/// list is signed, so a relay could serve one from before a
/// second home was added and quietly become the only mailbox again.
@Test("an older serial cannot replace a newer record")
func rollbackRefused() async throws {
@@ -132,7 +132,7 @@ struct KeyLogRoutesTests {
/// An older size is accepted so a client can verify against a head it
/// already holds rather than one it is being handed now, which is the
/// whole point of pinning a head.
/// point of pinning a head.
@Test("a proof can be requested against an older head")
func proofAgainstAnOlderHead() async throws {
try await withRelay { h in
@@ -6,8 +6,8 @@ import Testing
/// RFC 6962 Merkle tree, the substrate under key transparency.
///
/// **0% covered before this file, and it is the one piece of the relay that
/// is also a public claim.** The site tells people that a relay handing out a
/// 0% covered before this file, and it is the one piece of the relay that
/// is also a public claim. The site tells people that a relay handing out a
/// substituted key leaves evidence. That promise is only worth what these
/// hashes are worth: if `split` is off by one, or domain separation is wrong,
/// or a consistency proof can be produced across a rewritten history, then the
@@ -118,7 +118,7 @@ struct MerkleLogTests {
size: 8, proof: []) == nil)
}
/// The whole point: a key that is not in the log must not be provable.
///a key that is not in the log must not be provable.
@Test("a leaf that is not in the tree does not verify")
func forgedLeafFails() {
let root = MerkleLog.root(leaves)
@@ -150,7 +150,7 @@ struct MessagingRoutesTests {
}
}
/// Invisible reads as offline to everyone, which is the entire point of it.
/// Invisible reads as offline to everyone, which is the point of it.
@Test("invisible reads as offline even to a buddy")
func invisibleIsInvisible() async throws {
try await withRelay { h in
@@ -8,8 +8,8 @@ import Vapor
/// Multi-homing across two separate relays (relay#36's foundation).
///
/// **The existing tests all run on one relay, and multi-homing is a
/// property between relays.** `homesRoundTrip` and `rollbackRefused` prove a
/// The existing tests all run on one relay, and multi-homing is a
/// property between relays. `homesRoundTrip` and `rollbackRefused` prove a
/// relay stores a signed list and refuses to go backwards *against itself*,
/// which is worth having and is not the thing multi-homing exists for. What it
/// exists for is this: relay1 must not be able to make itself the only mailbox
@@ -107,7 +107,7 @@ struct MultiHomingAcrossRelaysTests {
///
/// A relay that wants to be your only mailbox does not have to forge
/// anything, it serves an *older* list, from before you added the second
/// relay. That list is genuinely signed by you, so a client checking only
/// relay. That list is signed by you, so a client checking only
/// the signature accepts it and stops delivering anywhere else.
///
/// No relay can detect this, and that is not a gap to fix here. The
@@ -168,8 +168,8 @@ struct MultiHomingAcrossRelaysTests {
}
}
/// **A signature from the wrong key is refused, and this is the check
/// that matters most.** Everything above rests on a relay being unable to
/// A signature from the wrong key is refused, and this is the check
/// that matters most. Everything above rests on a relay being unable to
/// write a list you did not sign, because a relay that could edit this
/// list would simply remove the other relays and become your only mailbox,
/// which is the position multi-homing exists to escape.
@@ -63,8 +63,8 @@ struct OutboundAddressGuardTests {
#expect(ok.isPubliclyRoutable)
}
/// **A parser differential, and the differential itself differs by
/// platform, which is how this test first went wrong.**
/// A parser differential, and the differential itself differs by
/// platform, which is how this test first went wrong.
///
/// `inet_pton` on Darwin accepts a leading zero and reads it as
/// DECIMAL: `0177.0.0.1` parses to `177.0.0.1`, a public address. On
@@ -6,8 +6,8 @@ import Testing
/// Screening the federation allowlist (relay#58).
///
/// **The most important test here is the one that proves screening cannot
/// refuse anything.** The ticket's own warning is that address screening is
/// The most important test here is the one that proves screening cannot
/// refuse anything. The ticket's own warning is that address screening is
/// whack-a-mole, addresses are cheap and rentable, so an adversary who wants a
/// relay on this network will get one that screens clean. The gate is and must
/// remain the allowlist plus the pinned key. A later change that "improves"
@@ -19,8 +19,8 @@ struct PeerScreeningTests {
/// A resolver built from a table, so no test touches DNS.
///
/// **The "healthy" addresses here are real public ones, not the
/// documentation ranges.** `192.0.2.x`, `198.51.100.x` and `203.0.113.x`
/// The "healthy" addresses here are real public ones, not the
/// documentation ranges. `192.0.2.x`, `198.51.100.x` and `203.0.113.x`
/// are TEST-NET-1/2/3 and `OutboundAddressGuard` refuses all three as not
/// publicly routable, which it should. Using them as stand-ins for a good
/// peer makes every test in this file fail for a reason that has nothing to
@@ -134,8 +134,8 @@ struct PeerScreeningTests {
#expect(findings.isEmpty)
}
/// **A pass that only speaks when something is wrong cannot be told
/// apart from one that never ran.** So a clean list says so.
/// A pass that only speaks when something is wrong cannot be told
/// apart from one that never ran. So a clean list says so.
@Test("a clean list is still reported, so silence is never ambiguous")
func cleanListStillLogs() async {
let recorder = Recorder()
@@ -3,8 +3,8 @@ import XCTest
/// What the relay must never start doing, if a transaction pooler is to stay
/// possible (relay#29).
///
/// **PgBouncer migrations do not fail on the day you do them. They fail
/// months later, when somebody adds an advisory lock.** Transaction pooling
/// PgBouncer migrations do not fail on the day you do them. They fail
/// months later, when somebody adds an advisory lock. Transaction pooling
/// hands a request whichever server connection is free and takes it back at
/// `COMMIT`, so anything that lives in a *session* rather than a transaction
/// silently attaches to the wrong backend, or to a backend that has since been
@@ -4,8 +4,8 @@ import XCTest
/// Noticing when a device's one-time prekeys run out (relay#35).
///
/// **An empty pool is a silent security downgrade, not an inventory
/// problem.** X3DH uses a one-time prekey so the first message of a session has
/// An empty pool is a silent security downgrade, not an inventory
/// problem. X3DH uses a one-time prekey so the first message of a session has
/// forward secrecy even against a later compromise of the signed prekey. With
/// the pool drained the handshake still succeeds, it falls back to the signed
/// prekey, and nothing anywhere says so. Both parties believe they got what
@@ -33,7 +33,7 @@ struct PresenceStatusTests {
#expect(ChosenStatus(clientValue: "offline") == .invisible)
}
/// Rejection is the whole point. If this ever returns non-nil for
/// Rejection is the point. If this ever returns non-nil for
/// nonsense, the route goes back to accepting anything.
@Test("anything else is refused rather than quietly dropped")
func rejected() {
@@ -143,8 +143,8 @@ final class PublicPagesTests: XCTestCase {
"og:image is built from the page URL rather than the origin")
}
/// **Every context rendered through the shared layout must set the
/// chrome, and three of them did not.**
/// Every context rendered through the shared layout must set the
/// chrome, and three of them did not.
///
/// `layout.leaf` emits `<link rel="canonical" href="#(canonical)">` and an
/// og:image built from `#(origin)`. Both default to `""` on the contexts,
@@ -178,8 +178,8 @@ final class PublicPagesTests: XCTestCase {
XCTAssertEqual(offenders, [], "\n" + offenders.joined(separator: "\n"))
}
/// **Setting the field is only half of it. Leaf renders the ENCODED
/// object.** A context with a hand-written `encode(to:)` drops anything its
/// Setting the field is only half of it. Leaf renders the ENCODED
/// object. A context with a hand-written `encode(to:)` drops anything its
/// `CodingKeys` do not list, without a word. `/releases` had `canonical`
/// declared on the struct AND assigned by its route, and still shipped
/// `href=""`, because the encoder written months earlier never knew about
@@ -213,8 +213,8 @@ final class PublicPagesTests: XCTestCase {
XCTAssertEqual(offenders, [], "\n" + offenders.joined(separator: "\n"))
}
/// **The transparency page has to be accurate in the unflattering
/// direction too.** `/metadata` called what it stores the "approximate
/// The transparency page has to be accurate in the unflattering
/// direction too. `/metadata` called what it stores the "approximate
/// size" of a message. It is not approximate: the ciphertext length is
/// exact, and lengths group by kind, so a byte count is a weak guess about
/// what an envelope carries (relay#59). A page that undersells what it
@@ -407,8 +407,8 @@ final class PublicPagesTests: XCTestCase {
}
}
/// **Every "not on iPhone yet" sentence flips together, or the site
/// contradicts itself** (relay#3). Seven of them live across two pages, and
/// Every "not on iPhone yet" sentence flips together, or the site
/// contradicts itself (relay#3). Seven of them live across two pages, and
/// on approval day the temptation is to edit the ones somebody remembers.
/// They are gated on one flag now, so the count cannot drift and a partial
/// flip is impossible.
+3 -3
View File
@@ -9,8 +9,8 @@ import Vapor
/// A whole relay, in memory, that real requests can be driven through.
///
/// **This file exists because its absence was the reason the relay sat at 3%
/// line coverage.** Every route handler hangs off `Routes`, `Routes` has nine
/// This file exists because its absence was the reason the relay sat at 3%
/// line coverage. Every route handler hangs off `Routes`, `Routes` has nine
/// stored dependencies, and three of them are protocols with only Redis-backed
/// or filesystem-backed conformances in the app. So testing any handler meant
/// standing all of that up first, and nobody ever did, which is why the routes
@@ -118,7 +118,7 @@ struct RelayHarness: @unchecked Sendable {
/// is not declared `Sendable` by swift-crypto on Linux, so a plain
/// `Sendable` conformance does not compile there even though the contents
/// are immutable and safe to read from two tasks. (I wrote "value types
/// throughout, so genuinely Sendable" here first, and Linux said no.)
/// throughout, so Sendable" here first, and Linux said no.)
struct TestDevice: @unchecked Sendable {
let userID: Int
let deviceID: String
+1 -1
View File
@@ -236,7 +236,7 @@ struct SendTests {
/// The limit still exists, and this is the half that stops the fix
/// above from being "remove the rate limit".
///
/// A sender who genuinely sends more messages than the budget allows is
/// A sender who sends more messages than the budget allows is
/// still refused. Same relay, same budget, one recipient device, so the
/// only thing being counted is how much this account is talking, which is
/// what the limit is for.
@@ -8,7 +8,7 @@ import XCTest
/// and `User.normalize` already refuses two accounts differing only in case for
/// exactly that reason. A digit drawn like a letter is the same attack with a
/// different keystroke: `paulh1tt` beside `paulhitt` is not a typo somebody
/// made, it is the whole point.
/// made, it is the point.
final class SignupValidationTests: XCTestCase {
// MARK: - Look-alike handles
@@ -9,7 +9,7 @@ import Vapor
///
/// Vouching ties each new account to somebody who already has one, which makes
/// bulk sign-ups costly and gives every account a provenance. It also slows
/// genuine growth and creates an in-group, so it is a phase with a stated end
/// growth and creates an in-group, so it is a phase with a stated end
/// date rather than a policy, and the tests that matter most are the ones about
/// it being off.
@Suite("vouch codes")
+1 -1
View File
@@ -32,7 +32,7 @@ struct WitnessTests {
#expect(Witness.decide(size: 10, rootBase64: rootA, remembered: seen) == .sign)
}
/// The whole point. Two different roots at one size is a forked log,
///Two different roots at one size is a forked log,
/// and a witness that signed both would be worth nothing.
@Test("a different root at a size already signed is refused")
func fork() {
+2 -2
View File
@@ -102,8 +102,8 @@ services:
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
# 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
+1 -1
View File
@@ -66,7 +66,7 @@ services:
# The only change the relay needs: point at the pooler instead of the
# database. No protocol change, no code change.
FN_POSTGRES_URL: "postgres://flamenet:${POSTGRES_PASSWORD}@pgbouncer:5432/flamenet"
# Raised, not lowered, and that is the point. Behind a pooler the
# Raised, not lowered. Behind a pooler the
# relay's connections are cheap client connections rather than Postgres
# backends, so the relay may hold many and the pooler keeps the real
# backends at DEFAULT_POOL_SIZE. Sixteen per loop on an 8-core box is 128
+6 -6
View File
@@ -175,8 +175,8 @@ services:
labels:
traefik.enable: "true"
traefik.docker.network: "web"
# **Router names here match `/srv/prod/messenger/docker-compose.yaml`
# on the box, which is the file that actually runs.** This one does not
# Router names here match `/srv/prod/messenger/docker-compose.yaml`
# on the box, which is the file that actually runs. This one does not
# deploy anything, it is the reference for what production looks like,
# so the names have to line up or the two describe the same hostnames
# under different labels and neither reads as authoritative.
@@ -216,8 +216,8 @@ services:
# The A record exists (added 2026-08-25, 135.148.121.177, unproxied) and
# the certificate has issued.
#
# **Traefik does not retry ACME for a host that had no DNS when it
# first saw the router.** This router existed for a while before the
# Traefik does not retry ACME for a host that had no DNS when it
# first saw the router. This router existed for a while before the
# record did, and it stayed certificate-less afterwards: port 80 answered
# 301, so routing looked healthy, while HTTPS refused the connection and
# nothing logged an error. Recreating the container re-emits the labels
@@ -289,7 +289,7 @@ services:
network_mode: host
command:
- "--no-cli"
# `:-`, not `:?`, even though coturn genuinely needs these. Compose
# `:-`, not `:?`, even though coturn needs these. Compose
# interpolates the WHOLE file before it filters by profile, so a `:?` here
# failed `up -d` for operators who had not enabled calls at all, the
# profile gates whether the service RUNS, not whether its variables are
@@ -329,7 +329,7 @@ services:
#
# A separate container, not part of the relay, and that is deliberate.
# obfs4 traffic is indistinguishable from random bytes: it carries no SNI and
# no headers, so Traefik cannot route it, that is the entire point. It
# no headers, so Traefik cannot route it, that is the point. It
# therefore needs its own published port rather than sitting behind the
# existing ingress.
#
+11 -11
View File
@@ -42,7 +42,7 @@ 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
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
@@ -76,7 +76,7 @@ Also mounted under `/api/flamenet/v1/messenger/{signup,signin}`.
"app_password": "<returned once, never retrievable>" }
```
**The credential is returned once and only its hash is kept.** Losing it
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.
@@ -84,7 +84,7 @@ invalidates it, which is what changing a password should do.
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
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.
@@ -97,7 +97,7 @@ whether it worked, and a UI that claims it can is lying.
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
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
@@ -149,14 +149,14 @@ A `Range` this relay will not serve, several ranges in one header, or anything
malformed, is **ignored** rather than refused, and you get `200` and the whole
blob.
**`416` means the blob is not the length you think it is.** A resume asks for
`416` means the blob is not the length you think it is. A resume asks for
`bytes=900-` because the client already holds 900 bytes; if the blob was
replaced or is shorter than that, the answer is `416 Range Not Satisfiable`
carrying `Content-Range: bytes */<real length>`. That is not "your request was
malformed", it is the length you need in order to start again from zero. Do not
malformed", it is the length you need to start again from zero. Do not
retry the same range.
**What a range costs you in privacy.** Asking for part of a file tells the
What a range costs you in privacy. Asking for part of a file tells the
relay which part you wanted: that somebody stopped listening to a voice note
after three seconds, which a whole-file fetch cannot say. There is no way to
offer resumable downloads without the server seeing the offset. This relay does
@@ -166,12 +166,12 @@ should fetch whole blobs.
### The three that bite
**`GET /e2e/keys/{user}` consumes a one-time prekey per device, every call.**
`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
`POST /e2e/messages` names the recipient once, at the top: not per
envelope:
```json
@@ -182,7 +182,7 @@ envelope:
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
`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.
@@ -196,7 +196,7 @@ Each authenticated request carries `X-FN-Timestamp`, `X-FN-Nonce` and
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
`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.
+2 -2
View File
@@ -5,7 +5,7 @@ because "delivered envelopes stay in the same table serving live polls,
forever. The hot path gets slower as history grows, for rows nobody will read
again."
**They do not stay forever, and the hot path does not get slower.** Both halves
They do not stay forever, and the hot path does not get slower. Both halves
of the premise are wrong, and both are now checkable. The recommendation is to
decline, and the one useful thing the spike produced is the measurement that
would tell you if the answer ever changed.
@@ -25,7 +25,7 @@ actually removes what is past them, so this document cannot go stale behind a
changed default, the tests fail if either window moves.
There is also `FN_DELETE_ON_ACK`, which deletes an envelope the moment a
client's cursor moves past it. It is **off by default and should stay off**,
client's cursor moves past it. It is off by default and should stay off,
it destroys ciphertext unrecoverably, and if a cursor is ever wrong there is no
way back. relay#28 correctly warned about being confused with it. Nothing here
touches it.
+8 -8
View File
@@ -19,7 +19,7 @@ default is a complete, self-contained relay.
| **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
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.
@@ -91,7 +91,7 @@ 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
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.
@@ -110,7 +110,7 @@ Leaving all of these unset gives you a relay with its own user table.
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
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.
@@ -125,7 +125,7 @@ Defaults are sane; raise them only with a reason.
| `FN_SIGNUP_MAX_PER_HOUR` | `50`/hr | New accounts per IP. An IP is not a person, carrier CGNAT, offices and campuses all share one. It was `5`, which refused the sixth user behind any shared connection. Per-IP is trivially rotated, so treat this as a speed bump against lazy bulk registration rather than a defence. |
| `FN_SIGNUP_MAX_PER_NETWORK` | `400`/hr | New accounts per network, a /24 for IPv4, a /48 for IPv6. The per-address limit counts a rented subnet as separate strangers, and more addresses is the cheapest thing an abuser buys. Deliberately generous: a campus or a carrier behind CGNAT is thousands of real people on one network, and refusing them is the worse failure. **`0` switches network counting off**, leaving only the per-address limit. |
| `FN_SIGNUP_POW_BITS` | `0` (off) | Leading zero bits of SHA-256 work a new account must do before it is created (relay#52). **Not a CAPTCHA on purpose**: a CAPTCHA hands a third party every visitor's connection, which is a strange thing for a product whose argument is that it collects almost nothing. This collects nothing at all. Capped at `24` however it is set: higher does not read as spam protection to the person it hits, it reads as an app that hangs on the sign-up button. |
| `FN_SIGNUP_MAX_PER_HOUR_WITHOUT_POW` | `5`/hr | What a client too old to answer the challenge gets instead, per address. **This is what makes `FN_SIGNUP_POW_BITS` switchable on at all.** Refusing an unanswered sign-up outright would break every build already in somebody's hand, and break it invisibly, the APK in the field is Android 0.6 (2026-08-31) and the client-side solver landed 2026-09-01, one day later. An old build now drops to this strict allowance rather than hitting a wall, so the opt-out costs something without stranding anyone. Per-address is trivially rotated, so this bounds a lazy attacker, not a determined one; the gain is that the strong thing can be enabled at all. **`0` restores the wall**: the right setting once both shipped clients solve challenges. |
| `FN_SIGNUP_MAX_PER_HOUR_WITHOUT_POW` | `5`/hr | What a client too old to answer the challenge gets instead, per address. This is what makes `FN_SIGNUP_POW_BITS` switchable on at all. Refusing an unanswered sign-up outright would break every build already in somebody's hand, and break it invisibly, the APK in the field is Android 0.6 (2026-08-31) and the client-side solver landed 2026-09-01, one day later. An old build now drops to this strict allowance rather than hitting a wall, so the opt-out costs something without stranding anyone. Per-address is trivially rotated, so this bounds a lazy attacker, not a determined one; the gain is that the strong thing can be enabled at all. **`0` restores the wall**: the right setting once both shipped clients solve challenges. |
| `FN_VOUCH_REQUIRED` | `false` | Require an invite from an existing member to sign up (relay#43). **Inert on its own**: without `FN_VOUCH_UNTIL` the gate never engages, deliberately: an invite-only server nobody can join is worse than an open one. |
| `FN_VOUCH_UNTIL` | unset | Unix seconds after which vouching stops being required. **The gate turns itself off.** Vouching makes bulk sign-ups costly and gives every account a provenance; it also slows genuine growth and creates an in-group. That is a reasonable trade for a phase and a bad one for a policy, so it cannot be switched on without saying when it stops. |
@@ -168,12 +168,12 @@ Off. An account can use the relay the moment it is created.
Two things have to be true before anybody is asked to prove anything: the switch above, **and** SMTP being configured. A relay that cannot send mail can never answer the question it is asking, so it does not ask, the same rule the key-log enforcement follows.
**Before you turn this on, check what your users are running.** The gate sits on the token route, which is the door to everything: an account that cannot mint a token cannot poll, register a device, or fetch a prekey bundle. A client with no screen for entering the code has no way through that door and no way to tell the person why. The Android build released on 2026-08-31 (v8) is one of these. Turning this on for a relay it talks to would let somebody create an account and then strand them.
Before you turn this on, check what your users are running. The gate sits on the token route, which is the door to everything: an account that cannot mint a token cannot poll, register a device, or fetch a prekey bundle. A client with no screen for entering the code has no way through that door and no way to tell the person why. The Android build released on 2026-08-31 (v8) is one of these. Turning this on for a relay it talks to would let somebody create an account and then strand them.
Two things this deliberately does **not** do:
- **It never touches an account that already exists.** The migration marks every account present at upgrade as verified. Switching this on does not lock out your current users.
- **It never gates an account with no email of its own**: those authenticated by the shared service have nothing to prove.
- It never touches an account that already exists. The migration marks every account present at upgrade as verified. Switching this on does not lock out your current users.
- It never gates an account with no email of its own: those authenticated by the shared service have nothing to prove.
Codes last 24 hours and one is live at a time; asking for another replaces the first. If the mail fails to send, the code is deleted rather than left in the database as a credential nobody received.
@@ -211,7 +211,7 @@ account, publications and retirements, with the device each names. It is
unauthenticated, like the rest of §19: a log only its own users can read is not a
transparency log, and a monitor is by definition somebody else.
**That has a cost, and it is stated here rather than left to be discovered.**
That has a cost, and it is stated here rather than left to be discovered.
Anyone holding somebody's fnid, the address they hand to contacts, can see how
many devices they have published and roughly when. This is inherent to key
transparency rather than an oversight: a substitution cannot be detected in a log
+5 -5
View File
@@ -50,12 +50,12 @@ One send-and-collect, with the presence window already warm:
send: devices.read users.read blocks.read devices.read envelopes.create
collect: devices.read users.read envelopes.read envelopes.update
**Six of the nine are authorisation and presence. Three touch a message.**
Six of the nine are authorisation and presence. Three touch a message.
That single line reorders the backlog: the relay is not spending its hot path
on message handling, and every idea about moving envelopes around is optimising
the smaller half.
**It was ten, and the tenth was a duplicate.** `authenticate` loaded the
It was ten, and the tenth was a duplicate. `authenticate` loaded the
device row to check the token's key binding, and the poll handler then loaded
the same row by the same primary key for the revocation check, microseconds
apart, on the busiest route in the system. Found by this measurement, filed as
@@ -95,7 +95,7 @@ retained envelopes: 21 KB against 381 bytes for the one message that is new, a
Ranked by what the measurement supports, not by what sounded largest when the
ideas were filed.
**1. Stop reading the same device row twice, relay#51, done.** Two identical
1. Stop reading the same device row twice, relay#51, done. Two identical
primary-key reads per poll for a row already in hand. It was the cheapest and
most certain item on this list, so it was fixed in the same pass that found it.
The numbers above are post-fix.
@@ -105,7 +105,7 @@ claims. Every one of those nine round trips borrows from the pool, so the
pool's value scales with nine times the message rate, not one times it.
PgBouncer stays the cheapest large win.
**3. relay#27, relay-side sealed fan-out. Declined, see `docs/FANOUT.md`.**
3. relay#27, relay-side sealed fan-out. Declined, see `docs/FANOUT.md`.
The premise was wrong twice. `/e2e/messages` takes one account and a *list* of
that account's devices, so ten people with two devices is ten requests and not
twenty, and the blob is not what repeats at all: an attachment is uploaded
@@ -128,7 +128,7 @@ round trips above show at both 20 and 120 retained. The rows that do accumulate
are the *uncollected* ones, which are somebody's mail waiting for a phone to
come back on and must stay exactly where they are.
**6. relay#36, shard mailboxes across relays. Declined.** Premature, exactly
6. relay#36, shard mailboxes across relays. Declined. Premature, exactly
as the issue says of itself: "only worth it well past the point PgBouncer stops
helping", and nothing measured here is within sight of that point. It would also
buy the one failure mode this relay does not have, a wrong shard map means mail
+3 -3
View File
@@ -12,7 +12,7 @@ first sight and demand inclusion proofs. Two validly-signed heads of the same
size with different roots are evidence the operator cannot retract. Tampering
is designed to be **detected**, not prevented at the server.
**The exposure this closes is blast radius from a flaw in the relay itself.**
The exposure this closes is blast radius from a flaw in the relay itself.
Any SQL injection or path that reaches a query with attacker-influenced input
used to become full cluster control, and superuser can run
`COPY … TO PROGRAM`, which turns a SQL flaw into remote code execution on the
@@ -43,7 +43,7 @@ and is still the superuser; the relay simply stopped being it.
GRANT USAGE, CREATE ON SCHEMA public TO relayapp;
-- every table, sequence and view reassigned to relayapp
**Ownership, not just grants, and that is deliberate.** Fluent runs
Ownership, not just grants, and that is deliberate. Fluent runs
migrations at boot. A role holding only `SELECT/INSERT/UPDATE/DELETE` cannot add
a table or an index, so the next deploy carrying a migration would fail at
start-up, and a start-up failure from a missing grant reads as an application
@@ -85,7 +85,7 @@ clients actually verify.
## Rehearsal
This was rehearsed before it was applied, against a throwaway Postgres holding
the **real production schema and the real `_fluent_migrations` rows**: schema
the real production schema and the real `_fluent_migrations` rows: schema
and migration bookkeeping only, no user data. The relay booted healthy, ran its
migrations, and answered signup, signin and both pages.
+3 -3
View File
@@ -14,12 +14,12 @@ smaller than the issue assumes. The recommendation is to decline it as filed.
The issue costs the problem as "a ten-person group with two devices each is
twenty uploads of the same blob". Two corrections, both measured:
**A second device on the same person is not a second request.**
A second device on the same person is not a second request.
`/e2e/messages` takes one `to` account and a *list* of that account's devices,
so ten people with two devices each is ten requests, not twenty
(`docs/COST.md`, and `CostTests.swift`).
**The blob is not what gets uploaded N times.** A picture, a voice note or a
The blob is not what gets uploaded N times. A picture, a voice note or a
file is an attachment: uploaded **once**, and named from inside each
recipient's sealed envelope by a token any authenticated member can fetch with.
`GroupFanOutTests.mediaIsUploadedOnce` pins this, one 64 KB picture sent to six
@@ -95,7 +95,7 @@ stored-rows protection for every group message, sealed or not.
group, in exchange for a durable membership record, is not a trade worth making
and the tokens cannot make it a different trade.
**If round trips ever become the real complaint**, that is a different issue
If round trips ever become the real complaint, that is a different issue
from the one filed, and it has a narrow answer worth writing down now. The cost
that would bite is not the bytes but the N round trips from a phone on a
high-latency mobile link. Batching several *accounts* into one request would fix
+11 -11
View File
@@ -36,11 +36,11 @@ Then check it is what you think it is:
curl https://relay.example.com/.well-known/flamenet/relay
```
**`FN_RELAY_AUDIENCE` must be the hostname clients dial.** Tokens carry it
`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
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.
@@ -55,7 +55,7 @@ Calls need a TURN server. **`docker-compose.prod.yml` runs one**: a `coturn`
service alongside the relay, so deploying with that file gets you working
calls rather than calls that work for some people.
**This is not the optional extra it sounds like.** STUN alone connects two
This is not the optional extra it sounds like. STUN alone connects two
peers only when at least one NAT is permissive. Symmetric NAT and CGNAT, most
mobile networks, need a relay in the middle, so without TURN a large share of
real calls fail while messaging keeps working perfectly. That is a miserable
@@ -74,11 +74,11 @@ FN_TURN_EXTERNAL_IP=203.0.113.10 # the host's PUBLIC address
A closed relay range fails exactly like a wrong secret: the call connects and
then carries no audio.
**Keep the TURN record unproxied in DNS.** Cloudflare does not proxy UDP, so
Keep the TURN record unproxied in DNS. Cloudflare does not proxy UDP, so
a proxied record makes every relayed call fail while the record looks perfectly
healthy.
**`FN_TURN_EXTERNAL_IP` must be the address the outside world sees**, not the
`FN_TURN_EXTERNAL_IP` must be the address the outside world sees, not the
one on the interface. Behind NAT, coturn that does not know its public address
hands out candidates nobody can reach, and it presents as packet loss.
@@ -105,12 +105,12 @@ 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
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
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.
@@ -126,7 +126,7 @@ FN_FEDERATION_PEERS=relay.friend.example
FN_FEDERATION_KEY=<base64 Ed25519 seed>
```
**A closed relay opens no outbound connections at all.** No telemetry, no
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.
@@ -157,7 +157,7 @@ curl https://relay.example.com/e2e/log/head
curl "https://relay.example.com/e2e/log/inclusion?index=0&size=<size>"
```
**Check a proof against a head you fetched separately.** A server that
Check a proof against a head you fetched separately. A server that
supplies both halves of a comparison has proven nothing.
---
@@ -172,12 +172,12 @@ 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.
**This relay holds no key to any message.** It used to: `FN_AT_REST_SECRET`
This relay holds no key to any message. It used to: `FN_AT_REST_SECRET`
sealed bodies on the plaintext path, which defended against a stolen database
dump and not against the operator. That path was removed on 2026-09-04 along
with the key, so there is no longer an "it does not defend against you" caveat
to pass on to your users. If the variable is still in your `.env`, nothing reads
it, delete it.
**Never raise `FN_LOG_LEVEL` to `debug` on a machine with real accounts.** It
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.
+4 -4
View File
@@ -31,13 +31,13 @@ belong to a `user_id` that does not join to `users` at all, the account was
deleted and its push token was not. The oldest is nine days old and nothing
sweeps the table.
**Two separate causes, and they need different fixes.**
Two separate causes, and they need different fixes.
**1. Nothing cascades.** Deleting a device, or an account, does not delete its
push token. There is no foreign key and no sweep. This is server-side and
fixable without touching a client, which makes it the one to do first.
**2. Sign-out clears the credential before it uses it.** `signOut` sets
2. Sign-out clears the credential before it uses it. `signOut` sets
`store.appPassword = nil` as step 1, deliberately, and the comment says why:
a relay token is minted with the app password, so clearing it first is what
makes the keys left behind unable to fetch anything. But `forgetPushToken()`
@@ -45,7 +45,7 @@ runs *after* that, and it is a `try?` call on an authenticated route. With a
live cached relay token it succeeds; once that token has expired there is no
app password left to mint another, the call 401s, and the `try?` swallows it.
**So it works most of the time and fails silently the rest**, which is worse
So it works most of the time and fails silently the rest, which is worse
than failing always, the failure is invisible and the rows it leaves look like
somebody simply never signed out. This is the same shape as the attachment
`401`: an authenticated call placed after the thing that authenticates it.
@@ -68,7 +68,7 @@ deploy, which discarded its log history, and only `…-json.log` and `…json.lo
exist so far. The script deliberately keeps the live file and one rotation and
starts deleting at `.2`, so there has been nothing in range.
**"Exit 0" and "0 MB" together are not evidence of a working sweep**, so the
"Exit 0" and "0 MB" together are not evidence of a working sweep, so the
glob was checked against a fixture instead of assumed:
```
+2 -2
View File
@@ -37,7 +37,7 @@ An operator reading the table sees each message's size to the byte, and sizes
group by kind. That is a shape, and a shape is a guess about content: a receipt
is not the size of a photo, and a one-word reply is not the size of a paragraph.
**It is exactly the thing sealed sender is for, arriving by another door.**
It is exactly the thing sealed sender is for, arriving by another door.
Sealed sender removes `from_user` so an envelope cannot be attributed. Length,
timing and the per-device index together still describe a conversation: who
received, when, how much, in what rhythm.
@@ -85,7 +85,7 @@ select count(*) from envelopes where from_user = 0; -- 14
select count(*) from envelopes where from_user <> 0; -- 0
```
**Sealed sender is at 14/14 on live traffic.** Fourteen envelopes from two
Sealed sender is at 14/14 on live traffic. Fourteen envelopes from two
accounts is not a sample anybody should generalise from, it says the unsealed
path is not the *common* case, and says nothing about how it behaves at scale.
The counters are the thing to watch; the snapshot is not.
+7 -7
View File
@@ -1,6 +1,6 @@
# Where plaintext can come to rest
> **Update, 2026-09-04, the question this document asks has changed.**
> Update, 2026-09-04, the question this document asks has changed.
>
> Everything below was written while the relay still had a route that accepted a
> readable message. That route is gone, along with its sibling on the signed
@@ -134,7 +134,7 @@ wants the **User-Agent** and not the IP; CrowdSec wants the **IP** and not the
User-Agent. They share one stream, so the only lever that helps both is **time**
which is why retention was the change and field-dropping was not.
**Truncating the file is safe, and that was verified rather than assumed.**
Truncating the file is safe, and that was verified rather than assumed.
Truncating a file a tailer is reading can leave it seeking past EOF. Measured
across the change with fresh traffic: CrowdSec's `reads` went 1,642,488 →
1,642,529 and `parsed` 1,585,311 → 1,585,351. It survived.
@@ -192,12 +192,12 @@ every week is a report nobody reads.
| Edge access log under 400 MB | The daily prune stopped |
| Fleet image matches production | Live tests are proving things about the wrong build |
**The CrowdSec row is the one that most needs a machine.** Nothing else would
The CrowdSec row is the one that most needs a machine. Nothing else would
notice IP banning going dark, and the ordinary way that happens is somebody
improving privacy by dropping the client IP from the access log, which is
exactly the change this document argues against above.
**A check that cannot run reports FAILURE, never a pass.** The guarded-against
A check that cannot run reports FAILURE, never a pass. The guarded-against
failure is a script that greps a file which has moved and reports a clean sweep
over nothing.
@@ -227,7 +227,7 @@ The decision this inventory was written to inform has been taken: remove it.
| `FN_AT_REST_SECRET`, `FN_MESSAGE_RETENTION_DAYS`, `FN_READ_MESSAGE_RETENTION_DAYS` | No longer read |
| The retention sweep's message branch | Nothing left to sweep |
**The relay now holds no key to any message content.** That is the part worth
The relay now holds no key to any message content. That is the part worth
stating plainly, because it is a stronger claim than "the route is gone". The
at-rest key existed only to seal those bodies; removing the route removed the
reason for the key, and the key went with it.
@@ -249,7 +249,7 @@ Checked rather than assumed, in four ways:
named only in the conformance harness, which tested them against themselves.
3. **No production traffic.** An hour of edge access log (3,931 requests) shows
every message request going to `/e2e/*` and none to any removed route.
4. **The suite proves the absence, over HTTP.** `EndToEndJourneyTests` sends a
4. The suite proves the absence, over HTTP. `EndToEndJourneyTests` sends a
well-formed, fully authenticated message and expects 404, deliberately not
"anything but 200", because a 401 would mean the route still existed and the
test had merely failed to authenticate.
@@ -276,7 +276,7 @@ missing.*
`FN_READ_MESSAGE_RETENTION_DAYS` are no longer read. Leaving them set is
harmless; delete them when convenient.
**Dropping the table does not reach the backups.** Rows that existed before
Dropping the table does not reach the backups. Rows that existed before
this ran were shipped to Backblaze in WAL segments and are in the weekly base
backups, exactly as described under *What this inventory does NOT cover*. Those
backups are encrypted, so it is a bounded exposure, but retention, not this
+3 -3
View File
@@ -26,7 +26,7 @@ produce the wrong numbers.
## What was verified
**The relay is compatible with transaction pooling.** This is the thing that
The relay is compatible with transaction pooling. This is the thing that
normally blocks the migration, so it was checked rather than assumed:
- **No named prepared statements.** postgres-nio's ordinary query path
@@ -40,7 +40,7 @@ normally blocks the migration, so it was checked rather than assumed:
- **No advisory locks.** Leader election for the retention sweeper is a Redis
`SET`, not `pg_advisory_lock`. A session-level advisory lock under transaction
pooling would be held on a connection that gets handed to somebody else.
- **No session GUCs and no temporary tables.**
- No session GUCs and no temporary tables.
- **Transactions everywhere, which is fine.** A transaction is the unit a
transaction pooler pools by: the connection is held for its duration and
returned at `COMMIT`. The dozen `db.transaction` blocks are all safe.
@@ -96,7 +96,7 @@ page. Not the inbox.
## What is still owed
**The pooler has not been run under load.** The compatibility question is
The pooler has not been run under load. The compatibility question is
settled and the settings are reasoned from measured round-trip counts, but
nothing here has had traffic through it. Before this goes on production: