Skip to content

relay: passkeys for accounts, the key as recovery - #328

Open
mishan wants to merge 12 commits into
accountsfrom
passkeys
Open

mishan wants to merge 12 commits into
accountsfrom
passkeys

Conversation

@mishan

@mishan mishan commented Oct 3, 2026 •

Copy link
Copy Markdown
Owner

Stacked on #326.

Passkeys as the way to log in, with the 8-word key as the recovery code. A passkey login issues the same session as a key login, so nothing downstream changes.

Relay (wasm/web/passkeys.mjs, @simplewebauthn/server)

  • Under /api/account/passkey/: register-options / register-verify make the account, its first passkey, its key and a session in one transaction; add-options (session and key) / add-verify (the same session); login-options (discoverable, user verification preferred) / login-verify; list; remove. All behind the account API's JSON, Origin, CORS and rate-limit rules, and absent without CORS_ORIGIN.
  • The relying party is the site, not the relay: PASSKEY_RP_ID (a domain the page's origin is on) and CORS_ORIGIN as the one expected origin, which must be a bare origin (no path or trailing slash), both checked exactly. A mismatch stops the relay at start. Without PASSKEY_RP_ID there are no passkeys.
  • Challenges are signed, not kept: an HMAC (a per-process secret) over purpose, expiry and a nonce — plus the handle for a registration, and the account, session and current key hash for an add — with a used-set until expiry for single use, keyed on the challenge's one canonical base64url spelling. Nothing stored per options request, so asking for challenges evicts nobody's.
  • A credentials table (migration 2). A counter that went back is refused, again in the UPDATE so two logins on one count can't both pass; synced passkeys that report 0 work. A credential id already registered, or not the one the authenticator signed, is refused. Banned and deleted accounts are refused. Transports are kept as known values only.
  • Algorithms pinned to EdDSA, ES256 and RS256; the verify routes take up to 16 KiB.
  • A new key removes every passkey, in the same transaction that ends every session and issues the new one ({ key, session, passkeysRemoved }): whoever had the old key could have added one. An add asked for under the old key is void.

Page

  • Create account: a passkey, then the recovery key in the existing save form; "Create with a key only" stays. Log in with a passkey by button or by autofill (conditional mediation, re-armed before its challenge expires and after a failure). Logged in: the passkeys listed with Add (takes the key) and Remove (logs nobody out — a new key does). After a new key, Add is offered with it filled in, and cleared once used.
  • Passkeys show only once the health line has confirmed accounts and names an RP ID the page's host is on, and the browser supports WebAuthn; otherwise accounts work with keys as before. A passkey's session is kept and sent like a key's, so the rejoin path and "sessions only after accounts are confirmed" cover it unchanged.

Tests

  • accountstest: a software ES256 authenticator in Node: register, log in, replayed, expired and cross-purpose challenges, another origin or RP ID, counter going back and two logins on one count, add needing the key and the same session, the signed-id check, transports, duplicate ids, remove, banned, deleted, a new key removing passkeys, a login still answerable after twelve thousand more challenges, an add held across a key change, one challenge spelled three ways giving one login, and the env config table (a trailing slash and a path refused).
  • jamtest (Chromium's virtual authenticator over CDP): an account made with a passkey and its recovery key; autofill and the button log in; a new key empties the list and Add uses it. Firefox skips this case (no virtual authenticator in Playwright).
  • accountstest, relaytest, jamtest (twice), chatcheck, panecheck, pagetest, browsertest pass; the Docker image builds and CI's image job, whose accounts-on container now also sets PASSKEY_RP_ID and checks the health line's passkeys, passes.

Not covered

  • User verification is asked for, not required: a security key without a PIN, or one whose passkey was made under credProtect level 1, logs in whoever holds it (as the key logs in whoever has it). A passkey login's session outlasts removing that passkey. RELAY.md says both.
  • No browser test of autofill re-arming.

Deploying

  • Add PASSKEY_RP_ID=<site-domain> (optionally PASSKEY_RP_NAME) to the relay's .env beside CORS_ORIGIN, then docker compose pull && docker compose up -d; migration 2 runs on start. curl 127.0.0.1:8787/ should name the RP ID in passkeys; a pair the relay refuses leaves the container restarting, and docker logs thinksynth-relay says why. RELAY.md's runbook has the steps.
  • The RP ID binds every passkey to that domain: changing the site's domain, or the RP ID, orphans them.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Stale add-passkey ceremonies can survive key replacement, and noncanonical origins can pass startup validation.

Review effort: Balanced
Findings: 1 High severity · 1 Medium severity

Open (2)
What changed in this PR

Adds passkey registration and login to relay accounts, retaining eight-word keys for recovery.

Changes:

  • Adds WebAuthn server routes, credential storage, and challenge verification.
  • Adds passkey account UI and browser/authenticator tests.
  • Updates deployment configuration, packaging, and documentation.
File Description
wasm/​web/​passkeys.mjs Implements passkey ceremonies and routes.
wasm/​web/​accounts.mjs Adds credential persistence and key-reset handling.
wasm/​web/​accountui.js Adds passkey account workflows.
wasm/​web/​accountstest.mjs Tests server-side passkey behavior.
wasm/​web/​jamtest.mjs Tests browser passkey workflows.
wasm/​web/​relay.mjs Integrates passkeys into the relay.
wasm/​web/​relaytest.mjs Checks disabled passkey health state.
wasm/​web/​style.css Styles the passkey list.
wasm/​web/​package.json Adds WebAuthn dependencies.
wasm/​web/​package-lock.json Locks new dependencies.
docs/​RELAY.md Documents passkey deployment and behavior.
docs/​JAM.md Updates account capabilities.
docs/​JAM_BACKLOG.md Records passkey account support.
docker/​relay.Dockerfile Packages passkey server code.
docker/​relay.Dockerfile.dockerignore Includes the passkey module.
docker/​compose.yaml Exposes passkey configuration.
.github/​workflows/​ci.yml Updates account-test documentation.
Files not reviewed (1)
  • wasm/web/package-lock.json: Generated file

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread wasm/web/passkeys.mjs Outdated
Comment thread wasm/web/passkeys.mjs Outdated
mishan added 12 commits October 3, 2026 13:38
WebAuthn through @simplewebauthn/server, under /api/account/passkey/.
A passkey logs in to the same s_ session a key does.

- register-options/register-verify make the account, its first passkey
  and its key at once; the key is the recovery code.
- add-options takes the session and the key: a passkey added from a
  borrowed browser would outlast every session the owner ends.
  add-verify must come from the same session.
- login-options is discoverable (no allowCredentials, UV preferred);
  login-verify is on the key-request limit.
- list and remove take a session; removing ends no sessions.
- Challenges are in memory, single use, 5 minutes, bound to their
  purpose and, for add, to the account and session.
- A count that did not go up is refused, checked again as it is written.
  Banned accounts are refused; a deleted account's passkeys go with it.
- A credentials table (migration 2). WebAuthn responses may be up to
  16 KiB; every other body stays capped at 1 KiB.
- PASSKEY_RP_ID names the site's domain and CORS_ORIGIN, which must be
  on it, is the one origin a response is taken from. Unset, there are no
  passkeys, and the health line's `passkeys' is null.

accountstest drives it with a software ES256 authenticator: register,
login, another origin or RP ID, replayed, expired and cross-purpose
challenges, a count that went back, adding, removing, bans and deletes.
Where the relay has passkeys and the page is on their RP ID, Create
account makes a passkey and then shows the key as the recovery key, in
the same password-manager form. "Create with a key only" and the key
login stay. "Log in with a passkey", and the login form's handle field
offers passkeys in autofill where the browser can. Logged in, the
passkeys are listed with when each was added and last used, with Add
(which takes the key) and Remove. A ceremony the person cancels says
nothing.

jamtest: a Chromium page on localhost makes an account with a virtual
authenticator's passkey, logs in through autofill, then with the button
on a reload. Firefox has no virtual authenticator Playwright drives.
Whoever had the old key could have added any of them, so replacing the
key removes them all, in the same transaction, and the response says
how many (`passkeysRemoved'). The dialog says so, and once the new key
is saved the passkey form has it filled in, so Add is one click.

accountstest: after a new key the list is empty and the passkey no
longer logs in. jamtest: the list is empty, and a passkey added with
the filled-in key is listed.
A challenge is now an HMAC, by a key that lives as long as the process,
over what it was issued for: its purpose, when it lapses, a nonce, the
handle and user handle for a registration, and for an add the account
and session it must come back with. Nothing is kept for one until it is
answered, and then only until it lapses, so it is good for one try. No
number of challenges asked for can push out anyone else's, where the
map of outstanding ones evicted the oldest at 10000.

- register-options is on the key limits, so asking whether a handle is
  taken is no cheaper than registering.
- A registration whose id is not the credential id the authenticator
  signed is refused.
- Transports that are not an array no longer fail the request; only
  known ones are kept, once each.

accountstest: an add from another session of the same account is
refused; of two logins with one count at once, one gets in; a login
challenge survives twelve thousand more; the id mismatch; transports
"usb", null and duplicates; register-options past the key limit.
The logged-out screen asks for a fresh autofill offer before its
challenge lapses, shortly after one fails, and once a passkey ceremony
of its own (Create account, Log in with a passkey) is over, which
aborts the offer while it runs.

A key filled in for adding a passkey is cleared once the passkey is
added or the screen changes, and the Passkeys section says removing one
logs nobody out, and that a new key does.
An add's challenge is signed over the account's key hash too, beside
the account and session, so one asked for under the old key cannot add
a passkey once the key is replaced.

A challenge is taken only in its one base64url spelling: Node's decoder
skips padding and stray characters, so `c', `c=' and `c.' were three
tries at one challenge.

RELAY.md: what not requiring user verification lets through, credProtect
level 1 included, and that a passkey's session outlasts the passkey.

accountstest: an add held across a key replacement is refused; the
three spellings give one login.
…sskeys

The first-time steps put PASSKEY_RP_ID in the .env beside CORS_ORIGIN,
check that the health line names it, and say where to look when the
container restarts over a pair the relay refuses. A restore brings back
the passkeys as they were on the day of the backup.

CI's image job runs its accounts container with PASSKEY_RP_ID too, and
checks each health line's `passkeys'.
passkeyConfig checked only CORS_ORIGIN's host, and kept the string
as the origin every response must name: `https://page.example.org/'
would have passed, and refused every passkey, since a browser writes
the origin bare. It now has to be exactly its URL's origin.

accountstest: a trailing slash and a path are refused.
The virtual authenticator answers the autofill offer as soon as the
logged-out screen makes it: the screen lived about 10 ms, and waiting
for its key field, as the check did, missed it on CI and threw. The
check now waits for a session other than the one logged out, with the
logged-in screen back.

A failure in the passkey case now says what the dialog's status line
and the page's log end with.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants