Remove the plaintext transport, and the key that made it possible (relay#50)

relay#50 asked which of two true-but-contradictory public claims should
govern: /comparison says "there is no plain mode to switch to" (true of the
apps) and /privacy described what happens to a message sent in the clear
(true of the server). The owner chose to remove it, which makes the first
sentence true of the whole system.

Gone: POST/GET /api/flamenet/v1/messenger/messages, and the same transport
on the signed surface — POST /messenger/messages, GET /messenger/thread/:id,
POST /messenger/read, GET /messenger/unread. With them, the `messages`
table (DropMessages migration), the retention sweep's message branch, and
the purge on account deletion.

🔴 **The relay now holds no key to any message content.** AtRestCrypto and
FN_AT_REST_SECRET existed only to seal those bodies; removing the route
removed the reason for the key. Worth stating because it is a stronger claim
than "the route is gone" — and because that key's default was the issuer
audience, a public value, which its own comment called "wrong as a secret".
Any deployment that never set the variable was resting "encrypted at rest"
on a non-secret. Now moot rather than fixed.

Checked before dropping, not assumed: the table held 0 live rows and 0 dead
tuples; iOS removed its callers on 2026-08-29 and Android never had any; a
grep of every local repo found the routes named only in the conformance
harness; and an hour of edge access log (3,931 requests) shows every message
request going to /e2e/*.

⚠️ Removing the transport exposed two controls that had quietly stopped
meaning anything. `clear` marked a thread read-up-to a message id, and there
are no ids left. `disappear` stored a lifetime the relay applied when
writing a plaintext row — both apps already do this inside the sealed
envelope, which is the only place it can work, and neither ever called the
route. Both now refuse with a 409, following the rule this routing code
already states one screen away: a privacy control that reports success and
does nothing is worse than one that is missing.

The proof that the routes are really unreachable is an HTTP one, in
EndToEndJourneyTests: a well-formed, fully authenticated send that gets a
404. Deliberately 404 and not "anything but 200" — a 401 would mean the
route still existed and the test had merely failed to authenticate.

639 tests in 57 suites pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-09-04 21:23:22 -04:00
co-authored by Claude Opus 5
parent ad95985589
commit 0f78fc05a5
27 changed files with 374 additions and 921 deletions
-2
View File
@@ -1,7 +1,6 @@
# Copy to .env and fill in. Generate the three secrets with:
#
# openssl rand -hex 32 # POSTGRES_PASSWORD
# openssl rand -base64 32 # FN_AT_REST_SECRET
#
# POSTGRES_PASSWORD is hex, not base64, and that is not a style choice: it is
# interpolated into `postgres://flamenet:<password>@db:5432/flamenet`, where
@@ -23,7 +22,6 @@
FN_HOST_PORT=8080
POSTGRES_PASSWORD=
FN_AT_REST_SECRET=
FN_ISSUER_PRIVATE_KEY=
FN_ISSUER_PUBLIC_KEY=
+1 -1
View File
@@ -26,7 +26,7 @@
<tbody>
<tr>
<th scope="row">Encrypted end to end by default</th>
<td class="mine"><strong class="yes">Yes</strong><br><small>Every conversation. There is no plain mode to switch to.</small></td>
<td class="mine"><strong class="yes">Yes</strong><br><small>Every conversation. There is no plain mode to switch to &mdash; the server has no route that accepts a readable message.</small></td>
<td><strong class="yes">Yes</strong></td>
<td><strong class="no">No</strong><br><small>Only in Secret Chats, which are one-to-one and have to be started deliberately.</small></td>
<td><strong class="yes">Yes</strong><br><small>Between Apple devices, and with Android over RCS since iOS 26.5.</small></td>
+1 -17
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: 6e383dace6a281bc -->
source-sha256: e7df9a44b7dc7de3 -->
<article class="band doc doc-generated">
<div class="wrap wrap-narrow">
<h1 id="configuring-a-relay">Configuring a relay</h1>
@@ -77,12 +77,6 @@ these.</td>
<td>Base64 Ed25519 public key of whatever mints this relay's tokens.
Never fetched over the network.</td>
</tr>
<tr>
<td><code>FN_AT_REST_SECRET</code></td>
<td>Encrypts stored bodies for conversations with encryption turned off.
Defends against a stolen database dump. It does not defend against you —
you hold the key.</td>
</tr>
</tbody>
</table>
<h2 id="identity--minting-your-own-tokens">Identity — minting your own
@@ -478,16 +472,6 @@ needed.</p>
<td><code>30</code></td>
<td></td>
</tr>
<tr>
<td><code>FN_MESSAGE_RETENTION_DAYS</code></td>
<td><code>90</code></td>
<td>Plaintext-mode messages only.</td>
</tr>
<tr>
<td><code>FN_READ_MESSAGE_RETENTION_DAYS</code></td>
<td><code>30</code></td>
<td></td>
</tr>
</tbody>
</table>
<h2 id="mail-calls-donations">Mail, calls, donations</h2>
+8 -5
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: cddcb5a3308c96cc -->
source-sha256: 7e47c2413873c194 -->
<article class="band doc doc-generated">
<div class="wrap wrap-narrow">
<h1 id="running-your-own-relay">Running your own relay</h1>
@@ -27,7 +27,6 @@ cat &gt; .env &lt;&lt;&#("#")39;ENV&#("#")39;
FN_RELAY_AUDIENCE=relay.example.com
FN_ISSUER_PRIVATE_KEY=&lt;the seed from issuer.pem&gt;
FN_ISSUER_PUBLIC_KEY=&lt;the public key from issuer.pem&gt;
FN_AT_REST_SECRET=&lt;32+ random bytes&gt;
FN_POSTGRES_URL=postgres://flamenet:...@db/flamenet
ENV
@@ -175,9 +174,13 @@ went from one device to another and when, and how large it was.
Retention windows for all of it are in <a
href="/configuration">Configuration</a>, and the relay purges on its own
timer with nothing external required.</p>
<p>⚠️ <strong><code>FN_AT_REST_SECRET</code> defends against a stolen
database dump. It does not defend against you</strong> — you hold the
key. Tell your users that plainly.</p>
<p>🔴 <strong>This relay holds no key to any message.</strong> 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>
+12 -8
View File
@@ -47,8 +47,6 @@
<li><strong>Used one-time prekeys</strong> — deleted after 7 days.</li>
<li><strong>Your contact list</strong> — stored while presence needs it; removable by you. "Take my contact list off the server" in the app deletes the server's copy for good, and your contacts live only in your devices' encrypted stores from then on.</li>
<li><strong>Devices silent for #(retention.deviceExpiryDays) days</strong> — expired: keys unpublished, queued mail deleted. The account's newest device is kept, and an account holds at most #(retention.maxDevices) devices.</li>
<li><strong>Unencrypted messages, once read</strong> — deleted after #(retention.legacyReadDays) days.</li>
<li><strong>Unencrypted messages, regardless</strong> — deleted after #(retention.legacyMessageDays) days.</li>
</ul>
<p>
An operator running their own server can configure these; the page always shows the
@@ -90,13 +88,19 @@
forgotten about. The app searches what it already has instead.
</p>
<h2>Unencrypted conversations</h2>
<h2>There is no unencrypted conversation</h2>
<p>
Messages in a conversation with encryption turned off are stored encrypted at rest,
under a key held by the operator. That protects against a stolen database copy on
its own. It does not protect against the operator, and it does not protect against a
lawful demand made to the operator. Treat those conversations as readable by whoever
runs the server.
This section used to describe what happened to a message sent with encryption
turned off: it was stored under a key the operator held, which protects against a
stolen copy of the database and against nothing else. That path is gone. On
2026-09-04 the routes that accepted a readable message were removed and the table
that held them was dropped, so there is no longer a way &mdash; for this app, an
older one, or anybody else's &mdash; to put a readable message on this server.
</p>
<p>
We are saying what changed rather than quietly deleting the paragraph, because
anyone who read the old one made a decision based on it and deserves to know it no
longer applies.
</p>
<h2>This website</h2>
+40 -16
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: 6826824aee365bf8 -->
source-sha256: add780e874497f30 -->
<article class="band doc doc-generated">
<div class="wrap wrap-narrow">
<h1 id="flamenet-messenger--end-to-end-encryption-spec">Flamenet
@@ -2576,7 +2576,7 @@ status for §13 and §14</h2>
<td>✅ sealed mode + delivery keys</td>
<td>✅ wired end to end</td>
<td>✅ wired end to end</td>
<td>⚠️ engine verified, not wired end to end</td>
<td>✅ wired end to end</td>
</tr>
</tbody>
</table>
@@ -2627,20 +2627,44 @@ gate this project already shipped once.</p>
not run it — CI is Linux. Release-time check, same standing as
<code>Scripts/selfhost-smoke.sh</code>:
<code>npm run test:interop:apps</code>.</p></li>
<li><p><strong>§14 on Android</strong> is ⚠️, and the reason moved
rather than went away. The engine half is now verified cross-engine —
six assertions seal in one engine and open in the other, both
directions, including a seal addressed elsewhere that must not open.
What is unproven is the column's actual claim, <em>wired end to
end</em>: no recorded run shows an Android client sealing and unsealing
against a relay.</p>
<p>⚠️ <strong><code>LiveSealedSenderTest</code> is not that evidence,
and reads as though it is.</strong> It returns early when
<code>FN_LIVE_FLEET</code> is unset, and JUnit records the early return
as <strong>passed</strong> —
<code>tests="2" skipped="0" failures="0"</code> on a run that did
nothing. A test that no-ops into green is worse than an absent
one.</p></li>
<li><p><strong>§14 on Android is now ✅</strong>, and the caveat that
used to sit here was right about what was missing. It said the engine
half was verified cross-engine but that <em>wired end to end</em> was
unproven, because no recorded run showed an Android client sealing and
unsealing against a relay — and it warned that
<code>LiveSealedSenderTest</code> was not that evidence, because it
returned early when <code>FN_LIVE_FLEET</code> was unset and JUnit
recorded the early return as <strong>passed</strong>.</p>
<p>Both halves are settled. The suite now announces a skip instead of
passing through, and it has been run against a relay.</p>
<p>⚠️ <strong>The discriminator, since this is exactly the claim that
reads as proven when it is not.</strong> Two runs, same command, same
day:</p>
<table>
<thead>
<tr>
<th></th>
<th>result</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>FN_LIVE_FLEET</code> unset</td>
<td><code>tests=2 skipped=2 failures=0</code> — reported
<strong>SKIPPED</strong></td>
</tr>
<tr>
<td>pointed at <code>relay1.flamenet.io</code></td>
<td><code>tests=2 skipped=0 failures=0 time=8.604s</code> —
<strong>PASSED</strong></td>
</tr>
</tbody>
</table>
<p>A no-op returns in milliseconds and reports zero skips; this took
eight and a half seconds and the unconfigured run is visibly a skip. The
two assertions are <em>a sealed message arrives and the relay records no
sender</em> and <em>rotating the delivery key refuses submissions
holding the old one</em>.</p></li>
</ul>
<p><strong>§13</strong> is complete and verified end to end on the web:
32 tests in <code>test/ratchet.mjs</code> covering lockstep advance with
-100
View File
@@ -1,100 +0,0 @@
import Crypto
import Foundation
/// At-rest encryption for stored message bodies.
///
/// This is **not** the end-to-end layer. There, the server never holds a key
/// and an encrypted thread writes no message row at all. This covers the other
/// case — a conversation with encryption turned off, stored so search and
/// previews can work.
///
/// What it defends against: a database dump on its own. The key lives in
/// configuration, so the table alone does not read.
///
/// What it does **not** defend against: us. We hold the key. A subpoena, an
/// operator, or anyone with both the dump and the config reads every row. Any
/// claim that there is "no decryptable archive to breach, subpoena, or mine" is
/// false for as long as this table has rows in it, and that is a statement
/// about the marketing copy as much as about the code.
///
/// Blob layout is byte-compatible with the PHP implementation
/// (`base64(iv[12] || tag[16] || ciphertext)`) so rows can be carried across
/// from a the previous platform install without re-encryption. Note the ordering: this is
/// **not** what `AES.GCM.SealedBox.combined` produces, which puts the tag last.
/// Assembling by hand is deliberate, not an oversight.
enum AtRestCrypto {
/// iv(12) + tag(16) + at least one byte of ciphertext.
static let minimumBlobBytes = 29
/// Derive the 32-byte key exactly as the PHP did — `sha256("fnmsg-" || secret)`
/// truncated to 32 bytes — so a migrated row opens under the same secret.
static func key(from secret: String) -> SymmetricKey {
let digest = SHA256.hash(data: Data("fnmsg-\(secret)".utf8))
return SymmetricKey(data: Data(digest))
}
/// Seal a body.
///
/// The PHP returned the plaintext unchanged when OpenSSL was missing,
/// reasoning that losing a message is worse than storing it as it was
/// stored yesterday. That fallback is not carried over: AES-GCM is not
/// optional in swift-crypto, so the branch could only ever fire on a
/// genuine fault — and silently writing plaintext into a column everything
/// downstream believes is sealed is a worse failure than a refused send.
static func seal(_ plaintext: String, using key: SymmetricKey) throws -> String {
let nonce = AES.GCM.Nonce()
let box = try AES.GCM.seal(Data(plaintext.utf8), using: key, nonce: nonce)
var blob = Data(nonce)
blob.append(box.tag)
blob.append(box.ciphertext)
return blob.base64EncodedString()
}
/// Open a stored body.
///
/// Anything that is not one of our envelopes comes back untouched. Rows
/// written before at-rest encryption shipped are plaintext, and a body that
/// will not decrypt should still render as itself rather than as base64
/// noise in somebody's conversation.
///
/// The cost of that leniency is that it cannot distinguish "this was never
/// encrypted" from "this will not decrypt any more" — a rotated secret
/// looks exactly like a legacy row. Rotating the secret without re-sealing
/// the table therefore turns every message into gibberish that renders,
/// rather than an error that reports.
static func open(_ stored: String, using key: SymmetricKey) -> String {
guard !stored.isEmpty else { return "" }
guard let raw = Data(base64Encoded: stored), raw.count >= minimumBlobBytes else {
return stored // never encrypted
}
let nonceBytes = raw.prefix(12)
let tag = raw.dropFirst(12).prefix(16)
let ciphertext = raw.dropFirst(28)
guard let nonce = try? AES.GCM.Nonce(data: nonceBytes),
let box = try? AES.GCM.SealedBox(nonce: nonce, ciphertext: ciphertext, tag: tag),
let plain = try? AES.GCM.open(box, using: key),
let text = String(data: plain, encoding: .utf8)
else {
// Either a plaintext body that happened to be valid base64, or a
// real failure: a rotated secret, or a corrupt row.
return stored
}
return text
}
}
/// The at-rest key, carried in a form that is `Sendable` on every platform.
///
/// Apple's CryptoKit marks `SymmetricKey` `Sendable`; swift-crypto does not, so
/// a `Sendable` struct storing one compiles on macOS and fails on Linux under
/// Swift 6 concurrency checking. Holding the raw bytes and materialising the
/// key at the use site is portable, and the key is read-only anyway.
struct AtRestKey: Sendable {
private let raw: Data
init(secret: String) {
self.raw = Data(SHA256.hash(data: Data("fnmsg-\(secret)".utf8)))
}
var key: SymmetricKey { SymmetricKey(data: raw) }
}
+12 -15
View File
@@ -237,17 +237,17 @@ struct Config: Sendable {
let issuerSigningKeyRaw: Data?
let tokenLifetime: Int
/// Secret the at-rest message key is derived from.
///
/// Defaults to the issuer audience, which is stable per deployment and
/// wrong as a secret — but the plaintext path is opt-in and a relay that
/// refuses to boot over a feature nobody enabled is worse. Set
/// FN_AT_REST_SECRET before storing anything on that path.
let atRestSecret: String
/// How long the non-E2E path keeps a message. The plugin kept them forever.
let messageRetentionDays: Int
let readMessageRetentionDays: Int
// 🔴 **`atRestSecret` is gone with the plaintext transport (relay#50).**
// It derived a key this server held over message bodies this server could
// therefore read. Its own comment admitted the default was the issuer
// audience — a value that is public — so on any deployment that never set
// FN_AT_REST_SECRET the "encrypted at rest" claim rested on a
// non-secret. Removing the route removed the reason for the key, and the
// relay now holds no content key at all.
//
// ⚠️ FN_AT_REST_SECRET, FN_MESSAGE_RETENTION_DAYS and
// FN_READ_MESSAGE_RETENTION_DAYS are no longer read. They are harmless if
// left set; see docs/PLAINTEXT.md for removing them from a deployment.
enum ConfigError: Error, CustomStringConvertible {
case missing(String)
@@ -389,10 +389,7 @@ struct Config: Sendable {
turnTTL: int("FN_TURN_TTL", 7200),
issuerSigningKeyRaw: env["FN_ISSUER_PRIVATE_KEY"]
.flatMap { $0.isEmpty ? nil : Data(base64Encoded: $0) },
tokenLifetime: int("FN_TOKEN_LIFETIME", Issuer.defaultLifetime),
atRestSecret: env["FN_AT_REST_SECRET"] ?? audience,
messageRetentionDays: int("FN_MESSAGE_RETENTION_DAYS", 90),
readMessageRetentionDays: int("FN_READ_MESSAGE_RETENTION_DAYS", 30)
tokenLifetime: int("FN_TOKEN_LIFETIME", Issuer.defaultLifetime)
)
}
}
+5 -18
View File
@@ -297,7 +297,6 @@ func configure(_ app: Application) async throws {
// the routes, and they simply have nothing to open.
let messaging = MessagingRoutes(
auth: routes,
atRestKey: AtRestKey(secret: config.atRestSecret),
limits: makeLimiter("messaging", 3600))
try register(messaging: messaging, on: app)
@@ -368,8 +367,7 @@ func configure(_ app: Application) async throws {
// against, and the routes would refuse every call.
let mobile = MobileRoutes(
accounts: accounts,
atRestKey: AtRestKey(secret: config.atRestSecret),
limits: makeLimiter("mobile", 3600))
limits: makeLimiter("mobile", 3600))
try register(mobile: mobile, on: app)
// The shipped client signs up and signs in under the messenger prefix,
@@ -678,8 +676,6 @@ func register(routes r: Routes, on app: Application) throws {
attachmentDays: r.config.attachmentRetentionDays,
deviceExpiryDays: r.config.deviceExpiryDays,
maxDevices: r.config.maxDevicesPerAccount,
legacyMessageDays: r.config.messageRetentionDays,
legacyReadDays: r.config.readMessageRetentionDays,
// Read per-request: the storage flag is set later in
// configure than this loop runs.
deleteOnAck: req.application.storage[Routes.DeleteOnAckKey.self] ?? false)))
@@ -1042,15 +1038,9 @@ func register(messaging m: MessagingRoutes, on app: Application) throws {
}
return try await m.getPresence(req, userID: id, now: unixNow())
}
g.post("messages") { req in try await m.send(req, body: body(of: req), now: unixNow()) }
g.get("thread", ":id") { req in
guard let id = req.parameters.get("id", as: Int.self) else {
throw WireError.badRequest("Malformed user id.")
}
return try await m.thread(req, with: id, now: unixNow())
}
g.post("read") { req in try await m.markRead(req, body: body(of: req), now: unixNow()) }
g.get("unread") { req in try await m.unread(req, now: unixNow()) }
// messages / thread / read / unread were the plaintext transport. Gone
// with the `messages` table (relay#50) — nothing is registered here, so
// they 404 rather than answering.
g.post("conversation", ":id") { req in
guard let id = req.parameters.get("id", as: Int.self) else {
throw WireError.badRequest("Malformed user id.")
@@ -1083,8 +1073,7 @@ func register(mobile m: MobileRoutes, on app: Application) throws {
}
return try await m.removeBuddy(req, buddyID: id)
}
g.post("messages") { req in try await m.send(req, now: unixNow()) }
g.get("messages") { req in try await m.poll(req) }
// messages POST/GET were the plaintext transport (relay#50). Removed.
g.post("status") { req in try await m.status(req) }
g.post("conversation") { req in try await m.conversation(req) }
g.get("tier") { req in try await m.tier(req) }
@@ -1398,8 +1387,6 @@ struct RetentionFacts: Encodable {
let deviceExpiryDays: Int
let maxDevices: Int
/// The legacy non-E2E path's windows.
let legacyMessageDays: Int
let legacyReadDays: Int
/// True when an acknowledged envelope is deleted at the moment of the ack
/// rather than swept `deliveredDays` later.
let deleteOnAck: Bool
+28 -16
View File
@@ -30,21 +30,33 @@ final class Buddy: Model, @unchecked Sendable {
/// This is the surface that gets subpoenaed and the one the marketing copy has
/// to stay honest about. Anything claiming there is "no decryptable archive"
/// is false while this table has rows in it.
final class Message: Model, @unchecked Sendable {
static let schema = "messages"
/// The plaintext message table's schema name, kept only so the migrations
/// below can create and then drop it.
///
/// 🔴 **The model is gone on purpose (relay#50).** A `Message` type in scope is
/// an invitation to write one more row into a table this server can read, which
/// is the whole thing that was removed. The name is a string here precisely so
/// nothing can accidentally build a record out of it.
enum LegacyMessages { static let schema = "messages" }
@ID(custom: "id", generatedBy: .database) var id: Int?
@Field(key: "from_id") var fromID: Int
@Field(key: "to_id") var toID: Int
/// Sealed by `AtRestCrypto`. Never read this without going through it.
@Field(key: "body") var body: String
@Field(key: "is_read") var isRead: Bool
/// When this body should cease to exist, if the conversation sets a
/// lifetime. Enforced by the retention sweep, not by the client hiding it.
@OptionalField(key: "expires_at") var expiresAt: Date?
@Timestamp(key: "sent_at", on: .create) var sentAt: Date?
init() {}
/// Drop the plaintext message table (relay#50).
///
/// 🔴 **This deletes data, and it was checked first rather than assumed.**
/// Production held **0 live rows and 0 dead tuples** in `messages` when this was
/// written (2026-09-04, recorded in `docs/PLAINTEXT.md`); the iOS client removed
/// its callers on 2026-08-29 and Android never had any; and an hour of edge
/// access log shows every message request going to `/e2e/*`. A table with
/// nothing in it is the only kind worth dropping without a migration path.
///
/// ⚠️ `revert` recreates the table but cannot bring back rows. That is honest
/// rather than lossy-by-surprise: there were none to keep.
struct DropMessages: AsyncMigration {
func prepare(on database: any Database) async throws {
try await database.schema(LegacyMessages.schema).delete()
}
func revert(on database: any Database) async throws {
try await CreateMessage().prepare(on: database)
}
}
struct CreateBuddy: AsyncMigration {
@@ -66,7 +78,7 @@ struct CreateBuddy: AsyncMigration {
struct CreateMessage: AsyncMigration {
func prepare(on database: any Database) async throws {
try await database.schema(Message.schema)
try await database.schema(LegacyMessages.schema)
.field("id", .int, .identifier(auto: true))
.field("from_id", .int, .required)
.field("to_id", .int, .required)
@@ -83,7 +95,7 @@ struct CreateMessage: AsyncMigration {
try await sql?.raw("CREATE INDEX IF NOT EXISTS messages_expiry ON messages(expires_at)").run()
}
func revert(on database: any Database) async throws {
try await database.schema(Message.schema).delete()
try await database.schema(LegacyMessages.schema).delete()
}
}
+2 -1
View File
@@ -686,6 +686,7 @@ enum RelayMigrations {
// ⚠️ Appended, never inserted. Fluent runs these in registration
// order and records what it has run; putting a new one in the middle
// changes the order for a database that has already migrated.
CreateContentReport(), CreateVouchCode(), AddEmailVerification(), AddUsernameSkeleton()]
CreateContentReport(), CreateVouchCode(), AddEmailVerification(), AddUsernameSkeleton(),
DropMessages()]
}
}
+5 -26
View File
@@ -129,31 +129,11 @@ struct Retention: @unchecked Sendable {
blobs += 1
}
// The non-E2E path. The plugin kept these forever: not just
// the bodies, but a permanent record of who wrote to whom and
// when, which is the more durable disclosure of the two.
//
// Expiry first, because a disappearing message that outlives
// its deadline is a broken promise rather than a full disk.
// Counted before deleting, because Fluent's delete() reports
// nothing and a sweep that cannot say what it removed is the
// same unverifiable claim the old external-cron purge used to make.
let lapsed = try await Message.query(on: db)
.filter(\.$expiresAt < Date()).count()
try await Message.query(on: db)
.filter(\.$expiresAt < Date()).delete()
let readCutoff = cutoff(config.readMessageRetentionDays)
let readOld = try await Message.query(on: db)
.filter(\.$isRead == true).filter(\.$sentAt < readCutoff).count()
try await Message.query(on: db)
.filter(\.$isRead == true).filter(\.$sentAt < readCutoff).delete()
let allCutoff = cutoff(config.messageRetentionDays)
let anyOld = try await Message.query(on: db)
.filter(\.$sentAt < allCutoff).count()
try await Message.query(on: db)
.filter(\.$sentAt < allCutoff).delete()
// The plaintext `messages` table was swept here — expiry
// first, then read-retention, then absolute retention. The
// table is gone (relay#50), so there is nothing left to sweep:
// an end-to-end encrypted message is deleted by the delivery
// that collects it, not by a timer that can read it.
// 🔴 **Password-reset tokens were never swept.** Every model
// above is bounded; these are not, and they are the one row
@@ -220,7 +200,6 @@ struct Retention: @unchecked Sendable {
"delivered": .string("\(delivered)"),
"undelivered": .string("\(undelivered)"),
"attachments": .string("\(blobs)"),
"messages": .string("\(lapsed + readOld + anyOld)"),
"resets": .string("\(resets)"),
"devices": .string("\(expiredDevices)"),
"sends_sealed": .string("\(sends.sealed)"),
+3 -2
View File
@@ -966,8 +966,9 @@ extension AccountRoutes {
try await Block.query(on: db).filter(\.$blockedID == userID).delete()
try await ConversationState.query(on: db).filter(\.$userID == userID).delete()
try await ConversationState.query(on: db).filter(\.$otherID == userID).delete()
try await Message.query(on: db).filter(\.$fromID == userID).delete()
try await Message.query(on: db).filter(\.$toID == userID).delete()
// The plaintext `messages` table was purged here on account deletion.
// It no longer exists (relay#50); sealed envelopes are addressed to a
// device and are removed with that device's rows below.
try await Notification.query(on: db).filter(\.$userID == userID).delete()
try await Envelope.query(on: db).filter(\.$toUser == userID).delete()
try await Envelope.query(on: db).filter(\.$fromUser == userID).delete()
+24 -102
View File
@@ -20,7 +20,6 @@ import Vapor
/// makes it an activity feed on any member for anyone who asks.
struct MessagingRoutes: Sendable {
let auth: Routes
let atRestKey: AtRestKey
let limits: any RateLimiting
/// How stale a heartbeat may be before someone reads as offline.
@@ -183,99 +182,15 @@ struct MessagingRoutes: Sendable {
return now.timeIntervalSince(lastSeen) < onlineWindow * 2
}
// MARK: - Messages
func send(_ request: Request, body: Data, now: Int) async throws -> Response {
let me = try await auth.authenticate(
request, body: body, path: "/messenger/messages", now: now)
let input = try auth.decodeBody(SendMessageRequest.self, from: body)
let text = input.body.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { throw WireError.badRequest("Message body required.") }
guard text.count <= 8000 else { throw WireError.badRequest("Message is too long.") }
guard input.to > 0, input.to != me.userID,
try await User.find(input.to, on: request.db) != nil
else { throw WireError.notFound("No such user.") }
guard await limits.consume("msg:\(me.userID)", limit: 600, now: now) else {
throw WireError.rateLimited("Too many messages sent recently.")
}
// The same single check the mobile path asks. Two send routes, one
// answer to "may this person write to that one".
guard try await !Block.blocks(input.to, me.userID, on: request.db) else {
throw WireError.forbidden("That member is not accepting messages from you.")
}
// The recipient's own view decides the lifetime: a disappearing
// conversation is a property of the conversation, and the sender does
// not get to override how long their words persist on someone else's
// device.
let theirState = try await state(for: input.to, other: me.userID, on: request.db)
let m = Message()
m.fromID = me.userID
m.toID = input.to
m.body = try AtRestCrypto.seal(text, using: atRestKey.key)
m.isRead = false
if let ttl = theirState.disappearAfter, ttl > 0 {
m.expiresAt = Date().addingTimeInterval(TimeInterval(ttl))
}
try await m.create(on: request.db)
return try .json(SendMessageResponse(
ok: true, id: m.id ?? 0,
// Told plainly, because a sender should know whether they are
// writing to an inbox or to a request that may never be opened.
pending: try await isRequest(from: me.userID, to: input.to, on: request.db)))
}
func thread(_ request: Request, with other: Int, now: Int) async throws -> Response {
let me = try await auth.authenticate(
request, body: Data(), path: "/messenger/thread/\(other)", now: now)
let mine = try await state(for: me.userID, other: other, on: request.db)
let after = max(request.query[Int.self, at: "after"] ?? 0, mine.clearedToID)
let limit = min(request.query[Int.self, at: "limit"] ?? 100, 200)
let rows = try await Message.query(on: request.db)
.group(.or) { pair in
pair.group(.and) { $0.filter(\.$fromID == me.userID).filter(\.$toID == other) }
pair.group(.and) { $0.filter(\.$fromID == other).filter(\.$toID == me.userID) }
}
.filter(\.$id > after)
.sort(\.$id, .ascending)
.limit(limit)
.all()
// Expiry is enforced on read as well as by the sweep. The sweep runs
// hourly; a message whose moment has passed must not be readable in
// the meantime just because the timer has not fired.
let live = rows.filter { ($0.expiresAt.map { $0 > Date() }) ?? true }
return try .json(ThreadResponse(
with: other,
messages: live.map {
.init(id: $0.id ?? 0, from: $0.fromID, to: $0.toID,
body: AtRestCrypto.open($0.body, using: atRestKey.key),
sent_at: iso8601($0.sentAt),
expires_at: $0.expiresAt.map(iso8601))
}))
}
func markRead(_ request: Request, body: Data, now: Int) async throws -> Response {
let me = try await auth.authenticate(
request, body: body, path: "/messenger/read", now: now)
let input = try auth.decodeBody(BuddyRequest.self, from: body)
try await Message.query(on: request.db)
.filter(\.$toID == me.userID).filter(\.$fromID == input.buddy_id)
.set(\.$isRead, to: true).update()
return try .json(OKResponse(ok: true))
}
func unread(_ request: Request, now: Int) async throws -> Response {
let me = try await auth.authenticate(
request, body: Data(), path: "/messenger/unread", now: now)
let total = try await Message.query(on: request.db)
.filter(\.$toID == me.userID).filter(\.$isRead == false).count()
return try .json(UnreadResponse(unread: total))
}
// 🔴 **The plaintext transport is gone (relay#50, 2026-09-04).**
//
// `send`, `thread`, `markRead` and `unread` lived here. They moved readable
// message bodies over a signed request and stored them sealed under a key
// this server holds — which is a different promise from end-to-end
// encryption, and a weaker one. Removed with the `messages` table.
//
// The only caller was the conformance harness, which tested these routes
// against themselves; production traffic is entirely `/e2e/*`.
// MARK: - Conversation settings
@@ -296,14 +211,21 @@ struct MessagingRoutes: Sendable {
row.encMode = mode
}
if let accept = input.accept, accept { row.acceptedAt = row.acceptedAt ?? Date() }
if let ttl = input.disappear_after {
// 0 means off. A negative or absurd value is a client bug, and a
// lifetime longer than retention would be a promise the sweep
// breaks.
guard ttl >= 0, ttl <= 30 * 24 * 3600 else {
throw WireError.badRequest("disappear_after must be 0–2592000 seconds.")
}
row.disappearAfter = ttl == 0 ? nil : ttl
if input.disappear_after != nil {
// 🔴 **Refused, out loud (relay#50).** This stored a lifetime the
// relay applied when it wrote a plaintext message. With that
// transport gone, the number would be saved and then consulted by
// nothing — a disappearing-messages control that answers 200 and
// lets a conversation live forever.
//
// ⚠️ Both apps already do this themselves, inside the sealed
// envelope, which is the only place it can work: a server that
// could enforce a lifetime would be a server that could read the
// message it was expiring. Neither client calls this route.
throw WireError("disappear_not_supported",
"Disappearing messages are set in the app, on the conversation "
+ "itself. This server cannot see your messages, so it cannot "
+ "expire them for you.", 409)
}
try await row.save(on: request.db)
return try .json(OKResponse(ok: true))
+49 -126
View File
@@ -25,7 +25,6 @@ import Vapor
/// no weaker, and no better.
struct MobileRoutes: Sendable {
let accounts: AccountRoutes
let atRestKey: AtRestKey
let limits: any RateLimiting
/// Resolve the Basic credential to a user, or refuse.
@@ -54,9 +53,17 @@ struct MobileRoutes: Sendable {
var out: [MobileBuddyList.Row] = []
for row in rows {
let unread = try await Message.query(on: request.db)
.filter(\.$toID == me).filter(\.$fromID == row.buddyID)
.filter(\.$isRead == false).count()
// 🔴 **Always zero, and that is the truthful answer.** This
// counted unread rows in the plaintext `messages` table, which no
// longer exists (relay#50). Unread state for an encrypted
// conversation is not knowable here — the relay cannot open a
// sealed envelope to see whether it has been read — so the client
// keeps that count itself.
//
// ⚠️ The field stays on the wire because the shipped iOS client
// decodes it as a non-optional `Int`; removing it would fail
// decoding on every build already in the world.
let unread = 0
let buddyUser = try await User.find(row.buddyID, on: request.db)
out.append(.init(
id: row.buddyID,
@@ -170,89 +177,17 @@ struct MobileRoutes: Sendable {
return try .json(MobileMessageOnly(message: "Forgotten."))
}
// MARK: - POST /messenger/messages
func send(_ request: Request, now: Int) async throws -> Response {
let me = try await caller(request)
let input = try request.content.decode(SendMessageRequest.self)
let text = input.body.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty, text.count <= 8000 else {
throw WireError.badRequest("Message body required.")
}
guard input.to != me, try await User.find(input.to, on: request.db) != nil else {
throw WireError.notFound("No such user.")
}
guard await limits.consume("msg:\(me)", limit: 600, now: now) else {
throw WireError.rateLimited("Too many messages sent recently.")
}
// One check, asked by every send path. The plugin routed both through a
// shared helper precisely so the block list and the send-path check
// could not drift apart, and that is worth keeping.
guard try await !Block.blocks(input.to, me, on: request.db) else {
throw WireError.forbidden("That member is not accepting messages from you.")
}
// Same rules as the designed route: the recipient's conversation
// decides the lifetime. Two entry points, one policy.
let theirTTL = try await ConversationState.query(on: request.db)
.filter(\.$userID == input.to).filter(\.$otherID == me).first()?.disappearAfter
let m = Message()
m.fromID = me; m.toID = input.to
m.body = try AtRestCrypto.seal(text, using: atRestKey.key)
m.isRead = false
if let ttl = theirTTL, ttl > 0 {
m.expiresAt = Date().addingTimeInterval(TimeInterval(ttl))
}
try await m.create(on: request.db)
let mine = try await User.find(me, on: request.db)
// A stranger's first message raises a notification rather than landing
// silently; collapsed, so forty messages do not make forty entries.
if try await isFirstContact(from: me, to: input.to, on: request.db) {
try await Notification.raise(
"message_request", for: input.to,
message: "\(mine?.displayName ?? "Someone") wants to message you.",
on: request.db)
}
return try .json(MobileSendResponse(
id: m.id ?? 0,
who: mine?.displayName ?? "Me",
body: text,
time: shortTime(m.sentAt ?? Date())))
}
// MARK: - GET /messenger/messages?buddy=&after=
func poll(_ request: Request) async throws -> Response {
let me = try await caller(request)
guard let buddy = request.query[Int.self, at: "buddy"] else {
throw WireError.badRequest("buddy is required.")
}
let after = request.query[Int.self, at: "after"] ?? 0
// Inbound only. The client already holds what it sent, and echoing it
// back would render every outgoing message twice.
let rows = try await Message.query(on: request.db)
.filter(\.$toID == me).filter(\.$fromID == buddy).filter(\.$id > after)
.sort(\.$id, .ascending).limit(200).all()
let live = rows.filter { ($0.expiresAt.map { $0 > Date() }) ?? true }
if !live.isEmpty {
try await Message.query(on: request.db)
.filter(\.$toID == me).filter(\.$fromID == buddy)
.set(\.$isRead, to: true).update()
}
let name = (try await User.find(buddy, on: request.db))?.displayName ?? "User #\(buddy)"
return try .json(MobilePollResponse(
messages: live.map {
.init(id: $0.id ?? 0, who: name,
body: AtRestCrypto.open($0.body, using: atRestKey.key),
time: shortTime($0.sentAt ?? Date()))
},
status: try await visibleStatus(of: buddy, to: me, on: request.db).rawValue))
}
// 🔴 **The plaintext transport is gone (relay#50, 2026-09-04.)**
//
// `POST /messenger/messages` took a readable `body` string, sealed it with
// a key this server holds, and stored it. `GET /messenger/messages`
// handed it back. Sealed at rest is not end-to-end encrypted: whoever runs
// the relay could open every one of them.
//
// Both are removed rather than deprecated, along with the `messages` table
// itself, so the sentence on /comparison — "there is no plain mode to
// switch to" — is now true of the whole system and not just of the apps.
// The iOS client dropped its callers on 2026-08-29; Android never had any.
// MARK: - POST /messenger/status
@@ -311,30 +246,39 @@ struct MobileRoutes: Sendable {
case "archive": row.archived = true
case "unarchive": row.archived = false
case "clear":
// Personal to this viewer: the other side keeps their copy, which
// is what the plugin did and what the word means here.
let newest = try await Message.query(on: request.db)
.group(.or) { g in
g.group(.and) { $0.filter(\.$fromID == me).filter(\.$toID == input.buddy) }
g.group(.and) { $0.filter(\.$fromID == input.buddy).filter(\.$toID == me) }
}
.sort(\.$id, .descending).first()
row.clearedToID = newest?.id ?? row.clearedToID
// 🔴 **Refused, out loud.** This marked the viewer's plaintext
// thread read-up-to a message id, and that table is gone
// (relay#50). There is nothing on the server left for it to clear:
// an encrypted conversation exists only in the two devices holding
// it, so clearing it is the client's job and always was.
//
// ⚠️ Answering 200 here would be the exact failure this file
// already refuses one screen down for unknown statuses — a privacy
// control that reports success and does nothing is worse than one
// that is missing.
throw WireError("clear_not_supported",
"Conversations are held on your devices, not here, so this "
+ "server has nothing to clear. Clear it in the app.", 409)
case "accept":
// Idempotent, and never un-accepts: there is no "unaccept" action
// because the way to undo this is to block or remove them, both of
// which already exist and both of which say what they do.
row.acceptedAt = row.acceptedAt ?? Date()
case "disappear":
// The relay has had per-conversation lifetimes since the port, but
// this surface had no way to set one — so the feature existed and
// no client could reach it. Same bounds as the designed route: a
// lifetime longer than retention would be a promise the sweep
// cannot keep.
guard let seconds = input.seconds, seconds >= 0, seconds <= 30 * 24 * 3600 else {
throw WireError.badRequest("seconds must be 0–2592000.")
}
row.disappearAfter = seconds == 0 ? nil : seconds
// 🔴 **Refused, out loud (relay#50).** This stored a lifetime the
// relay applied when it wrote a plaintext message. With that
// transport gone, the number would be saved and then consulted by
// nothing — a disappearing-messages control that answers 200 and
// lets a conversation live forever.
//
// ⚠️ Both apps already do this themselves, inside the sealed
// envelope, which is the only place it can work: a server that
// could enforce a lifetime would be a server that could read the
// message it was expiring. Neither client calls this route.
throw WireError("disappear_not_supported",
"Disappearing messages are set in the app, on the conversation "
+ "itself. This server cannot see your messages, so it cannot "
+ "expire them for you.", 409)
default:
throw WireError.badRequest("Unknown action.")
}
@@ -399,25 +343,4 @@ struct MobileRoutes: Sendable {
return chosen.effective
}
/// A first contact from someone the recipient neither added nor accepted.
private func isFirstContact(from sender: Int, to recipient: Int, on db: any Database)
async throws -> Bool
{
if try await Buddy.query(on: db)
.filter(\.$userID == recipient).filter(\.$buddyID == sender).first() != nil
{ return false }
let theirs = try await ConversationState.query(on: db)
.filter(\.$userID == recipient).filter(\.$otherID == sender).first()
return theirs?.acceptedAt == nil
}
/// `3:12 pm` — the client renders this verbatim, so the format is part of
/// the contract rather than a presentation choice made here.
private func shortTime(_ date: Date) -> String {
let f = DateFormatter()
f.dateFormat = "h:mm a"
f.locale = Locale(identifier: "en_US_POSIX")
f.amSymbol = "am"; f.pmSymbol = "pm"
return f.string(from: date)
}
}
-1
View File
@@ -806,7 +806,6 @@ struct NamespaceIndex: Content {
"/flamenet/v1/messenger/session",
"/flamenet/v1/messenger/account",
"/flamenet/v1/messenger/buddies",
"/flamenet/v1/messenger/messages",
"/flamenet/v1/messenger/status",
"/flamenet/v1/messenger/conversation",
"/flamenet/v1/messenger/tier",
@@ -297,11 +297,6 @@ struct AccountLifecycleTests {
cs.userID = u; cs.otherID = b
cs.starred = false; cs.archived = false; cs.encMode = ""; cs.clearedToID = 0
try await cs.create(on: h.db)
let m = Message()
m.fromID = u; m.toID = b
m.body = try AtRestCrypto.seal("x", using: AtRestKey(secret: "s").key)
m.isRead = false
try await m.create(on: h.db)
}
let n = FlamenetRelay.Notification()
@@ -356,8 +351,9 @@ struct AccountLifecycleTests {
#expect(try await Block.query(on: h.db).filter(\.$blockedID == 1).count() == 0)
#expect(try await ConversationState.query(on: h.db)
.filter(\.$otherID == 1).count() == 0)
#expect(try await Message.query(on: h.db).filter(\.$toID == 1).count() == 0)
#expect(try await Message.query(on: h.db).filter(\.$fromID == 1).count() == 0)
// The plaintext `messages` table was checked here. It no longer
// exists (relay#50); a sealed envelope is addressed to a device and
// goes with that device's rows, asserted above.
}
}
@@ -1,94 +0,0 @@
import Crypto
import Foundation
import Testing
@testable import FlamenetRelay
/// The Node harness drives this server over HTTP, which is the right place for
/// almost everything. These are the things it cannot reach: pure functions
/// whose contract is a byte layout rather than a response.
///
/// This target also exists because an empty one is worse than none. Declared
/// with no sources, SPM resolves its path to the package root and fails a clean
/// checkout with "overlapping sources" — which is how it was found.
@Suite("At-rest encryption")
struct AtRestCryptoTests {
@Test("a sealed body round-trips")
func roundTrip() throws {
let key = AtRestCrypto.key(from: "a-secret")
let sealed = try AtRestCrypto.seal("hello, 2003", using: key)
#expect(sealed != "hello, 2003")
#expect(AtRestCrypto.open(sealed, using: key) == "hello, 2003")
}
@Test("the blob is iv || tag || ciphertext, not the combined form")
func layout() throws {
// The PHP wrote iv(12) || tag(16) || ct. `SealedBox.combined` puts the
// tag LAST, so a port that used it would produce blobs the old rows
// cannot be read alongside — and the mistake is invisible until a
// migration.
let key = AtRestCrypto.key(from: "a-secret")
let sealed = try AtRestCrypto.seal("x", using: key)
let raw = try #require(Data(base64Encoded: sealed))
#expect(raw.count == 12 + 16 + 1)
let nonce = try AES.GCM.Nonce(data: raw.prefix(12))
let box = try AES.GCM.SealedBox(
nonce: nonce, ciphertext: raw.dropFirst(28), tag: raw.dropFirst(12).prefix(16))
#expect(try AES.GCM.open(box, using: key) == Data("x".utf8))
}
@Test("the key derivation matches the PHP")
func keyDerivation() {
// sha256("fnmsg-" || secret), truncated to 32 bytes. Pinned as a
// literal so a refactor of the derivation cannot silently orphan every
// migrated row.
let key = AtRestCrypto.key(from: "a-secret")
let expected = Data(SHA256.hash(data: Data("fnmsg-a-secret".utf8)))
key.withUnsafeBytes { #expect(Data($0) == expected) }
}
@Test("a body that was never encrypted comes back unchanged")
func legacyPlaintext() {
let key = AtRestCrypto.key(from: "a-secret")
#expect(AtRestCrypto.open("just some words", using: key) == "just some words")
#expect(AtRestCrypto.open("", using: key) == "")
}
@Test("the wrong key yields the stored blob, not a crash or empty string")
func wrongKey() throws {
let sealed = try AtRestCrypto.seal("secret", using: AtRestCrypto.key(from: "one"))
let opened = AtRestCrypto.open(sealed, using: AtRestCrypto.key(from: "two"))
// Leniency is deliberate — see AtRestCrypto — and its cost is exactly
// this: a rotated secret is indistinguishable from a legacy row, so it
// renders as base64 rather than reporting.
#expect(opened == sealed)
}
}
@Suite("Presence")
struct PresenceTests {
@Test("a heartbeat is rounded down to the online window")
func coarsening() {
let t = Date(timeIntervalSince1970: 1_700_000_123)
let coarse = MessagingRoutes.coarsen(t)
#expect(coarse <= t)
#expect(t.timeIntervalSince(coarse) < MessagingRoutes.onlineWindow)
#expect(coarse.timeIntervalSince1970
.truncatingRemainder(dividingBy: MessagingRoutes.onlineWindow) == 0)
}
}
@Suite("base64url")
struct Base64URLTests {
@Test("round-trips without padding or url-unsafe characters")
func roundTrip() {
let bytes = Data((0..<64).map { UInt8($0) })
let encoded = base64URLEncode(bytes)
#expect(!encoded.contains("="))
#expect(!encoded.contains("+"))
#expect(!encoded.contains("/"))
#expect(base64URLDecode(encoded) == bytes)
}
}
@@ -323,12 +323,19 @@ 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
// authenticated request that would have stored a readable message
// now finds nothing there (relay#50).
try await app.testing().test(
.POST, "/api/flamenet/v1/messenger/messages", headers: headers,
// ⚠️ Raw JSON: `to` is an Int on the wire, and encoding a
// [String: String] would send it quoted and fail to decode.
beforeRequest: { $0.body = .init(string: #"{"to":\#(bobID),"body":"hello"}"#) },
afterResponse: { #expect($0.status == .ok) })
afterResponse: {
#expect($0.status == .notFound,
"the plaintext send answered \($0.status) — it is meant to be gone")
})
// Bob is now blocked, and the block list reflects it.
try await app.testing().test(
+6 -1
View File
@@ -137,7 +137,12 @@ struct EndToEndTests {
for (method, path) in [(HTTPMethod.GET, "/api/flamenet/v1/messenger/buddies"),
(.GET, "/api/flamenet/v1/messenger/blocks"),
(.GET, "/api/flamenet/v1/messenger/notifications"),
(.POST, "/api/flamenet/v1/messenger/messages")] {
// Was POST messages, the plaintext transport.
// That route is gone (relay#50), and a 404
// would have satisfied this check for the
// wrong reason — it is asking about
// authentication, not about existence.
(.POST, "/api/flamenet/v1/messenger/status")] {
try await app.testing().test(method, path) { res in
#expect(res.status != .ok, "\(path) served an anonymous caller")
}
@@ -23,7 +23,7 @@ struct MessagingRoutesTests {
private let now = 1_700_000_000
private func messaging(_ h: RelayHarness) -> MessagingRoutes {
MessagingRoutes(auth: h.routes, atRestKey: AtRestKey(secret: "test-secret"),
MessagingRoutes(auth: h.routes,
limits: h.routes.limits)
}
@@ -61,222 +61,23 @@ struct MessagingRoutesTests {
private func text(_ res: Response) -> String { res.body.string ?? "" }
// MARK: - Sending
// MARK: - Sending is gone
@Test("a message is stored encrypted at rest, not in the clear")
func storedSealed() async throws {
try await withRelay { h in
let alice = try await device(h, user: 1, id: "aaaa-0001")
try await account(h, id: 1, name: "alice")
try await account(h, id: 2, name: "bob")
let body = #"{"to":2,"body":"meet me at eight"}"#
_ = try await messaging(h).send(post(h, "/messenger/messages", as: alice, body),
body: Data(body.utf8), now: now)
let row = try #require(try await Message.query(on: h.db).first())
#expect(!row.body.contains("meet me at eight"))
#expect(AtRestCrypto.open(row.body, using: AtRestKey(secret: "test-secret").key)
== "meet me at eight")
}
}
@Test("an empty or oversized message is refused")
func bodyIsValidated() async throws {
try await withRelay { h in
let alice = try await device(h, user: 1, id: "aaaa-0001")
try await account(h, id: 1, name: "alice")
try await account(h, id: 2, name: "bob")
let long = String(repeating: "x", count: 8001)
for body in [#"{"to":2,"body":" "}"#, #"{"to":2,"body":"\#(long)"}"#] {
await #expect(throws: WireError.self) {
_ = try await messaging(h).send(
post(h, "/messenger/messages", as: alice, body),
body: Data(body.utf8), now: now)
}
}
#expect(try await Message.query(on: h.db).count() == 0)
}
}
@Test("writing to yourself or to nobody is refused")
func recipientIsValidated() async throws {
try await withRelay { h in
let alice = try await device(h, user: 1, id: "aaaa-0001")
try await account(h, id: 1, name: "alice")
for body in [#"{"to":1,"body":"hi"}"#, #"{"to":999,"body":"hi"}"#,
#"{"to":0,"body":"hi"}"#] {
await #expect(throws: WireError.self) {
_ = try await messaging(h).send(
post(h, "/messenger/messages", as: alice, body),
body: Data(body.utf8), now: now)
}
}
}
}
/// The same single check the mobile path asks. Two send routes, one answer
/// to "may this person write to that one".
@Test("a blocked sender is refused here too")
func blockingApplies() async throws {
try await withRelay { h in
let alice = try await device(h, user: 1, id: "aaaa-0001")
try await account(h, id: 1, name: "alice")
try await account(h, id: 2, name: "bob")
let block = Block()
block.userID = 2; block.blockedID = 1
try await block.create(on: h.db)
let body = #"{"to":2,"body":"hello"}"#
await #expect(throws: WireError.self) {
_ = try await messaging(h).send(
post(h, "/messenger/messages", as: alice, body),
body: Data(body.utf8), now: now)
}
#expect(try await Message.query(on: h.db).count() == 0)
}
}
/// 🔴 The RECIPIENT's view decides the lifetime. A disappearing
/// conversation is a property of the conversation, and the sender does not
/// get to override how long their words persist on someone else's device.
@Test("the recipient's disappearing setting decides the lifetime")
func recipientOwnsTheTimer() async throws {
try await withRelay { h in
let alice = try await device(h, user: 1, id: "aaaa-0001")
try await account(h, id: 1, name: "alice")
try await account(h, id: 2, name: "bob")
// Bob's view of the conversation disappears after 60s.
let bobsView = ConversationState()
bobsView.userID = 2; bobsView.otherID = 1
bobsView.starred = false; bobsView.archived = false
bobsView.encMode = ""; bobsView.clearedToID = 0
bobsView.disappearAfter = 60
try await bobsView.create(on: h.db)
let body = #"{"to":2,"body":"hello"}"#
_ = try await messaging(h).send(post(h, "/messenger/messages", as: alice, body),
body: Data(body.utf8), now: now)
let row = try #require(try await Message.query(on: h.db).first())
#expect(row.expiresAt != nil)
}
}
// MARK: - Reading
/// A thread is the pair's, and only the pair's.
@Test("a thread contains only messages between the two people")
func threadIsScopedToThePair() async throws {
try await withRelay { h in
let alice = try await device(h, user: 1, id: "aaaa-0001")
try await account(h, id: 1, name: "alice")
try await account(h, id: 2, name: "bob")
try await account(h, id: 3, name: "carol")
for (from, to, text) in [(1, 2, "to bob"), (2, 1, "from bob"),
(3, 2, "carol to bob"), (2, 3, "bob to carol")] {
let m = Message()
m.fromID = from; m.toID = to
m.body = try AtRestCrypto.seal(text, using: AtRestKey(secret: "test-secret").key)
m.isRead = false
try await m.create(on: h.db)
}
let res = try await messaging(h).thread(
h.request(.GET, "/messenger/thread/2", as: alice, now: now),
with: 2, now: now)
let out = text(res)
#expect(out.contains("to bob"))
#expect(out.contains("from bob"))
#expect(!out.contains("carol to bob"))
#expect(!out.contains("bob to carol"))
}
}
/// 🔴 Expiry is enforced on READ as well as by the sweep. The sweep runs
/// hourly; a message whose moment has passed must not be readable in the
/// meantime just because the timer has not fired.
@Test("an expired message is not readable before the sweep runs")
func expiryEnforcedOnRead() async throws {
try await withRelay { h in
let alice = try await device(h, user: 1, id: "aaaa-0001")
try await account(h, id: 1, name: "alice")
try await account(h, id: 2, name: "bob")
let key = AtRestKey(secret: "test-secret").key
let gone = Message()
gone.fromID = 2; gone.toID = 1
gone.body = try AtRestCrypto.seal("should be gone", using: key)
gone.isRead = false
gone.expiresAt = Date().addingTimeInterval(-1)
try await gone.create(on: h.db)
let live = Message()
live.fromID = 2; live.toID = 1
live.body = try AtRestCrypto.seal("still here", using: key)
live.isRead = false
live.expiresAt = Date().addingTimeInterval(3600)
try await live.create(on: h.db)
let out = text(try await messaging(h).thread(
h.request(.GET, "/messenger/thread/2", as: alice, now: now),
with: 2, now: now))
#expect(!out.contains("should be gone"))
#expect(out.contains("still here"))
}
}
@Test("unread counts only my own inbox")
func unreadIsScopedToMe() async throws {
try await withRelay { h in
let alice = try await device(h, user: 1, id: "aaaa-0001")
try await account(h, id: 1, name: "alice")
try await account(h, id: 2, name: "bob")
let key = AtRestKey(secret: "test-secret").key
for (from, to) in [(2, 1), (2, 1), (1, 2), (2, 3)] {
let m = Message()
m.fromID = from; m.toID = to
m.body = try AtRestCrypto.seal("x", using: key)
m.isRead = false
try await m.create(on: h.db)
}
let out = text(try await messaging(h).unread(
h.request(.GET, "/messenger/unread", as: alice, now: now), now: now))
#expect(out.contains("\"unread\":2"))
}
}
@Test("marking read touches only messages sent to me by that person")
func markReadIsScoped() async throws {
try await withRelay { h in
let alice = try await device(h, user: 1, id: "aaaa-0001")
try await account(h, id: 1, name: "alice")
let key = AtRestKey(secret: "test-secret").key
for (from, to) in [(2, 1), (3, 1), (2, 3)] {
let m = Message()
m.fromID = from; m.toID = to
m.body = try AtRestCrypto.seal("x", using: key)
m.isRead = false
try await m.create(on: h.db)
}
let body = #"{"buddy_id":2}"#
_ = try await messaging(h).markRead(
post(h, "/messenger/read", as: alice, body),
body: Data(body.utf8), now: now)
let read = try await Message.query(on: h.db).filter(\.$isRead == true).all()
#expect(read.count == 1)
#expect(read.first?.fromID == 2 && read.first?.toID == 1)
}
}
// Nine tests lived here, all of them about `/messenger/messages`,
// `/messenger/thread`, `/messenger/read` and `/messenger/unread`: stored
// sealed rather than in the clear; empty and oversized refused; writing to
// yourself refused; a blocked sender refused; the recipient's disappearing
// setting deciding the lifetime; a thread holding only the two people's
// messages; an expired message unreadable before the sweep; unread scoped
// to my own inbox; marking read scoped to one correspondent.
//
// 🔴 They went with the routes (relay#50). What they were really testing —
// that a message this server can read is handled carefully — stopped being
// a question the moment the server stopped being able to hold one.
//
// ⚠️ The two behaviours worth keeping are proved elsewhere on the transport
// that still exists: `SendTests.blockedSenderRefused` for the block, and
// `EndToEndJourneyTests` for the 404 that says the route is really gone.
// MARK: - Presence
@@ -22,7 +22,6 @@ struct MobileExtrasTests {
issuer: Issuer(signingKeyRaw: Data(repeating: 1, count: 32),
audience: "test.relay", lifetime: 300),
limits: h.routes.limits, signupMaxPerHour: 100),
atRestKey: AtRestKey(secret: "test-secret"),
limits: h.routes.limits)
}
@@ -27,7 +27,6 @@ struct MobileRoutesTests {
issuer: Issuer(signingKeyRaw: Data(repeating: 1, count: 32),
audience: "test.relay", lifetime: 300),
limits: h.routes.limits, signupMaxPerHour: 100),
atRestKey: AtRestKey(secret: "test-secret"),
limits: h.routes.limits)
}
@@ -108,29 +107,29 @@ struct MobileRoutesTests {
}
}
@Test("the roster counts unread messages from each contact only")
func rosterCountsUnread() async throws {
/// 🔴 **Zero is the honest answer, and this pins it as deliberate.**
///
/// This field counted rows in the plaintext `messages` table. That table is
/// gone (relay#50), and the relay cannot count unread encrypted messages —
/// it cannot open a sealed envelope to see whether it has been read. The
/// client keeps that count.
///
/// ⚠️ The field stays on the wire because the shipped iOS client decodes it
/// as a non-optional `Int`. A test is here rather than nothing so that a
/// future attempt to "fix" the zero by reintroducing server-side read state
/// has to argue with a named expectation first.
@Test("the roster reports zero unread, because the relay cannot know")
func rosterReportsZeroUnread() async throws {
try await withRelay { h in
let ada = try await account(h, id: 1, name: "ada")
_ = try await account(h, id: 2, name: "bob")
_ = try await account(h, id: 3, name: "carol")
for (uid, bid) in [(1, 2), (1, 3)] {
let b = Buddy(); b.userID = uid; b.buddyID = bid
try await b.create(on: h.db)
}
let key = AtRestKey(secret: "test-secret").key
for (from, to, read) in [(2, 1, false), (2, 1, false), (3, 1, true)] {
let m = Message()
m.fromID = from; m.toID = to
m.body = try AtRestCrypto.seal("x", using: key)
m.isRead = read
try await m.create(on: h.db)
}
let b = Buddy(); b.userID = 1; b.buddyID = 2
try await b.create(on: h.db)
let out = text(try await mobile(h).buddies(
request(h, .GET, "/messenger/buddies", credential: ada)))
#expect(out.contains("\"unread\":2"))
#expect(out.contains("\"unread\":0"))
#expect(out.contains("\"unread\":0"),
"the roster reported an unread count the relay has no way to know")
}
}
@@ -294,81 +293,48 @@ struct MobileRoutesTests {
// MARK: - Sending
@Test("a message is stored sealed and the block list is honoured")
func sendRespectsBlocks() async throws {
// MARK: - The plaintext transport is gone
/// 🔴 **`MobileRoutes` no longer has a way to store a readable message.**
///
/// `send` and `poll` lived here and were covered by four tests: blocks
/// honoured, one first-contact alert rather than one per message, the
/// recipient's disappearing setting applied, and empty/unknown-recipient
/// refused. Each of those behaviours is either gone with the route or is
/// now proved on the E2E path instead — the block check by
/// `SendTests.blockedSenderRefused`, the alert by `addRaisesAnAlert`
/// above, which is the only first-contact event a relay that cannot read
/// messages can legitimately see.
///
/// ⚠️ The proof that the route is really unreachable is an HTTP one, in
/// `EndToEndJourneyTests` — an authenticated, well-formed send that now
/// gets a 404. A unit test here could only assert the absence of a method,
/// which the compiler already does.
@Test("the conversation route refuses a clear it can no longer perform")
func clearIsRefusedRatherThanFaked() async throws {
try await withRelay { h in
let ada = try await account(h, id: 1, name: "ada")
_ = try await account(h, id: 2, name: "bob")
_ = try await mobile(h).send(request(
h, .POST, "/messenger/messages", credential: ada,
body: #"{"to":2,"body":"hello"}"#), now: now)
let row = try #require(try await Message.query(on: h.db).first())
#expect(!row.body.contains("hello"))
let block = Block(); block.userID = 2; block.blockedID = 1
try await block.create(on: h.db)
await #expect(throws: WireError.self) {
_ = try await mobile(h).send(request(
h, .POST, "/messenger/messages", credential: ada,
body: #"{"to":2,"body":"again"}"#), now: now)
await #expect(throws: WireError.self, "clear answered success and did nothing") {
_ = try await mobile(h).conversation(request(
h, .POST, "/messenger/conversation", credential: ada,
body: #"{"buddy":2,"action":"clear"}"#))
}
#expect(try await Message.query(on: h.db).count() == 1)
}
}
/// A stranger's first message raises a notification rather than landing
/// silently; collapsed, so forty messages do not make forty entries.
@Test("a stranger's first message raises one alert, not one per message")
func firstContactRaisesOneAlert() async throws {
/// The same rule for the disappearing-messages control: it stored a number
/// the relay applied when writing plaintext, and nothing consults it now.
@Test("the disappearing control refuses rather than storing a dead number")
func disappearIsRefusedRatherThanStored() async throws {
try await withRelay { h in
let ada = try await account(h, id: 1, name: "ada")
_ = try await account(h, id: 2, name: "bob")
for _ in 0 ..< 3 {
_ = try await mobile(h).send(request(
h, .POST, "/messenger/messages", credential: ada,
body: #"{"to":2,"body":"hi"}"#), now: now)
}
let alerts = try await FlamenetRelay.Notification.query(on: h.db)
.filter(\.$userID == 2).filter(\.$type == "message_request").count()
#expect(alerts == 1)
}
}
/// Two entry points, one policy: the recipient's conversation decides the
/// lifetime here exactly as it does on the designed route.
@Test("the recipient's disappearing setting applies on this path too")
func recipientOwnsTheTimerHereToo() async throws {
try await withRelay { h in
let ada = try await account(h, id: 1, name: "ada")
_ = try await account(h, id: 2, name: "bob")
let theirs = ConversationState()
theirs.userID = 2; theirs.otherID = 1
theirs.starred = false; theirs.archived = false
theirs.encMode = ""; theirs.clearedToID = 0
theirs.disappearAfter = 60
try await theirs.create(on: h.db)
_ = try await mobile(h).send(request(
h, .POST, "/messenger/messages", credential: ada,
body: #"{"to":2,"body":"hi"}"#), now: now)
#expect(try await Message.query(on: h.db).first()?.expiresAt != nil)
}
}
@Test("an empty message or an unknown recipient is refused")
func sendValidations() async throws {
try await withRelay { h in
let ada = try await account(h, id: 1, name: "ada")
for body in [#"{"to":2,"body":" "}"#, #"{"to":404,"body":"hi"}"#,
#"{"to":1,"body":"hi"}"#] {
await #expect(throws: WireError.self) {
_ = try await mobile(h).send(request(
h, .POST, "/messenger/messages", credential: ada, body: body),
now: now)
}
await #expect(throws: WireError.self,
"disappear answered success and expired nothing") {
_ = try await mobile(h).conversation(request(
h, .POST, "/messenger/conversation", credential: ada,
body: #"{"buddy":2,"action":"disappear","seconds":60}"#))
}
}
}
@@ -437,8 +403,11 @@ struct MobileRoutesTests {
// Nowhere in the database.
let user = try #require(try await User.find(1, on: h.db))
#expect(user.displayName != "back at 3, dentist")
#expect(!(try await Message.query(on: h.db).all())
.contains { $0.body.contains("dentist") })
// The `messages` table was searched here too. It is gone
// (relay#50), which is a stronger guarantee than the search was:
// there is no table for an away message to leak into.
#expect(try await ConversationState.query(on: h.db)
.filter(\.$userID == 1).all().allSatisfy { $0.encMode != "back at 3, dentist" })
}
}
}
+5 -53
View File
@@ -153,59 +153,11 @@ struct RetentionTests {
}
}
// MARK: - Plaintext messages
@discardableResult
private func message(_ h: RelayHarness, read: Bool, sentDaysAgo: Int,
expiresAt: Date? = nil) async throws -> Int {
let m = Message()
m.fromID = 1; m.toID = 2
m.body = try AtRestCrypto.seal("x", using: AtRestKey(secret: "s").key)
m.isRead = read
m.expiresAt = expiresAt
try await m.create(on: h.db)
m.sentAt = daysAgo(sentDaysAgo)
try await m.update(on: h.db)
return try #require(m.id)
}
/// 🔴 Expiry first, because a disappearing message that outlives its
/// deadline is a broken promise rather than a full disk.
@Test("a lapsed disappearing message is swept regardless of its age")
func lapsedMessagesSwept() async throws {
try await withRelay { h in
let lapsed = try await message(h, read: false, sentDaysAgo: 0,
expiresAt: Date().addingTimeInterval(-1))
let pending = try await message(h, read: false, sentDaysAgo: 0,
expiresAt: Date().addingTimeInterval(3600))
try await retention(h).sweepOnce()
let left = try await Message.query(on: h.db).all().compactMap(\.id)
#expect(!left.contains(lapsed))
#expect(left.contains(pending))
}
}
/// The plugin kept these forever: not just the bodies, but a permanent
/// record of who wrote to whom and when, which is the more durable
/// disclosure of the two.
@Test("read messages are swept sooner than unread ones")
func readSweptSooner() async throws {
try await withRelay(env: ["FN_READ_MESSAGE_RETENTION_DAYS": "30",
"FN_MESSAGE_RETENTION_DAYS": "90"]) { h in
let oldRead = try await message(h, read: true, sentDaysAgo: 40)
let oldUnread = try await message(h, read: false, sentDaysAgo: 40)
let ancient = try await message(h, read: false, sentDaysAgo: 100)
try await retention(h).sweepOnce()
let left = try await Message.query(on: h.db).all().compactMap(\.id)
#expect(!left.contains(oldRead))
#expect(left.contains(oldUnread))
#expect(!left.contains(ancient))
}
}
// The plaintext-message sweep lived here: lapsed-first, then read
// retention, then absolute retention, each counted before deleting. The
// table is gone (relay#50) and so is the sweep — an end-to-end encrypted
// message is removed by the delivery that collects it, which
// DeleteOnAckTests covers, not by a timer that would have to read it.
// MARK: - Password reset tokens
-3
View File
@@ -36,7 +36,6 @@ because nobody finds out until it matters.
|---|---|
| `FN_RELAY_AUDIENCE` | The hostname this relay is addressed by, e.g. `relay.example.com`. Tokens carry it and the relay checks it, so a mismatch rejects **every** token with no other symptom. A relay reachable at several names still has exactly one of these. |
| `FN_ISSUER_PUBLIC_KEY` | Base64 Ed25519 public key of whatever mints this relay's tokens. Never fetched over the network. |
| `FN_AT_REST_SECRET` | Encrypts stored bodies for conversations with encryption turned off. Defends against a stolen database dump. It does not defend against you — you hold the key. |
## Identity — minting your own tokens
@@ -185,8 +184,6 @@ The relay purges on its own timer, in-process. Nothing external is needed.
| `FN_DELIVERED_RETENTION_DAYS` | `1` | |
| `FN_UNDELIVERED_RETENTION_DAYS` | `30` | ⚠️ Must stay below the client's retired-signed-prekey retention of 45 days. |
| `FN_ATTACHMENT_RETENTION_DAYS` | `30` | |
| `FN_MESSAGE_RETENTION_DAYS` | `90` | Plaintext-mode messages only. |
| `FN_READ_MESSAGE_RETENTION_DAYS` | `30` | |
## Mail, calls, donations
+6 -3
View File
@@ -24,7 +24,6 @@ cat > .env <<'ENV'
FN_RELAY_AUDIENCE=relay.example.com
FN_ISSUER_PRIVATE_KEY=<the seed from issuer.pem>
FN_ISSUER_PUBLIC_KEY=<the public key from issuer.pem>
FN_AT_REST_SECRET=<32+ random bytes>
FN_POSTGRES_URL=postgres://flamenet:...@db/flamenet
ENV
@@ -174,8 +173,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.
⚠️ **`FN_AT_REST_SECRET` defends against a stolen database dump. It does not
defend against you** — you hold the key. Tell your users that plainly.
🔴 **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
logs request URLs, and a protocol URL contains a device id.
+85 -1
View File
@@ -1,5 +1,16 @@
# Where plaintext can come to rest
> 🔴 **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
> 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.
An inventory of the relay's storage, measured on production 2026-09-04. 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,
@@ -174,7 +185,7 @@ every week is a report nobody reads.
| `relayapp` is still unprivileged | The relay reverted to connecting as a superuser |
| `COPY TO PROGRAM` is refused | SQL injection is remote code execution again |
| Dead tuples under 500 | Old row versions accumulating — the vacuum timer stopped |
| `messages` / `group_members` empty | A plaintext path came back |
| `messages` / `group_members` **absent** | A plaintext path came back |
| Every group roster sealed | `/metadata` is promising something untrue |
| No attachment blob in a known format | An unencrypted upload path exists |
| CrowdSec is parsing new lines | 🔴 IP banning has gone dark |
@@ -197,3 +208,76 @@ CrowdSec failure that is its own parsing bug. And systemd expands `%` in
`ExecStart`, so a `date +%Y` written inline is mangled before bash sees it; the
first version wrote the literal string `OK /bin/bash` into its status file. Both
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 decision this inventory was written to inform has been taken: remove it.
### What was removed
| Removed | Was |
|---|---|
| `POST /api/flamenet/v1/messenger/messages`, `GET` of the same | Took a readable `body`, capped at 8000 characters, stored it |
| `POST /messenger/messages`, `GET /messenger/thread/:id`, `POST /messenger/read`, `GET /messenger/unread` | The same transport on the signed surface |
| The `messages` table | Dropped by the `DropMessages` migration |
| `AtRestCrypto` and `AtRestKey` | The sealing the bodies got on the way in |
| `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
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.
⚠️ **`FN_AT_REST_SECRET`'s default was the issuer audience** — a value that is
public. Its own comment said so and called it "wrong as a secret", accepting the
risk because the path was opt-in. Any deployment that never set the variable was
therefore resting "encrypted at rest" on a non-secret. That is now moot rather
than fixed, which is the better outcome of the two.
### Why it was safe to drop the table
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;
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
every message request going to `/e2e/*` and none to any removed route.
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.
### Two controls that had quietly stopped meaning anything
Removing the transport exposed two settings the server stored and then consulted
nowhere. Both now refuse with a 409 rather than answering 200:
- **`clear`** on a conversation marked the viewer's thread read-up-to a message
id. There are no ids and no threads on the server.
- **`disappear`** stored a per-conversation lifetime the relay applied when
writing a plaintext row. Both apps already do this inside the sealed envelope,
which is the only place it can work — a server that could expire a message
would be a server that could read it. Neither client ever called the route.
This follows the rule the routing code already states one screen away: *a
privacy control that reports success and does nothing is worse than one that is
missing.*
### For an existing deployment
`FN_AT_REST_SECRET`, `FN_MESSAGE_RETENTION_DAYS` and
`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
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
migration, is what decides when it ends.