Prove an address before an account can use the relay #76

Merged
paulhitt merged 2 commits from feat/email-verification into main 2026-09-03 00:13:14 +00:00
8 changed files with 637 additions and 3 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>
+25
View File
@@ -55,6 +55,13 @@ final class User: Model, @unchecked Sendable {
@OptionalField(key: "fnid") var fnid: String?
@OptionalField(key: "last_seen_at") var lastSeenAt: Date?
/// When this address was proved reachable, or nil if it never was.
///
/// ⚠️ **Nil does not mean "refuse".** It means "unproved", and what that is
/// worth depends on whether this relay can send mail at all — a self-hoster
/// with no SMTP can never prove anything, and must not be locked out of
/// their own server for it. See `AccountRoutes.token`.
@OptionalField(key: "email_verified_at") var emailVerifiedAt: Date?
@Timestamp(key: "created_at", on: .create) var createdAt: Date?
@@ -90,6 +97,24 @@ final class User: Model, @unchecked Sendable {
}
}
/// A one-shot proof that somebody can read the address they signed up with.
///
/// Same shape as `PasswordResetToken` — selector in the clear for the lookup,
/// only the secret's hash stored — for the same reason: a database read must
/// not hand somebody a working link.
final class EmailVerificationToken: Model, @unchecked Sendable {
static let schema = "email_verification_tokens"
@ID(custom: "id", generatedBy: .database) var id: Int?
@Field(key: "user_id") var userID: Int
@Field(key: "selector") var selector: String
@Field(key: "secret_hash") var secretHash: String
@Field(key: "expires_at") var expiresAt: Date
@Timestamp(key: "created_at", on: .create) var createdAt: Date?
init() {}
}
/// A long-lived credential the client stores and presents to get short-lived
/// relay tokens.
///
+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),
+15 -1
View File
@@ -335,7 +335,16 @@ func configure(_ app: Application) async throws {
// ⚠️ Derived, not stored: stable across restarts so a challenge in
// flight survives a deploy, and never the issuer key itself — one
// secret doing two jobs is one compromise doing two.
challengeSecret: Data(SHA256.hash(data: seed + Data("signup-pow".utf8))))
challengeSecret: Data(SHA256.hash(data: seed + Data("signup-pow".utf8))),
// ⚠️ 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)
},
siteURL: config.audience)
try register(account: accounts, config: config, on: app)
// The shipped iOS client's surface, mounted where it already looks.
@@ -1081,6 +1090,11 @@ func register(account a: AccountRoutes, config: Config, on app: Application) thr
try await a.requestReset(req, mailer: mailer, siteURL: config.audience, now: unixNow())
}
account.post("reset", "confirm") { req in try await a.confirmReset(req, now: unixNow()) }
// Email verification. Registered unconditionally for the same reason as
// reset above — a 404 here would tell an anonymous caller whether this
// relay has mail configured.
account.post("verify") { req in try await a.verifyEmail(req, now: unixNow()) }
}
func unixNow() -> Int { Int(Date().timeIntervalSince1970) }
+39 -1
View File
@@ -526,6 +526,44 @@ struct CreateDeliveryToken: AsyncMigration {
}
}
/// Record when an address was proved reachable, and create the tokens that
/// prove it.
///
/// 🔴 **Every account that already exists is marked verified.** They signed up
/// before there was anything to prove, and a migration that left them unproved
/// would lock every current user out the moment the gate went live.
/// Grandfathering is not laziness here — it is the difference between adding a
/// rule and breaking a service.
struct AddEmailVerification: AsyncMigration {
func prepare(on database: any Database) async throws {
try await database.schema(User.schema)
.field("email_verified_at", .datetime)
.update()
// Everyone already here is already trusted.
if let sql = database as? any SQLDatabase {
try? await sql.raw("""
UPDATE users SET email_verified_at = NOW()
WHERE email_verified_at IS NULL
""").run()
}
try await database.schema(EmailVerificationToken.schema)
.field("id", .int, .identifier(auto: true))
.field("user_id", .int, .required)
.field("selector", .string, .required)
.field("secret_hash", .string, .required)
.field("expires_at", .datetime, .required)
.field("created_at", .datetime)
.unique(on: "selector")
.create()
}
func revert(on database: any Database) async throws {
try await database.schema(EmailVerificationToken.schema).delete()
try await database.schema(User.schema).deleteField("email_verified_at").update()
}
}
/// Give every device already registered a leaf in the transparency log.
///
/// 🔴 **Without this, turning on automatic verification strands the existing
@@ -612,6 +650,6 @@ 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()]
CreateContentReport(), CreateVouchCode(), AddEmailVerification()]
}
}
+146
View File
@@ -58,6 +58,30 @@ struct AccountRoutes: Sendable {
/// does not invalidate a challenge somebody is part-way through solving.
var challengeSecret: Data = Data()
/// Whether this relay can prove an address is reachable.
///
/// ⚠️ False on a relay with no SMTP configured, and everything about
/// 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 = ""
/// Five failures per IP+username per fifteen minutes, matching the plugin.
///
/// This route turns a password into a long-lived credential, so without a
@@ -227,6 +251,20 @@ struct AccountRoutes: Sendable {
guard let userID = user.id else {
throw WireError("server_error", "Could not create the account.", 500)
}
// ⚠️ 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.
//
// ⚠️ 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)
}
let secret = try await mintCredential(
userID: userID, label: "signup", on: request.db)
return try .json(AccountResponse(
@@ -516,6 +554,37 @@ struct AccountRoutes: Sendable {
throw WireError.unauthorized("Credential not accepted.")
}
// 🔴 **The one gate that covers everything, and the only place it can
// go.** "Verify before you can send" cannot be implemented on the send
// path: a sealed send is authorised by a delivery key and deliberately
// records NO sender (§14), so the relay does not know whose account to
// check. Gating the send would either break sealed sending or only
// catch the unsealed minority — the wrong half.
//
// A relay token is different: it is authenticated, it names the
// account, and everything downstream needs one. An account that cannot
// mint one cannot poll, register a device, or fetch a bundle — so this
// is "verify before you can use the relay", which is both enforceable
// and honest.
//
// ⚠️ **Inert unless this relay can actually send mail.** A self-hoster
// with no SMTP can never prove anything, and refusing them entry to
// their own server would be the worst possible reading of a security
// feature. Same shape as `enforceKeyLogIfServed`: enforce where the
// question can be answered, skip where it cannot.
//
// ⚠️ Accounts with no email — those authenticated by the shared
// service — have nothing to prove and are unaffected.
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 {
throw WireError("email_unverified",
"Check your email and enter the code to finish setting up your account.",
403)
}
}
// A device registered to someone else must not get a token naming this
// account: the relay would then see a token and a device row that
// disagree, and it refuses — but failing here says why.
@@ -578,6 +647,83 @@ struct AccountRoutes: Sendable {
secret: challengeSecret, now: now, bits: signupProofOfWorkBits))
}
// MARK: - Email verification
/// How long a verification code is good for. Generous on purpose: this
/// arrives while somebody is setting up an app, and an expired code at that
/// moment reads as the product being broken.
static let verificationLifetime: TimeInterval = 24 * 60 * 60
/// Issue a code and mail it. Best effort — a mail server having a bad day
/// must not fail the sign-up that already succeeded.
func sendVerification(userID: Int, email: String, username: String,
mailer: Mailer?, siteURL: String,
on db: any Database, now: Int) async {
guard let mailer else { return }
// One live code at a time. Two working links is a longer window than
// one for no benefit — the same rule password reset uses.
try? await EmailVerificationToken.query(on: db).filter(\.$userID == userID).delete()
let selector = Self.randomToken(bytes: 8)
let secret = Self.randomToken(bytes: 32)
let row = EmailVerificationToken()
row.userID = userID
row.selector = selector
row.secretHash = SHA256.hash(data: Data(secret.utf8))
.map { String(format: "%02x", $0) }.joined()
row.expiresAt = Date(timeIntervalSince1970: TimeInterval(now) + Self.verificationLifetime)
do { try await row.create(on: db) } catch { return }
let body = """
Welcome to Flamenet, \(username).
Enter this code in the app to finish setting up your account:
\(selector).\(secret)
It works once and expires in a day. If you did not sign up at
\(siteURL), you can ignore this — the account cannot be used until
somebody enters this code.
"""
do {
try await mailer.send(to: email, subject: "Finish setting up Flamenet", body: body)
} catch {
// The code is removed again: one nobody received is a live
// credential in the database with no owner.
try? await row.delete(on: db)
}
}
/// POST /account/verify — spend a code.
func verifyEmail(_ request: Request, now: Int) async throws -> Response {
struct Input: Content { let token: String }
let input = try request.content.decode(Input.self)
let parts = input.token.split(separator: ".", maxSplits: 1).map(String.init)
guard parts.count == 2 else {
throw WireError("bad_token", "That code is not in the right shape.", 400)
}
let hash = SHA256.hash(data: Data(parts[1].utf8))
.map { String(format: "%02x", $0) }.joined()
guard let row = try await EmailVerificationToken.query(on: request.db)
.filter(\.$selector == parts[0]).first(),
// ⚠️ Compared as data, not as a string: a `==` on Swift strings
// short-circuits on the first differing byte, and the timing of
// that is a hint about the secret.
constantTimeEquals(Data(row.secretHash.utf8), Data(hash.utf8)),
row.expiresAt.timeIntervalSince1970 > TimeInterval(now)
else {
throw WireError("bad_token", "That code is not valid, or it has expired.", 400)
}
if let user = try await User.find(row.userID, on: request.db) {
user.emailVerifiedAt = Date(timeIntervalSince1970: TimeInterval(now))
try await user.update(on: request.db)
}
try await row.delete(on: request.db)
return try .json(["ok": true])
}
// MARK: - Credentials
/// A credential is `<selector>.<secret>`. The selector is a lookup key held
@@ -0,0 +1,328 @@
import Crypto
import Fluent
import Foundation
import Testing
import Vapor
@testable import FlamenetRelay
/// Proving somebody can read the address they signed up with.
///
/// 🔴 **The gate is on the TOKEN route, and that is a design decision rather
/// than a convenience.** "Verify before you can send" cannot be implemented on
/// the send path: a sealed send is authorised by a delivery key and records no
/// sender at all (§14), so the relay does not know whose account to check.
/// Gating there would either break sealed sending or catch only the unsealed
/// minority — the wrong half. A relay token is authenticated, names the
/// account, and everything downstream needs one.
///
/// 🔴 **And it is inert on a relay that cannot send mail.** A self-hoster with
/// no SMTP can never prove anything, so refusing them entry to their own server
/// would be the worst possible reading of a security feature. Half of this
/// suite exists to hold that line.
@Suite("email verification")
struct EmailVerificationTests {
private let now = 1_700_000_000
private let ip = "203.0.113.9"
/// `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,
siteURL: "test.relay")
}
private func post(_ h: RelayHarness, _ path: String, _ body: String) -> Request {
var headers = HTTPHeaders()
headers.contentType = .json
var buf = ByteBufferAllocator().buffer(capacity: body.utf8.count)
buf.writeString(body)
return Request(application: h.app, method: .POST, url: URI(path: path),
headers: headers, collectedBody: buf,
on: h.app.eventLoopGroup.next())
}
/// An account with an email, deliberately left unproved.
/// `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":"\#(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!))
let user = try #require(try await User.find(created.user_id, on: h.app.db))
user.emailVerifiedAt = nil
try await user.update(on: h.app.db)
return (user, created.app_password)
}
private func tokenRequest(_ h: RelayHarness, credential: String) -> Request {
var headers = HTTPHeaders()
headers.contentType = .json
headers.basicAuthorization = .init(username: "ada", password: credential)
let body = #"{"device_id":"11111111-1111-1111-1111-111111111111","ik_sig":"\#(Data(repeating: 3, count: 32).base64EncodedString())"}"#
var buf = ByteBufferAllocator().buffer(capacity: body.utf8.count)
buf.writeString(body)
return Request(application: h.app, method: .POST, url: URI(path: "/account/token"),
headers: headers, collectedBody: buf,
on: h.app.eventLoopGroup.next())
}
// MARK: - The gate
/// 🔴 **The property that protects every self-hoster.** With no SMTP there
/// is no way to prove an address, so an unproved account must work exactly
/// as it always did. A relay that refuses its own users because its owner
/// never configured mail is worse than no verification at all.
@Test("a relay with no mail never refuses an unverified account")
func inertWithoutSMTP() async throws {
try await withRelay { h in
let (_, credential) = try await unverifiedUser(h)
let a = routes(h, canSendMail: false)
// Must not throw.
_ = try await a.token(tokenRequest(h, credential: credential), now: now)
}
}
/// And with mail configured, the same account is held at the door.
@Test("a relay with mail refuses an unverified account")
func gatedWithSMTP() async throws {
try await withRelay { h in
let (_, credential) = try await unverifiedUser(h)
let a = routes(h, canSendMail: true)
await #expect(throws: WireError.self) {
_ = try await a.token(self.tokenRequest(h, credential: credential), now: self.now)
}
}
}
/// ⚠️ The refusal must carry a code the client can switch on, or it renders
/// as a generic failure and the person is told nothing they can act on.
@Test("the refusal says why, in a code a client can read")
func refusalCarriesACode() async throws {
try await withRelay { h in
let (_, credential) = try await unverifiedUser(h)
let a = routes(h, canSendMail: true)
do {
_ = try await a.token(self.tokenRequest(h, credential: credential), now: self.now)
Issue.record("the token was issued to an unverified account")
} catch let error as WireError {
#expect(error.code == "email_unverified")
#expect(error.data.status == 403)
}
}
}
/// Once proved, the door opens.
@Test("a verified account gets its token")
func verifiedPasses() async throws {
try await withRelay { h in
let (user, credential) = try await unverifiedUser(h)
user.emailVerifiedAt = Date()
try await user.update(on: h.app.db)
let a = routes(h, canSendMail: true)
_ = try await a.token(tokenRequest(h, credential: credential), now: now)
}
}
/// ⚠️ An account authenticated by the shared service has no email here, so
/// it has nothing to prove and must never be gated.
@Test("an account with no email of its own is never gated")
func noEmailIsNeverGated() async throws {
try await withRelay { h in
let (user, credential) = try await unverifiedUser(h)
user.email = nil
user.enclavePseudonym = "someone-elsewhere"
try await user.update(on: h.app.db)
let a = routes(h, canSendMail: true)
_ = try await a.token(tokenRequest(h, credential: credential), now: now)
}
}
// MARK: - Spending a code
@Test("a valid code verifies the account and cannot be reused")
func codeWorksOnce() async throws {
try await withRelay { h in
let (user, _) = try await unverifiedUser(h)
let userID = try #require(user.id)
let selector = AccountRoutes.randomToken(bytes: 8)
let secret = AccountRoutes.randomToken(bytes: 32)
let row = EmailVerificationToken()
row.userID = userID
row.selector = selector
row.secretHash = SHA256.hash(data: Data(secret.utf8))
.map { String(format: "%02x", $0) }.joined()
row.expiresAt = Date(timeIntervalSince1970: TimeInterval(now) + 3600)
try await row.create(on: h.app.db)
let a = routes(h, canSendMail: true)
_ = try await a.verifyEmail(
post(h, "/account/verify", #"{"token":"\#(selector).\#(secret)"}"#), now: now)
let after = try #require(try await User.find(userID, on: h.app.db))
#expect(after.emailVerifiedAt != nil)
// 🔴 Spent, not merely marked. A code that keeps working is a
// credential sitting in somebody's inbox for ever.
await #expect(throws: WireError.self) {
_ = try await a.verifyEmail(
self.post(h, "/account/verify", #"{"token":"\#(selector).\#(secret)"}"#),
now: self.now)
}
}
}
@Test("an expired code is refused")
func expiredCodeRefused() async throws {
try await withRelay { h in
let (user, _) = try await unverifiedUser(h)
let selector = AccountRoutes.randomToken(bytes: 8)
let secret = AccountRoutes.randomToken(bytes: 32)
let row = EmailVerificationToken()
row.userID = try #require(user.id)
row.selector = selector
row.secretHash = SHA256.hash(data: Data(secret.utf8))
.map { String(format: "%02x", $0) }.joined()
// One second in the past.
row.expiresAt = Date(timeIntervalSince1970: TimeInterval(now) - 1)
try await row.create(on: h.app.db)
let a = routes(h, canSendMail: true)
await #expect(throws: WireError.self) {
_ = try await a.verifyEmail(
self.post(h, "/account/verify", #"{"token":"\#(selector).\#(secret)"}"#),
now: self.now)
}
}
}
/// ⚠️ The right selector with the wrong secret must fail — otherwise the
/// selector alone, which is not secret, would be enough.
@Test("a wrong secret is refused even with a real selector")
func wrongSecretRefused() async throws {
try await withRelay { h in
let (user, _) = try await unverifiedUser(h)
let selector = AccountRoutes.randomToken(bytes: 8)
let row = EmailVerificationToken()
row.userID = try #require(user.id)
row.selector = selector
row.secretHash = SHA256.hash(data: Data("the-real-secret".utf8))
.map { String(format: "%02x", $0) }.joined()
row.expiresAt = Date(timeIntervalSince1970: TimeInterval(now) + 3600)
try await row.create(on: h.app.db)
let a = routes(h, canSendMail: true)
await #expect(throws: WireError.self) {
_ = try await a.verifyEmail(
self.post(h, "/account/verify", #"{"token":"\#(selector).guessed"}"#),
now: self.now)
}
}
}
@Test("a malformed code is refused rather than crashing")
func malformedCodeRefused() async throws {
try await withRelay { h in
let a = routes(h, canSendMail: true)
for bad in ["", "nodot", ".", "a.", ".b"] {
await #expect(throws: WireError.self) {
_ = try await a.verifyEmail(
self.post(h, "/account/verify", #"{"token":"\#(bad)"}"#), now: self.now)
}
}
}
}
// 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