Public: anyone can read this; the crew can change it.
The passkey ceremony's sentences live in one module, read by sign-in, sign-up and Settings #5601
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/5582-the-passkey-ceremony-s-sentences-live-in → main 1 commit fork
5 files
changed +52
−13
src/web/pages/passkeys.ts +8 −7
| @@ -36,6 +36,7 @@ import { passkeyNote } from "../passkey-state.ts"; | ||
| 36 | 36 | import { card, empty, confirmAction, time } from "../ui.ts"; |
| 37 | 37 | import { accountShell } from "./account.ts"; |
| 38 | 38 | import { safeNext, sessionCookie, signinAllowed } from "./site.ts"; |
| 39 | import { PASSKEY_EXPIRED, PASSKEY_REJECTED, PASSKEY_TAKEN } from "../passkey-words.ts"; | |
| 39 | 40 | |
| 40 | 41 | /** One-time challenges, one store per ceremony per forge. */ |
| 41 | 42 | const stores = new WeakMap<App, { add: ReturnType<typeof challenges>; signin: ReturnType<typeof challenges>; recover: ReturnType<typeof challenges>; signup: ReturnType<typeof challenges>; claim: ReturnType<typeof challenges>; everyday: ReturnType<typeof challenges>; firstAdmin: ReturnType<typeof challenges> }>(); |
| @@ -135,18 +136,18 @@ export async function passkeyAdd(ctx: Ctx): Promise<Response> { | ||
| 135 | 136 | if (data === "too_large") return refuse(413, "payload too large"); |
| 136 | 137 | const challenge = str(data.challenge); |
| 137 | 138 | if (!store(ctx.app).add.consume(challenge)) |
| 138 | return refuse(400, "That request expired. Try again."); | |
| 139 | return refuse(400, PASSKEY_EXPIRED); | |
| 139 | 140 | const made = await verifyRegistration( |
| 140 | 141 | { clientDataJSON: str(data.clientDataJSON), attestationObject: str(data.attestationObject) }, |
| 141 | 142 | { challenge, ...relying(ctx) }, |
| 142 | 143 | ); |
| 143 | if (!made.ok) return refuse(400, "That passkey was not accepted."); | |
| 144 | if (!made.ok) return refuse(400, PASSKEY_REJECTED); | |
| 144 | 145 | const name = str(data.name).replace(/[\u0000-\u001f\u007f]/g, " ").trim().slice(0, PASSKEY_NAME_MAX) || "Passkey"; |
| 145 | 146 | const added = addPasskey(ctx.app.db, ctx.auth.user!.id, name, made); |
| 146 | 147 | if (!added.ok) |
| 147 | 148 | return refuse( |
| 148 | 149 | 409, |
| 149 | added.error === "too_many" ? "That is as many passkeys as an account keeps." : "That passkey is already registered.", | |
| 150 | added.error === "too_many" ? "That is as many passkeys as an account keeps." : PASSKEY_TAKEN, | |
| 150 | 151 | ); |
| 151 | 152 | return json({ ok: true }); |
| 152 | 153 | } |
| @@ -182,7 +183,7 @@ export async function signinPasskey(ctx: Ctx): Promise<Response> { | ||
| 182 | 183 | const data = await boundedBody(ctx.request, STRANGER_MAX); |
| 183 | 184 | if (data === "too_large") return refuse(413, "payload too large"); |
| 184 | 185 | const challenge = str(data.challenge); |
| 185 | const no = () => refuse(401, "That passkey was not accepted."); | |
| 186 | const no = () => refuse(401, PASSKEY_REJECTED); | |
| 186 | 187 | if (!store(ctx.app).signin.consume(challenge)) return no(); |
| 187 | 188 | const found = findPasskey(ctx.app.db, str(data.id)); |
| 188 | 189 | if (!found) return no(); |
| @@ -315,19 +316,19 @@ export async function recoverFinish(ctx: Ctx): Promise<Response> { | ||
| 315 | 316 | if (data === "too_large") return refuse(413, "payload too large"); |
| 316 | 317 | const token = str(data.token); |
| 317 | 318 | const challenge = str(data.challenge); |
| 318 | if (!store(ctx.app).recover.consume(challenge)) return refuse(400, "That request expired. Try again."); | |
| 319 | if (!store(ctx.app).recover.consume(challenge)) return refuse(400, PASSKEY_EXPIRED); | |
| 319 | 320 | const who = recoveryAccount(ctx.app, token); |
| 320 | 321 | if (who === undefined) return refuse(400, NOT_VALID); |
| 321 | 322 | const made = await verifyRegistration( |
| 322 | 323 | { clientDataJSON: str(data.clientDataJSON), attestationObject: str(data.attestationObject) }, |
| 323 | 324 | { challenge, ...relying(ctx) }, |
| 324 | 325 | ); |
| 325 | if (!made.ok) return refuse(400, "That passkey was not accepted."); | |
| 326 | if (!made.ok) return refuse(400, PASSKEY_REJECTED); | |
| 326 | 327 | // Refuse before spending: a link that cannot add its passkey stays usable. |
| 327 | 328 | const room = passkeyRoom(ctx.app.db, who.id, made.id); |
| 328 | 329 | if (!room.ok && room.error === "too_many") |
| 329 | 330 | return refuse(409, "That is as many passkeys as an account keeps. Ask for a new link after removing one, or sign in another way."); |
| 330 | if (!room.ok) return refuse(409, "That passkey is already registered."); | |
| 331 | if (!room.ok) return refuse(409, PASSKEY_TAKEN); | |
| 331 | 332 | const spent = spendRecoveryLink(ctx.app, token); |
| 332 | 333 | if (!spent.ok) return refuse(400, NOT_VALID); |
| 333 | 334 | const name = str(data.name).replace(/[\u0000-\u001f\u007f]/g, " ").trim().slice(0, PASSKEY_NAME_MAX) || "Passkey"; |
src/web/pages/signup-passkey.ts +4 −3
| @@ -17,6 +17,7 @@ import { clientAddress, clientBucket } from "../../http/peer.ts"; | ||
| 17 | 17 | import { boundedBody, json, str } from "../../http/respond.ts"; |
| 18 | 18 | import { relying, store } from "./passkeys.ts"; |
| 19 | 19 | import { EDGE_NAME_MESSAGE, inviteOf, pinnedTo, safeNext, sessionCookie, signinAllowed, signupSlot } from "./site.ts"; |
| 20 | import { PASSKEY_EXPIRED, PASSKEY_REJECTED, PASSKEY_TAKEN } from "../passkey-words.ts"; | |
| 20 | 21 | |
| 21 | 22 | const refuse = (status: number, message: string) => |
| 22 | 23 | json({ error: "refused", message }, status); |
| @@ -30,7 +31,7 @@ const WORDS: Record<string, string> = { | ||
| 30 | 31 | invalid_email: "That does not look like an email address.", |
| 31 | 32 | edge_name: EDGE_NAME_MESSAGE, |
| 32 | 33 | human_check: HUMAN_CHECK_MESSAGE, |
| 33 | passkey_taken: "That passkey is already registered.", | |
| 34 | passkey_taken: PASSKEY_TAKEN, | |
| 34 | 35 | signup_closed: SIGNUP_CLOSED_MESSAGE, |
| 35 | 36 | }; |
| 36 | 37 | |
| @@ -86,12 +87,12 @@ export async function signupPasskey(ctx: Ctx): Promise<Response> { | ||
| 86 | 87 | if (bad) return refuse(422, bad); |
| 87 | 88 | const challenge = str(data.challenge); |
| 88 | 89 | if (!store(ctx.app).signup.consume(challenge)) |
| 89 | return refuse(400, "That request expired. Try again."); | |
| 90 | return refuse(400, PASSKEY_EXPIRED); | |
| 90 | 91 | const made = await verifyRegistration( |
| 91 | 92 | { clientDataJSON: str(data.clientDataJSON), attestationObject: str(data.attestationObject) }, |
| 92 | 93 | { challenge, ...relying(ctx) }, |
| 93 | 94 | ); |
| 94 | if (!made.ok) return refuse(400, "That passkey was not accepted."); | |
| 95 | if (!made.ok) return refuse(400, PASSKEY_REJECTED); | |
| 95 | 96 | // The slot is taken once the key is proven, before the account is tried: |
| 96 | 97 | // a taken name or a closed door found there still spends it. Cheap |
| 97 | 98 | // validation above has already refused what it can. |
src/web/passkey-ceremony.ts +4 −3
| @@ -17,6 +17,7 @@ import { field, type Form } from "./form.ts"; | ||
| 17 | 17 | import { html, type Html } from "./html.ts"; |
| 18 | 18 | import { clientBucket } from "../http/peer.ts"; |
| 19 | 19 | import { relying, store } from "./pages/passkeys.ts"; |
| 20 | import { PASSKEY_EXPIRED, PASSKEY_REJECTED, PASSKEY_TAKEN } from "./passkey-words.ts"; | |
| 20 | 21 | |
| 21 | 22 | /** |
| 22 | 23 | * Which form is making the account. Each has its own challenge store, so a |
| @@ -46,9 +47,9 @@ export type PasskeyRefusal = "missing" | "expired" | "rejected" | "taken"; | ||
| 46 | 47 | /** The words for each way a posted key is refused, written for anyone. */ |
| 47 | 48 | export const PASSKEY_WORDS: Record<PasskeyRefusal, string> = { |
| 48 | 49 | missing: "This needs a passkey, and a browser with JavaScript on to make one. Passwords are not taken here.", |
| 49 | expired: "That request expired. Try again.", | |
| 50 | rejected: "That passkey was not accepted.", | |
| 51 | taken: "That passkey is already registered.", | |
| 50 | expired: PASSKEY_EXPIRED, | |
| 51 | rejected: PASSKEY_REJECTED, | |
| 52 | taken: PASSKEY_TAKEN, | |
| 52 | 53 | }; |
| 53 | 54 | |
| 54 | 55 | /** |
src/web/passkey-words.ts added +15
| @@ -0,0 +1,15 @@ | ||
| 1 | /** | |
| 2 | * The sentences a passkey ceremony says when it fails, written once for | |
| 3 | * anyone: Settings → Passkeys and sign-in (`pages/passkeys.ts`), sign-up | |
| 4 | * (`pages/signup-passkey.ts`) and the account-making forms | |
| 5 | * (`passkey-ceremony.ts`) all read them from here, so they cannot drift. | |
| 6 | */ | |
| 7 | ||
| 8 | /** The challenge was spent, unknown, or too old. */ | |
| 9 | export const PASSKEY_EXPIRED = "That request expired. Try again."; | |
| 10 | ||
| 11 | /** The browser's answer did not verify. */ | |
| 12 | export const PASSKEY_REJECTED = "That passkey was not accepted."; | |
| 13 | ||
| 14 | /** Another account (or this one) already holds the key. */ | |
| 15 | export const PASSKEY_TAKEN = "That passkey is already registered."; |
test/passkey-words.test.ts added +21
| @@ -0,0 +1,21 @@ | ||
| 1 | import { describe, expect, test } from "bun:test"; | |
| 2 | import { readFileSync } from "node:fs"; | |
| 3 | import { join } from "node:path"; | |
| 4 | import * as words from "../src/web/passkey-words.ts"; | |
| 5 | ||
| 6 | const READERS = ["src/web/pages/passkeys.ts", "src/web/pages/signup-passkey.ts", "src/web/passkey-ceremony.ts"]; | |
| 7 | const sentences = Object.entries(words) as [string, string][]; | |
| 8 | ||
| 9 | describe("passkey ceremony words", () => { | |
| 10 | test("names the three sentences, each non-empty", () => { | |
| 11 | expect(Object.keys(words).sort()).toEqual(["PASSKEY_EXPIRED", "PASSKEY_REJECTED", "PASSKEY_TAKEN"]); | |
| 12 | for (const [, sentence] of sentences) expect(sentence.trim().length).toBeGreaterThan(0); | |
| 13 | }); | |
| 14 | ||
| 15 | for (const file of READERS) | |
| 16 | test(`${file} reads them from the module, typing none out`, () => { | |
| 17 | const source = readFileSync(join(import.meta.dir, "..", file), "utf8"); | |
| 18 | expect(source).toContain("passkey-words.ts"); | |
| 19 | for (const [name, sentence] of sentences) expect({ name, typed: source.includes(sentence) }).toEqual({ name, typed: false }); | |
| 20 | }); | |
| 21 | }); |
new above
offline