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

Passkeys, part 2a: sign-up takes a passkey and an email, and no door makes a password-only account #2005

Merged opened this

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

Merged

scout/forge:scout/1996-passkeys-part-2a-sign-up-takes-a-passkey → main 2 commits fork 5 threads · 5 unresolved

  • Merged. scout/1996-passkeys-part-2a-sign-up-takes-a-passkey was merged into main as bef7a92.

Review

Sign in to review this change.
14 files changed +511 −21
README.md +1
@@ -116,6 +116,7 @@ account; `src/auth/webauthn.ts` verifies, no dependency, `none` attestation).
116116 The sign-in page's "Sign in with a passkey" gives the same HttpOnly session a
117117 password does. The relying party is the address the browser used (or
118118 `FORGE_URL`), so a passkey does not follow a forge to another hostname.
119 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`).
119120
120121 Your SSH key is your identity. A key the forge knows signs you straight in; any
121122 other key browses public repos as a guest and can press `s` to sign in with a
scripts/gate.ts +1
@@ -49,6 +49,7 @@ export const GATE: readonly string[] = [
4949 "test/thoughts.test.ts",
5050 "test/triage.test.ts",
5151 "test/passkeys.test.ts",
52 "test/signup-passkey.test.ts",
5253 "test/webauthn.test.ts",
5354 "test/chaos.test.ts",
5455 "test/flow-stage.test.ts",
src/actions/users.ts +69 −1
@@ -5,6 +5,8 @@
55
66 import { emailChanged, welcome } from "./verify.ts";
77 import type { App } from "../app.ts";
8 import type { Credential } from "../auth/webauthn.ts";
9 import { addPasskey, findPasskey } from "../core/passkeys.ts";
810 import {
911 addKey,
1012 deleteKey,
@@ -23,6 +25,7 @@ import {
2325 changePassword as rehash,
2426 cleanDisplayName,
2527 createUser,
28 discardFreshUser,
2629 hasAnyUser,
2730 updateProfile as writeProfile,
2831 verifyPassword,
@@ -42,7 +45,16 @@ export interface SignUpInput {
4245 humanToken?: string;
4346 }
4447
45 export type SignUpError = CreateUserError | "human_check";
48 export type SignUpError = CreateUserError | "human_check" | "passkey_required";
49
50 /** What anyone is told at a door that still offers a password-only sign-up. */
51 export const PASSKEY_REQUIRED_MESSAGE =
52 "New accounts sign up with a passkey and an email address. Open the sign-up page in a browser and choose \u201cCreate account with a passkey\u201d.";
53
54 /** A plausible address: one @, no spaces. The link sent to it is the proof. */
55 export function plausibleEmail(email: string): boolean {
56 return email.length <= 254 && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
57 }
4658
4759 /** What a person is told when the check fails. */
4860 export const HUMAN_CHECK_MESSAGE =
@@ -68,6 +80,11 @@ export async function signUp(
6880 door?: "terminal";
6981 } = {},
7082 ): Promise<Result<User, SignUpError>> {
83 // Password sign-in stays for the accounts that have a password. It can go
84 // when every such account has a passkey (Settings → Passkeys shows who is
85 // left) and recovery without a password exists (#1618): then delete this
86 // action's password path, `verifyPassword`'s doors and the password column.
87 if (app.config.passkeyOnlySignup === true) return err("passkey_required");
7188 const secret = app.config.turnstileSecret;
7289 const bootstrap = from.door === "terminal" && !hasAnyUser(app.db);
7390 if (secret !== undefined && !bootstrap) {
@@ -93,6 +110,57 @@ export async function signUp(
93110 return created;
94111 }
95112
113 export interface PasskeySignUpInput {
114 username: string;
115 email: string;
116 /** The verified key (`verifyRegistration`) and what its owner calls the device. */
117 passkey: Credential;
118 passkeyName: string;
119 humanToken?: string;
120 }
121
122 /**
123 * Sign up with a passkey and an email address: the one way a new account is
124 * made. The account has no password. The key was verified by the caller
125 * against a challenge this forge issued. The account is made, then its key;
126 * a refused key takes the account back (the hash-free insert cannot yield to
127 * another request, so no transaction spans the two calls).
128 */
129 export async function signUpWithPasskey(
130 app: App,
131 input: PasskeySignUpInput,
132 from: { address?: string | undefined } = {},
outdated

No test covers this check. It is the only bot gate left on sign-up. Add a test in test/signup-passkey.test.ts with turnstileSecret set and a verifyHuman stub that refuses: /signup/passkey must answer 422 and leave no user row.

133 ): Promise<Result<User, SignUpError | "passkey_taken">> {
134 const secret = app.config.turnstileSecret;
135 if (secret !== undefined) {
136 const verify = app.config.verifyHuman ?? turnstileVerifier(secret);
137 if (!(await verify(input.humanToken ?? "", from.address)))
138 return err("human_check");
139 }
140 if (findPasskey(app.db, input.passkey.id)) return err("passkey_taken");
141 const created = await createUser(app.db, {
142 username: input.username,
143 email: input.email,
144 adminIfFirst: true,
145 });
146 if (!created.ok) return created;
147 const kept = addPasskey(app.db, created.value.id, input.passkeyName, input.passkey);
outdated

The account and its key are not made in one transaction: a crash between createUser and addPasskey leaves an account with no password and no key, which can never sign in. This path also has no test. Wrap the insert of the account and its key in app.db.transaction, or at least add a test that a reused credential makes no account (409, and the user count unchanged). Raw SQL here also skips core/users.ts; if a delete is still needed after the transaction, give it a function in core.

Low. If the process dies between createUser and addPasskey, it leaves an account with no password and no key. Nobody can sign in to it, but it still holds the username and the email. The doc comment now says so honestly. When there's room, a cheap fix is for problem() (or createUser) to treat a keyless, password-less, unproven account older than a few minutes as free and run discardFreshUser on it. This doesn't block the pull.

148 if (!kept.ok) {
149 discardFreshUser(app.db, created.value.id);
150 return err("passkey_taken");
151 }
152 app.bus.append({
153 kind: "user.created",
154 actorId: created.value.id,
155 payload: {
156 via: "signup",
157 ...(from.address === undefined ? {} : { address: from.address }),
158 },
159 });
160 await welcome(app, created.value);
161 return created;
162 }
163
96164 /**
97165 * Mint a personal access token. Callers decide what credential may mint one
98166 * (a session or the password, never another token — no privilege
src/app.ts +8
@@ -120,6 +120,14 @@ export interface ForgeConfig {
120120 * Undefined means the forge is running without SSH.
121121 */
122122 sshPort?: number;
123 /**
124 * Refuse every sign-up that would make an account with only a password:
125 * the web form, `POST /api/v1/users` and the terminal answer in words,
126 * and `signUpWithPasskey` is the one way in. `main.ts` sets it; the
127 * library default is off so a test can still seed accounts with a
128 * password.
129 */
130 passkeyOnlySignup?: boolean;
123131 /**
124132 * The Turnstile secret (`FORGE_TURNSTILE_SECRET`). Set, signup refuses a
125133 * missing or bad token before it hashes a password or makes an account.
src/core/users.ts +9
@@ -145,6 +145,15 @@ export interface CreateUserInput {
145145 reservedSelf?: string;
146146 }
147147
148 /**
149 * Undo an account made a moment ago whose sign-up did not finish (its key
150 * was refused). Only an account with no password, no repository and no
151 * other trace is removed; anything else stays.
152 */
153 export function discardFreshUser(db: Database, userId: string): void {
154 db.query("DELETE FROM user WHERE id = ?1 AND password_hash = ''").run(userId);
155 }
156
148157 export async function createUser(
149158 db: Database,
150159 input: CreateUserInput,
src/http/api.ts +3
@@ -142,6 +142,7 @@ import {
142142 deleteSshKey,
143143 deleteToken,
144144 HUMAN_CHECK_MESSAGE,
145 PASSKEY_REQUIRED_MESSAGE,
145146 signUp,
146147 updateProfile,
147148 } from "../actions/users.ts";
@@ -1135,6 +1136,8 @@ export function apiRoutes(router: Router<Handler>): void {
11351136 },
11361137 { address: clientAddress(ctx.request) },
11371138 );
1139 if (!created.ok && created.error === "passkey_required")
1140 return apiError("passkey_required", 422, PASSKEY_REQUIRED_MESSAGE);
11381141 if (!created.ok && created.error === "human_check")
11391142 return apiError("human_check", 422, HUMAN_CHECK_MESSAGE);
11401143 if (!created.ok) return json({ error: created.error }, 422);
src/http/server.ts +3
@@ -5,6 +5,7 @@
55 * verdict. Auth is resolved once per request and handed to handlers as data.
66 */
77
8 import { signupOptions, signupPasskey } from "../web/pages/signup-passkey.ts";
89 import { TOOLBAR_NAME, toolbarResponse } from "../apps/toolbar.ts";
910 import { unsealReleased } from "../actions/sealed.ts";
1011 import { accessPage } from "../web/pages/access.ts";
@@ -685,6 +686,8 @@ export function buildRouter(): Router<Handler> {
685686 router.add("POST", "/settings/passkeys/:id/delete", passkeyDelete);
686687 router.add("POST", "/signin/passkey/options", signinOptions);
687688 router.add("POST", "/signin/passkey", signinPasskey);
689 router.add("POST", "/signup/passkey/options", signupOptions);
690 router.add("POST", "/signup/passkey", signupPasskey);
688691 // The crew: a forge admin's section, hidden from everyone else.
689692 router.add("GET", "/settings/subscriptions", subscriptionsPage);
690693 router.add("POST", "/settings/subscriptions", subscriptionAdd);
src/main.ts +2
@@ -195,6 +195,8 @@ const app = createApp({
195195 Number(process.env.FORGE_MAIL_DAILY) > 0
196196 ? { mailDailyCap: Number(process.env.FORGE_MAIL_DAILY) }
197197 : {}),
198 // No door makes a password-only account (#1996).
199 passkeyOnlySignup: true,
198200 // Signup asks Cloudflare Turnstile when its secret is set.
199201 ...(turnstileSecret === ""
200202 ? {}
src/tui/screens/common.ts +1
@@ -815,6 +815,7 @@ export function slug(path: string): string {
815815 const MESSAGES: Record<string, string> = {
816816 not_found: "not found",
817817 human_check: "we could not tell you are a person; sign up on the web page",
818 passkey_required: "new accounts sign up with a passkey and an email address: open the sign-up page in a browser",
818819 unauthorized: "sign in first",
819820 forbidden: "you do not have permission",
820821 forbidden_owner: "you cannot create repos for that owner",
src/web/pages/passkeys.ts +5 −5
@@ -33,15 +33,15 @@ import { accountShell } from "./account.ts";
3333 import { safeNext, sessionCookie, signinAllowed } from "./site.ts";
3434
3535 /** 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) {
36 const stores = new WeakMap<App, { add: ReturnType<typeof challenges>; signin: ReturnType<typeof challenges>; signup: ReturnType<typeof challenges> }>();
37 export function store(app: App) {
3838 let s = stores.get(app);
39 if (!s) stores.set(app, (s = { add: challenges(), signin: challenges() }));
39 if (!s) stores.set(app, (s = { add: challenges(), signin: challenges(), signup: challenges() }));
4040 return s;
4141 }
4242
4343 /** What the browser must see as the relying party: the forge's public address. */
44 function relying(ctx: Ctx) {
44 export function relying(ctx: Ctx) {
4545 return { origin: ctx.url.origin, rpId: ctx.url.hostname };
4646 }
4747
@@ -65,7 +65,7 @@ export function passkeysPage(ctx: Ctx): Response {
6565 a password. Add one per device; each can be removed on its own.
6666 </p>
6767 ${keys.length === 0
68 ? empty({ icon: "key", title: "No passkeys yet", note: "Add one to sign in without typing a password." })
68 ? empty({ icon: "key", title: "No passkeys yet", note: "Your password still works; add a passkey now, as every new account starts with one." })
6969 : card(
7070 html`<div class="table-scroll">
7171 <table class="table" id="passkeys">
src/web/pages/signup-passkey.ts added +101
@@ -0,0 +1,101 @@
1 /**
2 * Sign-up with a passkey: the browser asks for a challenge (after the name
3 * and address are checked), makes the key, and posts it back; the account,
4 * its email and its key are made together and the session cookie is the one
5 * a password gives. Both steps are JSON, same-origin checked, rate-limited
6 * per address; a challenge answers once.
7 */
8
9 import { newChallenge, verifyRegistration } from "../../auth/webauthn.ts";
10 import { plausibleEmail, signUpWithPasskey, HUMAN_CHECK_MESSAGE } from "../../actions/users.ts";
11 import { createSession } from "../../core/sessions.ts";
12 import { getUserByName } from "../../core/users.ts";
13 import { isSafeName } from "../../git/guard.ts";
14 import { crossOrigin, type Ctx } from "../../http/context.ts";
15 import { STRANGER_MAX } from "../../http/limits.ts";
16 import { clientAddress } from "../../http/peer.ts";
17 import { boundedBody, json, str } from "../../http/respond.ts";
18 import { relying, store } from "./passkeys.ts";
19 import { safeNext, sessionCookie, signinAllowed } from "./site.ts";
20
21 const refuse = (status: number, message: string) =>
22 json({ error: "refused", message }, status);
outdated

This is a second challenge store beside the one in passkeys.ts, so each kind of challenge gets its own 10k-entry map. Consider adding a signup store next to signin in passkeys.ts's store, so the challenge logic lives in one place.

23
24 /** The words for each way a sign-up can fail, written for anyone. */
25 const WORDS: Record<string, string> = {
26 invalid_username: "Usernames are letters, digits, dots and dashes.",
27 username_taken: "That username is taken.",
28 username_reserved: "That name is reserved.",
29 email_taken: "That email already has an account.",
30 invalid_email: "That does not look like an email address.",
31 human_check: HUMAN_CHECK_MESSAGE,
32 passkey_taken: "That passkey is already registered.",
33 };
34
35 /** Whether the name and address are fit to sign up with; else the words. */
36 function problem(ctx: Ctx, username: string, email: string): string | undefined {
37 if (!isSafeName(username)) return WORDS.invalid_username;
38 if (!plausibleEmail(email)) return WORDS.invalid_email;
39 if (getUserByName(ctx.app.db, username)) return WORDS.username_taken;
40 return undefined;
41 }
42
43 function guard(ctx: Ctx): Response | undefined {
44 if (crossOrigin(ctx.request, ctx.url, ctx.app.log)) return refuse(403, "forbidden");
45 const wait = signinAllowed(ctx, `signup:${clientAddress(ctx.request)}`);
46 if (wait !== undefined)
47 return json({ error: "rate_limited", message: `Too many attempts. Try again in ${wait}s.` }, 429, { "retry-after": String(wait) });
48 return undefined;
49 }
50
51 // POST /signup/passkey/options — a challenge, once the name and address pass.
52 export async function signupOptions(ctx: Ctx): Promise<Response> {
53 const denied = guard(ctx);
54 if (denied) return denied;
55 const data = await boundedBody(ctx.request, STRANGER_MAX);
56 if (data === "too_large") return refuse(413, "payload too large");
57 const username = str(data.username).trim();
58 const email = str(data.email).trim();
59 const bad = problem(ctx, username, email);
60 if (bad) return refuse(422, bad);
61 return json({
62 challenge: store(ctx.app).signup.issue(),
63 rpId: relying(ctx).rpId,
64 rpName: "forge",
65 user: { id: newChallenge(), name: username, displayName: username },
66 exclude: [],
67 });
68 }
69
70 // POST /signup/passkey — verify the key, make the account, start the session.
71 export async function signupPasskey(ctx: Ctx): Promise<Response> {
outdated

This repeats relying(ctx) from passkeys.ts (rpId here, and origin/rpId again in signupPasskey). Export it and reuse it, so sign-in and sign-up cannot drift apart on which relying party they use (the README promises FORGE_URL too).

72 const denied = guard(ctx);
73 if (denied) return denied;
outdated

The user handle is a random value that is thrown away. With residentKey: 'preferred' the authenticator stores it as the account's handle. Sign-in works today because it looks the key up by credential id, but this handle never matches the account. Low priority: use the new user's id, or a handle stored with the passkey.

74 const data = await boundedBody(ctx.request, STRANGER_MAX);
75 if (data === "too_large") return refuse(413, "payload too large");
76 const username = str(data.username).trim();
77 const email = str(data.email).trim();
78 const bad = problem(ctx, username, email);
79 if (bad) return refuse(422, bad);
80 const challenge = str(data.challenge);
81 if (!store(ctx.app).signup.consume(challenge))
82 return refuse(400, "That request expired. Try again.");
83 const made = await verifyRegistration(
84 { clientDataJSON: str(data.clientDataJSON), attestationObject: str(data.attestationObject) },
85 { challenge, ...relying(ctx) },
86 );
87 if (!made.ok) return refuse(400, "That passkey was not accepted.");
88 const created = await signUpWithPasskey(
89 ctx.app,
90 { username, email, passkey: made, passkeyName: "Passkey", humanToken: str(data.humanToken) },
91 { address: clientAddress(ctx.request) },
92 );
93 if (!created.ok)
94 return refuse(created.error === "human_check" ? 422 : 409, WORDS[created.error] ?? "Sign-up did not work. Try again.");
95 const session = createSession(ctx.app.db, created.value.id);
96 return json(
97 { ok: true, next: safeNext(ctx.url.searchParams.get("next")) ?? "/" },
98 201,
99 { "set-cookie": sessionCookie(session.token, session.expiresAt, ctx.url, ctx.app.config.url) },
100 );
101 }
src/web/pages/site.ts +35 −15
@@ -15,7 +15,7 @@ import { clientAddress } from "../../http/peer.ts";
1515 import { verifyEmail } from "../../actions/verify.ts";
1616 import { STRANGER_MAX } from "../../http/limits.ts";
1717 import { createRepo } from "../../actions/repos.ts";
18 import { HUMAN_CHECK_MESSAGE, signUp } from "../../actions/users.ts";
18 import { HUMAN_CHECK_MESSAGE, PASSKEY_REQUIRED_MESSAGE, signUp } from "../../actions/users.ts";
1919 import { listAssignedTo, type Issue } from "../../core/issues.ts";
2020 import { joinMany } from "../../core/pulls.ts";
2121 import { conflictsOf } from "../../crew/stale_asks.ts";
@@ -379,6 +379,10 @@ function authForm(
379379 </div>`,
380380 );
381381
382 // No door makes a password-only account: sign-up is a name, an address and
383 // a passkey, made in the browser (`signup-passkey.ts`).
384 const byPasskey = kind === "signup" && ctx.app.config.passkeyOnlySignup === true;
385
382386 const body = html`${throughEdge}
383387 <form method="post" action="${action}">
384388 ${formField({
@@ -407,22 +411,33 @@ function authForm(
407411 ...(errors.email ? { error: errors.email } : {}),
408412 }),
409413 )}
410 ${formField({
411 label: "Password",
412 name: "password",
413 type: "password",
414 autocomplete: kind === "signin" ? "current-password" : "new-password",
415 required: true,
416 ...(kind === "signup"
417 ? { minlength: 10, hint: "At least 10 characters." }
418 : {}),
419 ...(errors.password ? { error: errors.password } : {}),
420 })}
414 ${when(!byPasskey, () =>
415 formField({
416 label: "Password",
417 name: "password",
418 type: "password",
419 autocomplete: kind === "signin" ? "current-password" : "new-password",
420 required: true,
421 ...(kind === "signup"
422 ? { minlength: 10, hint: "At least 10 characters." }
423 : {}),
424 ...(errors.password ? { error: errors.password } : {}),
425 }),
426 )}
421427 ${when(
422428 kind === "signup" ? siteKey(ctx) : undefined,
423429 (key) =>
424430 html`<div class="cf-turnstile mb" data-sitekey="${key}"></div>`,
425431 )}
432 ${when(
433 byPasskey,
434 () => html`<p class="muted small mb">
435 A passkey signs you in with a fingerprint, face or device PIN; there
436 is no password to remember.
437 </p>
438 <p class="error-note mb" id="passkey-signup-note" role="alert">${errors.password ?? ""}</p>
439 <noscript><p class="muted small">Signing up needs JavaScript for the passkey.</p></noscript>`,
440 )}
426441 ${when(
427442 errors.human,
428443 (message) => html`<p class="error mb" role="alert">${message}</p>`,
@@ -436,9 +451,11 @@ function authForm(
436451 </p>`,
437452 )}
438453 <div class="form-actions">
439 ${btn(kind === "signin" ? "Sign in" : "Create account", {
440 variant: "primary",
441 })}
454 ${byPasskey
455 ? html`<button type="button" id="passkey-signup" class="btn" data-variant="primary">Create account with a passkey</button>`
456 : btn(kind === "signin" ? "Sign in" : "Create account", {
457 variant: "primary",
458 })}
442459 <span class="spacer"></span>
443460 ${kind === "signin"
444461 ? btn("Need an account? Sign up", {
@@ -497,6 +514,7 @@ export function signupPage(ctx: Ctx): Response {
497514 title: "Sign up",
498515 body: authForm(ctx, "signup"),
499516 turnstile: siteKey(ctx) !== undefined,
517 ...(ctx.app.config.passkeyOnlySignup === true ? { scriptJs: passkeyJs } : {}),
500518 });
501519 }
502520
@@ -628,6 +646,7 @@ export async function signupSubmit(ctx: Ctx): Promise<Response> {
628646 email_taken: { email: "That email already has an account." },
629647 weak_password: { password: "Passwords need at least 10 characters." },
630648 human_check: { human: HUMAN_CHECK_MESSAGE },
649 passkey_required: { human: PASSKEY_REQUIRED_MESSAGE },
631650 }[created.error] ?? { username: "Something went wrong." };
632651 return page(
633652 {
@@ -635,6 +654,7 @@ export async function signupSubmit(ctx: Ctx): Promise<Response> {
635654 title: "Sign up",
636655 body: authForm(ctx, "signup", { values: { username, email }, errors }),
637656 turnstile: siteKey(ctx) !== undefined,
657 ...(ctx.app.config.passkeyOnlySignup === true ? { scriptJs: passkeyJs } : {}),
638658 },
639659 422,
640660 );
src/web/passkey-client.ts +24
@@ -72,6 +72,30 @@ export const passkeyJs = /* js */ `
7272 });
7373 }).then(function () { location.reload(); });
7474 });
75 wire('passkey-signup', function () {
76 function val(n) { var el = doc.querySelector('[name=' + n + ']'); return el ? el.value : ''; }
77 var who = { username: val('username'), email: val('email') };
78 return post('/signup/passkey/options', who).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 authenticatorSelection: { residentKey: 'preferred', userVerification: 'required' },
85 attestation: 'none',
86 timeout: 120000
87 } }).then(function (cred) {
88 return post('/signup/passkey' + location.search, {
89 username: who.username,
90 email: who.email,
91 humanToken: val('cf-turnstile-response'),
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-signin', function () {
76100 return post('/signin/passkey/options').then(function (o) {
77101 return navigator.credentials.get({ publicKey: {
test/signup-passkey.test.ts added +249
@@ -0,0 +1,249 @@
1 /**
2 * Sign-up takes a passkey and an email, and no door makes a password-only
3 * account: the web form, the API and the terminal each refuse in plain words,
4 * an account signed up with a passkey has its email and its key, and an
5 * account that already has a password still signs in.
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 { signUp } from "../src/actions/users.ts";
14 import { createUser } from "../src/core/users.ts";
15 import { errorText } from "../src/tui/screens/common.ts";
16 import { serve, type Servers } from "../src/http/server.ts";
17
18 const te = new TextEncoder();
19 const sha = async (d: Uint8Array<ArrayBuffer>) => new Uint8Array(await crypto.subtle.digest("SHA-256", d));
20 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; };
21 function head(major: number, n: number): Uint8Array {
22 if (n < 24) return Uint8Array.of((major << 5) | n);
23 if (n < 256) return Uint8Array.of((major << 5) | 24, n);
24 return Uint8Array.of((major << 5) | 25, n >> 8, n & 255);
25 }
26 type V = number | string | Uint8Array | [V, V][];
27 function enc(v: V): Uint8Array {
28 if (typeof v === "number") return v >= 0 ? head(0, v) : head(1, -1 - v);
29 if (typeof v === "string") { const b = te.encode(v); return cat(head(3, b.length), b); }
30 if (v instanceof Uint8Array) return cat(head(2, v.length), v);
31 return cat(head(5, v.length), ...v.flatMap(([k, x]) => [enc(k), enc(x)]));
32 }
33 function rawToDer(raw: Uint8Array): Uint8Array<ArrayBuffer> {
34 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); };
35 const body = cat(int(raw.subarray(0, 32)), int(raw.subarray(32)));
36 return cat(Uint8Array.of(0x30, body.length), body);
37 }
38
39 async function device(origin: string, rp: string) {
40 const pair = (await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, ["sign"])) as CryptoKeyPair;
41 const jwk = await crypto.subtle.exportKey("jwk", pair.publicKey);
42 const unb = (s: string) => new Uint8Array(Buffer.from(s, "base64url"));
43 const cose: [number, V][] = [[1, 2], [3, -7], [-1, 1], [-2, unb(jwk.x!)], [-3, unb(jwk.y!)]];
44 const id = crypto.getRandomValues(new Uint8Array(32));
45 let count = 0;
46 const cd = (type: string, challenge: string) => te.encode(JSON.stringify({ type, challenge, origin }));
47 const counter = (n: number) => Uint8Array.of(n >>> 24, (n >>> 16) & 255, (n >>> 8) & 255, n & 255);
48 return {
49 credId: b64u(id),
50 async create(challenge: string) {
51 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));
52 return { clientDataJSON: b64u(cd("webauthn.create", challenge)), attestationObject: b64u(enc([["fmt", "none"], ["attStmt", []], ["authData", authData]])) };
53 },
54 async get(challenge: string) {
55 const clientData = cd("webauthn.get", challenge);
56 const authData = cat(await sha(te.encode(rp)), Uint8Array.of(0x05), counter(++count));
57 const sig = rawToDer(new Uint8Array(await crypto.subtle.sign({ name: "ECDSA", hash: "SHA-256" }, pair.privateKey, cat(authData, await sha(clientData)))));
58 return { id: b64u(id), clientDataJSON: b64u(clientData), authenticatorData: b64u(authData), signature: b64u(sig) };
59 },
60 };
61 }
62
63 describe("passkey sign-up", () => {
64 let dataDir: string;
65 let app: App;
66 let servers: Servers;
67 let base: string;
68 const origin = () => new URL(base).origin;
69 const rp = () => new URL(base).hostname;
70
71 const post = (path: string, body: unknown) =>
72 fetch(`${base}${path}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), redirect: "manual" });
73
74 async function signUpWith(d: Awaited<ReturnType<typeof device>>, username: string, email: string) {
75 const o = (await (await post("/signup/passkey/options", { username, email })).json()) as any;
76 return post("/signup/passkey", { username, email, challenge: o.challenge, ...(await d.create(o.challenge)) });
77 }
78
79 beforeAll(async () => {
80 dataDir = await mkdtemp(join(tmpdir(), "forge-signup-passkey-"));
81 app = createApp({ dataDir, port: 0, passkeyOnlySignup: true });
82 servers = await serve(app);
83 base = servers.url;
84 await createUser(app.db, { username: "olive", email: "[email protected]", password: "olive-password-1", trusted: true });
85 });
86
87 afterAll(async () => {
88 servers.stop();
89 app.close();
90 await rm(dataDir, { recursive: true, force: true });
91 });
92
93 test("a passkey and an email make the account, its key and a session", async () => {
94 const d = await device(origin(), rp());
95 const res = await signUpWith(d, "nova", "[email protected]");
96 expect(res.status).toBe(201);
97 const set = res.headers.get("set-cookie")!;
98 expect(set).toContain("forge_session=");
99 expect(set).toContain("HttpOnly");
100 const user = app.db.query<{ id: string; email: string; password_hash: string }, []>("SELECT * FROM user WHERE username = 'nova'").get()!;
101 expect(user.email).toBe("[email protected]");
102 expect(user.password_hash).toBe("");
103 const key = app.db.query<{ credential_id: string }, [string]>("SELECT credential_id FROM passkey WHERE user_id = ?1").all(user.id);
104 expect(key.map((k) => k.credential_id)).toEqual([d.credId]);
105 const mine = await fetch(`${base}/settings/passkeys`, { headers: { cookie: set.split(";")[0]! } });
106 expect(mine.status).toBe(200);
107 // And the key signs back in.
108 const o = (await (await post("/signin/passkey/options", {})).json()) as any;
109 const back = await post("/signin/passkey", { challenge: o.challenge, ...(await d.get(o.challenge)) });
110 expect(back.status).toBe(200);
111 });
112
113 test("a credential already registered makes no second account", async () => {
114 const d = await device(origin(), rp());
115 expect((await signUpWith(d, "reuse1", "[email protected]")).status).toBe(201);
116 const before = app.db.query<{ n: number }, []>("SELECT count(*) AS n FROM user").get()!.n;
117 const again = await signUpWith(d, "reuse2", "[email protected]");
118 expect(again.status).toBe(409);
119 expect(again.headers.get("set-cookie")).toBeNull();
120 expect(((await again.json()) as any).message).toContain("already registered");
121 expect(app.db.query<{ n: number }, []>("SELECT count(*) AS n FROM user").get()!.n).toBe(before);
122 expect(app.db.query("SELECT 1 FROM user WHERE username = 'reuse2'").get()).toBeNull();
123 });
124
125 test("?next= lands on a safe path; an off-site one falls back to /", async () => {
126 for (const [i, [qs, want]] of ([["?next=/settings", "/settings"], ["?next=https://evil.example/", "/"], ["?next=//evil.example", "/"]] as const).entries()) {
127 const d = await device(origin(), rp());
128 const name = `nexty${i}`;
129 const o = (await (await post("/signup/passkey/options", { username: name, email: `${name}@f.dev` })).json()) as any;
130 const res = await post(`/signup/passkey${qs}`, { username: name, email: `${name}@f.dev`, challenge: o.challenge, ...(await d.create(o.challenge)) });
131 expect(res.status).toBe(201);
132 expect(((await res.json()) as any).next).toBe(want);
133 }
134 });
135
136 test("a bad name, a bad address, a taken name or a replayed challenge makes nothing", async () => {
137 const bad = await post("/signup/passkey/options", { username: "x y", email: "[email protected]" });
138 expect(bad.status).toBe(422);
139 expect(((await bad.json()) as any).message).toContain("Usernames");
140 const mail = await post("/signup/passkey/options", { username: "zed", email: "not-an-address" });
141 expect(mail.status).toBe(422);
142 const taken = await post("/signup/passkey/options", { username: "olive", email: "[email protected]" });
143 expect(taken.status).toBe(422);
144 const d = await device(origin(), rp());
145 const o = (await (await post("/signup/passkey/options", { username: "zed", email: "[email protected]" })).json()) as any;
146 const made = await d.create(o.challenge);
147 expect((await post("/signup/passkey", { username: "zed", email: "[email protected]", challenge: o.challenge, ...made })).status).toBe(201);
148 const replay = await post("/signup/passkey", { username: "zed2", email: "[email protected]", challenge: o.challenge, ...made });
149 expect(replay.status).toBe(400);
150 expect(app.db.query("SELECT 1 FROM user WHERE username = 'zed2'").get()).toBeNull();
151 const cross = await fetch(`${base}/signup/passkey/options`, { method: "POST", headers: { "content-type": "application/json", origin: "https://evil.example" }, body: "{}" });
152 expect(cross.status).toBe(403);
153 });
154
155 test("a password-only sign-up is refused on the web form, the API and the terminal", async () => {
156 const before = app.db.query<{ n: number }, []>("SELECT count(*) AS n FROM user").get()!.n;
157
158 const form = await fetch(`${base}/signup`, {
159 method: "POST",
160 body: new URLSearchParams({ username: "webby", email: "[email protected]", password: "webby-password-1" }),
161 redirect: "manual",
162 });
163 expect(form.status).toBe(422);
164 expect(form.headers.get("set-cookie")).toBeNull();
165 const html = await form.text();
166 expect(html).toContain("sign up with a passkey and an email address");
167 expect(html).not.toContain('name="password"');
168
169 const api = await post("/api/v1/users", { username: "apiy", email: "[email protected]", password: "apiy-password-1" });
170 expect(api.status).toBe(422);
171 const apiBody = (await api.json()) as any;
172 expect(apiBody.error).toBe("passkey_required");
173 expect(apiBody.message ?? JSON.stringify(apiBody)).toContain("passkey");
174
175 // The terminal calls this action, bootstrap door included.
176 for (const door of [undefined, "terminal" as const]) {
177 const t = await signUp(app, { username: "termy", email: "[email protected]", password: "termy-password-1" }, door ? { door } : {});
178 expect(t.ok).toBe(false);
179 if (!t.ok) {
180 expect(t.error).toBe("passkey_required");
181 expect(errorText(t.error)).toContain("passkey");
182 }
183 }
184 expect(app.db.query<{ n: number }, []>("SELECT count(*) AS n FROM user").get()!.n).toBe(before);
185 });
186
187 test("an existing password account still signs in, and is invited to add a passkey", async () => {
188 const res = await post("/api/v1/sessions", { username: "olive", password: "olive-password-1" });
189 expect(res.status).toBe(201);
190 const form = await fetch(`${base}/signin`, {
191 method: "POST",
192 body: new URLSearchParams({ username: "olive", password: "olive-password-1" }),
193 redirect: "manual",
194 });
195 const cookie = (form.headers.get("set-cookie") ?? "").split(";")[0]!;
196 expect(cookie).toContain("forge_session=");
197 const page = await (await fetch(`${base}/settings/passkeys`, { headers: { cookie } })).text();
198 expect(page).toContain("Your password still works; add a passkey now");
199 });
200 });
201
202 describe("passkey sign-up and the bot check", () => {
203 let dataDir: string;
204 let app: App;
205 let servers: Servers;
206 let base: string;
207 let human = false;
208
209 const post = (path: string, body: unknown) =>
210 fetch(`${base}${path}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), redirect: "manual" });
211 async function attempt(username: string) {
212 const d = await device(new URL(base).origin, new URL(base).hostname);
213 const email = `${username}@f.dev`;
214 const o = (await (await post("/signup/passkey/options", { username, email })).json()) as any;
215 return post("/signup/passkey", { username, email, humanToken: "t", challenge: o.challenge, ...(await d.create(o.challenge)) });
216 }
217
218 beforeAll(async () => {
219 dataDir = await mkdtemp(join(tmpdir(), "forge-signup-human-"));
220 app = createApp({
221 dataDir,
222 port: 0,
223 passkeyOnlySignup: true,
224 turnstileSecret: "secret",
225 turnstileSiteKey: "site",
226 verifyHuman: async () => human,
227 });
228 servers = await serve(app);
229 base = servers.url;
230 });
231
232 afterAll(async () => {
233 servers.stop();
234 app.close();
235 await rm(dataDir, { recursive: true, force: true });
236 });
237
238 test("a failed human check makes no account; a passed one does", async () => {
239 human = false;
240 const refused = await attempt("robot");
241 expect(refused.status).toBe(422);
242 expect(refused.headers.get("set-cookie")).toBeNull();
243 expect(((await refused.json()) as any).message).toContain("could not tell you are a person");
244 expect(app.db.query("SELECT 1 FROM user WHERE username = 'robot'").get()).toBeNull();
245 human = true;
246 expect((await attempt("person")).status).toBe(201);
247 expect(app.db.query("SELECT 1 FROM user WHERE username = 'person'").get()).not.toBeNull();
248 });
249 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs