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

Merged opened this

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

Merged

mason/forge:mason/5582-the-passkey-ceremony-s-sentences-live-in → main 1 commit fork

  • Merged. mason/5582-the-passkey-ceremony-s-sentences-live-in was merged into main as 2279715.

Review

Sign in to review this change.
5 files changed +52 −13
src/web/pages/passkeys.ts +8 −7
@@ -36,6 +36,7 @@ import { passkeyNote } from "../passkey-state.ts";
3636 import { card, empty, confirmAction, time } from "../ui.ts";
3737 import { accountShell } from "./account.ts";
3838 import { safeNext, sessionCookie, signinAllowed } from "./site.ts";
39 import { PASSKEY_EXPIRED, PASSKEY_REJECTED, PASSKEY_TAKEN } from "../passkey-words.ts";
3940
4041 /** One-time challenges, one store per ceremony per forge. */
4142 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> {
135136 if (data === "too_large") return refuse(413, "payload too large");
136137 const challenge = str(data.challenge);
137138 if (!store(ctx.app).add.consume(challenge))
138 return refuse(400, "That request expired. Try again.");
139 return refuse(400, PASSKEY_EXPIRED);
139140 const made = await verifyRegistration(
140141 { clientDataJSON: str(data.clientDataJSON), attestationObject: str(data.attestationObject) },
141142 { challenge, ...relying(ctx) },
142143 );
143 if (!made.ok) return refuse(400, "That passkey was not accepted.");
144 if (!made.ok) return refuse(400, PASSKEY_REJECTED);
144145 const name = str(data.name).replace(/[\u0000-\u001f\u007f]/g, " ").trim().slice(0, PASSKEY_NAME_MAX) || "Passkey";
145146 const added = addPasskey(ctx.app.db, ctx.auth.user!.id, name, made);
146147 if (!added.ok)
147148 return refuse(
148149 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,
150151 );
151152 return json({ ok: true });
152153 }
@@ -182,7 +183,7 @@ export async function signinPasskey(ctx: Ctx): Promise<Response> {
182183 const data = await boundedBody(ctx.request, STRANGER_MAX);
183184 if (data === "too_large") return refuse(413, "payload too large");
184185 const challenge = str(data.challenge);
185 const no = () => refuse(401, "That passkey was not accepted.");
186 const no = () => refuse(401, PASSKEY_REJECTED);
186187 if (!store(ctx.app).signin.consume(challenge)) return no();
187188 const found = findPasskey(ctx.app.db, str(data.id));
188189 if (!found) return no();
@@ -315,19 +316,19 @@ export async function recoverFinish(ctx: Ctx): Promise<Response> {
315316 if (data === "too_large") return refuse(413, "payload too large");
316317 const token = str(data.token);
317318 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);
319320 const who = recoveryAccount(ctx.app, token);
320321 if (who === undefined) return refuse(400, NOT_VALID);
321322 const made = await verifyRegistration(
322323 { clientDataJSON: str(data.clientDataJSON), attestationObject: str(data.attestationObject) },
323324 { challenge, ...relying(ctx) },
324325 );
325 if (!made.ok) return refuse(400, "That passkey was not accepted.");
326 if (!made.ok) return refuse(400, PASSKEY_REJECTED);
326327 // Refuse before spending: a link that cannot add its passkey stays usable.
327328 const room = passkeyRoom(ctx.app.db, who.id, made.id);
328329 if (!room.ok && room.error === "too_many")
329330 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);
331332 const spent = spendRecoveryLink(ctx.app, token);
332333 if (!spent.ok) return refuse(400, NOT_VALID);
333334 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";
1717 import { boundedBody, json, str } from "../../http/respond.ts";
1818 import { relying, store } from "./passkeys.ts";
1919 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";
2021
2122 const refuse = (status: number, message: string) =>
2223 json({ error: "refused", message }, status);
@@ -30,7 +31,7 @@ const WORDS: Record<string, string> = {
3031 invalid_email: "That does not look like an email address.",
3132 edge_name: EDGE_NAME_MESSAGE,
3233 human_check: HUMAN_CHECK_MESSAGE,
33 passkey_taken: "That passkey is already registered.",
34 passkey_taken: PASSKEY_TAKEN,
3435 signup_closed: SIGNUP_CLOSED_MESSAGE,
3536 };
3637
@@ -86,12 +87,12 @@ export async function signupPasskey(ctx: Ctx): Promise<Response> {
8687 if (bad) return refuse(422, bad);
8788 const challenge = str(data.challenge);
8889 if (!store(ctx.app).signup.consume(challenge))
89 return refuse(400, "That request expired. Try again.");
90 return refuse(400, PASSKEY_EXPIRED);
9091 const made = await verifyRegistration(
9192 { clientDataJSON: str(data.clientDataJSON), attestationObject: str(data.attestationObject) },
9293 { challenge, ...relying(ctx) },
9394 );
94 if (!made.ok) return refuse(400, "That passkey was not accepted.");
95 if (!made.ok) return refuse(400, PASSKEY_REJECTED);
9596 // The slot is taken once the key is proven, before the account is tried:
9697 // a taken name or a closed door found there still spends it. Cheap
9798 // 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";
1717 import { html, type Html } from "./html.ts";
1818 import { clientBucket } from "../http/peer.ts";
1919 import { relying, store } from "./pages/passkeys.ts";
20 import { PASSKEY_EXPIRED, PASSKEY_REJECTED, PASSKEY_TAKEN } from "./passkey-words.ts";
2021
2122 /**
2223 * 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";
4647 /** The words for each way a posted key is refused, written for anyone. */
4748 export const PASSKEY_WORDS: Record<PasskeyRefusal, string> = {
4849 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,
5253 };
5354
5455 /**
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs