Switch address verification off by default

The gate sits on the token route, which is the door to everything: an
account that cannot mint a token cannot poll, register a device, or fetch a
bundle. The Android build in the field is v8 and has no screen for entering
a code, so a relay that demanded one would let somebody create an account
and then strand them, with nowhere to type the thing that would fix it.

FN_EMAIL_VERIFICATION_REQUIRED is off unless explicitly set, and enforcement
now needs both that flag and SMTP — read off one predicate so the issuing
path and the gate can never disagree. With it off no code is mailed either:
the message says the account cannot be used until the code is entered, and
that would simply be untrue.

Existing accounts were already backfilled as verified by the migration, so
this only ever concerned new sign-ups.

Tests: default-off lets an unverified account through on a mail-capable
relay; the gate needs both halves; the predicate is the conjunction; and
only an explicit yes turns it on. 483 pass.
This commit is contained in:
Paul Hitt
2026-09-02 20:11:18 -04:00
parent 428c31ce4a
commit a9bb216059
6 changed files with 192 additions and 6 deletions
+46 -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: 7c4f86cf179f27f2 -->
source-sha256: 6e383dace6a281bc -->
<article class="band doc doc-generated">
<div class="wrap wrap-narrow">
<h1 id="configuring-a-relay">Configuring a relay</h1>
@@ -405,6 +405,51 @@ old to know what a report is".</p>
They are read out of band by whoever operates the server — an endpoint
returning them would be a way to ask the relay what somebody reported,
which is a question nobody but the operator gets to ask.</p>
<h2 id="proving-an-address-at-sign-up">Proving an address at
sign-up</h2>
<p>Off. An account can use the relay the moment it is created.</p>
<table>
<thead>
<tr>
<th>Variable</th>
<th>Default</th>
<th>What it does</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>FN_EMAIL_VERIFICATION_REQUIRED</code></td>
<td><code>false</code></td>
<td>When on, an account with an email address must spend a code mailed
to it before the relay will issue that account a token. Accepts
<code>true</code>, <code>1</code> or <code>yes</code>; anything else,
including a typo, leaves it off.</td>
</tr>
</tbody>
</table>
<p>Two things have to be true before anybody is asked to prove anything:
the switch above, <strong>and</strong> SMTP being configured. A relay
that cannot send mail can never answer the question it is asking, so it
does not ask — the same rule the key-log enforcement follows.</p>
<p><strong>Before you turn this on, check what your users are
running.</strong> The gate sits on the token route, which is the door to
everything: an account that cannot mint a token cannot poll, register a
device, or fetch a prekey bundle. A client with no screen for entering
the code has no way through that door and no way to tell the person why.
The Android build released on 2026-08-31 (v8) is one of these. Turning
this on for a relay it talks to would let somebody create an account and
then strand them.</p>
<p>Two things this deliberately does <strong>not</strong> do:</p>
<ul>
<li><strong>It never touches an account that already exists.</strong>
The migration marks every account present at upgrade as verified.
Switching this on does not lock out your current users.</li>
<li><strong>It never gates an account with no email of its own</strong>
— those authenticated by the shared service have nothing to prove.</li>
</ul>
<p>Codes last 24 hours and one is live at a time; asking for another
replaces the first. If the mail fails to send, the code is deleted
rather than left in the database as a credential nobody received.</p>
<h2 id="retention">Retention</h2>
<p>The relay purges on its own timer, in-process. Nothing external is
needed.</p>
+20
View File
@@ -187,6 +187,20 @@ struct Config: Sendable {
/// Leading zero bits of work asked of a new account. 0 = off, and off is
/// the default: the shipped clients do not know how to answer yet.
let signupProofOfWorkBits: Int
/// Whether an emailed code must be spent before an account can mint a relay
/// token.
///
/// 🔴 **Off by default, and it stays off until the apps can answer it.** The
/// Android release in the field is v8, and it has no screen for entering a
/// code. Turning this on for a relay those clients talk to would leave a new
/// account unable to poll, register a device or fetch a bundle, with nowhere
/// to type the thing that would fix it. Existing accounts are backfilled as
/// verified by the migration, so the exposure is new sign-ups only — but a
/// new sign-up that dead-ends is the worst first impression there is.
///
/// Enforcement also still requires SMTP: see `canSendMail`. Both must be
/// true, because a rule that cannot be satisfied must not be enforced.
let emailVerificationRequired: Bool
let attachmentBytesPerHour: Int
/// The rolling 30-day per-account upload budget (C3). The hourly limits
/// above stop bursts; this stops a steady drip from quietly owning the
@@ -345,6 +359,12 @@ struct Config: Sendable {
vouchUntil: int("FN_VOUCH_UNTIL", 0) > 0 ? int("FN_VOUCH_UNTIL", 0) : nil,
signupProofOfWorkBits: int("FN_SIGNUP_POW_BITS", 0),
emailVerificationRequired: {
// Opt-in only: anything that is not an explicit yes leaves it
// off. Unset, empty and misspelt all mean the same safe thing.
let raw = (env["FN_EMAIL_VERIFICATION_REQUIRED"] ?? "").lowercased()
return raw == "true" || raw == "1" || raw == "yes"
}(),
attachmentBytesPerHour: int("FN_ATTACHMENT_BYTES_RATE", 200 * 1024 * 1024),
attachmentBytesPerMonth: int("FN_ATTACHMENT_BYTES_PER_MONTH", 500 * 1024 * 1024),
attachmentRetentionDays: int("FN_ATTACHMENT_RETENTION_DAYS", 30),
+2
View File
@@ -339,6 +339,8 @@ func configure(_ app: Application) async throws {
// ⚠️ The gate is inert without this. A relay with no SMTP can never
// prove an address, so it must never refuse one.
canSendMail: config.smtp != nil,
// 🔴 Off unless the operator asked for it. See Config.
emailVerificationRequired: config.emailVerificationRequired,
mailer: config.smtp.map {
Mailer(config: $0, group: app.eventLoopGroup, logger: app.logger)
},
+19 -2
View File
@@ -64,9 +64,21 @@ struct AccountRoutes: Sendable {
/// verification then does nothing at all — no mail, no gate. A rule that
/// cannot be satisfied must not be enforced.
var canSendMail: Bool = false
/// Whether the operator has asked for verification at all.
///
/// 🔴 **Off unless explicitly switched on** (`FN_EMAIL_VERIFICATION_REQUIRED`).
/// The Android build in the field is v8, which has no screen for entering a
/// code; a relay that demanded one would strand every new sign-up from it.
var emailVerificationRequired: Bool = false
/// Set when SMTP is configured; nil otherwise, which is what makes every
/// part of verification inert on a relay that cannot send mail.
var mailer: Mailer? = nil
/// The one place the two conditions meet. Verification happens only when the
/// operator asked for it **and** this relay can actually send the mail —
/// either alone is not enough, and reading it off one property means the
/// issuing path and the gate can never disagree about whether it is on.
var emailVerificationActive: Bool { emailVerificationRequired && canSendMail }
/// Named in the verification message so somebody can tell which relay asked.
var siteURL: String = ""
@@ -242,7 +254,12 @@ struct AccountRoutes: Sendable {
// ⚠️ After the account exists, and never able to fail it. Somebody
// whose mail server is having a bad day still has an account; they ask
// for another code rather than losing the sign-up they just completed.
if let email = user.email, !email.isEmpty {
//
// ⚠️ Only when verification is actually switched on. The message tells
// somebody their account cannot be used until they enter the code, and
// with the gate off that is simply untrue — mailing it anyway would send
// a working account a demand it does not have to meet.
if emailVerificationActive, let email = user.email, !email.isEmpty {
await sendVerification(userID: userID, email: email, username: user.username,
mailer: mailer, siteURL: siteURL,
on: request.db, now: now)
@@ -558,7 +575,7 @@ struct AccountRoutes: Sendable {
//
// ⚠️ Accounts with no email — those authenticated by the shared
// service — have nothing to prove and are unaffected.
if canSendMail {
if emailVerificationActive {
let account = try await User.find(userID, on: request.db)
if let account, let email = account.email, !email.isEmpty,
account.enclavePseudonym == nil, account.emailVerifiedAt == nil {
@@ -26,13 +26,19 @@ struct EmailVerificationTests {
private let now = 1_700_000_000
private let ip = "203.0.113.9"
private func routes(_ h: RelayHarness, canSendMail: Bool) -> AccountRoutes {
/// `required` defaults to true so the existing cases below keep asking the
/// question they were written to ask — "when verification is on, does the
/// gate hold?". The default-off behaviour is pinned separately, at the
/// bottom of this suite.
private func routes(_ h: RelayHarness, canSendMail: Bool,
required: Bool = true) -> AccountRoutes {
AccountRoutes(
issuer: Issuer(signingKeyRaw: Data(repeating: 1, count: 32),
audience: "test.relay", lifetime: 300),
limits: h.routes.limits,
signupMaxPerHour: 100,
canSendMail: canSendMail,
emailVerificationRequired: required,
// ⚠️ nil: no test may send real mail. The paths that matter here
// are the gate and the code, not SMTP itself.
mailer: nil,
@@ -50,10 +56,14 @@ struct EmailVerificationTests {
}
/// An account with an email, deliberately left unproved.
private func unverifiedUser(_ h: RelayHarness) async throws -> (User, String) {
/// `named` exists because a case that needs more than one account in the
/// same relay cannot reuse the username — the second sign-up is refused with
/// `username_taken`, which is the throttle working, not the thing under test.
private func unverifiedUser(_ h: RelayHarness,
named: String = "ada") async throws -> (User, String) {
let a = routes(h, canSendMail: false)
let signup = post(h, "/account/signup",
#"{"username":"ada","email":"ada@example.com","password":"hunter2hunter2"}"#)
#"{"username":"\#(named)","email":"\#(named)@example.com","password":"hunter2hunter2"}"#)
let response = try await a.signup(signup, clientIP: ip, now: now)
let created = try JSONDecoder().decode(
AccountResponse.self, from: Data(buffer: response.body.buffer!))
@@ -241,4 +251,78 @@ struct EmailVerificationTests {
}
}
}
// MARK: - Switched off by default
/// 🔴 The case that protects the app already in people's hands.
///
/// The Android release in the field is v8, and it has no screen for entering
/// a verification code. If this relay demanded one, a person installing that
/// build could create an account and then never mint a token — no polling,
/// no device registration, no bundles, and nowhere to type the thing that
/// would fix it. So the default has to be off, and it has to be off even on
/// a relay that is perfectly capable of sending the mail.
@Test("with the flag unset, a mail-capable relay still issues the token")
func offByDefaultLetsAnUnverifiedAccountIn() async throws {
try await withRelay { h in
let (_, credential) = try await unverifiedUser(h)
let a = routes(h, canSendMail: true, required: false)
// No throw: the account is unverified, the relay could have asked,
// and it does not, because nobody switched it on.
_ = try await a.token(tokenRequest(h, credential: credential), now: now)
}
}
/// Both conditions are needed, and neither one alone will do it.
@Test("the gate needs the flag and SMTP together")
func gateNeedsBothConditions() async throws {
try await withRelay { h in
let cases = [(false, false), (false, true), (true, false)]
for (i, (mail, required)) in cases.enumerated() {
let (_, credential) = try await unverifiedUser(h, named: "ada\(i)")
let a = routes(h, canSendMail: mail, required: required)
// Each combination has a half missing, so none of them may gate.
_ = try await a.token(tokenRequest(h, credential: credential), now: now)
}
}
}
/// The predicate is read in two places — the gate and the issuing path — and
/// they must never disagree about whether verification is on.
@Test("the active predicate is the conjunction, not either half")
func activePredicateIsTheConjunction() {
var a = AccountRoutes(
issuer: Issuer(signingKeyRaw: Data(repeating: 1, count: 32),
audience: "test.relay", lifetime: 300),
limits: RateLimiter(), signupMaxPerHour: 100,
canSendMail: false, emailVerificationRequired: false,
mailer: nil, siteURL: "test.relay")
#expect(a.emailVerificationActive == false)
a.canSendMail = true
#expect(a.emailVerificationActive == false)
a.emailVerificationRequired = true
#expect(a.emailVerificationActive == true)
a.canSendMail = false
#expect(a.emailVerificationActive == false)
}
/// Unset, empty, and a typo all have to mean the same safe thing.
@Test("only an explicit yes turns it on")
func onlyExplicitYesEnables() throws {
func flag(_ v: String?) throws -> Bool {
var env = [
"FN_ISSUER_PUBLIC_KEY": Curve25519.Signing.PrivateKey()
.publicKey.rawRepresentation.base64EncodedString(),
"FN_RELAY_AUDIENCE": "test.relay",
]
if let v { env["FN_EMAIL_VERIFICATION_REQUIRED"] = v }
return try Config.fromEnvironment(env).emailVerificationRequired
}
for off in [nil, "", "false", "no", "0", "off", "ture", "TRUE-ish"] {
#expect(try flag(off) == false, "\(off ?? "unset") should leave it off")
}
for on in ["true", "TRUE", "True", "1", "yes", "YES"] {
#expect(try flag(on) == true, "\(on) should turn it on")
}
}
}
+18
View File
@@ -157,6 +157,24 @@ Off is a legitimate answer, not a broken relay. If nobody is going to read them,
When off, two things happen so that nothing pretends otherwise: `/.well-known/flamenet/relay` reports `"reports": false`, which lets a client leave the Report option out of the interface entirely; and the route itself refuses with `reports_not_accepted` rather than a 404, so a client can tell "this server does not take reports" apart from "this server is too old to know what a report is".
There is deliberately no route that **reads** reports. They are read out of band by whoever operates the server — an endpoint returning them would be a way to ask the relay what somebody reported, which is a question nobody but the operator gets to ask.
## Proving an address at sign-up
Off. An account can use the relay the moment it is created.
| Variable | Default | What it does |
|---|---|---|
| `FN_EMAIL_VERIFICATION_REQUIRED` | `false` | When on, an account with an email address must spend a code mailed to it before the relay will issue that account a token. Accepts `true`, `1` or `yes`; anything else, including a typo, leaves it off. |
Two things have to be true before anybody is asked to prove anything: the switch above, **and** SMTP being configured. A relay that cannot send mail can never answer the question it is asking, so it does not ask — the same rule the key-log enforcement follows.
**Before you turn this on, check what your users are running.** The gate sits on the token route, which is the door to everything: an account that cannot mint a token cannot poll, register a device, or fetch a prekey bundle. A client with no screen for entering the code has no way through that door and no way to tell the person why. The Android build released on 2026-08-31 (v8) is one of these. Turning this on for a relay it talks to would let somebody create an account and then strand them.
Two things this deliberately does **not** do:
- **It never touches an account that already exists.** The migration marks every account present at upgrade as verified. Switching this on does not lock out your current users.
- **It never gates an account with no email of its own** — those authenticated by the shared service have nothing to prove.
Codes last 24 hours and one is live at a time; asking for another replaces the first. If the mail fails to send, the code is deleted rather than left in the database as a credential nobody received.
## Retention