Public: anyone can read this; the crew can change it.

Passkeys: Settings → Passkeys lists, adds and removes them, and signing in with one issues the same session as a password #1713

Merged opened this

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

Merged

scout/forge:scout/1694-passkeys-settings-passkeys-lists-adds-an → main 4 commits fork 2 threads · 2 unresolved

  • Merged. scout/1694-passkeys-settings-passkeys-lists-adds-an was merged into main as 56fef11.

Review

Sign in to review this change.
12 files changed +730 −4
README.md +6
@@ -84,6 +84,12 @@ sees `Reduced protection: …` in one line, under the filter on the terminal's
8484 first screen and in the web footer; nobody else does, and a fully protected
8585 forge shows nothing.
8686
87 Passkeys: Settings → Passkeys lists, adds and removes them (several per
88 account; `src/auth/webauthn.ts` verifies, no dependency, `none` attestation).
89 The sign-in page's "Sign in with a passkey" gives the same HttpOnly session a
90 password does. The relying party is the address the browser used (or
91 `FORGE_URL`), so a passkey does not follow a forge to another hostname.
92
8793 Your SSH key is your identity. A key the forge knows signs you straight in; any
8894 other key browses public repos as a guest and can press `s` to sign in with a
8995 username and password — which links that key to your account, so the next
scripts/gate.ts +1
@@ -36,6 +36,7 @@ export const GATE: readonly string[] = [
3636 "test/boards-query.test.ts",
3737 "test/turnstile.test.ts",
3838 "test/thoughts.test.ts",
39 "test/passkeys.test.ts",
3940 "test/webauthn.test.ts",
4041 "test/chaos.test.ts",
4142 "test/flow-stage.test.ts",
src/core/passkeys.ts added +132
@@ -0,0 +1,132 @@
1 /**
2 * Passkeys an account has registered: the public half only, several per
3 * account. Verification lives in `auth/webauthn.ts`; this is what is kept.
4 */
5
6 import type { Database } from "bun:sqlite";
7 import type { Alg, Credential, Jwk } from "../auth/webauthn.ts";
8 import { id as newId } from "../kernel/id.ts";
9 import { err, ok, type Result } from "../kernel/result.ts";
10
11 /** More than this is a script, not a person's devices. */
12 export const PASSKEY_MAX = 20;
13 export const PASSKEY_NAME_MAX = 60;
14
15 export interface PasskeyInfo {
16 readonly id: string;
17 readonly name: string;
18 readonly createdAt: string;
19 readonly lastUsedAt: string | null;
20 }
21
22 interface Row {
23 id: string;
24 user_id: string;
25 credential_id: string;
26 name: string;
27 alg: Alg;
28 key: string;
29 sign_count: number;
30 created_at: string;
31 last_used_at: string | null;
32 }
33
34 export function listPasskeys(db: Database, userId: string): PasskeyInfo[] {
35 return db
36 .query<Row, [string]>(
37 "SELECT * FROM passkey WHERE user_id = ?1 ORDER BY created_at, id",
38 )
39 .all(userId)
40 .map((r) => ({
41 id: r.id,
42 name: r.name,
43 createdAt: r.created_at,
44 lastUsedAt: r.last_used_at,
45 }));
46 }
47
48 /** The credential ids of an account, so a device is not enrolled twice. */
49 export function passkeyIds(db: Database, userId: string): string[] {
50 return db
51 .query<{ credential_id: string }, [string]>(
52 "SELECT credential_id FROM passkey WHERE user_id = ?1",
53 )
54 .all(userId)
55 .map((r) => r.credential_id);
56 }
57
58 export function addPasskey(
59 db: Database,
60 userId: string,
61 name: string,
62 credential: Credential,
63 ): Result<string, "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 id = newId();
71 try {
72 db.query(
73 `INSERT INTO passkey (id, user_id, credential_id, name, alg, key, sign_count)
74 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)`,
75 ).run(
76 id,
77 userId,
78 credential.id,
79 name.slice(0, PASSKEY_NAME_MAX),
80 credential.alg,
81 JSON.stringify(credential.key),
82 credential.signCount,
83 );
84 } catch {
85 return err("duplicate");
86 }
87 return ok(id);
88 }
89
90 /** Remove one of the account's own passkeys. False when it is not theirs. */
91 export function removePasskey(
92 db: Database,
93 userId: string,
94 passkeyId: string,
95 ): boolean {
96 return (
97 db
98 .query("DELETE FROM passkey WHERE id = ?1 AND user_id = ?2")
99 .run(passkeyId, userId).changes > 0
100 );
101 }
102
103 /** The account and verification material behind a credential id. */
104 export function findPasskey(
105 db: Database,
106 credentialId: string,
107 ): { id: string; userId: string; credential: Credential } | undefined {
108 const r = db
109 .query<Row, [string]>("SELECT * FROM passkey WHERE credential_id = ?1")
110 .get(credentialId);
111 if (!r) return undefined;
112 return {
113 id: r.id,
114 userId: r.user_id,
115 credential: {
116 id: r.credential_id,
117 alg: r.alg,
118 key: JSON.parse(r.key) as Jwk,
119 signCount: r.sign_count,
120 },
121 };
122 }
123
124 export function notePasskeyUsed(
125 db: Database,
126 passkeyId: string,
127 signCount: number,
128 ): void {
129 db.query(
130 "UPDATE passkey SET sign_count = ?2, last_used_at = ?3 WHERE id = ?1",
131 ).run(passkeyId, signCount, new Date().toISOString());
132 }
src/core/schema.ts +17
@@ -1688,6 +1688,23 @@ export const migrations: readonly string[] = [
16881688 CREATE TRIGGER thought_no_delete BEFORE DELETE ON thought
16891689 BEGIN SELECT RAISE(ABORT, 'a thought is never deleted'); END;
16901690 `,
1691 // 82 — passkeys (#1694): several per account. `credential_id` is the
1692 // base64url id the authenticator names itself by; `key` is the public JWK,
1693 // `sign_count` the last counter seen (a clone shows as one that stalls).
1694 `
1695 CREATE TABLE passkey (
1696 id TEXT PRIMARY KEY,
1697 user_id TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE,
1698 credential_id TEXT NOT NULL UNIQUE,
1699 name TEXT NOT NULL,
1700 alg TEXT NOT NULL,
1701 key TEXT NOT NULL,
1702 sign_count INTEGER NOT NULL DEFAULT 0,
1703 created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
1704 last_used_at TEXT
1705 );
1706 CREATE INDEX passkey_user ON passkey(user_id);
1707 `,
16911708 ];
16921709
16931710 /**
src/http/server.ts +14
@@ -89,6 +89,14 @@ import {
8989 verifyPage,
9090 themeSubmit,
9191 } from "../web/pages/site.ts";
92 import {
93 passkeyAdd,
94 passkeyDelete,
95 passkeyOptions,
96 passkeysPage,
97 signinOptions,
98 signinPasskey,
99 } from "../web/pages/passkeys.ts";
92100 import { confirmPage, searchPage } from "../web/pages/search.ts";
93101 import { followingSubmit } from "../web/pages/following.ts";
94102 import { reconnectSubmit } from "../web/pages/frozen.ts";
@@ -655,6 +663,12 @@ export function buildRouter(): Router<Handler> {
655663 router.add("POST", "/settings/tokens", tokenCreate);
656664 router.add("POST", "/settings/tokens/:id/delete", tokenDelete);
657665 router.add("POST", "/settings/sessions/:id/delete", sessionDelete);
666 router.add("GET", "/settings/passkeys", passkeysPage);
667 router.add("POST", "/settings/passkeys/options", passkeyOptions);
668 router.add("POST", "/settings/passkeys", passkeyAdd);
669 router.add("POST", "/settings/passkeys/:id/delete", passkeyDelete);
670 router.add("POST", "/signin/passkey/options", signinOptions);
671 router.add("POST", "/signin/passkey", signinPasskey);
658672 // The crew: a forge admin's section, hidden from everyone else.
659673 router.add("GET", "/settings/subscriptions", subscriptionsPage);
660674 router.add("POST", "/settings/subscriptions", subscriptionAdd);
src/web/pages/account.ts +3 −1
@@ -54,7 +54,7 @@ import {
5454 } from "../ui.ts";
5555 import { everydayLine } from "./everyday.ts";
5656
57 type Section = "profile" | "password" | "tokens" | "keys";
57 type Section = "profile" | "password" | "tokens" | "keys" | "passkeys";
5858
5959 // The section list lives beside the newest page that joins it, so adding one
6060 // cannot leave the others pointing at a nav that has forgotten it.
@@ -68,11 +68,13 @@ export function accountShell(
6868 title: string,
6969 body: Html,
7070 status = 200,
71 scriptJs?: string,
7172 ): Response {
7273 return page(
7374 {
7475 ctx,
7576 title,
77 ...(scriptJs === undefined ? {} : { scriptJs }),
7678 body: html`<div class="page-head"><h1>${title}</h1></div>
7779 ${settingsLayout(sections(ctx, current), body, "Account settings")}`,
7880 },
src/web/pages/origins.ts +7
@@ -73,6 +73,7 @@ export type AccountSection =
7373 | "password"
7474 | "tokens"
7575 | "keys"
76 | "passkeys"
7677 | "origins"
7778 | "crew"
7879 | "accounts"
@@ -170,6 +171,12 @@ export function accountSections(
170171 icon: "lock",
171172 current: current === "keys",
172173 },
174 {
175 label: "Passkeys",
176 href: "/settings/passkeys",
177 icon: "key",
178 current: current === "passkeys",
179 },
173180 {
174181 label: "Origins",
175182 href: "/settings/origins",
src/web/pages/passkeys.ts added +198
@@ -0,0 +1,198 @@
1 /**
2 * Passkeys: Settings → Passkeys lists, adds and removes them, and the
3 * sign-in page's passkey button gives the same session a password does.
4 * The ceremonies are JSON (a browser's `navigator.credentials` cannot post a
5 * form); the verification is `auth/webauthn.ts`. Every mutation is
6 * same-origin checked, and a challenge answers once.
7 */
8
9 import { challenges, verifyAssertion, verifyRegistration } from "../../auth/webauthn.ts";
10 import type { App } from "../../app.ts";
11 import {
12 addPasskey,
13 findPasskey,
14 listPasskeys,
15 notePasskeyUsed,
16 passkeyIds,
17 removePasskey,
18 PASSKEY_NAME_MAX,
19 } from "../../core/passkeys.ts";
20 import { createSession } from "../../core/sessions.ts";
21 import { getUserById, isActive } from "../../core/users.ts";
22 import { crossOrigin, type Ctx } from "../../http/context.ts";
23 import { clientAddress } from "../../http/peer.ts";
24 import { STRANGER_MAX } from "../../http/limits.ts";
25 import { boundedBody, json, str } from "../../http/respond.ts";
26 import { signInFirst } from "../chrome.ts";
27 import { readForm } from "../form.ts";
28 import { html } from "../html.ts";
29 import { flashTo } from "../layout.ts";
30 import { passkeyJs } from "../passkey-client.ts";
31 import { card, empty, confirmAction, time } from "../ui.ts";
32 import { accountShell } from "./account.ts";
33 import { safeNext, sessionCookie, signinAllowed } from "./site.ts";
34
35 /** One-time challenges, one store per ceremony per forge. */
36 const stores = new WeakMap<App, { add: ReturnType<typeof challenges>; signin: ReturnType<typeof challenges> }>();
37 function store(app: App) {
38 let s = stores.get(app);
39 if (!s) stores.set(app, (s = { add: challenges(), signin: challenges() }));
40 return s;
41 }
42
43 /** What the browser must see as the relying party: the forge's public address. */
44 function relying(ctx: Ctx) {
45 return { origin: ctx.url.origin, rpId: ctx.url.hostname };
46 }
47
48 const refuse = (status: number, message: string) =>
49 json({ error: "refused", message }, status);
50
51 /** A signed-in browser, on a same-origin request; else the refusal to send. */
52 function sessionOnly(ctx: Ctx): Response | undefined {
53 if (crossOrigin(ctx.request, ctx.url, ctx.app.log)) return refuse(403, "forbidden");
54 if (!ctx.auth.user) return refuse(401, "Sign in first.");
55 if (ctx.auth.via !== "session") return refuse(403, "Passkeys are changed from a signed-in browser.");
56 return undefined;
57 }
58
59 // GET /settings/passkeys
60 export function passkeysPage(ctx: Ctx): Response {
61 if (!ctx.auth.user) return signInFirst(ctx);
62 const keys = listPasskeys(ctx.app.db, ctx.auth.user.id);
63 const body = html`<p class="muted small mb">
64 A passkey signs you in with a fingerprint, face or device PIN instead of
65 a password. Add one per device; each can be removed on its own.
66 </p>
67 ${keys.length === 0
68 ? empty({ icon: "key", title: "No passkeys yet", note: "Add one to sign in without typing a password." })
69 : card(
70 html`<div class="table-scroll">
71 <table class="table" id="passkeys">
72 <thead><tr><th>Name</th><th>Added</th><th>Last used</th><th></th></tr></thead>
73 <tbody>
74 ${keys.map(
75 (k) => html`<tr>
76 <td><strong>${k.name}</strong></td>
77 <td class="muted">${time(k.createdAt)}</td>
78 <td class="muted">${k.lastUsedAt ? time(k.lastUsedAt) : "never"}</td>
79 <td>${confirmAction({ action: `/settings/passkeys/${k.id}/delete`, verb: "Remove", size: "sm" })}</td>
80 </tr>`,
81 )}
82 </tbody>
83 </table>
84 </div>`,
85 { pad: false },
86 )}
87 ${card(
88 html`<div class="field">
89 <label for="passkey-name">Name</label>
90 <input id="passkey-name" name="name" maxlength="${PASSKEY_NAME_MAX}" autocomplete="off" placeholder="Laptop, phone, security key" />
91 </div>
92 <p class="error-note" id="passkey-add-note" role="status"></p>
93 <noscript><p class="muted small">Adding a passkey needs JavaScript.</p></noscript>
94 <div class="form-actions">
95 <button type="button" id="passkey-add" class="btn" data-variant="primary">Add a passkey</button>
96 </div>`,
97 { head: html`<h3>New passkey</h3>` },
98 )}`;
99 return accountShell(ctx, "passkeys", "Passkeys", body, 200, passkeyJs);
100 }
101
102 // POST /settings/passkeys/options — a challenge for a new passkey.
103 export function passkeyOptions(ctx: Ctx): Response {
104 const denied = sessionOnly(ctx);
105 if (denied) return denied;
106 const user = ctx.auth.user!;
107 return json({
108 challenge: store(ctx.app).add.issue(),
109 rpId: relying(ctx).rpId,
110 rpName: "forge",
111 user: {
112 id: Buffer.from(user.id).toString("base64url"),
113 name: user.username,
114 displayName: user.displayName || user.username,
115 },
116 exclude: passkeyIds(ctx.app.db, user.id),
117 });
118 }
119
120 // POST /settings/passkeys — verify the browser's answer and keep the key.
121 export async function passkeyAdd(ctx: Ctx): Promise<Response> {
122 const denied = sessionOnly(ctx);
123 if (denied) return denied;
124 const data = await boundedBody(ctx.request, STRANGER_MAX);
125 if (data === "too_large") return refuse(413, "payload too large");
126 const challenge = str(data.challenge);
127 if (!store(ctx.app).add.consume(challenge))
128 return refuse(400, "That request expired. Try again.");
129 const made = await verifyRegistration(
130 { clientDataJSON: str(data.clientDataJSON), attestationObject: str(data.attestationObject) },
131 { challenge, ...relying(ctx) },
132 );
133 if (!made.ok) return refuse(400, "That passkey was not accepted.");
134 const name = str(data.name).replace(/[\u0000-\u001f\u007f]/g, " ").trim().slice(0, PASSKEY_NAME_MAX) || "Passkey";
135 const added = addPasskey(ctx.app.db, ctx.auth.user!.id, name, made);
136 if (!added.ok)
137 return refuse(
138 409,
139 added.error === "too_many" ? "That is as many passkeys as an account keeps." : "That passkey is already registered.",
140 );
141 return json({ ok: true });
142 }
143
144 // POST /settings/passkeys/:id/delete
145 export async function passkeyDelete(ctx: Ctx): Promise<Response> {
146 if (!ctx.auth.user) return signInFirst(ctx);
147 const form = await readForm(ctx, "admin");
148 if (form instanceof Response) return form;
149 const gone = removePasskey(ctx.app.db, ctx.auth.user.id, ctx.params.id ?? "");

This user_id scoping is the authorization check on this write, and no test covers it. Add one where a second account posts to /settings/passkeys/<someone else's id>/delete and gets "No such passkey.", with the row still present.

150 return flashTo(
151 ctx,
152 "/settings/passkeys",
153 gone ? { message: "Passkey removed." } : { tone: "danger", message: "No such passkey." },
154 );
155 }
156
157 // POST /signin/passkey/options — a challenge for someone with no session yet.
158 export function signinOptions(ctx: Ctx): Response {
159 if (crossOrigin(ctx.request, ctx.url, ctx.app.log)) return refuse(403, "forbidden");
160 const wait = signinAllowed(ctx, `passkey:${clientAddress(ctx.request)}`);

No test covers this limit. Lower signin.per_account (or call enough times) and assert a 429 with retry-after from both /signin/passkey/options and /signin/passkey. Note that each passkey sign-in takes two tokens from the forge-wide sign-in bucket (options and the answer). That is fine, but a test should pin it.

161 if (wait !== undefined)
162 return json({ error: "rate_limited", message: `Too many attempts. Try again in ${wait}s.` }, 429, { "retry-after": String(wait) });
163 return json({ challenge: store(ctx.app).signin.issue(), rpId: relying(ctx).rpId });
164 }
165
166 // POST /signin/passkey — a good assertion gets the session a password gets.
167 export async function signinPasskey(ctx: Ctx): Promise<Response> {
168 if (crossOrigin(ctx.request, ctx.url, ctx.app.log)) return refuse(403, "forbidden");
169 const wait = signinAllowed(ctx, `passkey:${clientAddress(ctx.request)}`);
170 if (wait !== undefined)
171 return json({ error: "rate_limited", message: `Too many attempts. Try again in ${wait}s.` }, 429, { "retry-after": String(wait) });
172 const data = await boundedBody(ctx.request, STRANGER_MAX);
173 if (data === "too_large") return refuse(413, "payload too large");
174 const challenge = str(data.challenge);
175 const no = () => refuse(401, "That passkey was not accepted.");
176 if (!store(ctx.app).signin.consume(challenge)) return no();
177 const found = findPasskey(ctx.app.db, str(data.id));
178 if (!found) return no();
179 const user = getUserById(ctx.app.db, found.userId);
180 if (!user || !isActive(user)) return no();
181 const verified = await verifyAssertion(
182 {
183 clientDataJSON: str(data.clientDataJSON),
184 authenticatorData: str(data.authenticatorData),
185 signature: str(data.signature),
186 },
187 { challenge, ...relying(ctx) },
188 found.credential,
189 );
190 if (!verified.ok) return no();
191 notePasskeyUsed(ctx.app.db, found.id, verified.signCount);
192 const session = createSession(ctx.app.db, user.id);
193 return json(
194 { ok: true, next: safeNext(ctx.url.searchParams.get("next")) ?? "/" },
195 200,
196 { "set-cookie": sessionCookie(session.token, session.expiresAt, ctx.url, ctx.app.config.url) },
197 );
198 }
src/web/pages/search.ts +9
@@ -536,6 +536,15 @@ const DESTRUCTIVE: {
536536 phrase: "",
537537 }),
538538 },
539 {
540 pattern: /^\/settings\/passkeys\/([^/]+)\/delete$/,
541 describe: () => ({
542 verb: "Remove passkey",
543 title: "Remove this passkey?",
544 detail: "The device holding it can no longer sign in as you.",
545 phrase: "",
546 }),
547 },
539548 {
540549 pattern: /^\/([^/]+)\/-\/teams\/([^/]+)\/delete$/,
541550 describe: (m) => ({
src/web/pages/site.ts +16 −3
@@ -40,6 +40,7 @@ import { sshCommand } from "../../ssh/command.ts";
4040 import { hostOf, pageHead, signInFirst } from "../chrome.ts";
4141 import { RateLimiter } from "../../core/ratelimit.ts";
4242 import { field, readForm } from "../form.ts";
43 import { passkeyJs } from "../passkey-client.ts";
4344 import { html, when, type Html } from "../html.ts";
4445 import { icon } from "../icons.ts";
4546 import { flashTo, page, redirect } from "../layout.ts";
@@ -77,7 +78,7 @@ export function sessionCookie(
7778 * protocol-relative `//evil.example` is a redirect off-site wearing a path's
7879 * clothes, so it is refused with everything else that isn't a plain path.
7980 */
80 function safeNext(raw: string | null): string | undefined {
81 export function safeNext(raw: string | null): string | undefined {
8182 if (raw === null || raw === "") return undefined;
8283 if (!raw.startsWith("/") || raw.startsWith("//")) return undefined;
8384 if (raw.includes("\\") || /[\n\r]/.test(raw)) return undefined;
@@ -460,6 +461,13 @@ function authForm(
460461 return html`<div class="narrow">
461462 ${pageHead(kind === "signin" ? "Sign in" : "Create your account")}
462463 ${card(body)}
464 ${when(
465 kind === "signin",
466 () => html`<p class="mt">
467 <button type="button" id="passkey-signin" class="btn" data-variant="outline">Sign in with a passkey</button>
468 <span class="error-note small" id="passkey-signin-note" role="status"></span>
469 </p>`,
470 )}
463471 ${claimable
464472 ? html`<p class="muted small mt" id="claim-door">
465473 Was this forge made for you? <a href="/claim">Claim it with your code</a>.
@@ -469,7 +477,12 @@ function authForm(
469477 }
470478
471479 export function signinPage(ctx: Ctx): Response {
472 return page({ ctx, title: "Sign in", body: authForm(ctx, "signin") });
480 return page({
481 ctx,
482 title: "Sign in",
483 body: authForm(ctx, "signin"),
484 scriptJs: passkeyJs,
485 });
473486 }
474487
475488 /** The widget's public site key, when signup is gated on Turnstile. */
@@ -499,7 +512,7 @@ export function signupPage(ctx: Ctx): Response {
499512 */
500513 const signinLimits = new WeakMap<Ctx["app"], RateLimiter>();
501514
502 function signinAllowed(ctx: Ctx, username: string): number | undefined {
515 export function signinAllowed(ctx: Ctx, username: string): number | undefined {
503516 let limiter = signinLimits.get(ctx.app);
504517 if (limiter === undefined) {
505518 limiter = new RateLimiter({ perMinute: 0, adminPerMinute: 0, enabled: true });
src/web/passkey-client.ts added +94
@@ -0,0 +1,94 @@
1 /**
2 * The browser half of passkeys: two small ceremonies, inlined (nonce'd) on
3 * the two pages that need them. A page without script still works; it just
4 * has no passkey button. Binary travels as base64url, as the server expects.
5 */
6 export const passkeyJs = /* js */ `
7 (function () {
8 var doc = document;
9 function bytes(s) {
10 s = s.replace(/-/g, '+').replace(/_/g, '/');
11 while (s.length % 4) s += '=';
12 var raw = atob(s), out = new Uint8Array(raw.length);
13 for (var i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
14 return out;
15 }
16 function text(buf) {
17 var a = new Uint8Array(buf), s = '';
18 for (var i = 0; i < a.length; i++) s += String.fromCharCode(a[i]);
19 return btoa(s).replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '');
20 }
21 function post(url, data) {
22 return fetch(url, {
23 method: 'POST',
24 credentials: 'same-origin',
25 headers: { 'content-type': 'application/json' },
26 body: JSON.stringify(data || {})
27 }).then(function (r) {
28 return r.json().catch(function () { return {}; }).then(function (j) {
29 if (!r.ok) throw new Error(j.message || 'That did not work.');
30 return j;
31 });
32 });
33 }
34 function wire(id, run) {
35 var el = doc.getElementById(id);
36 if (!el) return;
37 var note = doc.getElementById(id + '-note');
38 if (!window.PublicKeyCredential || !navigator.credentials) {
39 el.disabled = true;
40 if (note) note.textContent = 'This browser has no passkey support.';
41 return;
42 }
43 el.addEventListener('click', function (e) {
44 e.preventDefault();
45 el.disabled = true;
46 if (note) note.textContent = '';
47 run().catch(function (err) {
48 if (note) note.textContent = err && err.name === 'NotAllowedError' ? 'Cancelled.' : (err && err.message) || 'That did not work.';
49 el.disabled = false;
50 });
51 });
52 }
53 wire('passkey-add', function () {
54 var name = doc.getElementById('passkey-name');
55 return post('/settings/passkeys/options').then(function (o) {
56 return navigator.credentials.create({ publicKey: {
57 challenge: bytes(o.challenge),
58 rp: { id: o.rpId, name: o.rpName },
59 user: { id: bytes(o.user.id), name: o.user.name, displayName: o.user.displayName },
60 pubKeyCredParams: [{ type: 'public-key', alg: -7 }, { type: 'public-key', alg: -257 }],
61 excludeCredentials: o.exclude.map(function (c) { return { type: 'public-key', id: bytes(c) }; }),
62 authenticatorSelection: { residentKey: 'preferred', userVerification: 'required' },
63 attestation: 'none',
64 timeout: 120000
65 } }).then(function (cred) {
66 return post('/settings/passkeys', {
67 name: name ? name.value : '',
68 challenge: o.challenge,
69 clientDataJSON: text(cred.response.clientDataJSON),
70 attestationObject: text(cred.response.attestationObject)
71 });
72 });
73 }).then(function () { location.reload(); });
74 });
75 wire('passkey-signin', function () {
76 return post('/signin/passkey/options').then(function (o) {
77 return navigator.credentials.get({ publicKey: {
78 challenge: bytes(o.challenge),
79 rpId: o.rpId,
80 userVerification: 'required',
81 timeout: 120000
82 } }).then(function (cred) {
83 return post('/signin/passkey' + location.search, {
84 challenge: o.challenge,
85 id: text(cred.rawId),
86 clientDataJSON: text(cred.response.clientDataJSON),
87 authenticatorData: text(cred.response.authenticatorData),
88 signature: text(cred.response.signature)
89 });
90 });
91 }).then(function (r) { location.href = r.next; });
92 });
93 })();
94 `;
test/passkeys.test.ts added +233
@@ -0,0 +1,233 @@
1 /**
2 * Passkeys end to end: add two, remove one, sign in with the other (same
3 * HttpOnly session cookie as a password), and the removed one is refused.
4 * The software authenticator signs like a browser's.
5 */
6 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7 import { mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { createApp, type App } from "../src/app.ts";
11 import { b64u } from "../src/auth/webauthn.ts";
12 import { createToken } from "../src/core/tokens.ts";
13 import { serve, type Servers } from "../src/http/server.ts";
14 import { setLimit } from "../src/settings/limits.ts";
15
16 const te = new TextEncoder();
17 const sha = async (d: Uint8Array<ArrayBuffer>) => new Uint8Array(await crypto.subtle.digest("SHA-256", d));
18 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; };
19 function head(major: number, n: number): Uint8Array {
20 if (n < 24) return Uint8Array.of((major << 5) | n);
21 if (n < 256) return Uint8Array.of((major << 5) | 24, n);
22 return Uint8Array.of((major << 5) | 25, n >> 8, n & 255);
23 }
24 type V = number | string | Uint8Array | [V, V][];
25 function enc(v: V): Uint8Array {
26 if (typeof v === "number") return v >= 0 ? head(0, v) : head(1, -1 - v);
27 if (typeof v === "string") { const b = te.encode(v); return cat(head(3, b.length), b); }
28 if (v instanceof Uint8Array) return cat(head(2, v.length), v);
29 return cat(head(5, v.length), ...v.flatMap(([k, x]) => [enc(k), enc(x)]));
30 }
31 function rawToDer(raw: Uint8Array): Uint8Array<ArrayBuffer> {
32 const int = (b: Uint8Array) => { let i = 0; while (i < b.length - 1 && b[i] === 0) i++; b = b.subarray(i); if (b[0]! & 0x80) b = cat(Uint8Array.of(0), b); return cat(Uint8Array.of(2, b.length), b); };
33 const body = cat(int(raw.subarray(0, 32)), int(raw.subarray(32)));
34 return cat(Uint8Array.of(0x30, body.length), body);
35 }
36
37 async function device(origin: string, rp: string) {
38 const pair = (await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, ["sign"])) as CryptoKeyPair;
39 const jwk = await crypto.subtle.exportKey("jwk", pair.publicKey);
40 const unb = (s: string) => new Uint8Array(Buffer.from(s, "base64url"));
41 const cose: [number, V][] = [[1, 2], [3, -7], [-1, 1], [-2, unb(jwk.x!)], [-3, unb(jwk.y!)]];
42 const id = crypto.getRandomValues(new Uint8Array(32));
43 let count = 0;
44 const cd = (type: string, challenge: string) => te.encode(JSON.stringify({ type, challenge, origin }));
45 const counter = (n: number) => Uint8Array.of(n >>> 24, (n >>> 16) & 255, (n >>> 8) & 255, n & 255);
46 return {
47 credId: b64u(id),
48 async create(challenge: string) {
49 const authData = cat(await sha(te.encode(rp)), Uint8Array.of(0x45), counter(0), new Uint8Array(16), Uint8Array.of(0, id.length), id, enc(cose as V));
50 return { clientDataJSON: b64u(cd("webauthn.create", challenge)), attestationObject: b64u(enc([["fmt", "none"], ["attStmt", []], ["authData", authData]])) };
51 },
52 async get(challenge: string) {
53 const clientData = cd("webauthn.get", challenge);
54 const authData = cat(await sha(te.encode(rp)), Uint8Array.of(0x05), counter(++count));
55 const sig = rawToDer(new Uint8Array(await crypto.subtle.sign({ name: "ECDSA", hash: "SHA-256" }, pair.privateKey, cat(authData, await sha(clientData)))));
56 return { id: b64u(id), clientDataJSON: b64u(clientData), authenticatorData: b64u(authData), signature: b64u(sig) };
57 },
58 };
59 }
60
61 describe("passkeys", () => {
62 let dataDir: string;
63 let app: App;
64 let servers: Servers;
65 let base: string;
66 let cookie = "";
67
68 const post = (path: string, body: unknown, headers: Record<string, string> = {}) =>
69 fetch(`${base}${path}`, { method: "POST", headers: { "content-type": "application/json", ...headers }, body: JSON.stringify(body), redirect: "manual" });
70 const authed = (path: string, body: unknown = {}) => post(path, body, { cookie });
71
72 async function enroll(name: string) {
73 const d = await device(new URL(base).origin, new URL(base).hostname);
74 const opts = (await (await authed("/settings/passkeys/options")).json()) as any;
75 const res = await authed("/settings/passkeys", { name, challenge: opts.challenge, ...(await d.create(opts.challenge)) });
76 return { d, res };
77 }
78 async function signIn(d: Awaited<ReturnType<typeof device>>, qs = "") {
79 const o = (await (await post("/signin/passkey/options", {})).json()) as any;
80 return post(`/signin/passkey${qs}`, { challenge: o.challenge, ...(await d.get(o.challenge)) });
81 }
82
83 beforeAll(async () => {
84 dataDir = await mkdtemp(join(tmpdir(), "forge-passkeys-"));
85 app = createApp({ dataDir, port: 0 });
86 servers = await serve(app);
87 base = servers.url;
88 const res = await fetch(`${base}/signup`, {
89 method: "POST",
90 body: new URLSearchParams({ username: "pat", email: "[email protected]", password: "passkey-password" }),
91 redirect: "manual",
92 });
93 cookie = (res.headers.get("set-cookie") ?? "").split(";")[0]!;
94 });
95
96 afterAll(async () => {
97 servers.stop();
98 app.close();
99 await rm(dataDir, { recursive: true, force: true });
100 });
101
102 test("add two, remove one, sign in with the other; the removed one is refused", async () => {
103 const one = await enroll("Laptop");
104 const two = await enroll("Phone");
105 expect(one.res.status).toBe(200);
106 expect(two.res.status).toBe(200);
107
108 let page = await (await fetch(`${base}/settings/passkeys`, { headers: { cookie } })).text();
109 expect(page).toContain("Laptop");
110 expect(page).toContain("<strong>Phone</strong>");
111
112 const id = app.db.query<{ id: string }, []>("SELECT id FROM passkey WHERE name = 'Laptop'").get()!.id;
113 const removed = await fetch(`${base}/settings/passkeys/${id}/delete`, { method: "POST", headers: { cookie }, redirect: "manual" });
114 expect(removed.status).toBe(303);
115 page = await (await fetch(`${base}/settings/passkeys`, { headers: { cookie } })).text();
116 expect(page).not.toContain("<strong>Laptop</strong>");
117 expect(page).toContain("<strong>Phone</strong>");
118
119 const refused = await signIn(one.d);
120 expect(refused.status).toBe(401);
121 expect(refused.headers.get("set-cookie")).toBeNull();
122
123 const good = await signIn(two.d, "?next=/settings");
124 expect(good.status).toBe(200);
125 expect(await good.json()).toEqual({ ok: true, next: "/settings" });
126 const set = good.headers.get("set-cookie")!;
127 expect(set).toContain("forge_session=");
128 expect(set).toContain("HttpOnly");
129 const me = await fetch(`${base}/settings/passkeys`, { headers: { cookie: set.split(";")[0]! } });
130 expect(me.status).toBe(200);
131 expect(await me.text()).toContain("Phone");
132 });
133
134 test("a challenge answers once, and a bad signature or a stranger's key signs nobody in", async () => {
135 const d = await device(new URL(base).origin, new URL(base).hostname);
136 const o = (await (await post("/signin/passkey/options", {})).json()) as any;
137 const unknown = await post("/signin/passkey", { challenge: o.challenge, ...(await d.get(o.challenge)) });
138 expect(unknown.status).toBe(401);
139 // The challenge is spent even by a refusal.
140 const e = await enroll("Key");
141 const spent = await post("/signin/passkey", { challenge: o.challenge, ...(await e.d.get(o.challenge)) });
142 expect(spent.status).toBe(401);
143 const o2 = (await (await post("/signin/passkey/options", {})).json()) as any;
144 const forged = { challenge: o2.challenge, ...(await e.d.get(o2.challenge)) };
145 const bad = await post("/signin/passkey", { ...forged, signature: b64u(new Uint8Array(70)) });
146 expect(bad.status).toBe(401);
147 });
148
149 test("registration needs a session and a same-origin request, and a challenge is single-use", async () => {
150 expect((await post("/settings/passkeys/options", {})).status).toBe(401);
151 const cross = await post("/settings/passkeys/options", {}, { cookie, origin: "https://evil.example" });
152 expect(cross.status).toBe(403);
153 const crossSignin = await post("/signin/passkey/options", {}, { origin: "https://evil.example" });
154 expect(crossSignin.status).toBe(403);
155
156 const d = await device(new URL(base).origin, new URL(base).hostname);
157 const opts = (await (await authed("/settings/passkeys/options")).json()) as any;
158 const answer = { name: "x", challenge: opts.challenge, ...(await d.create(opts.challenge)) };
159 expect((await authed("/settings/passkeys", answer)).status).toBe(200);
160 expect((await authed("/settings/passkeys", answer)).status).toBe(400);
161 // Enrolling the same device twice is also refused, with a fresh challenge.
162 const again = (await (await authed("/settings/passkeys/options")).json()) as any;
163 expect(again.exclude).toContain(d.credId);
164 const dup = await authed("/settings/passkeys", { name: "x", challenge: again.challenge, ...(await d.create(again.challenge)) });
165 expect(dup.status).toBe(409);
166 });
167
168 test("a name is escaped on the page, and the sign-in page offers a passkey", async () => {
169 await enroll("<script>alert(1)</script>");
170 const page = await (await fetch(`${base}/settings/passkeys`, { headers: { cookie } })).text();
171 expect(page).not.toContain("<script>alert(1)</script>");
172 expect(page).toContain("&lt;script&gt;alert(1)");
173 expect(await (await fetch(`${base}/signin`)).text()).toContain("passkey-signin");
174 });
175
176 const signup = async (username: string) => {
177 const res = await fetch(`${base}/signup`, {
178 method: "POST",
179 body: new URLSearchParams({ username, email: `${username}@f.dev`, password: "passkey-password" }),
180 redirect: "manual",
181 });
182 return (res.headers.get("set-cookie") ?? "").split(";")[0]!;
183 };
184 const userId = (name: string) => app.db.query<{ id: string }, [string]>("SELECT id FROM user WHERE username = ?1").get(name)!.id;
185
186 test("another account cannot remove a passkey that is not theirs", async () => {
187 await enroll("Pat's key");
188 const id = app.db.query<{ id: string }, [string]>("SELECT id FROM passkey WHERE user_id = ?1 AND name = 'Pat''s key'").get(userId("pat"))!.id;
189 const eve = await signup("eve");
190 const res = await fetch(`${base}/settings/passkeys/${id}/delete`, { method: "POST", headers: { cookie: eve }, redirect: "manual" });
191 expect(res.status).toBe(303);
192 const carried = (res.headers.getSetCookie?.() ?? []).map((c) => c.split(";")[0]).join("; ");
193 const flash = await (await fetch(`${base}/settings/passkeys`, { headers: { cookie: `${eve}; ${carried}` } })).text();
194 expect(flash).toContain("No such passkey.");
195 expect(app.db.query("SELECT 1 FROM passkey WHERE id = ?1").get(id)).not.toBeNull();
196 });
197
198 test("a token cannot register a passkey", async () => {
199 const { token } = createToken(app.db, userId("pat"), "ci", "admin");
200 const headers = { authorization: `Bearer ${token}` };
201 const before = app.db.query<{ n: number }, []>("SELECT count(*) AS n FROM passkey").get()!.n;
202 const opts = await post("/settings/passkeys/options", {}, headers);
203 expect(opts.status).toBe(403);
204 const d = await device(new URL(base).origin, new URL(base).hostname);
205 const add = await post("/settings/passkeys", { name: "t", challenge: "x", ...(await d.create("x")) }, headers);
206 expect(add.status).toBe(403);
207 expect(app.db.query<{ n: number }, []>("SELECT count(*) AS n FROM passkey").get()!.n).toBe(before);
208 });
209
210 test("a suspended account cannot sign in with its passkey", async () => {
211 const dan = await signup("dan");
212 const d = await device(new URL(base).origin, new URL(base).hostname);
213 const opts = (await post("/settings/passkeys/options", {}, { cookie: dan }).then((r) => r.json())) as any;
214 expect((await post("/settings/passkeys", { name: "d", challenge: opts.challenge, ...(await d.create(opts.challenge)) }, { cookie: dan })).status).toBe(200);
215 app.db.query("UPDATE user SET suspended_at = ?1 WHERE username = 'dan'").run(new Date().toISOString());
216 const res = await signIn(d);
217 expect(res.status).toBe(401);
218 expect(res.headers.get("set-cookie")).toBeNull();
219 });
220
221 test("passkey sign-in is rate limited, options and answer share one bucket", async () => {
222 setLimit(app.db, "signin.per_account", 2, userId("pat"));
223 // Earlier sign-ins from this address already spent some of the minute.
224 let third = await post("/signin/passkey/options", {});
225 for (let i = 0; i < 3 && third.status === 200; i++) third = await post("/signin/passkey/options", {});
226 expect(third.status).toBe(429);
227 expect(Number(third.headers.get("retry-after"))).toBeGreaterThan(0);
228 const answer = await post("/signin/passkey", { challenge: "x" });
229 expect(answer.status).toBe(429);
230 expect(Number(answer.headers.get("retry-after"))).toBeGreaterThan(0);
231 setLimit(app.db, "signin.per_account", null, userId("pat"));
232 });
233 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs