| 1 | import { loopback } from "./dev.js"; |
| 2 | import type { Env } from "../env.js"; |
| 3 | import type { Account } from "../lib/directory.js"; |
| 4 | import { hmacSha256, matchesAnyKey, signingSecrets } from "../lib/crypto.js"; |
| 5 | import { HttpError, b64url, newId, now, randomToken, sha256 } from "../lib/util.js"; |
| 6 | |
| 7 | /** |
| 8 | * Open sign-up (SIGNUP=open). Someone asks for a code at /signup, proves they read mail at |
| 9 | * the address by entering it, and gets a short-lived ticket that lets them pick a username |
| 10 | * and create a passkey, the way an invite link does. Off, the instance stays invite-only. |
| 11 | * Codes and tickets are stored only as hashes. |
| 12 | */ |
| 13 | |
| 14 | export const CODE_MINUTES = 10; |
| 15 | export const TICKET_MINUTES = 30; |
| 16 | /** Codes entered for one code, right or wrong; after this many it stops working. */ |
| 17 | export const MAX_CODE_ATTEMPTS = 5; |
| 18 | /** Sign-up emails (a code, or the "you already have an account" note) per address and per IP address in 24 hours. */ |
| 19 | export const CODES_PER_EMAIL_PER_DAY = 5; |
| 20 | export const CODES_PER_IP_PER_DAY = 20; |
| 21 | |
| 22 | export const TURNSTILE_SCRIPT = "https://challenges.cloudflare.com/turnstile/v0/api.js"; |
| 23 | export const TURNSTILE_ORIGIN = "https://challenges.cloudflare.com"; |
| 24 | const SITEVERIFY = "https://challenges.cloudflare.com/turnstile/v0/siteverify"; |
| 25 | |
| 26 | export function signupOpen(env: Pick<Env, "SIGNUP">): boolean { |
| 27 | return env.SIGNUP === "open"; |
| 28 | } |
| 29 | |
| 30 | /** |
| 31 | * How sign-up, and a security report from someone not signed in (web/security-reports.tsx), check |
| 32 | * for a person: "verify" with Turnstile, "skip" in local development |
| 33 | * (PUBLIC_URL on this machine) without it, and "missing" when a deployed instance has no |
| 34 | * Turnstile keys, which refuses sign-up rather than leave it open to scripts. |
| 35 | */ |
| 36 | export function turnstileMode(env: Pick<Env, "TURNSTILE_SITE_KEY" | "TURNSTILE_SECRET" | "PUBLIC_URL">): "verify" | "skip" | "missing" { |
| 37 | if (env.TURNSTILE_SITE_KEY && env.TURNSTILE_SECRET) return "verify"; |
| 38 | return loopback(env.PUBLIC_URL) ? "skip" : "missing"; |
| 39 | } |
| 40 | |
| 41 | /** Ask Cloudflare whether the widget's token is good. Any failure counts as no. */ |
| 42 | export async function verifyTurnstile(secret: string, token: string, ip: string | null): Promise<boolean> { |
| 43 | if (!token || token.length > 2048) return false; |
| 44 | const body = new URLSearchParams({ secret, response: token }); |
| 45 | if (ip) body.set("remoteip", ip); |
| 46 | try { |
| 47 | const r = await fetch(SITEVERIFY, { method: "POST", body }); |
| 48 | if (!r.ok) return false; |
| 49 | return ((await r.json()) as { success?: boolean }).success === true; |
| 50 | } catch (e) { |
| 51 | console.error("turnstile siteverify failed", e); |
| 52 | return false; |
| 53 | } |
| 54 | } |
| 55 | |
| 56 | /** An address worth sending a code to, lower-cased; null when it isn't one. */ |
| 57 | export function normalEmail(v: string): string | null { |
| 58 | const e = v.trim().toLowerCase(); |
| 59 | return e.length <= 254 && /^[^\s@<>()",;]+@[^\s@<>()",;]+\.[^\s@<>()",;]+$/.test(e) ? e : null; |
| 60 | } |
| 61 | |
| 62 | /** Six random digits, each code equally likely. */ |
| 63 | export function newCode(): string { |
| 64 | const buf = new Uint32Array(1); |
| 65 | // 4,294,000,000 is the largest multiple of a million below 2^32: drawing below it avoids modulo bias. |
| 66 | for (;;) { |
| 67 | crypto.getRandomValues(buf); |
| 68 | if (buf[0]! < 4_294_000_000) return String(buf[0]! % 1_000_000).padStart(6, "0"); |
| 69 | } |
| 70 | } |
| 71 | |
| 72 | /** |
| 73 | * The stored form of a code: an HMAC keyed from MASTER_KEY over the code's row id and the |
| 74 | * code, so a copy of the database alone can't try the million codes. |
| 75 | */ |
| 76 | export async function codeHash(env: Pick<Env, "MASTER_KEY" | "MASTER_KEY_PREV" | "PUBLIC_URL">, id: string, code: string): Promise<string> { |
| 77 | return codeHashWith(codeSecrets(env)[0]!, id, code); |
| 78 | } |
| 79 | |
| 80 | /** Whether `code` is the one stored as `hash`, keyed with MASTER_KEY or, while it is being rotated, MASTER_KEY_PREV. */ |
| 81 | export async function codeMatches(env: Pick<Env, "MASTER_KEY" | "MASTER_KEY_PREV" | "PUBLIC_URL">, id: string, code: string, hash: string): Promise<boolean> { |
| 82 | return matchesAnyKey(codeSecrets(env), hash, (s) => codeHashWith(s, id, code)); |
| 83 | } |
| 84 | |
| 85 | function codeSecrets(env: Pick<Env, "MASTER_KEY" | "MASTER_KEY_PREV" | "PUBLIC_URL">): string[] { |
| 86 | // Local development has no MASTER_KEY unless one is passed; production always does. |
| 87 | return signingSecrets(env, loopback(env.PUBLIC_URL) ? "diffinery-dev-signup" : null, "MASTER_KEY is not set, so sign-up codes can't be stored"); |
| 88 | } |
| 89 | |
| 90 | const codeHashWith = async (secret: string, id: string, code: string): Promise<string> => b64url(await hmacSha256(`diffinery-signup-code-v1:${secret}`, `${id}:${code}`)); |
| 91 | |
| 92 | const minutes = (n: number): string => new Date(Date.now() + n * 60_000).toISOString(); |
| 93 | |
| 94 | export type CodeCheck = { ok: true } | { ok: false; left: number }; |
| 95 | |
| 96 | /** Sign-up codes and tickets in D1. */ |
| 97 | export class Signups { |
| 98 | constructor(private env: Env) {} |
| 99 | |
| 100 | private get db() { |
| 101 | return this.env.DB; |
| 102 | } |
| 103 | |
| 104 | /** The person account using this address, whatever its case. */ |
| 105 | async accountFor(email: string): Promise<Account | null> { |
| 106 | return this.db.prepare("SELECT * FROM accounts WHERE kind = 'user' AND lower(email) = ? LIMIT 1").bind(email.toLowerCase()).first<Account>(); |
| 107 | } |
| 108 | |
| 109 | /** |
| 110 | * A new code for `email`, requested from `ip`, after the daily limits; older codes for the |
| 111 | * address stop working. An address that already has an account gets a code too, which is |
| 112 | * never sent: wrong guesses then answer exactly as they would for a new address. |
| 113 | */ |
| 114 | async issue(email: string, ip: string): Promise<{ code: string; expiresAt: string }> { |
| 115 | const since = new Date(Date.now() - 86400_000).toISOString(); |
| 116 | const sent = await this.db |
| 117 | .prepare("SELECT (SELECT count(*) FROM signup_codes WHERE email = ? AND created_at > ?) AS email, (SELECT count(*) FROM signup_codes WHERE ip = ? AND created_at > ?) AS ip") |
| 118 | .bind(email, since, ip, since) |
| 119 | .first<{ email: number; ip: number }>(); |
| 120 | if ((sent?.email ?? 0) >= CODES_PER_EMAIL_PER_DAY) throw new HttpError(429, `We have sent several codes to ${email} today. Check that inbox (and its spam folder), or try again tomorrow.`); |
| 121 | if ((sent?.ip ?? 0) >= CODES_PER_IP_PER_DAY) throw new HttpError(429, "Too many sign-ups from your network today. Try again tomorrow."); |
| 122 | const id = newId(); |
| 123 | const code = newCode(); |
| 124 | const at = now(); |
| 125 | const expiresAt = minutes(CODE_MINUTES); |
| 126 | await this.db.batch([ |
| 127 | this.db.prepare("UPDATE signup_codes SET expires_at = ? WHERE email = ? AND used_at IS NULL AND expires_at > ?").bind(at, email, at), |
| 128 | this.db.prepare("INSERT INTO signup_codes (id, email, ip, code_hash, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?)").bind(id, email, ip, await codeHash(this.env, id, code), at, expiresAt), |
| 129 | // Rows older than a day no longer count towards any limit. |
| 130 | this.db.prepare("DELETE FROM signup_codes WHERE created_at < ?").bind(since), |
| 131 | ]); |
| 132 | return { code, expiresAt }; |
| 133 | } |
| 134 | |
| 135 | /** |
| 136 | * Check a code against the address's live one. Every try counts, so at most |
| 137 | * MAX_CODE_ATTEMPTS codes are ever compared; a right one works once. |
| 138 | */ |
| 139 | async check(email: string, code: string): Promise<CodeCheck> { |
| 140 | if (!/^\d{6}$/.test(code)) throw new HttpError(422, "Enter the 6-digit code from the email."); |
| 141 | const t = now(); |
| 142 | const row = await this.db |
| 143 | .prepare( |
| 144 | "UPDATE signup_codes SET attempts = attempts + 1 WHERE id = (SELECT id FROM signup_codes WHERE email = ? AND used_at IS NULL AND expires_at > ? ORDER BY created_at DESC LIMIT 1) AND attempts < ? RETURNING id, code_hash, attempts", |
| 145 | ) |
| 146 | .bind(email, t, MAX_CODE_ATTEMPTS) |
| 147 | .first<{ id: string; code_hash: string; attempts: number }>(); |
| 148 | if (!row) return { ok: false, left: 0 }; |
| 149 | if (await codeMatches(this.env, row.id, code, row.code_hash)) { |
| 150 | const r = await this.db.prepare("UPDATE signup_codes SET used_at = ? WHERE id = ? AND used_at IS NULL").bind(t, row.id).run(); |
| 151 | if ((r.meta.changes ?? 0) > 0) return { ok: true }; |
| 152 | } |
| 153 | return { ok: false, left: Math.max(0, MAX_CODE_ATTEMPTS - row.attempts) }; |
| 154 | } |
| 155 | |
| 156 | /** A single-use ticket for a verified address; returns the token for the passkey form. */ |
| 157 | async createTicket(email: string): Promise<string> { |
| 158 | const token = randomToken(32); |
| 159 | await this.db.batch([ |
| 160 | this.db.prepare("DELETE FROM signup_tickets WHERE expires_at < ?").bind(now()), |
| 161 | this.db.prepare("INSERT INTO signup_tickets (id, token_hash, email, created_at, expires_at) VALUES (?, ?, ?, ?, ?)").bind(newId(), await sha256(token), email, now(), minutes(TICKET_MINUTES)), |
| 162 | ]); |
| 163 | return token; |
| 164 | } |
| 165 | |
| 166 | /** An unused, unexpired ticket by its token. */ |
| 167 | async ticket(token: string): Promise<{ hash: string; email: string } | null> { |
| 168 | if (!/^[A-Za-z0-9_-]{20,64}$/.test(token)) return null; |
| 169 | const hash = await sha256(token); |
| 170 | const row = await this.db.prepare("SELECT email FROM signup_tickets WHERE token_hash = ? AND used_at IS NULL AND expires_at > ?").bind(hash, now()).first<{ email: string }>(); |
| 171 | return row ? { hash, email: row.email } : null; |
| 172 | } |
| 173 | |
| 174 | /** Mark a ticket used; false when it was used first or expired. */ |
| 175 | async useTicket(hash: string): Promise<boolean> { |
| 176 | const r = await this.db.prepare("UPDATE signup_tickets SET used_at = ? WHERE token_hash = ? AND used_at IS NULL AND expires_at > ?").bind(now(), hash, now()).run(); |
| 177 | return (r.meta.changes ?? 0) > 0; |
| 178 | } |
| 179 | } |