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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/1996-passkeys-part-2a-sign-up-takes-a-passkey → main 2 commits fork 5 threads · 5 unresolved
README.md +1
| @@ -116,6 +116,7 @@ account; `src/auth/webauthn.ts` verifies, no dependency, `none` attestation). | ||
| 116 | 116 | The sign-in page's "Sign in with a passkey" gives the same HttpOnly session a |
| 117 | 117 | password does. The relying party is the address the browser used (or |
| 118 | 118 | `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`). | |
| 119 | 120 | |
| 120 | 121 | Your SSH key is your identity. A key the forge knows signs you straight in; any |
| 121 | 122 | 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[] = [ | ||
| 49 | 49 | "test/thoughts.test.ts", |
| 50 | 50 | "test/triage.test.ts", |
| 51 | 51 | "test/passkeys.test.ts", |
| 52 | "test/signup-passkey.test.ts", | |
| 52 | 53 | "test/webauthn.test.ts", |
| 53 | 54 | "test/chaos.test.ts", |
| 54 | 55 | "test/flow-stage.test.ts", |
src/actions/users.ts +69 −1
| @@ -5,6 +5,8 @@ | ||
| 5 | 5 | |
| 6 | 6 | import { emailChanged, welcome } from "./verify.ts"; |
| 7 | 7 | import type { App } from "../app.ts"; |
| 8 | import type { Credential } from "../auth/webauthn.ts"; | |
| 9 | import { addPasskey, findPasskey } from "../core/passkeys.ts"; | |
| 8 | 10 | import { |
| 9 | 11 | addKey, |
| 10 | 12 | deleteKey, |
| @@ -23,6 +25,7 @@ import { | ||
| 23 | 25 | changePassword as rehash, |
| 24 | 26 | cleanDisplayName, |
| 25 | 27 | createUser, |
| 28 | discardFreshUser, | |
| 26 | 29 | hasAnyUser, |
| 27 | 30 | updateProfile as writeProfile, |
| 28 | 31 | verifyPassword, |
| @@ -42,7 +45,16 @@ export interface SignUpInput { | ||
| 42 | 45 | humanToken?: string; |
| 43 | 46 | } |
| 44 | 47 | |
| 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 | } | |
| 46 | 58 | |
| 47 | 59 | /** What a person is told when the check fails. */ |
| 48 | 60 | export const HUMAN_CHECK_MESSAGE = |
| @@ -68,6 +80,11 @@ export async function signUp( | ||
| 68 | 80 | door?: "terminal"; |
| 69 | 81 | } = {}, |
| 70 | 82 | ): 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"); | |
| 71 | 88 | const secret = app.config.turnstileSecret; |
| 72 | 89 | const bootstrap = from.door === "terminal" && !hasAnyUser(app.db); |
| 73 | 90 | if (secret !== undefined && !bootstrap) { |
| @@ -93,6 +110,57 @@ export async function signUp( | ||
| 93 | 110 | return created; |
| 94 | 111 | } |
| 95 | 112 | |
| 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 } = {}, | |
| 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 Low. If the process dies between |
||
| 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 | ||
| 96 | 164 | /** |
| 97 | 165 | * Mint a personal access token. Callers decide what credential may mint one |
| 98 | 166 | * (a session or the password, never another token — no privilege |
src/app.ts +8
| @@ -120,6 +120,14 @@ export interface ForgeConfig { | ||
| 120 | 120 | * Undefined means the forge is running without SSH. |
| 121 | 121 | */ |
| 122 | 122 | 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; | |
| 123 | 131 | /** |
| 124 | 132 | * The Turnstile secret (`FORGE_TURNSTILE_SECRET`). Set, signup refuses a |
| 125 | 133 | * 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 { | ||
| 145 | 145 | reservedSelf?: string; |
| 146 | 146 | } |
| 147 | 147 | |
| 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 | ||
| 148 | 157 | export async function createUser( |
| 149 | 158 | db: Database, |
| 150 | 159 | input: CreateUserInput, |
src/http/api.ts +3
| @@ -142,6 +142,7 @@ import { | ||
| 142 | 142 | deleteSshKey, |
| 143 | 143 | deleteToken, |
| 144 | 144 | HUMAN_CHECK_MESSAGE, |
| 145 | PASSKEY_REQUIRED_MESSAGE, | |
| 145 | 146 | signUp, |
| 146 | 147 | updateProfile, |
| 147 | 148 | } from "../actions/users.ts"; |
| @@ -1135,6 +1136,8 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 1135 | 1136 | }, |
| 1136 | 1137 | { address: clientAddress(ctx.request) }, |
| 1137 | 1138 | ); |
| 1139 | if (!created.ok && created.error === "passkey_required") | |
| 1140 | return apiError("passkey_required", 422, PASSKEY_REQUIRED_MESSAGE); | |
| 1138 | 1141 | if (!created.ok && created.error === "human_check") |
| 1139 | 1142 | return apiError("human_check", 422, HUMAN_CHECK_MESSAGE); |
| 1140 | 1143 | if (!created.ok) return json({ error: created.error }, 422); |
src/http/server.ts +3
| @@ -5,6 +5,7 @@ | ||
| 5 | 5 | * verdict. Auth is resolved once per request and handed to handlers as data. |
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | import { signupOptions, signupPasskey } from "../web/pages/signup-passkey.ts"; | |
| 8 | 9 | import { TOOLBAR_NAME, toolbarResponse } from "../apps/toolbar.ts"; |
| 9 | 10 | import { unsealReleased } from "../actions/sealed.ts"; |
| 10 | 11 | import { accessPage } from "../web/pages/access.ts"; |
| @@ -685,6 +686,8 @@ export function buildRouter(): Router<Handler> { | ||
| 685 | 686 | router.add("POST", "/settings/passkeys/:id/delete", passkeyDelete); |
| 686 | 687 | router.add("POST", "/signin/passkey/options", signinOptions); |
| 687 | 688 | router.add("POST", "/signin/passkey", signinPasskey); |
| 689 | router.add("POST", "/signup/passkey/options", signupOptions); | |
| 690 | router.add("POST", "/signup/passkey", signupPasskey); | |
| 688 | 691 | // The crew: a forge admin's section, hidden from everyone else. |
| 689 | 692 | router.add("GET", "/settings/subscriptions", subscriptionsPage); |
| 690 | 693 | router.add("POST", "/settings/subscriptions", subscriptionAdd); |
src/main.ts +2
| @@ -195,6 +195,8 @@ const app = createApp({ | ||
| 195 | 195 | Number(process.env.FORGE_MAIL_DAILY) > 0 |
| 196 | 196 | ? { mailDailyCap: Number(process.env.FORGE_MAIL_DAILY) } |
| 197 | 197 | : {}), |
| 198 | // No door makes a password-only account (#1996). | |
| 199 | passkeyOnlySignup: true, | |
| 198 | 200 | // Signup asks Cloudflare Turnstile when its secret is set. |
| 199 | 201 | ...(turnstileSecret === "" |
| 200 | 202 | ? {} |
src/tui/screens/common.ts +1
| @@ -815,6 +815,7 @@ export function slug(path: string): string { | ||
| 815 | 815 | const MESSAGES: Record<string, string> = { |
| 816 | 816 | not_found: "not found", |
| 817 | 817 | 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", | |
| 818 | 819 | unauthorized: "sign in first", |
| 819 | 820 | forbidden: "you do not have permission", |
| 820 | 821 | 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"; | ||
| 33 | 33 | import { safeNext, sessionCookie, signinAllowed } from "./site.ts"; |
| 34 | 34 | |
| 35 | 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) { | |
| 36 | const stores = new WeakMap<App, { add: ReturnType<typeof challenges>; signin: ReturnType<typeof challenges>; signup: ReturnType<typeof challenges> }>(); | |
| 37 | export function store(app: App) { | |
| 38 | 38 | 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() })); | |
| 40 | 40 | return s; |
| 41 | 41 | } |
| 42 | 42 | |
| 43 | 43 | /** 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) { | |
| 45 | 45 | return { origin: ctx.url.origin, rpId: ctx.url.hostname }; |
| 46 | 46 | } |
| 47 | 47 | |
| @@ -65,7 +65,7 @@ export function passkeysPage(ctx: Ctx): Response { | ||
| 65 | 65 | a password. Add one per device; each can be removed on its own. |
| 66 | 66 | </p> |
| 67 | 67 | ${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." }) | |
| 69 | 69 | : card( |
| 70 | 70 | html`<div class="table-scroll"> |
| 71 | 71 | <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 |
||
| 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 |
||
| 72 | const denied = guard(ctx); | |
| 73 | if (denied) return denied; | |
outdated
The user handle is a random value that is thrown away. With |
||
| 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"; | ||
| 15 | 15 | import { verifyEmail } from "../../actions/verify.ts"; |
| 16 | 16 | import { STRANGER_MAX } from "../../http/limits.ts"; |
| 17 | 17 | 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"; | |
| 19 | 19 | import { listAssignedTo, type Issue } from "../../core/issues.ts"; |
| 20 | 20 | import { joinMany } from "../../core/pulls.ts"; |
| 21 | 21 | import { conflictsOf } from "../../crew/stale_asks.ts"; |
| @@ -379,6 +379,10 @@ function authForm( | ||
| 379 | 379 | </div>`, |
| 380 | 380 | ); |
| 381 | 381 | |
| 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 | ||
| 382 | 386 | const body = html`${throughEdge} |
| 383 | 387 | <form method="post" action="${action}"> |
| 384 | 388 | ${formField({ |
| @@ -407,22 +411,33 @@ function authForm( | ||
| 407 | 411 | ...(errors.email ? { error: errors.email } : {}), |
| 408 | 412 | }), |
| 409 | 413 | )} |
| 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 | )} | |
| 421 | 427 | ${when( |
| 422 | 428 | kind === "signup" ? siteKey(ctx) : undefined, |
| 423 | 429 | (key) => |
| 424 | 430 | html`<div class="cf-turnstile mb" data-sitekey="${key}"></div>`, |
| 425 | 431 | )} |
| 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 | )} | |
| 426 | 441 | ${when( |
| 427 | 442 | errors.human, |
| 428 | 443 | (message) => html`<p class="error mb" role="alert">${message}</p>`, |
| @@ -436,9 +451,11 @@ function authForm( | ||
| 436 | 451 | </p>`, |
| 437 | 452 | )} |
| 438 | 453 | <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 | })} | |
| 442 | 459 | <span class="spacer"></span> |
| 443 | 460 | ${kind === "signin" |
| 444 | 461 | ? btn("Need an account? Sign up", { |
| @@ -497,6 +514,7 @@ export function signupPage(ctx: Ctx): Response { | ||
| 497 | 514 | title: "Sign up", |
| 498 | 515 | body: authForm(ctx, "signup"), |
| 499 | 516 | turnstile: siteKey(ctx) !== undefined, |
| 517 | ...(ctx.app.config.passkeyOnlySignup === true ? { scriptJs: passkeyJs } : {}), | |
| 500 | 518 | }); |
| 501 | 519 | } |
| 502 | 520 | |
| @@ -628,6 +646,7 @@ export async function signupSubmit(ctx: Ctx): Promise<Response> { | ||
| 628 | 646 | email_taken: { email: "That email already has an account." }, |
| 629 | 647 | weak_password: { password: "Passwords need at least 10 characters." }, |
| 630 | 648 | human_check: { human: HUMAN_CHECK_MESSAGE }, |
| 649 | passkey_required: { human: PASSKEY_REQUIRED_MESSAGE }, | |
| 631 | 650 | }[created.error] ?? { username: "Something went wrong." }; |
| 632 | 651 | return page( |
| 633 | 652 | { |
| @@ -635,6 +654,7 @@ export async function signupSubmit(ctx: Ctx): Promise<Response> { | ||
| 635 | 654 | title: "Sign up", |
| 636 | 655 | body: authForm(ctx, "signup", { values: { username, email }, errors }), |
| 637 | 656 | turnstile: siteKey(ctx) !== undefined, |
| 657 | ...(ctx.app.config.passkeyOnlySignup === true ? { scriptJs: passkeyJs } : {}), | |
| 638 | 658 | }, |
| 639 | 659 | 422, |
| 640 | 660 | ); |
src/web/passkey-client.ts +24
| @@ -72,6 +72,30 @@ export const passkeyJs = /* js */ ` | ||
| 72 | 72 | }); |
| 73 | 73 | }).then(function () { location.reload(); }); |
| 74 | 74 | }); |
| 75 | wire('passkey-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 | }); | |
| 75 | 99 | wire('passkey-signin', function () { |
| 76 | 100 | return post('/signin/passkey/options').then(function (o) { |
| 77 | 101 | 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 | }); |
No test covers this check. It is the only bot gate left on sign-up. Add a test in
test/signup-passkey.test.tswithturnstileSecretset and averifyHumanstub that refuses:/signup/passkeymust answer 422 and leave nouserrow.