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

Passkeys, part 3: the claim link and the everyday account take a passkey, not a password #2024

Merged opened this

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

Merged

torque/forge:torque/1890-passkeys-part-3-the-claim-link-and-the-e → main 7 commits fork 5 threads · 5 unresolved

  • Merged. torque/1890-passkeys-part-3-the-claim-link-and-the-e was merged into main as 8ef3372.

Review

Sign in to review this change.
20 files changed +486 −163
README.md +1 −1
@@ -129,7 +129,7 @@ to `/recover`: give the email on your account and, if it is verified, a link
129129 that lasts 30 minutes and works once registers a new passkey and signs you in.
130130 The relying party is the address the browser used (or
131131 `FORGE_URL`), so a passkey does not follow a forge to another hostname.
132 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`).
132 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. The owner claim of a spawned forge (`/claim`, `web/pages/claim.ts`) and an admin's everyday account (`web/pages/everyday.ts`) make their account with a passkey too (`web/passkey-ceremony.ts`, challenges from `POST /claim/options` and `POST /settings/everyday/options`); neither reads a password, and without JavaScript the page says a passkey needs it. A claim post with no passkey is refused and does not spend the link.
133133
134134 Your SSH key is your identity. A key the forge knows signs you straight in; any
135135 other key browses public repos as a guest and can press `s` to sign in with a
src/http/server.ts +4 −2
@@ -212,8 +212,8 @@ import {
212212 composerSubmit,
213213 tellSubmit,
214214 } from "../web/studio/actions.ts";
215 import { everydayCreate, everydayKeep, everydaySwitch } from "../web/pages/everyday.ts";
216 import { claimFragmentPage, claimSubmit } from "../web/pages/claim.ts";
215 import { everydayCreate, everydayKeep, everydayOptions, everydaySwitch } from "../web/pages/everyday.ts";
216 import { claimFragmentPage, claimOptions, claimSubmit } from "../web/pages/claim.ts";
217217 import { familyPage } from "../web/pages/family.ts";
218218 import { forgeLinkSubmit, forgesPage } from "../web/pages/forges.ts";
219219 import { newForgePage, newForgeSubmit } from "../web/pages/new-forge.ts";
@@ -593,6 +593,7 @@ export function buildRouter(): Router<Handler> {
593593 // A spawned child's claim link: GET shows, POST takes it (spawn/claim.ts).
594594 router.add("GET", "/claim", claimFragmentPage);
595595 router.add("POST", "/claim", claimSubmit);
596 router.add("POST", "/claim/options", claimOptions);
596597 // "+ New forge": the spawn door as a page, any signed-in person (spawn/door.ts).
597598 router.add("GET", "/forges", forgesPage);
598599 router.add("GET", "/family", familyPage);
@@ -691,6 +692,7 @@ export function buildRouter(): Router<Handler> {
691692 router.add("POST", "/settings/password", passwordUpdate);
692693 // The everyday account: an admin's daily account, apart from admin (#596).
693694 router.add("POST", "/settings/everyday", everydayCreate);
695 router.add("POST", "/settings/everyday/options", everydayOptions);
694696 router.add("POST", "/settings/everyday/switch", everydaySwitch);
695697 router.add("POST", "/settings/everyday/keep", everydayKeep);
696698 router.add("GET", "/settings/tokens", tokensPage);
src/web/pages/account.ts +6 −1
@@ -53,6 +53,7 @@ import {
5353 field as uiField,
5454 } from "../ui.ts";
5555 import { everydayLine } from "./everyday.ts";
56 import { passkeyJs } from "../passkey-client.ts";
5657
5758 type Section = "profile" | "password" | "tokens" | "keys" | "passkeys";
5859
@@ -222,11 +223,15 @@ function profileView(ctx: Ctx, values: ProfileForm): Html {
222223 // GET /settings
223224 export function accountPage(ctx: Ctx): Response {
224225 if (!ctx.auth.user) return signInFirst(ctx);
226 const pair = everydayLine(ctx, ctx.auth.user);
225227 return accountShell(
226228 ctx,
227229 "profile",
228230 "Settings",
229 html`${everydayLine(ctx, ctx.auth.user)}${profileView(ctx, currentProfile(ctx.auth.user))}${notifyView(ctx)}`,
231 html`${pair}${profileView(ctx, currentProfile(ctx.auth.user))}${notifyView(ctx)}`,
232 200,
233 // The everyday form makes its account with a passkey (#1890).
234 pair ? passkeyJs : undefined,
230235 );
231236 }
232237
src/web/pages/claim.ts +66 −29
@@ -2,8 +2,9 @@
22 * `/claim`: the page a spawned child's link opens (`spawn/claim.ts`).
33 * The link is `/claim#<token>`, so the secret never leaves the browser in a
44 * request line. The path: the person a forge was made for opens the link they were sent,
5 * reads who will be able to see it, makes their account, and lands in
6 * their forge as its admin. The GET only shows the form — a link unfurled
5 * reads who will be able to see it, makes their account with a passkey
6 * (`passkey-ceremony.ts`; there is no password), and lands in their forge
7 * as its admin. The GET only shows the form — a link unfurled
78 * in a chat app must not spend itself — and the POST, same-origin, makes
89 * the account and burns the link in one step. A link that is used, expired
910 * or made up says which, plainly; a forge that was not spawned has no
@@ -15,7 +16,11 @@
1516 * so before anyone submits, and where to remove it.
1617 */
1718
18 import type { Ctx } from "../../http/context.ts";
19 import { crossOrigin, type Ctx } from "../../http/context.ts";
20 import { STRANGER_MAX } from "../../http/limits.ts";
21 import { clientAddress } from "../../http/peer.ts";
22 import { boundedBody, json, str } from "../../http/respond.ts";
23 import { addPasskey } from "../../core/passkeys.ts";
1924 import {
2025 addTeamMember,
2126 listTeamMembers,
@@ -25,7 +30,7 @@ import {
2530 type Team,
2631 } from "../../core/orgs.ts";
2732 import { createSession } from "../../core/sessions.ts";
28 import { createUser, getUserByName, type User } from "../../core/users.ts";
33 import { createUser, discardFreshUser, getUserByName, type User } from "../../core/users.ts";
2934 import {
3035 burnClaim,
3136 readClaim,
@@ -38,10 +43,11 @@ import { pageHead } from "../chrome.ts";
3843 import { field, readForm } from "../form.ts";
3944 import { html, type Html } from "../html.ts";
4045 import { page, redirect } from "../layout.ts";
46 import { makeOptions, PASSKEY_WORDS, passkeyFields, readPasskey } from "../passkey-ceremony.ts";
47 import { passkeyJs } from "../passkey-client.ts";
4148 import { btn, card, field as formField } from "../ui.ts";
42 import { sessionCookie } from "./site.ts";
49 import { sessionCookie, signinAllowed } from "./site.ts";
4350
44 const MIN_PASSWORD = 10;
4551 const USERNAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,38}$/;
4652
4753 interface Values {
@@ -50,7 +56,7 @@ interface Values {
5056 /** The claim code, from the form's body. */
5157 readonly token?: string;
5258 }
53 type Errors = Partial<Record<"username" | "email" | "password", string>>;
59 type Errors = Partial<Record<"username" | "email" | "passkey", string>>;
5460
5561 function dead(ctx: Ctx, state: Exclude<ClaimState["state"], "ok">): Response {
5662 const says = {
@@ -164,18 +170,15 @@ function claimForm(
164170 required: true,
165171 ...(errors.email ? { error: errors.email } : {}),
166172 })}
167 ${formField({
168 label: "Password",
169 name: "password",
170 type: "password",
171 autocomplete: "new-password",
172 required: true,
173 minlength: MIN_PASSWORD,
174 hint: "At least 10 characters.",
175 ...(errors.password ? { error: errors.password } : {}),
176 })}
173 ${passkeyFields()}
174 <p class="muted small mb">
175 You sign in with a passkey: a fingerprint, face or device PIN.
176 There is no password to remember.
177 </p>
178 <p class="passkey-note mb" id="passkey-make-note" role="alert">${errors.passkey ?? ""}</p>
179 <noscript><p class="muted small">Making the passkey needs JavaScript.</p></noscript>
177180 <div class="form-actions">
178 ${btn("Claim this forge", { variant: "primary" })}
181 <button type="submit" id="passkey-make" class="btn" data-variant="primary" data-options="/claim/options">Claim this forge with a passkey</button>
179182 </div>
180183 </form>`,
181184 )}
@@ -210,10 +213,44 @@ export async function claimFragmentPage(ctx: Ctx): Promise<Response> {
210213 ctx,
211214 title: "This forge is yours",
212215 body: claimForm(ctx, spawned, owners(ctx, spawned).spawner, {}, {}),
213 scriptJs: fragmentJs,
216 scriptJs: fragmentJs + passkeyJs,
214217 });
215218 }
216219
220 /** Whether a name and address are fit for the first admin; else the words. */
221 function problems(ctx: Ctx, username: string, email: string): Errors {
222 const errors: Errors = {};
223 if (!USERNAME.test(username))
224 errors.username = "Usernames are letters, digits, dots, dashes.";
225 else if (getUserByName(ctx.app.db, username))
226 errors.username = "That username is taken.";
227 if (!email.includes("@")) errors.email = "An email address, please.";
228 return errors;
229 }
230
231 // POST /claim/options — a passkey challenge, for a live link and a fit name.
232 // Nothing is spent: the link burns only when the key comes back.
233 export async function claimOptions(ctx: Ctx): Promise<Response> {
234 const refuse = (status: number, message: string) =>
235 json({ error: "refused", message }, status);
236 if (crossOrigin(ctx.request, ctx.url, ctx.app.log)) return refuse(403, "forbidden");
237 const wait = signinAllowed(ctx, `claim:${clientAddress(ctx.request)}`);

The rate limit and the cross-origin refusal on this route have no tests. Add one for a 403 from a foreign Origin and one for a 429 with retry-after once the limit is reached.

238 if (wait !== undefined)
239 return json({ error: "rate_limited", message: `Too many attempts. Try again in ${wait}s.` }, 429, { "retry-after": String(wait) });
240 const data = await boundedBody(ctx.request, STRANGER_MAX);
241 if (data === "too_large") return refuse(413, "payload too large");
242 const spawned = await spawnedOrg(ctx.app);
243 if (spawned === undefined) return refuse(404, "This is not a claim link.");
244 const state = readClaim(ctx.app.db, spawned.org.id, str(data.token)).state;
245 if (state !== "ok")
246 return refuse(state === "unknown" ? 404 : 410, state === "unknown" ? "This is not a claim link." : `This link is ${state === "owned" ? "already claimed" : state}.`);
247 const username = str(data.username).trim();
248 const bad = problems(ctx, username, str(data.email).trim());
249 const first = bad.username ?? bad.email;
250 if (first !== undefined) return refuse(422, first);
251 return json(makeOptions(ctx, "claim", username));
252 }
253
217254 // POST /claim — make the account, burn the link, sign the person in.
218255 export async function claimSubmit(ctx: Ctx): Promise<Response> {
219256 const form = await readForm(ctx);
@@ -233,25 +270,21 @@ export async function claimSubmit(ctx: Ctx): Promise<Response> {
233270 email: field(form, "email"),
234271 token,
235272 };
236 const password = field(form, "password");
237 const errors: Errors = {};
238 if (!USERNAME.test(values.username))
239 errors.username = "Usernames are letters, digits, dots, dashes.";
240 else if (getUserByName(ctx.app.db, values.username))
241 errors.username = "That username is taken.";
242 if (!values.email.includes("@")) errors.email = "An email address, please.";
243 if (password.length < MIN_PASSWORD)
244 errors.password = "Passwords need at least 10 characters.";
245273 const again = (e: Errors) =>
246274 page(
247275 {
248276 ctx,
249277 title: "This forge is yours",
250278 body: claimForm(ctx, spawned, spawner, values, e),
279 scriptJs: passkeyJs,
251280 },
252281 422,
253282 );
283 const errors = problems(ctx, values.username, values.email);
254284 if (Object.keys(errors).length > 0) return again(errors);
285 // The key, or nothing: a password in the form is not read at all.
286 const key = await readPasskey(ctx, "claim", form);
287 if (!key.ok) return again({ passkey: PASSKEY_WORDS[key.error] });
255288
256289 // Spent first, in one statement: of two people on one link, one wins.
257290 const spent = burnClaim(ctx.app.db, orgId, token);
@@ -259,7 +292,6 @@ export async function claimSubmit(ctx: Ctx): Promise<Response> {
259292 const made = await createUser(ctx.app.db, {
260293 username: values.username,
261294 email: values.email,
262 password,
263295 isAdmin: true,
264296 // The one-time claim link the owner handed out is the proof.
265297 trusted: true,
@@ -274,6 +306,11 @@ export async function claimSubmit(ctx: Ctx): Promise<Response> {
274306 : { username: "That username is taken." },
275307 );
276308 }
309 if (!addPasskey(ctx.app.db, made.value.id, "Passkey", key.value).ok) {

This rollback (discard the user, unburn the link) has no test. It is the part that keeps a failed claim from spending the owner's only link, so cover it, together with the taken test on passkey-ceremony.ts.

Low: this rollback (discardFreshUser + unburnClaim) has no test, and the everyday one has none either. It only runs in a race. If it ever regresses, the claim link is spent with no usable owner. A test that registers the credential to another user between readPasskey and here (for example, by stubbing findPasskey once) would pin it down. Not blocking.

310 discardFreshUser(ctx.app.db, made.value.id);
311 unburnClaim(ctx.app.db, orgId, token);
312 return again({ passkey: PASSKEY_WORDS.taken });
313 }
277314 if (team) {
278315 addTeamMember(ctx.app.db, team, made.value.id);
279316 // As the page said: unless the spawner kept access, they leave now.
src/web/pages/everyday.ts +50 −19
@@ -19,17 +19,20 @@ import {
1919 import { spawnedOrg } from "../../spawn/claim.ts";
2020 import { requireVerified } from "../../actions/verify.ts";
2121 import { createSession } from "../../core/sessions.ts";
22 import { createUser, getUserByName, type User } from "../../core/users.ts";
23 import { cookieValue, type Ctx } from "../../http/context.ts";
22 import { createUser, discardFreshUser, getUserByName, type User } from "../../core/users.ts";
23 import { addPasskey } from "../../core/passkeys.ts";
24 import { cookieValue, crossOrigin, type Ctx } from "../../http/context.ts";
25 import { STRANGER_MAX } from "../../http/limits.ts";
26 import { boundedBody, json, str } from "../../http/respond.ts";
2427 import { signInFirst } from "../chrome.ts";
2528 import { field, readForm } from "../form.ts";
2629 import { html, type Html } from "../html.ts";
2730 import { flashTo, redirect } from "../layout.ts";
31 import { makeOptions, PASSKEY_WORDS, passkeyFields, readPasskey } from "../passkey-ceremony.ts";
2832 import { btn, card, field as formField } from "../ui.ts";
2933 import { signOutHere } from "../../actions/signins.ts";
3034 import { sessionCookie } from "./site.ts";
3135
32 const MIN_PASSWORD = 10;
3336 const USERNAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,38}$/;
3437
3538 /** The everyday name offered first: the admin's, with `-me`, if it is free. */
@@ -100,17 +103,15 @@ function everydayFields(ctx: Ctx, user: User, offerKeep: boolean): Html {
100103 autocomplete: "email",
101104 required: true,
102105 })}
103 ${formField({
104 label: "Password",
105 name: "password",
106 type: "password",
107 autocomplete: "new-password",
108 required: true,
109 minlength: MIN_PASSWORD,
110 hint: "At least 10 characters.",
111 })}
106 ${passkeyFields()}
107 <p class="muted small">
108 It signs in with a passkey of its own: a fingerprint, face or
109 device PIN. There is no password.
110 </p>
111 <p class="passkey-note" id="passkey-make-note" role="alert"></p>
112 <noscript><p class="muted small">Making the passkey needs JavaScript.</p></noscript>
112113 <div class="form-actions">
113 ${btn("Make it", { variant: "primary" })}
114 <button type="submit" id="passkey-make" class="btn" data-variant="primary" data-options="/settings/everyday/options">Make it with a passkey</button>
114115 <span class="spacer"></span>
115116 ${offerKeep ? btn("Keep using admin", {
116117 form: "everyday-keep",
@@ -127,6 +128,33 @@ function everydayFields(ctx: Ctx, user: User, offerKeep: boolean): Html {
127128 ></form>`;
128129 }
129130
131 /** Why `username` cannot be the everyday account's name, or undefined. */

Nit: this check and problems() in claim.ts are the same rule, along with the USERNAME regex each file copies. Moving the check next to the ceremony (or into core/users.ts) would keep the two forms from drifting apart.

132 function nameProblem(ctx: Ctx, username: string): string | undefined {
133 if (!USERNAME.test(username)) return "Usernames are letters, digits, dots, dashes.";
134 if (getUserByName(ctx.app.db, username)) return "That username is taken.";
135 return undefined;
136 }
137
138 // POST /settings/everyday/options — a passkey challenge for the everyday
139 // account, for a signed-in admin without one. Nothing is written.
140 export async function everydayOptions(ctx: Ctx): Promise<Response> {
141 const refuse = (status: number, message: string) =>
142 json({ error: "refused", message }, status);
143 if (crossOrigin(ctx.request, ctx.url, ctx.app.log)) return refuse(403, "forbidden");
144 const admin = ctx.auth.user;
145 if (!admin) return refuse(401, "Sign in first.");
146 if (ctx.auth.via !== "session") return refuse(403, "Passkeys are made from a signed-in browser.");
147 if (!admin.isAdmin) return refuse(403, "forbidden");
148 if (everydayOf(ctx.app.db, admin) !== undefined)
149 return refuse(409, "You already have an everyday account.");
150 const data = await boundedBody(ctx.request, STRANGER_MAX);
151 if (data === "too_large") return refuse(413, "payload too large");
152 const username = str(data.username).trim();
153 const bad = nameProblem(ctx, username) ?? (str(data.email).includes("@") ? undefined : "An email address, please.");
154 if (bad !== undefined) return refuse(422, bad);
155 return json(makeOptions(ctx, "everyday", username));
156 }
157
130158 // POST /settings/everyday — make the everyday account, link it, sign in as it.
131159 export async function everydayCreate(ctx: Ctx): Promise<Response> {
132160 const admin = ctx.auth.user;
@@ -141,13 +169,13 @@ export async function everydayCreate(ctx: Ctx): Promise<Response> {
141169 });
142170 const username = field(form, "username");
143171 const email = field(form, "email");
144 const password = field(form, "password");
145172 const say = (message: string) =>
146173 flashTo(ctx, "/", { tone: "danger", message });
147 if (!USERNAME.test(username))
148 return say("Usernames are letters, digits, dots, dashes.");
149 if (password.length < MIN_PASSWORD)
150 return say("Passwords need at least 10 characters.");
174 const bad = nameProblem(ctx, username);
175 if (bad !== undefined) return say(bad);
176 // The key, or nothing: a password in the form is not read at all.
177 const key = await readPasskey(ctx, "everyday", form);
178 if (!key.ok) return say(PASSKEY_WORDS[key.error]);
151179 // The same names a sign-up may not take (a crew member's, a page's,
152180 // another admin's), held where accounts are made; the admin's own handle
153181 // may appear in their everyday name ("ada-me"). A proven admin vouches for their own second account, as a claim
@@ -155,7 +183,6 @@ export async function everydayCreate(ctx: Ctx): Promise<Response> {
155183 const made = await createUser(ctx.app.db, {
156184 username,
157185 email,
158 password,
159186 reservedSelf: admin.id,
160187 trusted: requireVerified(ctx.app, admin).ok,
161188 });
@@ -169,6 +196,10 @@ export async function everydayCreate(ctx: Ctx): Promise<Response> {
169196 ? "That name is taken by the forge itself; pick another."
170197 : "That account could not be made.",
171198 );
199 if (!addPasskey(ctx.app.db, made.value.id, "Passkey", key.value).ok) {
200 discardFreshUser(ctx.app.db, made.value.id);
201 return say(PASSKEY_WORDS.taken);
202 }
172203 linkEveryday(ctx.app.db, admin, made.value);
173204 // On a spawned forge the apps are the client's org's: the everyday
174205 // account writes to it, or it could not open or tell the crew anything.
src/web/pages/passkeys.ts +2 −2
@@ -36,10 +36,10 @@ import { accountShell } from "./account.ts";
3636 import { safeNext, sessionCookie, signinAllowed } from "./site.ts";
3737
3838 /** One-time challenges, one store per ceremony per forge. */
39 const stores = new WeakMap<App, { add: ReturnType<typeof challenges>; signin: ReturnType<typeof challenges>; recover: ReturnType<typeof challenges>; signup: ReturnType<typeof challenges> }>();
39 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> }>();
4040 export function store(app: App) {
4141 let s = stores.get(app);
42 if (!s) stores.set(app, (s = { add: challenges(), signin: challenges(), recover: challenges(), signup: challenges() }));
42 if (!s) stores.set(app, (s = { add: challenges(), signin: challenges(), recover: challenges(), signup: challenges(), claim: challenges(), everyday: challenges() }));
4343 return s;
4444 }
4545
src/web/passkey-ceremony.ts added +75
@@ -0,0 +1,75 @@
1 /**
2 * The server half of a form that makes an account with a passkey — the
3 * claim link (`pages/claim.ts`) and the everyday account
4 * (`pages/everyday.ts`). The page's button asks its options route for a
5 * challenge (`makeOptions`), the browser makes the key, `passkey-client.ts`
6 * writes the answer into the form's hidden fields (`passkeyFields`) and
7 * submits it; the page's POST reads it back (`readPasskey`). A form with no
8 * key in it — a password, or a browser without script — makes nothing.
9 */
10
11 import type { Credential } from "../auth/webauthn.ts";
12 import { newChallenge, verifyRegistration } from "../auth/webauthn.ts";
13 import { findPasskey } from "../core/passkeys.ts";
14 import type { Ctx } from "../http/context.ts";
15 import { field, type Form } from "./form.ts";
16 import { html, type Html } from "./html.ts";
17 import { relying, store } from "./pages/passkeys.ts";
18
19 /**
20 * Which form is making the account. Each has its own challenge store, so a
21 * challenge one options route issued is spent only at that form's POST.
22 */
outdated

Low: claimOptions and everydayOptions share one store, so a challenge from either can be spent at both POSTs. A store per ceremony (claim, everyday) keeps each options route's gate meaningful.

23 export type Ceremony = "claim" | "everyday";
24
25 /** What the browser needs for `navigator.credentials.create`, for `username`. */
26 export function makeOptions(ctx: Ctx, ceremony: Ceremony, username: string) {
27 return {
28 challenge: store(ctx.app)[ceremony].issue(),
29 rpId: relying(ctx).rpId,
30 rpName: "forge",
31 user: { id: newChallenge(), name: username, displayName: username },
32 exclude: [],
33 };
34 }
35
36 /** The hidden fields the client script fills before the form posts. */
37 export const passkeyFields = (): Html =>
38 html`<input type="hidden" name="challenge" value="" />
39 <input type="hidden" name="clientDataJSON" value="" />
40 <input type="hidden" name="attestationObject" value="" />`;
41
42 export type PasskeyRefusal = "missing" | "expired" | "rejected" | "taken";
43
44 /** The words for each way a posted key is refused, written for anyone. */
45 export const PASSKEY_WORDS: Record<PasskeyRefusal, string> = {
46 missing: "This needs a passkey, and a browser with JavaScript on to make one. Passwords are not taken here.",
47 expired: "That request expired. Try again.",
48 rejected: "That passkey was not accepted.",
49 taken: "That passkey is already registered.",
50 };
51
52 /**
53 * The verified key a form carries, or why not. The challenge answers once,
54 * whatever else is wrong; a key another account holds is refused here, so
55 * the caller never makes an account it then cannot give a key.
56 */
57 export async function readPasskey(
58 ctx: Ctx,
59 ceremony: Ceremony,
60 form: Form,
61 ): Promise<{ ok: true; value: Credential } | { ok: false; error: PasskeyRefusal }> {
62 const clientDataJSON = field(form, "clientDataJSON");
63 const attestationObject = field(form, "attestationObject");
64 const challenge = field(form, "challenge");
65 if (clientDataJSON === "" || attestationObject === "" || challenge === "")
66 return { ok: false, error: "missing" };
outdated

High (untested): refusing a credential another account holds matters for security, and no test reaches it. Add a test that claims with a device whose key is already registered. Assert a 422 with this message, no user made, and the link unspent.

67 if (!store(ctx.app)[ceremony].consume(challenge)) return { ok: false, error: "expired" };
68 const made = await verifyRegistration(
69 { clientDataJSON, attestationObject },
70 { challenge, ...relying(ctx) },
71 );
72 if (!made.ok) return { ok: false, error: "rejected" };
73 if (findPasskey(ctx.app.db, made.id)) return { ok: false, error: "taken" };
74 return { ok: true, value: made };
75 }
src/web/passkey-client.ts +26 −2
@@ -1,6 +1,6 @@
11 /**
2 * The browser half of passkeys: two small ceremonies, inlined (nonce'd) on
3 * the two pages that need them. A page without script still works; it just
2 * The browser half of passkeys: small ceremonies, inlined (nonce'd) on the
3 * pages that need them. A page without script still works; it just
44 * has no passkey button. Binary travels as base64url, as the server expects.
55 */
66 export const passkeyJs = /* js */ `
@@ -120,6 +120,30 @@ export const passkeyJs = /* js */ `
120120 });
121121 }).then(function (r) { location.href = r.next; });
122122 });
123 // A form that makes an account (the claim link, the everyday account):
124 // the key goes into its hidden fields and the form posts as it always did.
125 wire('passkey-make', function () {
126 var el = doc.getElementById('passkey-make'), form = el.form;
127 function val(n) { var f = form.elements[n]; return f ? f.value : ''; }
128 if (form.reportValidity && !form.reportValidity()) { el.disabled = false; return Promise.resolve(); }
129 var who = { token: val('token'), username: val('username'), email: val('email') };
130 return post(el.getAttribute('data-options'), who).then(function (o) {
131 return navigator.credentials.create({ publicKey: {
132 challenge: bytes(o.challenge),
133 rp: { id: o.rpId, name: o.rpName },
134 user: { id: bytes(o.user.id), name: o.user.name, displayName: o.user.displayName },
135 pubKeyCredParams: [{ type: 'public-key', alg: -7 }, { type: 'public-key', alg: -257 }],
136 authenticatorSelection: { residentKey: 'preferred', userVerification: 'required' },
137 attestation: 'none',
138 timeout: 120000
139 } }).then(function (cred) {
140 form.elements.challenge.value = o.challenge;
141 form.elements.clientDataJSON.value = text(cred.response.clientDataJSON);
142 form.elements.attestationObject.value = text(cred.response.attestationObject);
143 form.submit();
144 });
145 });
146 });
123147 wire('passkey-signin', function () {
124148 return post('/signin/passkey/options').then(function (o) {
125149 return navigator.credentials.get({ publicKey: {
src/web/studio/home.ts +6 −2
@@ -54,6 +54,7 @@ import { everydayCard } from "../pages/everyday.ts";
5454 import { nextStepsCard, nextStepsOf } from "../pages/next-steps.ts";
5555 import { spawnedOrg } from "../../spawn/claim.ts";
5656 import { clientApps } from "./client_home.ts";
57 import { passkeyJs } from "../passkey-client.ts";
5758
5859 /** The events that can change what is on you. */
5960 const ON_YOU_LIVE =
@@ -212,10 +213,13 @@ export async function studioHome(
212213 ctx,
213214 title: "Home",
214215 body: lean,
216 // The everyday step makes its account with a passkey (#1890).
217 ...(firstSteps ? { scriptJs: passkeyJs } : {}),
215218 });
216219 }
220 const ask = firstSteps ?? everydayCard(ctx, user);
217221 const body = html`<h1 class="sr-only">Home</h1>
218 ${firstSteps ?? everydayCard(ctx, user)} ${address} ${liveLine(ctx)}
222 ${ask} ${address} ${liveLine(ctx)}
219223 ${when(
220224 steps,
221225 () => html`<p class="small muted" id="lean-home">
@@ -250,7 +254,7 @@ export async function studioHome(
250254 ctx,
251255 title: "Home",
252256 body,
253 scriptJs: `${liveClientJs}\n${liveRegionsJs}\n${feedJs}\n${needsYou.scriptJs}`,
257 scriptJs: `${liveClientJs}\n${liveRegionsJs}\n${feedJs}\n${needsYou.scriptJs}${ask ? `\n${passkeyJs}` : ""}`,
254258 });
255259 }
256260
src/web/style/components.ts +2 −1
@@ -130,7 +130,8 @@ fieldset.field > .check + .check { margin-top: 8px; }
130130 .field-row { display: flex; gap: 8px; flex-wrap: wrap; align-items: flex-end; }
131131 .field-row .field { flex: 1; min-width: 160px; margin-bottom: 0; }
132132 .form-actions { margin-top: 16px; display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
133 .error-note {
133 /* A passkey ceremony's note slot looks like an error only once it says one. */
134 .error-note, .passkey-note:not(:empty) {
134135 display: block; color: var(--danger); font-size: 13px; font-weight: 500;
135136 padding: 8px 12px; margin: 0 0 12px;
136137 border: 1px solid color-mix(in srgb, var(--danger) 32%, transparent);
test/client-home.test.ts +3 −4
@@ -25,6 +25,7 @@ import { createForgeApp } from "../src/tui/forge.ts";
2525 import { headlessTerminal } from "../src/tui/term/terminal.ts";
2626 import { quietLogs } from "./helpers/globals.ts";
2727 import { settleApp } from "./helpers/tui.ts";
28 import { passkeyFields } from "./helpers/passkey.ts";
2829
2930 quietLogs();
3031
@@ -136,10 +137,8 @@ describe("a client's first screen", () => {
136137 method: "POST",
137138 headers: { origin: servers.url },
138139 body: new URLSearchParams({
139 token,
140 username: "maria",
141 email: "[email protected]",
142 password: PASSWORD,
140 ...{ token, username: "maria", email: "[email protected]" },
141 ...(await passkeyFields(servers.url, "/claim/options", { token, username: "maria", email: "[email protected]" })),
143142 }),
144143 redirect: "manual",
145144 });
test/client-list.test.ts +4 −4
@@ -22,6 +22,7 @@ import { serve, type Servers } from "../src/http/server.ts";
2222 import { useChildFetch } from "../src/spawn/clients.ts";
2323 import { firstBoot } from "../src/spawn/first-boot.ts";
2424 import { quietLogs } from "./helpers/globals.ts";
25 import { passkeyFields } from "./helpers/passkey.ts";
2526
2627 quietLogs();
2728
@@ -225,14 +226,13 @@ describe("the client list", () => {
225226 });
226227
227228 test("once claimed, the row says so, and the MCP tool answers the same rows", async () => {
229 const who = { token: claimUrl.split("#")[1]!, username: "mara", email: "[email protected]" };
228230 const claimed = await fetch(`${childServers.url}/claim`, {
229231 method: "POST",
230232 headers: { origin: childServers.url },
231233 body: new URLSearchParams({
232 token: claimUrl.split("#")[1]!,
233 username: "mara",
234 email: "[email protected]",
235 password: PASSWORD,
234 ...who,
235 ...(await passkeyFields(childServers.url, "/claim/options", who)),
236236 }),
237237 redirect: "manual",
238238 });
test/everyday.test.ts +42 −17
@@ -1,7 +1,8 @@
11 /**
22 * The everyday account (#596): a forge's admin is asked once, kindly, to
33 * make an account apart from admin for daily work; making it signs them in
4 * as it, keeping admin is recorded; both account pages name the pair. A
4 * as it, keeping admin is recorded; both account pages name the pair. The
5 * everyday account is made with a passkey and never a password (#1890). A
56 * token for an admin is minted only on purpose, and an MCP client holding
67 * one is told it acts as admin.
78 */
@@ -26,9 +27,11 @@ import {
2627 orgAccessByOrg,
2728 } from "../src/core/orgs.ts";
2829 import { createToken } from "../src/core/tokens.ts";
30 import { listPasskeys } from "../src/core/passkeys.ts";
2931 import { createUser, getUserByName } from "../src/core/users.ts";
3032 import { serve, type Servers } from "../src/http/server.ts";
3133 import { quietLogs } from "./helpers/globals.ts";
34 import { passkeyFields } from "./helpers/passkey.ts";
3235
3336 quietLogs();
3437
@@ -66,6 +69,14 @@ describe("the everyday account", () => {
6669 body: new URLSearchParams(form),
6770 redirect: "manual",
6871 });
72 /** What the page does: a passkey from the options route, then the form. */
73 const make = async (cookie: string, form: Record<string, string>) =>
74 post(cookie, "/settings/everyday", {
75 ...form,
76 ...(await passkeyFields(servers.url, "/settings/everyday/options", form, { headers: { cookie } })),
77 });
78 const hasPassword = (username: string) =>
79 (app.db.query("SELECT password_hash AS h FROM user WHERE username = ?1").get(username) as { h: string }).h !== "";
6980
7081 beforeAll(async () => {
7182 dataDir = await mkdtemp(join(tmpdir(), "forge-everyday-"));
@@ -109,14 +120,19 @@ describe("the everyday account", () => {
109120 const refused = await post(kim, "/settings/everyday", {
110121 username: "kim-me",
111122 email: "[email protected]",
112 password: PASSWORD,
113123 });
114124 expect(refused.status).toBe(403);
125 const asked = await fetch(url("/settings/everyday/options"), {
126 method: "POST",
127 headers: { cookie: kim, origin: servers.url, "content-type": "application/json" },
128 body: JSON.stringify({ username: "kim-me", email: "[email protected]" }),
129 });
130 expect(asked.status).toBe(403);
115131 const root = await signIn("ada");
116132 const crossed = await post(
117133 root,
118134 "/settings/everyday",
119 { username: "x-me", email: "[email protected]", password: PASSWORD },
135 { username: "x-me", email: "[email protected]" },
120136 "https://evil.test",
121137 );
122138 expect(crossed.status).toBe(403);
@@ -124,18 +140,24 @@ describe("the everyday account", () => {
124140 expect(getUserByName(app.db, "x-me")).toBeUndefined();
125141 });
126142
127 test("a weak password or a taken name makes nothing", async () => {
143 test("a password and no passkey, or a taken name, makes nothing", async () => {
128144 const root = await signIn("ada");
129 await post(root, "/settings/everyday", {
145 const refused = await post(root, "/settings/everyday", {
130146 username: "ada-me",
131147 email: "[email protected]",
132 password: "short",
133 });
134 await post(root, "/settings/everyday", {
135 username: "kim",
136 email: "[email protected]",
137148 password: PASSWORD,
138149 });
150 expect(refused.status).toBe(303);
151 const flash = (refused.headers.get("set-cookie") ?? "").split(";")[0]!;
152 expect(await page(`${root}; ${flash}`, "/")).toContain("Passwords are not taken here.");
153 // A taken name is refused before any key is made.
154 const asked = await fetch(url("/settings/everyday/options"), {
155 method: "POST",
156 headers: { cookie: root, origin: servers.url, "content-type": "application/json" },
157 body: JSON.stringify({ username: "kim", email: "[email protected]" }),
158 });
159 expect(asked.status).toBe(422);
160 expect(await asked.text()).toContain("That username is taken.");
139161 expect(getUserByName(app.db, "ada-me")).toBeUndefined();
140162 expect(everydayOf(app.db, getUserByName(app.db, "ada")!)).toBeUndefined();
141163 });
@@ -143,10 +165,9 @@ describe("the everyday account", () => {
143165 test("a reserved name makes nothing (#720): a page's, or another admin's; the admin's own stays theirs", async () => {
144166 const root = await signIn("ada");
145167 for (const username of ["settings", "bea-me"]) {
146 const res = await post(root, "/settings/everyday", {
168 const res = await make(root, {
147169 username,
148170 email: `${username}@x.test`,
149 password: PASSWORD,
150171 });
151172 expect(res.status).toBe(303);
152173 expect(getUserByName(app.db, username)).toBeUndefined();
@@ -156,10 +177,12 @@ describe("the everyday account", () => {
156177
157178 test("Make it: an ordinary account, linked both ways, and the person is signed in as it", async () => {
158179 const root = await signIn("ada");
159 const res = await post(root, "/settings/everyday", {
180 const home = await page(root, "/");
181 expect(home).toContain('id="passkey-make"');
182 expect(home).not.toContain('name="password"');
183 const res = await make(root, {
160184 username: "ada-me",
161185 email: "[email protected]",
162 password: PASSWORD,
163186 });
164187 expect(res.status).toBe(303);
165188 expect(res.headers.get("location")).toBe("/");
@@ -167,6 +190,8 @@ describe("the everyday account", () => {
167190 expect(cookie).toContain("forge_session=");
168191 const daily = getUserByName(app.db, "ada-me")!;
169192 expect(daily.isAdmin).toBe(false);
193 expect(listPasskeys(app.db, daily.id)).toHaveLength(1);
194 expect(hasPassword("ada-me")).toBe(false);
170195 const admin = getUserByName(app.db, "ada")!;
171196 expect(everydayOf(app.db, admin)?.username).toBe("ada-me");
172197 expect(adminOf(app.db, daily)?.username).toBe("ada");
@@ -287,7 +312,7 @@ describe("the everyday account", () => {
287312
288313 test("an admin who already has one is shown the way to it: asked again, and on their settings, a sign-in as it", async () => {
289314 const ada = await signIn("ada");
290 const again = await post(ada, "/settings/everyday", { username: "ada-two", email: "[email protected]", password: PASSWORD });
315 const again = await post(ada, "/settings/everyday", { username: "ada-two", email: "[email protected]" });
291316 expect(again.status).toBe(303);
292317 const flash = (again.headers.get("set-cookie") ?? "").split(";")[0]!;
293318 const settings = await page(`${ada}; ${flash}`, again.headers.get("location")!);
@@ -307,7 +332,7 @@ describe("the everyday account", () => {
307332
308333 test("an everyday account may not take a name the forge reserves", async () => {
309334 const boss = await signIn("bea");
310 await post(boss, "/settings/everyday", { username: "settings", email: "[email protected]", password: PASSWORD });
335 await make(boss, { username: "settings", email: "[email protected]" });
311336 expect(getUserByName(app.db, "settings")).toBeUndefined();
312337 });
313338
@@ -351,7 +376,7 @@ describe("the everyday account", () => {
351376 if (!cyd.ok) throw new Error("cyd");
352377 await Bun.write(join(dataDir, "parent.json"), JSON.stringify({ org: "stalls" }));
353378 const admin = await signIn("cyd");
354 const res = await post(admin, "/settings/everyday", { username: "cyd-me", email: "[email protected]", password: PASSWORD });
379 const res = await make(admin, { username: "cyd-me", email: "[email protected]" });
355380 expect(res.status).toBe(303);
356381 expect(res.headers.get("location")).toContain("/stalls#teams");
357382 // No session for the new account: the admin is still the one signed in.
test/first-login.test.ts +7 −7
@@ -26,6 +26,7 @@ import { serve, type Servers } from "../src/http/server.ts";
2626 import { firstBoot } from "../src/spawn/first-boot.ts";
2727 import { hostedPlacement } from "../src/spawn/placement.ts";
2828 import { quietLogs } from "./helpers/globals.ts";
29 import { passkeyFields } from "./helpers/passkey.ts";
2930
3031 quietLogs();
3132
@@ -143,14 +144,13 @@ describe("the first login to a spawned forge", () => {
143144 servers = await serve(child);
144145 const link = spawned.value.claimUrl!;
145146 const token = link.split("/claim#")[1]!;
147 const who = { token, username: "maria", email: "[email protected]" };
146148 const claimed = await fetch(url("/claim"), {
147149 method: "POST",
148150 headers: { origin: servers.url },
149151 body: new URLSearchParams({
150 token,
151 username: "maria",
152 email: "[email protected]",
153 password: PASSWORD,
152 ...who,
153 ...(await passkeyFields(servers.url, "/claim/options", who)),
154154 }),
155155 redirect: "manual",
156156 });
@@ -215,10 +215,10 @@ describe("the first login to a spawned forge", () => {
215215 });
216216
217217 test("the everyday account, made first, joins the client's org and sees the card part-way, every step one it can do", async () => {
218 const who = { username: "maria-me", email: "[email protected]" };
218219 const res = await post(owner, "/settings/everyday", {
219 username: "maria-me",
220 email: "me@bakery.test",
221 password: PASSWORD,
220 ...who,
221 ...(await passkeyFields(servers.url, "/settings/everyday/options", who, { headers: { cookie: owner } })),
222222 });
223223 expect(res.status).toBe(303);
224224 daily = (res.headers.get("set-cookie") ?? "").split(";")[0]!;
test/helpers/passkey.ts added +79
@@ -0,0 +1,79 @@
1 /**
2 * A software authenticator for tests: a P-256 key that answers the creation
3 * and sign-in ceremonies the way a browser's would (attestation "none"), and
4 * `passkeyFields`, which runs a page's options route and returns the hidden
5 * fields `passkey-client.ts` fills before a form that makes an account posts.
6 */
7 import { b64u } from "../../src/auth/webauthn.ts";
8
9 const te = new TextEncoder();
10 const sha = async (d: Uint8Array<ArrayBuffer>) => new Uint8Array(await crypto.subtle.digest("SHA-256", d));
11 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; };
12 function head(major: number, n: number): Uint8Array {
13 if (n < 24) return Uint8Array.of((major << 5) | n);
14 if (n < 256) return Uint8Array.of((major << 5) | 24, n);
15 return Uint8Array.of((major << 5) | 25, n >> 8, n & 255);
16 }
17 type V = number | string | Uint8Array | [V, V][];
18 function enc(v: V): Uint8Array {
19 if (typeof v === "number") return v >= 0 ? head(0, v) : head(1, -1 - v);
20 if (typeof v === "string") { const b = te.encode(v); return cat(head(3, b.length), b); }
21 if (v instanceof Uint8Array) return cat(head(2, v.length), v);
22 return cat(head(5, v.length), ...v.flatMap(([k, x]) => [enc(k), enc(x)]));
23 }
24 function rawToDer(raw: Uint8Array): Uint8Array<ArrayBuffer> {
25 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); };
26 const body = cat(int(raw.subarray(0, 32)), int(raw.subarray(32)));
27 return cat(Uint8Array.of(0x30, body.length), body);
28 }
29
30 export type Device = Awaited<ReturnType<typeof device>>;
31
32 /** An authenticator bound to one origin, e.g. `http://127.0.0.1:1234`. */
33 export async function device(origin: string, rp = new URL(origin).hostname) {
34 const pair = (await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, ["sign"])) as CryptoKeyPair;
35 const jwk = await crypto.subtle.exportKey("jwk", pair.publicKey);
36 const unb = (s: string) => new Uint8Array(Buffer.from(s, "base64url"));
37 const cose: [number, V][] = [[1, 2], [3, -7], [-1, 1], [-2, unb(jwk.x!)], [-3, unb(jwk.y!)]];
38 const id = crypto.getRandomValues(new Uint8Array(32));
39 let count = 0;
40 const cd = (type: string, challenge: string) => te.encode(JSON.stringify({ type, challenge, origin }));
41 const counter = (n: number) => Uint8Array.of(n >>> 24, (n >>> 16) & 255, (n >>> 8) & 255, n & 255);
42 return {
43 credId: b64u(id),
44 async create(challenge: string) {
45 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));
46 return { clientDataJSON: b64u(cd("webauthn.create", challenge)), attestationObject: b64u(enc([["fmt", "none"], ["attStmt", []], ["authData", authData]])) };
47 },
48 async get(challenge: string) {
49 const clientData = cd("webauthn.get", challenge);
50 const authData = cat(await sha(te.encode(rp)), Uint8Array.of(0x05), counter(++count));
51 const sig = rawToDer(new Uint8Array(await crypto.subtle.sign({ name: "ECDSA", hash: "SHA-256" }, pair.privateKey, cat(authData, await sha(clientData)))));
52 return { id: b64u(id), clientDataJSON: b64u(clientData), authenticatorData: b64u(authData), signature: b64u(sig) };
53 },
54 };
55 }
56
57 /**
58 * What the page's button does: ask `base + optionsPath` for a challenge with
59 * `who` (the form's token, username, email), make the key, and return the
60 * hidden fields to post with the form. `headers` carries a session cookie
61 * where the page needs one. Throws when the options route refuses.
62 */
63 export async function passkeyFields(
64 base: string,
65 optionsPath: string,
66 who: Record<string, string>,
67 opts: { headers?: Record<string, string>; device?: Device } = {},
68 ): Promise<{ challenge: string; clientDataJSON: string; attestationObject: string }> {
69 const origin = new URL(base).origin;
70 const res = await fetch(`${base}${optionsPath}`, {
71 method: "POST",
72 headers: { "content-type": "application/json", origin, ...opts.headers },
73 body: JSON.stringify(who),
74 });
75 if (res.status !== 200) throw new Error(`passkey options ${res.status} ${await res.text()}`);
76 const { challenge } = (await res.json()) as { challenge: string };
77 const d = opts.device ?? (await device(origin));
78 return { challenge, ...(await d.create(challenge)) };
79 }
test/signup-passkey.test.ts +1 −46
@@ -9,56 +9,11 @@ import { mkdtemp, rm } from "node:fs/promises";
99 import { tmpdir } from "node:os";
1010 import { join } from "node:path";
1111 import { createApp, type App } from "../src/app.ts";
12 import { b64u } from "../src/auth/webauthn.ts";
1312 import { signUp } from "../src/actions/users.ts";
1413 import { createUser } from "../src/core/users.ts";
1514 import { errorText } from "../src/tui/screens/common.ts";
1615 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 }
16 import { device } from "./helpers/passkey.ts";
6217
6318 describe("passkey sign-up", () => {
6419 let dataDir: string;
test/spawn-child-apps.test.ts +4 −4
@@ -28,6 +28,7 @@ import { firstBoot } from "../src/spawn/first-boot.ts";
2828 import { hostedPlacement } from "../src/spawn/placement.ts";
2929 import { quietLogs } from "./helpers/globals.ts";
3030 import { emailVerified } from "../src/core/verify.ts";
31 import { passkeyFields } from "./helpers/passkey.ts";
3132
3233 quietLogs();
3334
@@ -131,14 +132,13 @@ describe("a spawned child's apps", () => {
131132 await firstBoot(child);
132133 servers = await serve(child);
133134 const token = spawned.value.claimUrl!.split("/claim#")[1]!;
135 const who = { token, username: "maria", email: "[email protected]" };
134136 const claimed = await fetch(`${servers.url}/claim`, {
135137 method: "POST",
136138 headers: { origin: servers.url },
137139 body: new URLSearchParams({
138 token,
139 username: "maria",
140 email: "[email protected]",
141 password: "a-long-bakery-passphrase",
140 ...who,
141 ...(await passkeyFields(servers.url, "/claim/options", who)),
142142 }),
143143 redirect: "manual",
144144 });
test/spawn-first-boot.test.ts +12 −7
@@ -36,6 +36,7 @@ import { firstBoot } from "../src/spawn/first-boot.ts";
3636 import { hostedPlacement } from "../src/spawn/placement.ts";
3737 import type { SpawnSource } from "../src/spawn/plan.ts";
3838 import { quietLogs } from "./helpers/globals.ts";
39 import { passkeyFields } from "./helpers/passkey.ts";
3940
4041 quietLogs();
4142
@@ -65,13 +66,15 @@ describe("a spawned child's first boot", () => {
6566 body: new URLSearchParams({ token: t, ...form }),
6667 redirect: "manual",
6768 });
69 /** What the page does: a passkey from `/claim/options`, then the form. */
70 const claimWithKey = async (t: string, form: Record<string, string>) =>
71 claim(t, { ...form, ...(await passkeyFields(servers.url, "/claim/options", { token: t, ...form })) });
6872 /** The link is still good: nothing claimed it yet. */
6973 const unspent = (t: string) =>
7074 readClaim(child.db, getOrg(child.db, "bakery")!.id, t).state === "ok";
7175 const maria = {
7276 username: "maria",
7377 email: "[email protected]",
74 password: "a-long-bakery-passphrase",
7578 };
7679
7780 beforeAll(async () => {
@@ -293,15 +296,16 @@ describe("a spawned child's first boot", () => {
293296 expect(unspent(token)).toBe(true);
294297 });
295298
296 test("a weak password is refused, and the link is not spent on it", async () => {
297 const res = await claim(token, { ...maria, password: "short" });
299 test("a password and no passkey is refused, and the link is not spent on it", async () => {
300 const res = await claim(token, { ...maria, password: "a-long-bakery-passphrase" });
298301 expect(res.status).toBe(422);
302 expect(await res.text()).toContain("Passwords are not taken here.");
299303 expect(getUserByName(child.db, "maria")).toBeUndefined();
300304 expect(unspent(token)).toBe(true);
301305 });
302306
303307 test("claiming makes the person the child's admin and an owner of its organisation, signed in", async () => {
304 const res = await claim(token, maria);
308 const res = await claimWithKey(token, maria);
305309 expect(res.status).toBe(303);
306310 expect(res.headers.get("location")).toBe("/");
307311 const cookie = res.headers.get("set-cookie") ?? "";
@@ -411,7 +415,7 @@ describe("claims are bound to a spawned forge, and the link is shown once", () =
411415 const res = await fetch(`${servers.url}/claim`, {
412416 method: "POST",
413417 headers: { origin: "https://forge.test" },
414 body: new URLSearchParams({ token: crafted, username: "mallory", email: "[email protected]", password: "a-long-mallory-passphrase" }),
418 body: new URLSearchParams({ token: crafted, username: "mallory", email: "[email protected]" }),
415419 redirect: "manual",
416420 });
417421 expect(res.status).toBe(404);
@@ -449,10 +453,11 @@ describe("claims are bound to a spawned forge, and the link is shown once", () =
449453 try {
450454 const page = (await (await fetch(`${kidServers.url}/claim`)).text()).replace(/\s+/g, " ");
451455 expect(page).toContain("alice keeps owner access to this forge; you can remove it in your organisation");
456 const who = { token, username: "kai-owner", email: "[email protected]" };
452457 const res = await fetch(`${kidServers.url}/claim`, {
453458 method: "POST",
454459 headers: { origin: kidServers.url },
455 body: new URLSearchParams({ token, username: "kai-owner", email: "[email protected]", password: "a-long-kai-passphrase" }),
460 body: new URLSearchParams({ ...who, ...(await passkeyFields(kidServers.url, "/claim/options", who)) }),
456461 redirect: "manual",
457462 });
458463 expect(res.status).toBe(303);
@@ -465,7 +470,7 @@ describe("claims are bound to a spawned forge, and the link is shown once", () =
465470 const crafted = await fetch(`${kidServers.url}/claim`, {
466471 method: "POST",
467472 headers: { origin: kidServers.url },
468 body: new URLSearchParams({ token: other, username: "mallory", email: "[email protected]", password: "a-long-mallory-passphrase" }),
473 body: new URLSearchParams({ token: other, username: "mallory", email: "[email protected]" }),
469474 redirect: "manual",
470475 });
471476 expect(crafted.status).toBe(404);
test/spawn-keys.test.ts +3 −4
@@ -36,6 +36,7 @@ import { loadKey, open } from "../src/kernel/secret.ts";
3636 import { firstBoot } from "../src/spawn/first-boot.ts";
3737 import { loadOrCreateHostKey } from "../src/ssh/keys.ts";
3838 import { quietLogs } from "./helpers/globals.ts";
39 import { passkeyFields } from "./helpers/passkey.ts";
3940
4041 quietLogs();
4142
@@ -391,10 +392,8 @@ describe("a spawned child's own keys and domain", () => {
391392 method: "POST",
392393 headers: { origin: childServers.url },
393394 body: new URLSearchParams({
394 token,
395 username: "maria-owner",
396 email: "[email protected]",
397 password: "a-long-maria-passphrase",
395 ...{ token, username: "maria-owner", email: "[email protected]" },
396 ...(await passkeyFields(childServers.url, "/claim/options", { token, username: "maria-owner", email: "[email protected]" })),
398397 }),
399398 redirect: "manual",
400399 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs