Prove an address before an account can use the relay
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.
This commit is contained in:
@@ -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.
|
||||
///
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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,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 `<selector>.<secret>`. The selector is a lookup key held
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user