Cut the remaining was-then-now framing from scripts and docs

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-09-09 09:42:06 -04:00
co-authored by Claude Opus 5
parent 564f698a69
commit a53deddcf6
9 changed files with 286 additions and 81 deletions
+1 -1
View File
@@ -1 +1 @@
196b42451895fa67cde415e95e3d0af9a714207c
57732df84e8d94366374d7576a6d156cf703e432
+157 -1
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: 45aadd4e888382a9 -->
source-sha256: 20fa36ab23879f61 -->
<article class="band doc doc-generated">
<div class="wrap wrap-narrow">
<h1 id="configuring-a-relay">Configuring a relay</h1>
@@ -547,6 +547,162 @@ looking wrong. With this set, the publishable key stays the
</tr>
</tbody>
</table>
<h2 id="iphone-push-apns">iPhone push (APNs)</h2>
<p>Only the iOS app needs this; Android is woken directly. All five must
be set together, and the relay logs that push is disabled if any is
missing.</p>
<table>
<thead>
<tr>
<th>Variable</th>
<th>Default</th>
<th>Notes</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>FN_APNS_KEY_P8</code></td>
<td>unset</td>
<td>Path to the <code>.p8</code> signing key from Apple. Mount it as a
secret; it is not a file to bake into an image.</td>
</tr>
<tr>
<td><code>FN_APNS_KEY_ID</code></td>
<td>unset</td>
<td>The key's ten-character id.</td>
</tr>
<tr>
<td><code>FN_APNS_TEAM_ID</code></td>
<td>unset</td>
<td>Your Apple developer team id.</td>
</tr>
<tr>
<td><code>FN_APNS_TOPIC</code></td>
<td><code>net.flamenet.messenger</code></td>
<td>The app's bundle id. Change it if you ship your own build.</td>
</tr>
<tr>
<td><code>FN_APNS_ENV</code></td>
<td><code>sandbox</code></td>
<td><code>sandbox</code> or <code>production</code>. A token from a
TestFlight or App Store build is meaningless to the sandbox host and the
other way round, and APNs returns the same 400 either way. Getting this
wrong means no iPhone is ever woken, so set it to
<code>production</code> the moment you ship a build that is not from
Xcode.</td>
</tr>
</tbody>
</table>
<h2 id="behind-a-proxy">Behind a proxy</h2>
<table>
<thead>
<tr>
<th>Variable</th>
<th>Default</th>
<th>Notes</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>FN_TRUSTED_PROXIES</code></td>
<td><code>0</code></td>
<td>How many proxy hops to trust when reading
<code>X-Forwarded-For</code>. Left at 0 the relay uses the socket
address, which is your proxy, so every request looks like it comes from
one IP and per-IP limits apply to everyone at once. Set it to the number
of proxies in front of the relay, not higher: a client can forge the
header for any hop you trust beyond the ones you actually run.</td>
</tr>
</tbody>
</table>
<h2 id="pluggable-transport-obfs4">Pluggable transport (obfs4)</h2>
<p>Reachability, not privacy: a second address that does not look like a
relay, for networks that block the first. It is what
<code>docker-compose.prod.yml</code> runs under the <code>obfs4</code>
profile, and the variables are read by that compose file rather than by
the relay.</p>
<table>
<thead>
<tr>
<th>Variable</th>
<th>Default</th>
<th>Notes</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>FN_OBFS4_PORT</code></td>
<td><code>8443</code></td>
<td>Published on every interface, unlike the relay. A bridge only
loopback can reach helps nobody; what makes that safe is that the
protocol answers nobody who cannot complete a handshake.</td>
</tr>
<tr>
<td><code>FN_OBFS4_ADVERTISE</code></td>
<td>unset</td>
<td>The public address to put in the bridge line handed to clients. A
container cannot know its own, so without this the line says
<code>&lt;this-host&gt;</code> and is not dialable.</td>
</tr>
<tr>
<td><code>FN_OBFS4_IMAGE</code></td>
<td><code>flamenet-obfs4bridge:prod</code></td>
<td>Set it to an image you can pull.</td>
</tr>
<tr>
<td><code>FN_OBFS4_CONTEXT</code></td>
<td><code>../flamenet-elligator2</code></td>
<td>Build context, a sibling checkout. That repository is private, so
its registry image cannot be pulled anonymously.</td>
</tr>
<tr>
<td><code>FN_OBFS4_UPSTREAM</code></td>
<td><code>relay:8080</code></td>
<td>Where the bridge forwards to.</td>
</tr>
</tbody>
</table>
<p>The bridge's state volume holds its identity key, which clients pin
through the <code>cert</code> in the bridge line. Losing it invalidates
every bridge line you have handed out.</p>
<h2 id="site-copy">Site copy</h2>
<table>
<thead>
<tr>
<th>Variable</th>
<th>Default</th>
<th>Notes</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>FN_IOS_LIVE</code></td>
<td><code>false</code></td>
<td>Flips clauses on the public pages that describe iPhone availability.
Turning it on states general availability, so it belongs on after an App
Store release and not during a beta.</td>
</tr>
<tr>
<td><code>FN_DELETE_ON_ACK</code></td>
<td>see Retention</td>
<td>Delete a delivered envelope the moment the recipient's app confirms
it, rather than after the delivered-retention window. The metadata page
reads this and describes whichever is true.</td>
</tr>
<tr>
<td><code>FN_DISPOSABLE_EMAIL_DOMAINS</code></td>
<td>unset</td>
<td>Comma-separated domains refused at sign-up. Empty means none
are.</td>
</tr>
<tr>
<td><code>FN_FEDERATION_SCREEN</code></td>
<td>unset</td>
<td>Advisory IP screening for federated peers. It does not gate
federation; the allowlist and the pinned key do.</td>
</tr>
</tbody>
</table>
<h2 id="watching-the-key-log-relay32">Watching the key log
(relay#("#")32)</h2>
<table>
+82 -17
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: 4173d817c93eb9bf -->
source-sha256: de7acaf9474886e5 -->
<article class="band doc doc-generated">
<div class="wrap wrap-narrow">
<h1 id="self-hosting">Self-hosting</h1>
@@ -162,26 +162,91 @@ defaults:</p>
<td>Used one-time prekeys</td>
<td>7 days</td>
</tr>
<tr>
<td>Unencrypted messages, once read</td>
<td>30 days</td>
</tr>
<tr>
<td>Unencrypted messages, regardless</td>
<td>90 days</td>
</tr>
</tbody>
</table>
<p>Override any of them with <code>FN_DELIVERED_RETENTION_DAYS</code>,
<code>FN_UNDELIVERED_RETENTION_DAYS</code>,
<code>FN_ATTACHMENT_RETENTION_DAYS</code>,
<code>FN_READ_MESSAGE_RETENTION_DAYS</code>,
<code>FN_MESSAGE_RETENTION_DAYS</code>.</p>
<code>FN_UNDELIVERED_RETENTION_DAYS</code> and
<code>FN_ATTACHMENT_RETENTION_DAYS</code>.</p>
<p>There is no unencrypted message store any more, so
<code>FN_MESSAGE_RETENTION_DAYS</code> and
<code>FN_READ_MESSAGE_RETENTION_DAYS</code> are not read. Setting them
does nothing; see <code>docs/PLAINTEXT.md</code> in the relay
repository.</p>
<h2 id="the-compose-files-and-what-each-one-runs">The compose files, and
what each one runs</h2>
<p>Five files, layered. <code>docker-compose.yml</code> is the base and
the others are overlays, so
<code>-f docker-compose.yml -f &lt;overlay&gt;</code> is the shape.</p>
<table>
<thead>
<tr>
<th>File</th>
<th>Services</th>
<th>For</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>docker-compose.yml</code></td>
<td><code>db</code>, <code>relay</code></td>
<td>The base. Publishes to <code>127.0.0.1</code>; what the quick start
uses.</td>
</tr>
<tr>
<td><code>docker-compose.build.yml</code></td>
<td><code>relay</code></td>
<td>Builds the image from source instead of pulling it. Needed on
ARM.</td>
</tr>
<tr>
<td><code>docker-compose.prod.yml</code></td>
<td><code>db</code>, <code>redis</code>, <code>relay</code>,
<code>coturn</code>, <code>obfs4bridge</code></td>
<td>A public relay: rate limiting that survives a restart, TURN for
calls, and a bridge for networks that block the relay.</td>
</tr>
<tr>
<td><code>docker-compose.federated.yml</code></td>
<td><code>db</code>, <code>redis</code>, <code>relay</code>,
<code>caddy</code>, <code>coturn</code></td>
<td>The same, with Caddy terminating TLS for you.</td>
</tr>
<tr>
<td><code>docker-compose.pooled.yml</code></td>
<td><code>pgbouncer</code>, <code>relay</code></td>
<td>Connection pooling, for when one Postgres is serving several relays.
An overlay on <code>prod</code>, not on the base: it uses the
<code>internal</code> network that file defines.</td>
</tr>
</tbody>
</table>
<p><strong><code>redis</code> is optional and the relay says so at
boot.</strong> Without it, rate limits are counted in process: correct
for one relay, and reset by a restart. With it they are shared and
durable. Nothing else depends on it.</p>
<p><strong><code>obfs4bridge</code> is for reachability, not
privacy.</strong> It gives the relay a second address that does not look
like a relay, for networks that block the first. It publishes its own
port because it carries no SNI for a reverse proxy to route on. Clients
paste the bridge line into Settings; see <code>FN_OBFS4_*</code> in
<code>docs/CONFIGURATION.md</code> in the relay repository.</p>
<p><strong><code>pgbouncer</code> is only worth it above one
relay.</strong> A single relay opens few enough connections that
Postgres handles them directly. Layer it on prod:</p>
<pre class="bash"><code>docker compose -f docker-compose.yml -f docker-compose.prod.yml \
-f docker-compose.pooled.yml up -d</code></pre>
<h2 id="voice-calls">Voice calls</h2>
<p>Optional, and only needed for calls. The compose file ships a coturn
service behind a profile, so nothing about calls runs, or has to be
configured, unless you ask for it:</p>
<pre class="bash"><code>docker compose --profile calls up -d</code></pre>
<p>Optional, and only needed for calls.</p>
<p><code>docker-compose.yml</code>, the file the steps above use, ships
<strong>no coturn</strong>, on purpose: it publishes to
<code>127.0.0.1</code> and is meant for working on the relay, where two
clients on one machine reach each other directly. <code>/e2e/turn</code>
answering 503 there is correct rather than a missing piece.</p>
<p>Calls across symmetric NAT and CGNAT need a relay in the middle, and
that lives in <code>docker-compose.prod.yml</code>, behind a profile so
nothing about calls runs, or has to be configured, unless you ask for
it:</p>
<pre class="bash"><code>docker compose -f docker-compose.yml -f docker-compose.prod.yml --profile calls up -d</code></pre>
<p>Set <code>FN_TURN_HOST</code>, <code>FN_TURN_SECRET</code> and
<code>FN_TURN_EXTERNAL_IP</code> in <code>.env</code> before you do;
coturn refuses to start without them. The relay mints credentials from
+23 -27
View File
@@ -3,7 +3,7 @@
<!-- GENERATED by Scripts/build-doc-pages.sh from flamenet-e2e/docs/SPEC.md.
Edits made HERE are deleted the next time that script runs.
Change the markdown source and re-run it.
source-sha256: 822aed4d9c79bbbb -->
source-sha256: 8d1cd3d583818c4f -->
<article class="band doc doc-generated">
<div class="wrap wrap-narrow">
<h1 id="flamenet-messenger-end-to-end-encryption-spec">Flamenet
@@ -418,11 +418,11 @@ receivers MUST ignore it unless the sender is a member of
</ul>
<p>The group <strong>roster</strong> lives in a server-side registry
(§8.9) in plaintext, and that is a real leak (§22). The
<strong>name</strong> does not, and no longer did as of 2026-08-30: it
was stored in a column the relay never read, which made it the single
most revealing string the server held for a field that bought it
nothing. Members learn it from each other over the pairwise ratchets
group traffic already uses, and hold it locally.</p>
<strong>name</strong> does not: it is not stored in a column the relay
never read, which made it the single most revealing string the server
held for a field that bought it nothing. Members learn it from each
other over the pairwise ratchets group traffic already uses, and hold it
locally.</p>
<p>Two deliveries carry it, and both are needed:</p>
<ul>
<li>an <strong>announcement</strong> at creation and on adding a member,
@@ -1004,8 +1004,8 @@ paragraph previously said a group pin could not resolve, because a group
transcript did not record a <code>mid</code> for each message. That
stopped being true: <code>Session.sendGroup</code> mints a
<code>mid</code> and records it, and <code>GroupPinResolvesTest</code>
(Android, 2026-09-04) pins a message in a group thread and reads the
message back out of the transcript by the id the pin names.</p>
(Android) pins a message in a group thread and reads the message back
out of the transcript by the id the pin names.</p>
<p>The pin stores an id and never a copy of the text, so it follows an
edit rather than quoting words that are no longer in the conversation,
and it does not outlive the message being deleted. The same test covers
@@ -1567,7 +1567,7 @@ message differently from a 1:1 message and cannot tell them apart.</p>
the owner. Cap: <strong>32 members</strong> including the creator
(fan-out cost is per-member; see §6.7).</p>
<p>A <code>name</code> MAY be present and MUST be ignored: it is
accepted only so clients written before 2026-08-30 keep working, and the
accepted only so clients written written before it keep working, and the
relay neither reads nor stores it. Clients from here on MUST omit the
field and tell members the name over the ratchet (§6.7).</p>
<p>Every member must be <strong>reachable</strong>: at least one
@@ -1683,13 +1683,12 @@ client-side. Harness: <code>test/relay.mjs</code>, which drives the
compiled Swift relay rather than a stand-in.</li>
<li><strong>iOS ↔︎ live server</strong>, v1 only: a real
<code>register → send → poll → decrypt</code> round trip against
production (2026-08-11).</li>
production.</li>
<li>Each of the <strong>iOS</strong> and <strong>Web</strong> engines
separately passes X3DH, Double Ratchet, out-of-order (skipped keys), GCM
tamper rejection and safety numbers.</li>
</ul>
<h3 id="android-verified-as-of-2026-08-29">Android, verified as of
2026-08-29</h3>
<h3 id="android-verified">Android, verified</h3>
<p>The Android engine exists and is exchanged with the web engine. It
lives in <a
href="https://gitlab.com/paulhitt/flamenet-messenger-android">flamenet-messenger-android</a>
@@ -1714,7 +1713,7 @@ initiator. Verified by <code>test/interop-swift-kotlin.mjs</code>, the
Swift driver compiled from the iOS app's own <code>Sources/E2E</code>,
the Kotlin side driven through <code>session_*</code> so the negotiation
decision belongs to <code>E2ESession</code> and not to the harness.
<strong>77 assertions, 0 failures, re-run 2026-09-07.</strong></p>
<strong>77 assertions, 0 failures.</strong></p>
<p>The rule for this section: establish what has been run.
<code>PQKEM.kt</code> being present is not evidence; the cross-engine
run is. A spec that understates what exists sends an auditor away from a
@@ -1722,10 +1721,9 @@ third of the client code, and one that overstates it sends them to code
that does not do what it says.</p>
<blockquote>
<p>Do not infer interop from structural similarity. An earlier version
of this section did exactly that, and it was wrong for months: the iOS
engine was structurally correct and still could not talk to the server,
because the two disagreed about a wire format no component test
exercised. Byte-identical constructions are necessary and nowhere near
of An engine can be structurally correct and still fail to talk to the
server, when the two disagree about a wire format no component test
exercises. Byte-identical constructions are necessary and nowhere near
sufficient. What makes the Android claim above sayable is the fixture
exchange, not the code reading correctly.</p>
</blockquote>
@@ -2131,8 +2129,7 @@ round-trips.</li>
<p>§10's warning applies with full force: <strong>do not write these
claims into the spec until the runs have actually happened.</strong> v1
asserted interop for months that was impossible.</p>
<p><strong>Status as of 2026-08-19</strong>: what has actually been run,
and on what:</p>
<p><strong>Status</strong>: what has actually been run, and on what:</p>
<table>
<thead>
<tr>
@@ -2580,7 +2577,7 @@ in the wrong order (§13.2) derives a different root key with no error
anywhere. Also pinned: a distinct ML-KEM key per step, so a reused one,
which still decrypts, and silently gives up the healing §13.1 exists
for, fails.</p>
<p>That run is also what found the shipped code was wrong. The engine
<p>That run is also what caught a defect in the shipped code. The engine
was attaching <code>pqdh</code>/<code>pqct</code> to messages for any
peer that published a PQ prekey, which every v2 device does. A build
with that would have broken every conversation with the released 0.6
@@ -2588,7 +2585,7 @@ client, different root key AND different AAD, so the first message never
opens. Sixteen Kotlin unit tests did not catch it and could not: both
sides of a self-test share the author's reading. Recorded because it is
the argument for this table's whole discipline.</p>
<p>Kotlin against Swift directly has now been run, as of 2026-08-31:
<p>Kotlin against Swift directly has been run:
<code>test/interop-swift-kotlin.mjs</code>, 22 assertions, both
directions, no web engine anywhere in the path. This closes an inference
that was never valid, two engines each agreeing with a third agree with
@@ -3434,12 +3431,11 @@ every group over time.</p>
<p>This is the largest remaining metadata leak in the protocol. Message
<em>contents</em> in a group are end-to-end encrypted; <em>who is in
it</em> is not protected at all.</p>
<p>The group's <strong>name</strong> was part of this leak and is not
any more: as of 2026-08-30 it is not stored on the relay at all (§6.7,
§8.9). That was the cheap half, the name was a column nothing read, so
removing it cost a compatibility placeholder and nothing else. The
roster is the expensive half, and it is still open; the rest of this
section is about that.</p>
<p>The group's <strong>name</strong> is not part of this leak: it is not
stored on the relay at all (§6.7, §8.9). That was the cheap half, the
name was a column nothing read, so removing it cost a compatibility
placeholder and nothing else. The roster is the expensive half, and it
is still open; the rest of this section is about that.</p>
<h3 id="222-what-it-would-take">22.2 What it would take</h3>
<p>The same primitive as §21, applied to membership rather than
sending:</p>
+3 -9
View File
@@ -11,15 +11,9 @@
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# The engine is a separate repository. These
# sources used to be `$repo/engine/...`, a subtree of the iOS app this lived
# inside, and the split left them pointing at a directory that does not exist.
# The script then died on its first `render`, so nobody regenerated the pages:
# /spec, /audit and /security drifted for a month while still being served as
# current. A public audit-scope page describing a system that has changed
# underneath it is worse than having no page.
#
# Found as a sibling checkout, or wherever FLAMENET_E2E_DIR points.
# The engine is a separate repository, found as a sibling checkout or wherever
# FLAMENET_E2E_DIR points. If it cannot be found, fail here: a public page
# describing a system that has changed underneath it is worse than no page.
engine="${FLAMENET_E2E_DIR:-$(cd "$here/../flamenet-e2e" 2>/dev/null && pwd || true)}"
if [ -z "$engine" ] || [ ! -f "$engine/docs/SPEC.md" ]; then
echo "cannot find the flamenet-e2e checkout." >&2
@@ -2,10 +2,9 @@
Description=Daily sweep of GitLab-runner cache volumes
[Timer]
# 🔴 Daily, not weekly. Measured 2026-09-08: 3.7G of runner cache accumulated
# in the few hours of CI after the disk was cleared that morning. A weekly
# sweep would let ~50G build up between runs, which is the size that filled
# the disk and took the database down in the first place.
# Daily, not weekly. CI accumulates roughly 3.7G of runner cache in a few
# hours, so a weekly sweep would let ~50G build up between runs: enough to
# fill the disk and take the database down.
OnCalendar=*-*-* 04:30:00
# The box is not always up at 04:30, and a sweep that silently never runs is
# the same as no sweep.
+3 -6
View File
@@ -108,12 +108,9 @@ LP=$(curl -s -m 5 -o /dev/null -w '%{http_code}' http://127.0.0.1:${PORT}/)
# world-read bit, the binary was healthy, the page was not. Render every
# public page, not just the front door.
#
# Read out of the source, not typed here. The list used to be five
# hand-written names out of nineteen pages, and it drifted the moment anybody
# added one: /metadata, /comparison, /spec and /self-hosting, the four this
# project points people at when it makes a claim about what it stores, were
# never rendered by the gate at all. A deploy that broke one of them would have
# passed, exactly the way the donate page did before this loop existed.
# Read out of the source, not typed here. A hand-written list drifts the
# moment anybody adds a page, and a deploy that broke an unlisted page would
# pass the gate.
#
# `Entrypoint.swift` has the real table, one `("slug", "title", …)` per page.
# Deriving from it means a page added tomorrow is gated tomorrow, with nobody
+2 -2
View File
@@ -13,8 +13,8 @@ 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.
Any SQL injection or path that reaches a query with attacker-influenced input
used to become full cluster control, and superuser can run
Without it, any SQL injection or path that reaches a query with
attacker-influenced input becomes full cluster control, and superuser can run
`COPY … TO PROGRAM`, which turns a SQL flaw into remote code execution on the
database host. That single fact is why this was worth doing regardless of how
much confidence anyone has in the query layer.
+12 -14
View File
@@ -1,17 +1,15 @@
# Where plaintext can come to rest
> Update, 2026-09-04, the question this document asks has changed.
> The relay has no route that accepts a readable message. That route, its
> sibling on the signed surface, the `messages` table, and `FN_AT_REST_SECRET`,
> the key the server held over those bodies, are all gone. See **"The plaintext
> transport is gone"** at the end.
>
> 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
> surface, the `messages` table, and `FN_AT_REST_SECRET`, the key the server
> held over those bodies. See **"The plaintext transport is gone"** at the end.
>
> The findings below are kept as written rather than edited to match. Finding
> 2 in particular, old row versions surviving an overwrite, is about Postgres,
> not about messages, and applies to every table here.
> The inventory below was taken while that route still existed, and is kept as
> taken. Finding 2 in particular, old row versions surviving an overwrite, is
> about Postgres, not about messages, and applies to every table here.
An inventory of the relay's storage, measured on production 2026-09-04. The
An inventory of the relay's storage, measured on production. The
question is deliberately narrow: **not what is decrypted in memory to be
handled, that is most of the code and is fine, but what is written to disk,
sent elsewhere, or left behind after an overwrite.**
@@ -98,7 +96,7 @@ an expectation rather than a measurement.
# Identifying data at the edge
The section above is about the database. This one is about the IP addresses that
reach the box, measured 2026-09-04.
reach the box.
## The edge access log, the big surface
@@ -212,7 +210,7 @@ are the same shape: a monitor reporting on itself rather than on the system.
---
## The plaintext transport is gone (relay#50, 2026-09-04)
## The plaintext transport is gone (relay#50)
The decision this inventory was written to inform has been taken: remove it.
@@ -243,8 +241,8 @@ than fixed, which is the better outcome of the two.
Checked rather than assumed, in four ways:
1. **The table was empty**: 0 live rows and 0 dead tuples, finding 4 above.
2. **No client called it.** iOS removed `send(to:body:)` and `poll` on
2026-08-29 with a comment saying the route was left for other callers;
2. **No client called it.** iOS had removed `send(to:body:)` and `poll`,
leaving a comment saying the route was there for other callers;
Android never had them; a grep of every local repository found the routes
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