Prove an address before an account can use the relay #76
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
///
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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()]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user