Public: anyone can read this; the crew can change it.
Passkeys, part 2b: a verified email gets a single-use, 30-minute link that registers a new passkey #2008
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/1997-passkeys-part-2b-a-verified-email-gets-a → main 4 commits fork 3 threads · 3 unresolved
11 files
changed +541
−8
README.md +4 −1
| @@ -116,7 +116,10 @@ running — bakery asleep"). | ||
| 116 | 116 | Passkeys: Settings → Passkeys lists, adds and removes them (several per |
| 117 | 117 | account; `src/auth/webauthn.ts` verifies, no dependency, `none` attestation). |
| 118 | 118 | The sign-in page's "Sign in with a passkey" gives the same HttpOnly session a |
| 119 | password does. The relying party is the address the browser used (or | |
| 119 | password does. Lost your device? "Lost your device?" on the sign-in page leads | |
| 120 | to `/recover`: give the email on your account and, if it is verified, a link | |
| 121 | that lasts 30 minutes and works once registers a new passkey and signs you in. | |
| 122 | The relying party is the address the browser used (or | |
| 120 | 123 | `FORGE_URL`), so a passkey does not follow a forge to another hostname. |
| 121 | 124 | Sign-up is a username, an email address and a passkey (`/signup`, `src/web/pages/signup-passkey.ts`); the running server refuses a password-only sign-up on the form, `POST /api/v1/users` and the terminal (`passkeyOnlySignup`, set in `main.ts`). Accounts that already have a password still sign in. Two paths still make an account without a passkey and are the next step: the owner claim of a spawned forge (`web/pages/claim.ts`) and an admin's everyday account (`web/pages/everyday.ts`). |
| 122 | 125 |
scripts/gate.ts +1
| @@ -51,6 +51,7 @@ export const GATE: readonly string[] = [ | ||
| 51 | 51 | "test/thoughts.test.ts", |
| 52 | 52 | "test/triage.test.ts", |
| 53 | 53 | "test/passkeys.test.ts", |
| 54 | "test/passkey-recovery.test.ts", | |
| 54 | 55 | "test/signup-passkey.test.ts", |
| 55 | 56 | "test/webauthn.test.ts", |
| 56 | 57 | "test/chaos.test.ts", |
src/actions/verify.ts +64 −1
| @@ -12,11 +12,14 @@ import { | ||
| 12 | 12 | consumeLink, |
| 13 | 13 | emailVerified, |
| 14 | 14 | issueLink, |
| 15 | issueRecovery, | |
| 16 | recoveryOwner, | |
| 17 | spendRecovery, | |
| 15 | 18 | markVerified, |
| 16 | 19 | resetVerification, |
| 17 | 20 | } from "../core/verify.ts"; |
| 18 | 21 | import { listSecretNames, resolveSecrets } from "../core/secret_store.ts"; |
| 19 | import { getUserById, type User } from "../core/users.ts"; | |
| 22 | import { getUserById, isActive, type User } from "../core/users.ts"; | |
| 20 | 23 | import { sendMail, type Mailer } from "../kernel/mail.ts"; |
| 21 | 24 | import { mayMail, sendCapped } from "./mail.ts"; |
| 22 | 25 | import { err, ok, type Result } from "../kernel/result.ts"; |
| @@ -165,3 +168,63 @@ export async function resendVerification( | ||
| 165 | 168 | const outcome = await sendVerification(app, user.value); |
| 166 | 169 | return outcome === "sent" ? ok("sent") : err(outcome); |
| 167 | 170 | } |
| 171 | ||
| 172 | function recoveryUrl(app: App, token: string): string { | |
| 173 | const base = app.config.url ?? `http://localhost:${app.config.port}`; | |
| 174 | return `${new URL("/recover", base).toString()}?token=${token}`; | |
| 175 | } | |
| 176 | ||
| 177 | /** | |
| 178 | * Someone who cannot sign in asks for a way back by address. Nothing here | |
| 179 | * tells an address with an account from one without: the caller answers the | |
| 180 | * same either way. A link goes only to a person whose email is proven, is | |
| 181 | * not suspended or removed, and only when the forge can send mail. The send | |
| 182 | * is not waited for, so the answer takes as long for a stranger as for a | |
| 183 | * member. | |
| 184 | */ | |
| 185 | export function requestRecovery( | |
| 186 | app: App, | |
| 187 | email: string, | |
| 188 | now: number = Date.now(), | |
| 189 | ): void { | |
| 190 | const row = app.db | |
| 191 | .query<{ id: string }, [string]>( | |
| 192 | "SELECT id FROM user WHERE email = ?1 COLLATE NOCASE AND kind = 'user' AND is_agent = 0", | |
| 193 | ) | |
| 194 | .get(email.trim()); | |
| 195 | const user = row === null ? undefined : getUserById(app.db, row.id); | |
| 196 | if (user === undefined || !isActive(user)) return; | |
| 197 | const mailer = mailerOf(app, user); | |
| 198 | if (mailer === undefined || !mayMail(app, user.email, now)) return; | |
| 199 | const token = issueRecovery(app.db, user.id, now); | |
| 200 | if (token === undefined) return; | |
| 201 | void sendCapped(app, mailer, { | |
| 202 | to: user.email, | |
| 203 | subject: "Get back into your forge account", | |
| 204 | text: [ | |
| 205 | `Hello ${user.username},`, | |
| 206 | "", | |
| 207 | "Follow this link to register a new passkey and sign in. It works once, for 30 minutes:", | |
| 208 | "", | |
| 209 | recoveryUrl(app, token), | |
| 210 | "", | |
| 211 | "If you did not ask for this, ignore this message.", | |
| 212 | ].join("\n"), | |
| 213 | }, now).catch(() => undefined); | |
| 214 | } | |
| 215 | ||
| 216 | /** The account a recovery link is for, if it still works; nothing is spent. */ | |
| 217 | export function recoveryAccount(app: App, token: string): User | undefined { | |
| 218 | const id = recoveryOwner(app.db, token, Date.now()); | |
| 219 | const user = id === undefined ? undefined : getUserById(app.db, id); | |
| 220 | return user !== undefined && isActive(user) ? user : undefined; | |
| 221 | } | |
| 222 | ||
| 223 | /** Spend the link: the account to sign in, or `invalid` for a used or stale one. */ | |
| 224 | export function spendRecoveryLink(app: App, token: string): Result<User, "invalid"> { | |
| 225 | const id = spendRecovery(app.db, token, Date.now()); | |
| 226 | const user = id === undefined ? undefined : getUserById(app.db, id); | |
| 227 | if (user === undefined || !isActive(user)) return err("invalid"); | |
| 228 | app.bus.append({ kind: "account.recovered", actorId: user.id }); | |
| 229 | return ok(user); | |
| 230 | } |
src/core/passkeys.ts +18
| @@ -55,6 +55,24 @@ export function passkeyIds(db: Database, userId: string): string[] { | ||
| 55 | 55 | .map((r) => r.credential_id); |
| 56 | 56 | } |
| 57 | 57 | |
| 58 | /** Whether `addPasskey` would accept this credential, without adding it. */ | |
| 59 | export function passkeyRoom( | |
| 60 | db: Database, | |
| 61 | userId: string, | |
| 62 | credentialId: string, | |
| 63 | ): Result<true, "too_many" | "duplicate"> { | |
| 64 | const count = db | |
| 65 | .query<{ n: number }, [string]>( | |
| 66 | "SELECT count(*) AS n FROM passkey WHERE user_id = ?1", | |
| 67 | ) | |
| 68 | .get(userId)!.n; | |
| 69 | if (count >= PASSKEY_MAX) return err("too_many"); | |
| 70 | const taken = db | |
| 71 | .query("SELECT 1 FROM passkey WHERE credential_id = ?1") | |
| 72 | .get(credentialId); | |
| 73 | return taken === null ? ok(true) : err("duplicate"); | |
| 74 | } | |
| 75 | ||
| 58 | 76 | export function addPasskey( |
| 59 | 77 | db: Database, |
| 60 | 78 | userId: string, |
src/core/reserved.ts +1
| @@ -99,6 +99,7 @@ export const SYSTEM_NAMES: readonly string[] = [ | ||
| 99 | 99 | "tell", |
| 100 | 100 | "theme", |
| 101 | 101 | "verify", |
| 102 | "recover", | |
| 102 | 103 | "www", |
| 103 | 104 | "asked", |
| 104 | 105 | "claim", |
src/core/verify.ts +63 −1
| @@ -122,10 +122,72 @@ export function consumeLink( | ||
| 122 | 122 | .query< |
| 123 | 123 | { user_id: string; expires_at: string | null }, |
| 124 | 124 | [string] |
| 125 | >("SELECT user_id, expires_at FROM email_check WHERE token_hash = ?1") | |
| 125 | >("SELECT user_id, expires_at FROM email_check WHERE token_hash = ?1 AND verified_at IS NULL") | |
| 126 | 126 | .get(sha256(token)); |
| 127 | 127 | if (row === null || row.expires_at === null) return undefined; |
| 128 | 128 | if (Date.parse(row.expires_at) <= now) return undefined; |
| 129 | 129 | markVerified(db, row.user_id, new Date(now).toISOString()); |
| 130 | 130 | return row.user_id; |
| 131 | 131 | } |
| 132 | ||
| 133 | /** How long a recovery link works. */ | |
| 134 | export const RECOVERY_TTL_MS = 30 * 60_000; | |
| 135 | ||
| 136 | /** | |
| 137 | * Mint a recovery link for an account whose email is proven: the same | |
| 138 | * `email_check` slot a verification link uses, so there is one token store. | |
| 139 | * A proven account holds no verification link, so nothing is overwritten. | |
| 140 | * Undefined for an account that is not proven. | |
| 141 | */ | |
| 142 | export function issueRecovery( | |
| 143 | db: Database, | |
| 144 | userId: string, | |
| 145 | now: number, | |
| 146 | ): string | undefined { | |
| 147 | const token = secret(); | |
| 148 | const changed = db | |
| 149 | .query( | |
| 150 | `UPDATE email_check SET token_hash = ?2, expires_at = ?3 | |
| 151 | WHERE user_id = ?1 AND verified_at IS NOT NULL`, | |
| 152 | ) | |
| 153 | .run(userId, sha256(token), new Date(now + RECOVERY_TTL_MS).toISOString()); | |
| 154 | return changed.changes > 0 ? token : undefined; | |
| 155 | } | |
| 156 | ||
| 157 | /** | |
| 158 | * Whose recovery link this is, without spending it. Only a proven account's | |
| 159 | * link counts, so a pending verification link opens nothing here. | |
| 160 | */ | |
| 161 | export function recoveryOwner( | |
| 162 | db: Database, | |
| 163 | token: string, | |
| 164 | now: number, | |
| 165 | ): string | undefined { | |
| 166 | if (token === "") return undefined; | |
| 167 | return ( | |
| 168 | db | |
| 169 | .query<{ user_id: string }, [string, string]>( | |
| 170 | `SELECT user_id FROM email_check | |
| 171 | WHERE token_hash = ?1 AND verified_at IS NOT NULL AND expires_at > ?2`, | |
| 172 | ) | |
| 173 | .get(sha256(token), new Date(now).toISOString())?.user_id ?? undefined | |
| 174 | ); | |
| 175 | } | |
| 176 | ||
| 177 | /** Spend a recovery link: the account it was for, once; undefined after. */ | |
| 178 | export function spendRecovery( | |
| 179 | db: Database, | |
| 180 | token: string, | |
| 181 | now: number, | |
| 182 | ): string | undefined { | |
| 183 | if (token === "") return undefined; | |
| 184 | return ( | |
| 185 | db | |
| 186 | .query<{ user_id: string }, [string, string]>( | |
| 187 | `UPDATE email_check SET token_hash = NULL, expires_at = NULL | |
| 188 | WHERE token_hash = ?1 AND verified_at IS NOT NULL AND expires_at > ?2 | |
| 189 | RETURNING user_id`, | |
| 190 | ) | |
| 191 | .get(sha256(token), new Date(now).toISOString())?.user_id ?? undefined | |
| 192 | ); | |
| 193 | } |
src/http/server.ts +8
| @@ -100,6 +100,10 @@ import { | ||
| 100 | 100 | passkeyDelete, |
| 101 | 101 | passkeyOptions, |
| 102 | 102 | passkeysPage, |
| 103 | recoverFinish, | |
| 104 | recoverOptions, | |
| 105 | recoverPage, | |
| 106 | recoverRequest, | |
| 103 | 107 | signinOptions, |
| 104 | 108 | signinPasskey, |
| 105 | 109 | } from "../web/pages/passkeys.ts"; |
| @@ -687,6 +691,10 @@ export function buildRouter(): Router<Handler> { | ||
| 687 | 691 | router.add("POST", "/settings/passkeys/:id/delete", passkeyDelete); |
| 688 | 692 | router.add("POST", "/signin/passkey/options", signinOptions); |
| 689 | 693 | router.add("POST", "/signin/passkey", signinPasskey); |
| 694 | router.add("GET", "/recover", recoverPage); | |
| 695 | router.add("POST", "/recover", recoverRequest); | |
| 696 | router.add("POST", "/recover/options", recoverOptions); | |
| 697 | router.add("POST", "/recover/passkey", recoverFinish); | |
| 690 | 698 | router.add("POST", "/signup/passkey/options", signupOptions); |
| 691 | 699 | router.add("POST", "/signup/passkey", signupPasskey); |
| 692 | 700 | // The crew: a forge admin's section, hidden from everyone else. |
src/web/pages/passkeys.ts +138 −5
| @@ -14,29 +14,32 @@ import { | ||
| 14 | 14 | listPasskeys, |
| 15 | 15 | notePasskeyUsed, |
| 16 | 16 | passkeyIds, |
| 17 | passkeyRoom, | |
| 17 | 18 | removePasskey, |
| 18 | 19 | PASSKEY_NAME_MAX, |
| 19 | 20 | } from "../../core/passkeys.ts"; |
| 21 | import { RateLimiter } from "../../core/ratelimit.ts"; | |
| 20 | 22 | import { createSession } from "../../core/sessions.ts"; |
| 23 | import { recoveryAccount, requestRecovery, spendRecoveryLink } from "../../actions/verify.ts"; | |
| 21 | 24 | import { getUserById, isActive } from "../../core/users.ts"; |
| 22 | 25 | import { crossOrigin, type Ctx } from "../../http/context.ts"; |
| 23 | import { clientAddress } from "../../http/peer.ts"; | |
| 26 | import { clientAddress, clientBucket } from "../../http/peer.ts"; | |
| 24 | 27 | import { STRANGER_MAX } from "../../http/limits.ts"; |
| 25 | 28 | import { boundedBody, json, str } from "../../http/respond.ts"; |
| 26 | 29 | import { signInFirst } from "../chrome.ts"; |
| 27 | import { readForm } from "../form.ts"; | |
| 30 | import { field, readForm } from "../form.ts"; | |
| 28 | 31 | import { html } from "../html.ts"; |
| 29 | import { flashTo } from "../layout.ts"; | |
| 32 | import { flashTo, page } from "../layout.ts"; | |
| 30 | 33 | import { passkeyJs } from "../passkey-client.ts"; |
| 31 | 34 | import { card, empty, confirmAction, time } from "../ui.ts"; |
| 32 | 35 | import { accountShell } from "./account.ts"; |
| 33 | 36 | import { safeNext, sessionCookie, signinAllowed } from "./site.ts"; |
| 34 | 37 | |
| 35 | 38 | /** One-time challenges, one store per ceremony per forge. */ |
| 36 | const stores = new WeakMap<App, { add: ReturnType<typeof challenges>; signin: ReturnType<typeof challenges>; signup: ReturnType<typeof challenges> }>(); | |
| 39 | const stores = new WeakMap<App, { add: ReturnType<typeof challenges>; signin: ReturnType<typeof challenges>; recover: ReturnType<typeof challenges>; signup: ReturnType<typeof challenges> }>(); | |
| 37 | 40 | export function store(app: App) { |
| 38 | 41 | let s = stores.get(app); |
| 39 | if (!s) stores.set(app, (s = { add: challenges(), signin: challenges(), signup: challenges() })); | |
| 42 | if (!s) stores.set(app, (s = { add: challenges(), signin: challenges(), recover: challenges(), signup: challenges() })); | |
| 40 | 43 | return s; |
| 41 | 44 | } |
| 42 | 45 | |
| @@ -196,3 +199,133 @@ export async function signinPasskey(ctx: Ctx): Promise<Response> { | ||
| 196 | 199 | { "set-cookie": sessionCookie(session.token, session.expiresAt, ctx.url, ctx.app.config.url) }, |
| 197 | 200 | ); |
| 198 | 201 | } |
| 202 | ||
| 203 | /** | |
| 204 | * Recovery by email: a proven address gets a 30-minute, single-use link | |
| 205 | * (`actions/verify.ts`); opening it registers a new passkey and signs the | |
| 206 | * person in. Asking is limited per address and per source, an hour at a | |
| 207 | * time, and answers the same for an address with no account. | |
| 208 | */ | |
| 209 | export const RECOVER_PER_ADDRESS = 3; | |
| 210 | export const RECOVER_PER_SOURCE = 10; | |
| 211 | const RECOVER_WINDOW_MS = 3_600_000; | |
| 212 | const recoverLimits = new WeakMap<App, RateLimiter>(); | |
| 213 | ||
| 214 | function recoverWait(ctx: Ctx, email: string): number | undefined { | |
| 215 | let limiter = recoverLimits.get(ctx.app); | |
| 216 | if (limiter === undefined) { | |
| 217 | limiter = new RateLimiter({ perMinute: 0, adminPerMinute: 0, enabled: true, windowMs: RECOVER_WINDOW_MS }); | |
| 218 | recoverLimits.set(ctx.app, limiter); | |
| 219 | } | |
| 220 | limiter.setLimits({ perMinute: RECOVER_PER_ADDRESS, adminPerMinute: RECOVER_PER_SOURCE, enabled: true, windowMs: RECOVER_WINDOW_MS }); | |
| 221 | const source = limiter.take(`from:${clientBucket(ctx.request)}`, "admin"); | |
| 222 | if (!source.allowed) return source.retryAfter; | |
| 223 | const address = limiter.take(`to:${email.toLowerCase()}`, "ro"); | |
| 224 | return address.allowed ? undefined : address.retryAfter; | |
| 225 | } | |
| 226 | ||
| 227 | const NOT_VALID = "This link has expired, been used, or is not valid. Ask for a new one."; | |
| 228 | ||
| 229 | // GET /recover — the request form, or (with ?token=) the new-passkey step. | |
| 230 | export function recoverPage(ctx: Ctx): Response { | |
| 231 | const token = ctx.url.searchParams.get("token"); | |
| 232 | if (token === null) { | |
| 233 | return page({ | |
| 234 | ctx, | |
| 235 | title: "Recover your account", | |
| 236 | body: html`<div class="page-head"><h1>Recover your account</h1></div> | |
| 237 | <p class="muted small mb">Give the email on your account. If it is verified, we send a link that lasts 30 minutes and works once; it lets you register a new passkey.</p> | |
| 238 | <form method="post" action="/recover"> | |
| 239 | <div class="field"><label for="recover-email">Email</label> | |
| 240 | <input id="recover-email" name="email" type="email" maxlength="254" autocomplete="email" required /></div> | |
| 241 | <div class="form-actions"><button class="btn" data-variant="primary">Send the link</button></div> | |
| 242 | </form>`, | |
| 243 | }); | |
| 244 | } | |
| 245 | const user = recoveryAccount(ctx.app, token); | |
| 246 | if (user === undefined) | |
| 247 | return page({ ctx, title: "Link not valid", body: html`<div class="page-head"><h1>Link not valid</h1></div><p>${NOT_VALID}</p><p><a href="/recover">Ask for a new link</a></p>` }, 400); | |
| 248 | return page({ | |
| 249 | ctx, | |
| 250 | title: "Register a new passkey", | |
| 251 | scriptJs: passkeyJs, | |
| 252 | body: html`<div class="page-head"><h1>Register a new passkey</h1></div> | |
| 253 | <p class="muted small mb">This signs you in as ${user.username}. Name the device, then follow your browser's prompt.</p> | |
| 254 | <div class="field"><label for="passkey-name">Name</label> | |
| 255 | <input id="passkey-name" name="name" maxlength="${PASSKEY_NAME_MAX}" autocomplete="off" placeholder="Laptop, phone, security key" /></div> | |
| 256 | <p class="error-note" id="passkey-recover-note" role="status"></p> | |
| 257 | <noscript><p class="muted small">Registering a passkey needs JavaScript.</p></noscript> | |
| 258 | <div class="form-actions"><button type="button" id="passkey-recover" class="btn" data-variant="primary">Register and sign in</button></div>`, | |
| 259 | }); | |
| 260 | } | |
| 261 | ||
| 262 | // POST /recover — ask for a link. The same page for every address. | |
| 263 | export async function recoverRequest(ctx: Ctx): Promise<Response> { | |
| 264 | const form = await readForm(ctx, undefined, STRANGER_MAX); | |
| 265 | if (form instanceof Response) return form; | |
| 266 | const email = field(form, "email").trim().slice(0, 254); | |
| 267 | const wait = email === "" ? undefined : recoverWait(ctx, email); | |
| 268 | if (wait !== undefined) { | |
| 269 | const res = page({ ctx, title: "Recover your account", body: html`<div class="page-head"><h1>Too many requests</h1></div><p>Try again in ${wait} seconds.</p>` }, 429); | |
| 270 | res.headers.set("retry-after", String(wait)); | |
| 271 | return res; | |
| 272 | } | |
| 273 | if (email !== "") requestRecovery(ctx.app, email); | |
| 274 | return page({ | |
| 275 | ctx, | |
| 276 | title: "Check your email", | |
| 277 | body: html`<div class="page-head"><h1>Check your email</h1></div> | |
| 278 | <p>If that address belongs to an account with a verified email, a link is on its way. It lasts 30 minutes and works once.</p>`, | |
| 279 | }); | |
| 280 | } | |
| 281 | ||
| 282 | // POST /recover/options — a challenge, for a link that still works. | |
| 283 | export async function recoverOptions(ctx: Ctx): Promise<Response> { | |
| 284 | if (crossOrigin(ctx.request, ctx.url, ctx.app.log)) return refuse(403, "forbidden"); | |
| 285 | const data = await boundedBody(ctx.request, STRANGER_MAX); | |
| 286 | if (data === "too_large") return refuse(413, "payload too large"); | |
| 287 | const user = recoveryAccount(ctx.app, str(data.token)); | |
| 288 | if (user === undefined) return refuse(400, NOT_VALID); | |
| 289 | return json({ | |
| 290 | challenge: store(ctx.app).recover.issue(), | |
| 291 | rpId: relying(ctx).rpId, | |
| 292 | rpName: "forge", | |
| 293 | user: { | |
| 294 | id: Buffer.from(user.id).toString("base64url"), | |
| 295 | name: user.username, | |
| 296 | displayName: user.displayName || user.username, | |
| 297 | }, | |
| 298 | exclude: passkeyIds(ctx.app.db, user.id), | |
| 299 | }); | |
| 300 | } | |
| 301 | ||
| 302 | // POST /recover/passkey — a good registration spends the link and signs in. | |
| 303 | export async function recoverFinish(ctx: Ctx): Promise<Response> { | |
| 304 | if (crossOrigin(ctx.request, ctx.url, ctx.app.log)) return refuse(403, "forbidden"); | |
| 305 | const data = await boundedBody(ctx.request, STRANGER_MAX); | |
| 306 | if (data === "too_large") return refuse(413, "payload too large"); | |
| 307 | const token = str(data.token); | |
| 308 | const challenge = str(data.challenge); | |
| 309 | if (!store(ctx.app).recover.consume(challenge)) return refuse(400, "That request expired. Try again."); | |
| 310 | const who = recoveryAccount(ctx.app, token); | |
| 311 | if (who === undefined) return refuse(400, NOT_VALID); | |
| 312 | const made = await verifyRegistration( | |
| 313 | { clientDataJSON: str(data.clientDataJSON), attestationObject: str(data.attestationObject) }, | |
| 314 | { challenge, ...relying(ctx) }, | |
outdated
High: the link is spent here, then |
||
| 315 | ); | |
| 316 | if (!made.ok) return refuse(400, "That passkey was not accepted."); | |
| 317 | // Refuse before spending: a link that cannot add its passkey stays usable. | |
| 318 | const room = passkeyRoom(ctx.app.db, who.id, made.id); | |
| 319 | if (!room.ok && room.error === "too_many") | |
| 320 | return refuse(409, "That is as many passkeys as an account keeps. Ask for a new link after removing one, or sign in another way."); | |
| 321 | if (!room.ok) return refuse(409, "That passkey is already registered."); | |
| 322 | const spent = spendRecoveryLink(ctx.app, token); | |
| 323 | if (!spent.ok) return refuse(400, NOT_VALID); | |
| 324 | const name = str(data.name).replace(/[\u0000-\u001f\u007f]/g, " ").trim().slice(0, PASSKEY_NAME_MAX) || "Passkey"; | |
| 325 | const added = addPasskey(ctx.app.db, spent.value.id, name, made); | |
| 326 | if (!added.ok) return refuse(409, "That passkey could not be added."); | |
| 327 | const session = createSession(ctx.app.db, spent.value.id); | |
| 328 | return json({ ok: true, next: "/settings/passkeys" }, 200, { | |
| 329 | "set-cookie": sessionCookie(session.token, session.expiresAt, ctx.url, ctx.app.config.url), | |
| 330 | }); | |
| 331 | } |
src/web/pages/site.ts +1
| @@ -483,6 +483,7 @@ function authForm( | ||
| 483 | 483 | () => html`<p class="mt"> |
| 484 | 484 | <button type="button" id="passkey-signin" class="btn" data-variant="outline">Sign in with a passkey</button> |
| 485 | 485 | <span class="error-note small" id="passkey-signin-note" role="status"></span> |
| 486 | <a class="small" href="/recover">Lost your device?</a> | |
| 486 | 487 | </p>`, |
| 487 | 488 | )} |
| 488 | 489 | ${claimable |
src/web/passkey-client.ts +24
| @@ -72,6 +72,30 @@ export const passkeyJs = /* js */ ` | ||
| 72 | 72 | }); |
| 73 | 73 | }).then(function () { location.reload(); }); |
| 74 | 74 | }); |
| 75 | wire('passkey-recover', function () { | |
| 76 | var name = doc.getElementById('passkey-name'); | |
| 77 | var token = new URLSearchParams(location.search).get('token') || ''; | |
| 78 | return post('/recover/options', { token: token }).then(function (o) { | |
| 79 | return navigator.credentials.create({ publicKey: { | |
| 80 | challenge: bytes(o.challenge), | |
| 81 | rp: { id: o.rpId, name: o.rpName }, | |
| 82 | user: { id: bytes(o.user.id), name: o.user.name, displayName: o.user.displayName }, | |
| 83 | pubKeyCredParams: [{ type: 'public-key', alg: -7 }, { type: 'public-key', alg: -257 }], | |
| 84 | excludeCredentials: o.exclude.map(function (c) { return { type: 'public-key', id: bytes(c) }; }), | |
| 85 | authenticatorSelection: { residentKey: 'preferred', userVerification: 'required' }, | |
| 86 | attestation: 'none', | |
| 87 | timeout: 120000 | |
| 88 | } }).then(function (cred) { | |
| 89 | return post('/recover/passkey', { | |
| 90 | token: token, | |
| 91 | name: name ? name.value : '', | |
| 92 | challenge: o.challenge, | |
| 93 | clientDataJSON: text(cred.response.clientDataJSON), | |
| 94 | attestationObject: text(cred.response.attestationObject) | |
| 95 | }); | |
| 96 | }); | |
| 97 | }).then(function (r) { location.href = r.next; }); | |
| 98 | }); | |
| 75 | 99 | wire('passkey-signup', function () { |
| 76 | 100 | function val(n) { var el = doc.querySelector('[name=' + n + ']'); return el ? el.value : ''; } |
| 77 | 101 | var who = { username: val('username'), email: val('email') }; |
test/passkey-recovery.test.ts added +219
| @@ -0,0 +1,219 @@ | ||
| 1 | /** | |
| 2 | * Recovering an account by email: a verified address gets a single-use, | |
| 3 | * 30-minute link that registers a new passkey and signs in. Expired, reused | |
| 4 | * and forged links are refused; an unknown address is answered like a known | |
| 5 | * one; the token is stored only as a hash. | |
| 6 | */ | |
| 7 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 8 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 9 | import { tmpdir } from "node:os"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import { createApp, type App } from "../src/app.ts"; | |
| 12 | import { b64u } from "../src/auth/webauthn.ts"; | |
| 13 | import { addPasskey, PASSKEY_MAX } from "../src/core/passkeys.ts"; | |
| 14 | import { consumeLink, issueRecovery, markVerified, RECOVERY_TTL_MS } from "../src/core/verify.ts"; | |
| 15 | import { sha256 } from "../src/kernel/hash.ts"; | |
| 16 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 17 | import type { Mail } from "../src/kernel/mail.ts"; | |
| 18 | ||
| 19 | const te = new TextEncoder(); | |
| 20 | const sha = async (d: Uint8Array<ArrayBuffer>) => new Uint8Array(await crypto.subtle.digest("SHA-256", d)); | |
| 21 | const cat = (...p: Uint8Array[]): Uint8Array<ArrayBuffer> => { const o: Uint8Array<ArrayBuffer> = new Uint8Array(p.reduce((n, a) => n + a.length, 0)); let i = 0; for (const a of p) { o.set(a, i); i += a.length; } return o; }; | |
| 22 | function head(major: number, n: number): Uint8Array { | |
| 23 | if (n < 24) return Uint8Array.of((major << 5) | n); | |
| 24 | if (n < 256) return Uint8Array.of((major << 5) | 24, n); | |
| 25 | return Uint8Array.of((major << 5) | 25, n >> 8, n & 255); | |
| 26 | } | |
| 27 | type V = number | string | Uint8Array | [V, V][]; | |
| 28 | function enc(v: V): Uint8Array { | |
| 29 | if (typeof v === "number") return v >= 0 ? head(0, v) : head(1, -1 - v); | |
| 30 | if (typeof v === "string") { const b = te.encode(v); return cat(head(3, b.length), b); } | |
| 31 | if (v instanceof Uint8Array) return cat(head(2, v.length), v); | |
| 32 | return cat(head(5, v.length), ...v.flatMap(([k, x]) => [enc(k), enc(x)])); | |
| 33 | } | |
| 34 | ||
| 35 | /** A software authenticator that can only register. */ | |
| 36 | async function register(origin: string, rp: string, challenge: string, fixedId?: Uint8Array) { | |
| 37 | const pair = (await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, ["sign"])) as CryptoKeyPair; | |
| 38 | const jwk = await crypto.subtle.exportKey("jwk", pair.publicKey); | |
| 39 | const unb = (s: string) => new Uint8Array(Buffer.from(s, "base64url")); | |
| 40 | const cose: [number, V][] = [[1, 2], [3, -7], [-1, 1], [-2, unb(jwk.x!)], [-3, unb(jwk.y!)]]; | |
| 41 | const id = fixedId ?? crypto.getRandomValues(new Uint8Array(32)); | |
| 42 | const authData = cat(await sha(te.encode(rp)), Uint8Array.of(0x45), new Uint8Array(4), new Uint8Array(16), Uint8Array.of(0, id.length), id, enc(cose as V)); | |
| 43 | return { | |
| 44 | clientDataJSON: b64u(te.encode(JSON.stringify({ type: "webauthn.create", challenge, origin }))), | |
| 45 | attestationObject: b64u(enc([["fmt", "none"], ["attStmt", []], ["authData", authData]])), | |
| 46 | }; | |
| 47 | } | |
| 48 | ||
| 49 | describe("passkey recovery", () => { | |
| 50 | let dataDir: string; | |
| 51 | let app: App; | |
| 52 | let servers: Servers; | |
| 53 | let base: string; | |
| 54 | const sent: Mail[] = []; | |
| 55 | ||
| 56 | const post = (path: string, body: unknown) => | |
| 57 | fetch(`${base}${path}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), redirect: "manual" }); | |
outdated
|
||
| 58 | const ask = (email: string) => | |
| 59 | fetch(`${base}/recover`, { | |
| 60 | method: "POST", | |
| 61 | body: new URLSearchParams({ email }), | |
| 62 | }); | |
| 63 | const linkOf = (mail: Mail): string => /token=([\w-]+)/.exec(mail.text)![1]!; | |
| 64 | async function finish(token: string, fixedId?: Uint8Array) { | |
| 65 | const o = await post("/recover/options", { token }); | |
| 66 | if (o.status !== 200) return { options: o, done: undefined }; | |
| 67 | const opts = (await o.json()) as any; | |
| 68 | const origin = new URL(base); | |
| 69 | const reg = await register(origin.origin, origin.hostname, opts.challenge, fixedId); | |
| 70 | return { options: o, done: await post("/recover/passkey", { token, name: "Laptop", challenge: opts.challenge, ...reg }) }; | |
| 71 | } | |
| 72 | ||
| 73 | beforeAll(async () => { | |
| 74 | dataDir = await mkdtemp(join(tmpdir(), "forge-recover-")); | |
| 75 | app = createApp({ | |
| 76 | dataDir, | |
| 77 | port: 0, | |
| 78 | mailer: async (mail) => { sent.push(mail); return true; }, | |
| 79 | }); | |
| 80 | servers = await serve(app); | |
| 81 | base = servers.url; | |
| 82 | for (const [name, email] of [["pat", "[email protected]"], ["una", "[email protected]"]] as const) { | |
| 83 | await fetch(`${base}/signup`, { | |
| 84 | method: "POST", | |
| 85 | body: new URLSearchParams({ username: name, email, password: "recover-password" }), | |
| 86 | redirect: "manual", | |
| 87 | }); | |
| 88 | } | |
| 89 | // The first account is the owner; pat is the person who verifies. | |
| 90 | const id = (n: string) => app.db.query<{ id: string }, [string]>("SELECT id FROM user WHERE username = ?1").get(n)!.id; | |
| 91 | markVerified(app.db, id("pat"), new Date().toISOString()); | |
| 92 | }); | |
| 93 | ||
| 94 | afterAll(async () => { | |
| 95 | servers.stop(); | |
| 96 | app.close(); | |
| 97 | await rm(dataDir, { recursive: true, force: true }); | |
| 98 | }); | |
| 99 | ||
| 100 | test("a verified address gets a link that registers a passkey, signs in, and works once", async () => { | |
| 101 | sent.length = 0; | |
| 102 | const res = await ask("[email protected]"); | |
| 103 | expect(res.status).toBe(200); | |
| 104 | expect(sent).toHaveLength(1); | |
| 105 | expect(sent[0]!.to).toBe("[email protected]"); | |
| 106 | const token = linkOf(sent[0]!); | |
| 107 | ||
| 108 | // Only a hash is kept. | |
| 109 | const row = app.db.query<{ token_hash: string; expires_at: string }, []>("SELECT token_hash, expires_at FROM email_check WHERE token_hash IS NOT NULL AND verified_at IS NOT NULL").get()!; | |
| 110 | expect(row.token_hash).toBe(sha256(token)); | |
| 111 | expect(JSON.stringify(row)).not.toContain(token); | |
| 112 | const life = Date.parse(row.expires_at) - Date.now(); | |
| 113 | expect(life).toBeGreaterThan(RECOVERY_TTL_MS - 60_000); | |
| 114 | expect(life).toBeLessThanOrEqual(RECOVERY_TTL_MS); | |
| 115 | ||
| 116 | // Opening the page spends nothing. | |
| 117 | expect((await fetch(`${base}/recover?token=${token}`)).status).toBe(200); | |
| 118 | ||
| 119 | const { done } = await finish(token); | |
| 120 | expect(done!.status).toBe(200); | |
| 121 | expect(await done!.json()).toEqual({ ok: true, next: "/settings/passkeys" }); | |
| 122 | const cookie = done!.headers.get("set-cookie")!; | |
| 123 | expect(cookie).toContain("HttpOnly"); | |
| 124 | const keys = await fetch(`${base}/settings/passkeys`, { headers: { cookie: cookie.split(";")[0]! } }); | |
| 125 | expect(await keys.text()).toContain("<strong>Laptop</strong>"); | |
| 126 | ||
| 127 | // Reused: refused, and no second session. | |
| 128 | const again = await finish(token); | |
| 129 | expect(again.options.status).toBe(400); | |
| 130 | expect((await fetch(`${base}/recover?token=${token}`)).status).toBe(400); | |
| 131 | }); | |
| 132 | ||
| 133 | test("an expired link and a forged one are refused with one sentence", async () => { | |
| 134 | sent.length = 0; | |
| 135 | await ask("[email protected]"); | |
| 136 | const token = linkOf(sent[0]!); | |
| 137 | app.db.query("UPDATE email_check SET expires_at = ?1 WHERE token_hash = ?2").run(new Date(Date.now() - 1000).toISOString(), sha256(token)); | |
| 138 | const expired = await finish(token); | |
| 139 | expect(expired.options.status).toBe(400); | |
| 140 | const sentence = ((await expired.options.json()) as any).message as string; | |
| 141 | const forged = await post("/recover/options", { token: "not-a-real-token" }); | |
| 142 | expect(forged.status).toBe(400); | |
| 143 | expect(((await forged.json()) as any).message).toBe(sentence); | |
| 144 | const page = await fetch(`${base}/recover?token=${token}`); | |
| 145 | expect(page.status).toBe(400); | |
| 146 | expect(await page.text()).toContain(sentence); | |
| 147 | expect((await post("/recover/options", {})).status).toBe(400); | |
| 148 | }); | |
| 149 | ||
| 150 | test("a pending verification link opens nothing here", async () => { | |
| 151 | // una never proved the address: her verification link must not sign in. | |
| 152 | const una = app.db.query<{ id: string }, []>("SELECT id FROM user WHERE username = 'una'").get()!.id; | |
| 153 | app.db.query("UPDATE email_check SET verified_at = NULL, token_hash = ?2, expires_at = ?3 WHERE user_id = ?1").run(una, sha256("pending"), new Date(Date.now() + 60_000).toISOString()); | |
| 154 | expect((await post("/recover/options", { token: "pending" })).status).toBe(400); | |
| 155 | sent.length = 0; | |
| 156 | await ask("[email protected]"); | |
| 157 | expect(sent).toHaveLength(0); | |
| 158 | }); | |
| 159 | ||
| 160 | test("an unknown address is answered exactly like a known one", async () => { | |
| 161 | const known = await ask("[email protected]"); | |
| 162 | const unknown = await ask("[email protected]"); | |
| 163 | expect(unknown.status).toBe(known.status); | |
| 164 | const plain = async (r: Response) => (await r.text()).replace(/nonce="[^"]*"/g, ""); | |
| 165 | expect(await plain(unknown)).toBe(await plain(known)); | |
| 166 | }); | |
| 167 | ||
| 168 | test("asking is limited per address and per source", async () => { | |
| 169 | sent.length = 0; | |
| 170 | const statuses: number[] = []; | |
| 171 | for (let i = 0; i < 4; i++) statuses.push((await ask("[email protected]")).status); | |
| 172 | expect(statuses).toEqual([200, 200, 200, 429]); | |
| 173 | }); | |
| 174 | ||
| 175 | test("asking is limited per source, whatever the addresses", async () => { | |
| 176 | const other = createApp({ dataDir: await mkdtemp(join(tmpdir(), "forge-recover-src-")), port: 0 }); | |
| 177 | const more = await serve(other); | |
| 178 | try { | |
| 179 | const source: number[] = []; | |
| 180 | for (let i = 0; i < 11; i++) | |
| 181 | source.push((await fetch(`${more.url}/recover`, { method: "POST", body: new URLSearchParams({ email: `s${i}@f.dev` }) })).status); | |
| 182 | expect(source.slice(0, 10)).toEqual(Array(10).fill(200)); | |
| 183 | expect(source[10]).toBe(429); | |
| 184 | } finally { | |
| 185 | more.stop(); | |
| 186 | other.close(); | |
| 187 | await rm(other.config.dataDir, { recursive: true, force: true }); | |
| 188 | } | |
| 189 | }); | |
| 190 | ||
| 191 | test("a link that cannot add its passkey is not spent", async () => { | |
| 192 | const pat = app.db.query<{ id: string }, []>("SELECT id FROM user WHERE username = 'pat'").get()!.id; | |
| 193 | const mint = async () => issueRecovery(app.db, pat, Date.now())!; | |
| 194 | const usable = async (t: string) => (await post("/recover/options", { token: t })).status === 200; | |
| 195 | ||
| 196 | // A duplicate credential. | |
| 197 | const dup = crypto.getRandomValues(new Uint8Array(32)); | |
| 198 | const t1 = await mint(); | |
| 199 | expect((await finish(t1, dup)).done!.status).toBe(200); | |
| 200 | const t2 = await mint(); | |
| 201 | expect((await finish(t2, dup)).done!.status).toBe(409); | |
| 202 | expect(await usable(t2)).toBe(true); | |
| 203 | ||
| 204 | // An account at the limit. | |
| 205 | const have = app.db.query<{ n: number }, [string]>("SELECT count(*) AS n FROM passkey WHERE user_id = ?1").get(pat)!.n; | |
| 206 | for (let i = have; i < PASSKEY_MAX; i++) | |
| 207 | addPasskey(app.db, pat, `k${i}`, { id: `fill-${i}`, alg: -7 as never, key: {} as any, signCount: 0 }); | |
| 208 | const t3 = await mint(); | |
| 209 | expect((await finish(t3)).done!.status).toBe(409); | |
| 210 | expect(await usable(t3)).toBe(true); | |
| 211 | }); | |
| 212 | ||
| 213 | test("a recovery link opened at /verify is not burned", () => { | |
| 214 | const pat = app.db.query<{ id: string }, []>("SELECT id FROM user WHERE username = 'pat'").get()!.id; | |
| 215 | app.db.query("UPDATE email_check SET token_hash = ?2, expires_at = ?3 WHERE user_id = ?1").run(pat, sha256("rec"), new Date(Date.now() + 60_000).toISOString()); | |
| 216 | expect(consumeLink(app.db, "rec", Date.now())).toBeUndefined(); | |
| 217 | expect(app.db.query("SELECT token_hash FROM email_check WHERE user_id = ?1").get(pat)).toEqual({ token_hash: sha256("rec") }); | |
| 218 | }); | |
| 219 | }); |
new above
offline
Use
clientBucket(ctx.request)(http/peer.ts:92), as the other limits do. With the raw address, an IPv6 client can walk its /64 and get around RECOVER_PER_SOURCE.