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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/1997-passkeys-part-2b-a-verified-email-gets-a → main 4 commits fork 3 threads · 3 unresolved

  • Merged. scout/1997-passkeys-part-2b-a-verified-email-gets-a was merged into main as 490d4a4.

Review

Sign in to review this change.
11 files changed +541 −8
README.md +4 −1
@@ -116,7 +116,10 @@ running — bakery asleep").
116116 Passkeys: Settings → Passkeys lists, adds and removes them (several per
117117 account; `src/auth/webauthn.ts` verifies, no dependency, `none` attestation).
118118 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
120123 `FORGE_URL`), so a passkey does not follow a forge to another hostname.
121124 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`).
122125
scripts/gate.ts +1
@@ -51,6 +51,7 @@ export const GATE: readonly string[] = [
5151 "test/thoughts.test.ts",
5252 "test/triage.test.ts",
5353 "test/passkeys.test.ts",
54 "test/passkey-recovery.test.ts",
5455 "test/signup-passkey.test.ts",
5556 "test/webauthn.test.ts",
5657 "test/chaos.test.ts",
src/actions/verify.ts +64 −1
@@ -12,11 +12,14 @@ import {
1212 consumeLink,
1313 emailVerified,
1414 issueLink,
15 issueRecovery,
16 recoveryOwner,
17 spendRecovery,
1518 markVerified,
1619 resetVerification,
1720 } from "../core/verify.ts";
1821 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";
2023 import { sendMail, type Mailer } from "../kernel/mail.ts";
2124 import { mayMail, sendCapped } from "./mail.ts";
2225 import { err, ok, type Result } from "../kernel/result.ts";
@@ -165,3 +168,63 @@ export async function resendVerification(
165168 const outcome = await sendVerification(app, user.value);
166169 return outcome === "sent" ? ok("sent") : err(outcome);
167170 }
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[] {
5555 .map((r) => r.credential_id);
5656 }
5757
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
5876 export function addPasskey(
5977 db: Database,
6078 userId: string,
src/core/reserved.ts +1
@@ -99,6 +99,7 @@ export const SYSTEM_NAMES: readonly string[] = [
9999 "tell",
100100 "theme",
101101 "verify",
102 "recover",
102103 "www",
103104 "asked",
104105 "claim",
src/core/verify.ts +63 −1
@@ -122,10 +122,72 @@ export function consumeLink(
122122 .query<
123123 { user_id: string; expires_at: string | null },
124124 [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")
126126 .get(sha256(token));
127127 if (row === null || row.expires_at === null) return undefined;
128128 if (Date.parse(row.expires_at) <= now) return undefined;
129129 markVerified(db, row.user_id, new Date(now).toISOString());
130130 return row.user_id;
131131 }
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 {
100100 passkeyDelete,
101101 passkeyOptions,
102102 passkeysPage,
103 recoverFinish,
104 recoverOptions,
105 recoverPage,
106 recoverRequest,
103107 signinOptions,
104108 signinPasskey,
105109 } from "../web/pages/passkeys.ts";
@@ -687,6 +691,10 @@ export function buildRouter(): Router<Handler> {
687691 router.add("POST", "/settings/passkeys/:id/delete", passkeyDelete);
688692 router.add("POST", "/signin/passkey/options", signinOptions);
689693 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);
690698 router.add("POST", "/signup/passkey/options", signupOptions);
691699 router.add("POST", "/signup/passkey", signupPasskey);
692700 // The crew: a forge admin's section, hidden from everyone else.
src/web/pages/passkeys.ts +138 −5
@@ -14,29 +14,32 @@ import {
1414 listPasskeys,
1515 notePasskeyUsed,
1616 passkeyIds,
17 passkeyRoom,
1718 removePasskey,
1819 PASSKEY_NAME_MAX,
1920 } from "../../core/passkeys.ts";
21 import { RateLimiter } from "../../core/ratelimit.ts";
2022 import { createSession } from "../../core/sessions.ts";
23 import { recoveryAccount, requestRecovery, spendRecoveryLink } from "../../actions/verify.ts";
2124 import { getUserById, isActive } from "../../core/users.ts";
2225 import { crossOrigin, type Ctx } from "../../http/context.ts";
23 import { clientAddress } from "../../http/peer.ts";
26 import { clientAddress, clientBucket } from "../../http/peer.ts";
2427 import { STRANGER_MAX } from "../../http/limits.ts";
2528 import { boundedBody, json, str } from "../../http/respond.ts";
2629 import { signInFirst } from "../chrome.ts";
27 import { readForm } from "../form.ts";
30 import { field, readForm } from "../form.ts";
2831 import { html } from "../html.ts";
29 import { flashTo } from "../layout.ts";
32 import { flashTo, page } from "../layout.ts";
3033 import { passkeyJs } from "../passkey-client.ts";
3134 import { card, empty, confirmAction, time } from "../ui.ts";
3235 import { accountShell } from "./account.ts";
3336 import { safeNext, sessionCookie, signinAllowed } from "./site.ts";
3437
3538 /** 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> }>();
3740 export function store(app: App) {
3841 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() }));
4043 return s;
4144 }
4245
@@ -196,3 +199,133 @@ export async function signinPasskey(ctx: Ctx): Promise<Response> {
196199 { "set-cookie": sessionCookie(session.token, session.expiresAt, ctx.url, ctx.app.config.url) },
197200 );
198201 }
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 }
outdated

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.

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 addPasskey below can still refuse (too_many / duplicate). The person is left with no link and no session, and an account at PASSKEY_MAX can never recover. Check the add will succeed before spending, or run spend and add in one transaction.

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(
483483 () => html`<p class="mt">
484484 <button type="button" id="passkey-signin" class="btn" data-variant="outline">Sign in with a passkey</button>
485485 <span class="error-note small" id="passkey-signin-note" role="status"></span>
486 <a class="small" href="/recover">Lost your device?</a>
486487 </p>`,
487488 )}
488489 ${claimable
src/web/passkey-client.ts +24
@@ -72,6 +72,30 @@ export const passkeyJs = /* js */ `
7272 });
7373 }).then(function () { location.reload(); });
7474 });
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 });
7599 wire('passkey-signup', function () {
76100 function val(n) { var el = doc.querySelector('[name=' + n + ']'); return el ? el.value : ''; }
77101 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

from is never used: every request comes from loopback, so the per-call "sources" mean nothing. Drop the parameter, or wire it up.

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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs