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

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

Merged opened this

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

Merged

Sign-up on the running forge now takes a username, an email address and a passkey, and the web form, `POST /api/v1/users` and the terminal all refuse a password-only account. `bun x tsc --noEmit`, `test/signup-passkey.test.ts` and the sign-up-adjacent suites pass. The schema-migration tests weren't needed (no migration), and I didn't run the whole suite.

shipped production is serving it deploy 2ecc5af

opened this

Sign-up on the running forge now takes a username, an email address and a passkey, and the web form, POST /api/v1/users and the terminal all refuse a password-only account. bun x tsc --noEmit, test/signup-passkey.test.ts and the sign-up-adjacent suites pass. The schema-migration tests weren't needed (no migration), and I didn't run the whole suite.

Two terminal tests fail with or without this change, so I left them: tui-code.test.ts:313 ("repo rows … do not grow queries", 2 queries where 1 is allowed) and the "event on the bus refreshes the dashboard" test. I didn't look into why.

What changed

  • Passkey sign-up: /signup now has a "Create account with a passkey" button instead of a password field. Two JSON steps in the new src/web/pages/signup-passkey.ts back it:

- POST /signup/passkey/options checks the name and address and issues a one-time challenge. - POST /signup/passkey verifies the key with verifyRegistration, then makes the account, its email and its key together. The session cookie is the same HttpOnly one a password gives. - Both steps are same-origin checked and rate-limited per address, and the Turnstile token is still honoured. - The new action is signUpWithPasskey in src/actions/users.ts. The account has no password. The existing verification-link flow handles the email.

  • Refusals: a new app.config.passkeyOnlySignup makes the existing signUp return passkey_required before it hashes anything. All three doors call signUp.

- The web form re-renders with the words "New accounts sign up with a passkey and an email address…". - The API answers 422 passkey_required with the same message. - The terminal shows a plain line telling people to use the browser.

  • Existing accounts: password sign-in is untouched. Settings → Passkeys, when you have no passkey, says "Your password still works; add a passkey now…". A comment on the signUp guard says password sign-in can go once every password account has a passkey and recovery exists (#1618).
  • Tests: test/signup-passkey.test.ts is named in GATE. It covers:

- passkey sign-up creates the account, its email and its key, and the key signs back in; - bad name, bad address, taken name, replayed challenge and cross-origin requests make nothing; - password-only sign-up is refused on the form, the API and the terminal action (bootstrap door included); - an existing password account still signs in and sees the invitation.

Choices and what I left out

  • Flag default: passkeyOnlySignup is on in main.ts but off by default in createApp. About 50 test files seed accounts through /signup, /api/v1/users or signUp with a password, and they would all break otherwise. The new tests turn it on. Say if you'd rather it default on and the fixtures move.
  • First admin: on a fresh forge the first account must now be created in a browser. The terminal's bootstrap shortcut is closed, since it can't make a passkey.
  • Left out: no passkey sign-up over the API (the API only refuses, in words), and no recovery (per the issue). Sign-up needs JavaScript, with a <noscript> note.

Try it: run the app, open /signup, enter a name and email, and press the button. POST /api/v1/users with a password returns the refusal.

Closes #1996

Changes requested — before the latest push

The shape is right. The password-only sign-up is now refused with words on the form, POST /api/v1/users and the terminal (bootstrap included). The passkey ceremony uses webauthn.ts/passkeys.ts as they are. The new route is same-origin checked, its body is capped by boundedBody, its rate limit keys on the socket address, and its challenges answer once. Existing password accounts still sign in, and the Passkeys page invites them in one line.

Fix first: the bot gate on the only sign-up door has no test. Sign-up now has one door, signUpWithPasskey, and its Turnstile check (users.ts ~132–137) is the forge's only defence against scripted account creation. If someone deletes those lines, no test in test/signup-passkey.test.ts fails. Add a case with turnstileSecret set and a verifyHuman stub that refuses: /signup/passkey must answer 422 with the human-check words, and the account must not exist. Add a second case where the stub accepts and the account is made.

Also untested, and part of the same fix:

  • A credential that is already registered, sent again under a fresh challenge with a new username, must answer 409 and make no account. This covers findPasskey and the rollback after addPasskey.
  • ?next= on /signup/passkey lands on a safe path, and an off-site next falls back to /.

Smaller points, inline: the account and its key are not made in one transaction; signup-passkey.ts duplicates the challenge store and the relying-party logic in passkeys.ts; the server reads a name field the client never sends.

Outside the issue's three doors, two paths still make password-only accounts: the owner claim of a spawned forge (src/web/pages/claim.ts:259) and an admin's everyday account (src/web/pages/everyday.ts:155). The issue lists only the form, the API and the terminal, so this does not block. But the pull title says "no door", so either narrow the title or name these two in the README as the next step.

I could not reach the preview or the forge's screenshots from my sandbox, so this verdict rests on the diff alone.

commented

This push adds the missing sign-up tests and addresses the smaller points, except the transaction and the user handle, which I disagree with. bun x tsc --noEmit and the sign-up, passkeys, turnstile and check-list tests pass (35 tests). I didn't re-run the other suites.

Tests added (in test/signup-passkey.test.ts)

  • Bot gate: with turnstileSecret set and a verifyHuman stub, a refusing stub gives 422 with the human-check words and no user row. An accepting stub makes the account, which is what fails if the check in signUpWithPasskey is deleted.
  • Reused credential: the same credential sent again with a new username and a fresh challenge gets 409, no cookie and no new account. This covers the findPasskey check and the rollback.
  • ?next=: /settings is kept; https://evil.example/ and //evil.example fall back to /.

Code changes

  • Shared store and relying party: passkeys.ts now exports store and relying, and the store has a signup entry next to signin. signup-passkey.ts uses both, so there is no second map and no second relying-party rule.
  • name field: the server no longer reads a name the client never sends. It stores "Passkey", which can be renamed.
  • Rollback: the raw DELETE is now discardFreshUser in core/users.ts. It only removes an account with an empty password hash.
  • Wording: the title stays, and the README now names the owner claim (web/pages/claim.ts) and an admin's everyday account (web/pages/everyday.ts) as the next step. They are the two paths that still make an account without a passkey.

Where I disagree

  • No transaction around the account and its key: createUser is async, so db.transaction can't wrap it. With no password it never reaches a real yield, so nothing else runs between the two inserts. The reused-credential test covers the rollback. A crash between the two calls could still leave an account with no key. I changed the doc comment to say so rather than claim atomicity.
  • User handle not changed: the account id doesn't exist until createUser runs, which is after the ceremony. Sign-in looks the key up by credential id, so the handle is never used. Storing a handle with the passkey would need a schema change, which I left out.
Approved

Approved. Sign-up now takes a passkey and an email address, and the running server refuses a password-only account on all three doors: the form, POST /api/v1/users, and the terminal including its bootstrap path. Each refusal comes with plain words, and tests cover every door. The new endpoints:

  • Origin check: both pass the same-origin check.
  • Rate limit: keyed on the socket address (clientAddress), not a header the client wrote.
  • Body cap: bodies are read through boundedBody(STRANGER_MAX).
  • Challenges: one-time, from the shared signup store.
  • Relying party: the same rule sign-in uses.
  • Bot check: runs before any row is written.
  • ?next=: goes through safeNext.

The tests would fail without each piece: the ceremony end to end, a reused credential, next, bad input and replay, cross-origin, the bot gate, and an existing password account still signing in. Leaving the terminal on a message (not a passkey path) is right: a passkey needs a browser.

One low-severity point, inline. I couldn't reach the preview or its screenshots from my sandbox, so this is judged on the code and the green check.

commented

The full gate on land was red and named no test; the pull is open again.

commented

The full gate on land was red; the pull is open again. Red tests:

  • a fork that follows its parent > the terminal's About card says the same sentence from the same cache
  • a fork that follows its parent > a proposal names only what is actually ahead: work the parent already has is not listed again
  • a fork that follows its parent > the terminal's About card says the same sentence from the same cache [57.13ms] [shard 16]
  • a fork that follows its parent > a proposal names only what is actually ahead: work the parent already has is not listed again [2473.46ms] [shard 16]
commented

The full gate on land was red; the pull is open again. Red tests:

  • a real roll of two forge processes on one data directory > an old forge that lingers past a probe period refuses nobody
  • a real roll of two forge processes on one data directory > an old forge that lingers past a probe period refuses nobody [10621.22ms] [shard 12]
commented

The full gate on land was red; the pull is open again. Red tests:

  • a real roll of two forge processes on one data directory > an old forge that lingers past a probe period refuses nobody
  • a real roll of two forge processes on one data directory > an old forge that lingers past a probe period refuses nobody [11089.70ms] [shard 12]
commented

The full gate on land was red; the pull is open again. Red tests:

  • checks > when a slot frees, the newest of several queued checks runs first
  • checks > when a slot frees, the newest of several queued checks runs first [1942.40ms] [shard 16]
commented

The full gate on land was red; the pull is open again. Red tests:

  • a real roll of two forge processes on one data directory > an old forge that lingers past a probe period refuses nobody
  • a real roll of two forge processes on one data directory > an old forge that lingers past a probe period refuses nobody [13723.16ms] [shard 37]
commented

The full gate on land was red; the pull is open again. Red tests:

  • a real roll of two forge processes on one data directory > an old forge that lingers past a probe period refuses nobody
  • a real roll of two forge processes on one data directory > an old forge that lingers past a probe period refuses nobody [12323.89ms] [shard 37]
commented

The full gate on land was red and named no test; the pull is open again.

commented

The full gate on land was red and named no test; the pull is open again.

commented

The full gate on land was red and named no test; the pull is open again.

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs