From 428c31ce4aec60fd034eeab1e1bc0df1e1847cc0 Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Tue, 1 Sep 2026 20:58:07 -0400 Subject: [PATCH 1/2] Prove an address before an account can use the relay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes part of #40. Addresses were collected, used for password reset, and never verified โ€” so an account could be created against an address nobody owns, and the reset path could be aimed at one too. ๐Ÿ”ด **The gate is on the TOKEN route, and it could not go where the ticket said.** "Verify before you can send" is unimplementable: a sealed send is authorised by a delivery key and records NO sender (ยง14), so the relay does not know whose account to check. Gating the send path 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, so an account that cannot mint one cannot poll, register a device or fetch a bundle. "Verify before you can use the relay" is both enforceable and honest. ๐Ÿ”ด **Inert on a relay that cannot 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. Two tests hold that line from both sides. ๐Ÿ”ด **The migration grandfathers every existing account.** They signed up before there was anything to prove; leaving them unproved would lock out every current user the moment this went live. โš ๏ธ Accounts authenticated by the shared service have no email here and are never gated. โš ๏ธ The code is spent on use, expires in a day, and is compared through the existing constant-time helper โ€” a `==` on the hash leaks how much of it matched. โš ๏ธ Sending cannot fail the sign-up: a mail server having a bad day still leaves somebody with an account. Nine tests, proven able to fail: disabling the gate turns six red. 479 tests in 41 suites pass, up from 470. โš ๏ธ Not deployed, and SMTP is NOT configured on production โ€” so on the live relay every part of this is currently inert by design. --- Sources/FlamenetRelay/Account.swift | 25 ++ Sources/FlamenetRelay/Entrypoint.swift | 14 +- Sources/FlamenetRelay/Migrations.swift | 40 ++- Sources/FlamenetRelay/RoutesAccount.swift | 129 +++++++++ .../EmailVerificationTests.swift | 244 ++++++++++++++++++ 5 files changed, 450 insertions(+), 2 deletions(-) create mode 100644 Tests/FlamenetRelayTests/EmailVerificationTests.swift diff --git a/Sources/FlamenetRelay/Account.swift b/Sources/FlamenetRelay/Account.swift index 39391e0..1766605 100644 --- a/Sources/FlamenetRelay/Account.swift +++ b/Sources/FlamenetRelay/Account.swift @@ -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. /// diff --git a/Sources/FlamenetRelay/Entrypoint.swift b/Sources/FlamenetRelay/Entrypoint.swift index f43791b..0aa017c 100644 --- a/Sources/FlamenetRelay/Entrypoint.swift +++ b/Sources/FlamenetRelay/Entrypoint.swift @@ -335,7 +335,14 @@ 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, + 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 +1088,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) } diff --git a/Sources/FlamenetRelay/Migrations.swift b/Sources/FlamenetRelay/Migrations.swift index 4a68842..d8cb513 100644 --- a/Sources/FlamenetRelay/Migrations.swift +++ b/Sources/FlamenetRelay/Migrations.swift @@ -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()] } } diff --git a/Sources/FlamenetRelay/RoutesAccount.swift b/Sources/FlamenetRelay/RoutesAccount.swift index ae41024..c0a4a98 100644 --- a/Sources/FlamenetRelay/RoutesAccount.swift +++ b/Sources/FlamenetRelay/RoutesAccount.swift @@ -58,6 +58,18 @@ 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 + /// 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 + /// 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 +239,15 @@ 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. + if 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 +537,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 canSendMail { + 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 +630,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 `.`. The selector is a lookup key held diff --git a/Tests/FlamenetRelayTests/EmailVerificationTests.swift b/Tests/FlamenetRelayTests/EmailVerificationTests.swift new file mode 100644 index 0000000..b3cfcef --- /dev/null +++ b/Tests/FlamenetRelayTests/EmailVerificationTests.swift @@ -0,0 +1,244 @@ +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" + + private func routes(_ h: RelayHarness, canSendMail: Bool) -> AccountRoutes { + AccountRoutes( + issuer: Issuer(signingKeyRaw: Data(repeating: 1, count: 32), + audience: "test.relay", lifetime: 300), + limits: h.routes.limits, + signupMaxPerHour: 100, + canSendMail: canSendMail, + // โš ๏ธ 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. + private func unverifiedUser(_ h: RelayHarness) async throws -> (User, String) { + let a = routes(h, canSendMail: false) + let signup = post(h, "/account/signup", + #"{"username":"ada","email":"ada@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) + } + } + } + } +} -- 2.54.0 From a9bb216059f53d7d6e34bdde09c2ea82f1f0cda6 Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Wed, 2 Sep 2026 15:18:45 -0400 Subject: [PATCH 2/2] Switch address verification off by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- Resources/Views/configuration.leaf | 47 +++++++++- Sources/FlamenetRelay/Config.swift | 20 +++++ Sources/FlamenetRelay/Entrypoint.swift | 2 + Sources/FlamenetRelay/RoutesAccount.swift | 21 ++++- .../EmailVerificationTests.swift | 90 ++++++++++++++++++- docs/CONFIGURATION.md | 18 ++++ 6 files changed, 192 insertions(+), 6 deletions(-) diff --git a/Resources/Views/configuration.leaf b/Resources/Views/configuration.leaf index 85920db..1bf45b5 100644 --- a/Resources/Views/configuration.leaf +++ b/Resources/Views/configuration.leaf @@ -3,7 +3,7 @@ + source-sha256: 6e383dace6a281bc -->

Configuring a relay

@@ -405,6 +405,51 @@ old to know what a report is".

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.

+ + + + + + + + + + + + + + + +
VariableDefaultWhat it does
FN_EMAIL_VERIFICATION_REQUIREDfalseWhen 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

The relay purges on its own timer, in-process. Nothing external is needed.

diff --git a/Sources/FlamenetRelay/Config.swift b/Sources/FlamenetRelay/Config.swift index 70ed2fb..453f9bd 100644 --- a/Sources/FlamenetRelay/Config.swift +++ b/Sources/FlamenetRelay/Config.swift @@ -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), diff --git a/Sources/FlamenetRelay/Entrypoint.swift b/Sources/FlamenetRelay/Entrypoint.swift index 0aa017c..9066c29 100644 --- a/Sources/FlamenetRelay/Entrypoint.swift +++ b/Sources/FlamenetRelay/Entrypoint.swift @@ -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) }, diff --git a/Sources/FlamenetRelay/RoutesAccount.swift b/Sources/FlamenetRelay/RoutesAccount.swift index c0a4a98..72596e7 100644 --- a/Sources/FlamenetRelay/RoutesAccount.swift +++ b/Sources/FlamenetRelay/RoutesAccount.swift @@ -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 { diff --git a/Tests/FlamenetRelayTests/EmailVerificationTests.swift b/Tests/FlamenetRelayTests/EmailVerificationTests.swift index b3cfcef..f8449e9 100644 --- a/Tests/FlamenetRelayTests/EmailVerificationTests.swift +++ b/Tests/FlamenetRelayTests/EmailVerificationTests.swift @@ -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") + } + } } diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index e0f0608..3e0d88f 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -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 -- 2.54.0