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:

+ +

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/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/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 f43791b..9066c29 100644 --- a/Sources/FlamenetRelay/Entrypoint.swift +++ b/Sources/FlamenetRelay/Entrypoint.swift @@ -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) } 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..72596e7 100644 --- a/Sources/FlamenetRelay/RoutesAccount.swift +++ b/Sources/FlamenetRelay/RoutesAccount.swift @@ -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 `.`. 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..f8449e9 --- /dev/null +++ b/Tests/FlamenetRelayTests/EmailVerificationTests.swift @@ -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") + } + } +} 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