diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2c34a3f6..be099ac8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -957,7 +957,8 @@ jobs: run: node relaytest.mjs # The relay's accounts over HTTP, on a clock the script turns: keys, - # handles, sessions, the limits and the admin commands. + # handles, sessions, passkeys from a software authenticator, the + # limits and the admin commands. - name: Accounts working-directory: wasm/web run: node accountstest.mjs @@ -1028,7 +1029,8 @@ jobs: # A room joined, not just the health line: that one answers before # a room has been seeded from the image's gen/ and dsp/. # Twice: as it starts with nothing set, and with CORS_ORIGIN, which - # is what turns its accounts on (and opens its database). + # is what turns its accounts on (and opens its database), and + # PASSKEY_RP_ID, its passkeys. - name: It seeds and welcomes a room run: | join() { @@ -1047,10 +1049,11 @@ jobs: } docker run -d --init --name relay -p 127.0.0.1:8787:8787 thinksynth-relay docker run -d --init --name accounts -p 127.0.0.1:8788:8787 \ - -e CORS_ORIGIN=https://pages.example.org thinksynth-relay + -e CORS_ORIGIN=https://pages.example.org \ + -e PASSKEY_RP_ID=pages.example.org thinksynth-relay for i in $(seq 30); do curl -fs 127.0.0.1:8787/ && curl -fs 127.0.0.1:8788/ && break; sleep 1; done - curl -fs 127.0.0.1:8787/ | jq -e '.accounts == false' - curl -fs 127.0.0.1:8788/ | jq -e '.accounts == true' + curl -fs 127.0.0.1:8787/ | jq -e '.accounts == false and .passkeys == null' + curl -fs 127.0.0.1:8788/ | jq -e '.accounts == true and .passkeys == "pages.example.org"' join relay join accounts docker logs relay diff --git a/docker/compose.yaml b/docker/compose.yaml index 97adf7ef..99df2733 100644 --- a/docker/compose.yaml +++ b/docker/compose.yaml @@ -30,6 +30,10 @@ services: # has no accounts: the account API is not there, and a session in # a hello is ignored. CORS_ORIGIN: ${CORS_ORIGIN:-} + # The site's domain, which CORS_ORIGIN is on, for passkeys, and the + # name they are saved under; unset, there are none. + PASSKEY_RP_ID: ${PASSKEY_RP_ID:-} + PASSKEY_RP_NAME: ${PASSKEY_RP_NAME:-} # nginx in front, appending the client's address to X-Forwarded-For, # which the account API's rate limits read. TRUST_PROXY: "1" diff --git a/docker/relay.Dockerfile b/docker/relay.Dockerfile index d4267313..43b884e4 100644 --- a/docker/relay.Dockerfile +++ b/docker/relay.Dockerfile @@ -12,8 +12,8 @@ COPY wasm/web/package.json wasm/web/package-lock.json ./ RUN npm ci --omit=dev --ignore-scripts && npm cache clean --force COPY wasm/web/relay.mjs wasm/web/doc.js wasm/web/commands.js \ - wasm/web/account.js wasm/web/accounts.mjs wasm/web/wordlist.mjs \ - wasm/web/confusables.js ./ + wasm/web/account.js wasm/web/accounts.mjs wasm/web/passkeys.mjs \ + wasm/web/wordlist.mjs wasm/web/confusables.js ./ COPY gen /srv/thinksynth/gen COPY dsp /srv/thinksynth/dsp diff --git a/docker/relay.Dockerfile.dockerignore b/docker/relay.Dockerfile.dockerignore index 131c891c..c1fb6ba0 100644 --- a/docker/relay.Dockerfile.dockerignore +++ b/docker/relay.Dockerfile.dockerignore @@ -8,6 +8,7 @@ !wasm/web/commands.js !wasm/web/account.js !wasm/web/accounts.mjs +!wasm/web/passkeys.mjs !wasm/web/wordlist.mjs !wasm/web/confusables.js !gen diff --git a/docs/JAM.md b/docs/JAM.md index f5252a65..a2df7590 100644 --- a/docs/JAM.md +++ b/docs/JAM.md @@ -395,8 +395,9 @@ Where it stands: the mirror, as the solo page does, so an `osc::sample` instrument sounds in a room. - **Text chat**, a pane on the room page (section 4). -- **Accounts.** A handle nobody else can join as, logged in with an - eight-word key from the relay; guests are marked as guests. The +- **Accounts.** A handle nobody else can join as, logged in with a + passkey or an eight-word key from the relay; guests are marked as + guests. The document socket is let in by a ticket from the room socket. Running it is [RELAY.md](RELAY.md#accounts). - **An invite link**, so a room is joined without typing its name, and diff --git a/docs/JAM_BACKLOG.md b/docs/JAM_BACKLOG.md index b823b21b..9ce6a795 100644 --- a/docs/JAM_BACKLOG.md +++ b/docs/JAM_BACKLOG.md @@ -53,7 +53,9 @@ else can take the handle, or one its owner renamed from in the last 30 days. Guests are still a name per session, marked as guests wherever the room shows names. The document is no longer open to anyone who can reach the relay: its socket needs a short-lived ticket the room socket -hands out. Roles, visibility and moderation within a room are not done. +hands out. An account logs in with a passkey, or with its key, which +is the way back in when a passkey is lost. Roles, visibility and +moderation within a room are not done. ## 1. The headless peer, and load diff --git a/docs/RELAY.md b/docs/RELAY.md index e0359988..3d92d5cc 100644 --- a/docs/RELAY.md +++ b/docs/RELAY.md @@ -54,16 +54,21 @@ served from. 3. Copy `docker/compose.yaml` over, with a `.env` beside it: CORS_ORIGIN=https://pages.example.org + PASSKEY_RP_ID=pages.example.org - (exactly an origin: scheme, host and any port, no path or trailing - slash; the relay will not start on anything else). Then + (`CORS_ORIGIN` exactly an origin: scheme, host and any port, no path + or trailing slash; `PASSKEY_RP_ID` the domain it is on, for passkeys + -- see [Passkeys](#passkeys) before picking it, and leave it out for + none. The relay will not start on anything else.) Then docker compose -f compose.yaml pull docker compose -f compose.yaml up -d The relay listens on `127.0.0.1:8787`, for the proxy only, and - `curl 127.0.0.1:8787/` answers with its protocol, `accounts: true` - and its rooms. + `curl 127.0.0.1:8787/` answers with its protocol, `accounts: true`, + `passkeys` naming the RP ID, and its rooms. A `.env` the relay + refuses leaves the container restarting over and over; + `docker logs thinksynth-relay` says why. 4. Set the repository variable `JAM_RELAY` to `wss://relay.example.org`. The next master build writes it into the Pages site's `config.json`, @@ -113,7 +118,8 @@ Accounts belong to the relay the site's `config.json` names: a page sent to another relay with `?relay=` joins it as a guest and keeps its session to itself. -The image reads three variables, which `compose.yaml` passes on: +The image reads three variables, which `compose.yaml` passes on, and +two more for passkeys (below): - `DB`: the file, `/data/relay.db` in the image. `compose.yaml` mounts the named volume `accounts` on `/data`. Run from the tree, the relay @@ -133,6 +139,47 @@ The image reads three variables, which `compose.yaml` passes on: of `docker/nginx.conf`, which appends it. Set it only behind proxies that do, or a client picks its own address. +### Passkeys + +A passkey logs in to the same session a key does. With passkeys on, the +Account dialog makes an account with a passkey and shows the key once as +the recovery key; one can still be made with a key alone, and the key +always logs in. Adding a passkey takes the key; removing one takes only +a session, and ends none: replacing the key is what logs out every +other browser. Replacing the key removes every passkey the account +has, since whoever had the old key could have added any of them; the +dialog then offers to add one with the new key. + +A passkey asks the authenticator to verify its user (a PIN, a +fingerprint) where it can, but does not require it. So a security key +with no PIN logs in whoever holds it, as the key logs in whoever has it; +and so does one with a PIN whose passkey was made under credProtect +level 1, which lets it answer without the PIN. A session a passkey +logged in to outlasts the passkey: removing it ends none, and replacing +the key is what ends them. + +The relying party is the site the page is served from, not the relay: +WebAuthn binds a passkey to an RP ID, a domain the page's own origin +must be on. Two more variables: + +- `PASSKEY_RP_ID`: that domain -- `pages.example.org` for a page at + `https://pages.example.org`, or `example.org`, which would let the + same passkeys work on the site's other subdomains if they ever took + them. `CORS_ORIGIN` must be on it, and is still the one origin whose + page can log in to this relay with a passkey; the relay will not start + otherwise. Unset, there are no passkeys: the health line's `passkeys` + is null and the page offers none. A page on another host (`127.0.0.1` + for a relay set up for `localhost`, say) offers none either. +- `PASSKEY_RP_NAME`: the name a passkey is saved under; `thinksynth` if + unset. + +Both go in the `.env` beside `compose.yaml` (The first time, above). + +Pick the domain for good. A passkey works only on the RP ID it was made +for, so moving the site to another domain, or changing `PASSKEY_RP_ID`, +orphans every passkey made so far; their owners log in with their keys +and add new ones. + The document socket is let in by a ticket in its query string, good for five minutes. `docker/nginx.conf` logs requests by path alone so that tickets stay out of the access log; keep that `log_format` if the file is @@ -168,7 +215,9 @@ the image): docker compose -f compose.yaml start relay A restore goes back to the day of the backup, for better and worse: -keys replaced since work again and the new ones do not, sessions ended +keys replaced since work again and the new ones do not, passkeys +removed since -- by their owners, or with a replaced key -- log in +again and those added since do not, sessions ended since -- logged out, revoked -- are live again, and an account banned since is not. After one, ban those accounts again (`admin ban`), and ask anyone who replaced a key because it was lost or seen to replace it @@ -185,16 +234,16 @@ The admin commands run against the same file, beside the running relay: docker exec thinksynth-relay node relay.mjs admin revoke docker exec thinksynth-relay node relay.mjs admin delete -A ban ends the account's sessions and refuses its key until an unban; a -banned account cannot delete itself. `revoke` ends the sessions and -leaves the key working. A deleted account's handles -- its own and any it -was renamed from -- stay nobody's for 30 days from the delete, so a -name cannot be taken over to impersonate its owner; `delete ---free` frees them at once. `rename` takes the same handles an owner -could pick, so none starting with `guest-`. The relay looks -at the sessions behind its open rooms once a minute and closes those -that have ended, so a ban or a revoke empties the account out of every -room within the minute. +A ban ends the account's sessions and refuses its key and passkeys until +an unban; a banned account cannot delete itself. `revoke` ends the +sessions and leaves the key and passkeys working. A deleted account's +handles -- its own and any it was renamed from -- stay nobody's for 30 +days from the delete, so a name cannot be taken over to impersonate its +owner; `delete --free` frees them at once. `rename` takes the +same handles an owner could pick, so none starting with `guest-`. The +relay looks at the sessions behind its open rooms once a minute and +closes those that have ended, so a ban or a revoke empties the account +out of every room within the minute. ## Not yet diff --git a/wasm/web/account.js b/wasm/web/account.js index e7af7abc..222b98cb 100644 --- a/wasm/web/account.js +++ b/wasm/web/account.js @@ -195,3 +195,17 @@ export function apiOriginOf (relayUrl) return null; } } + +/* Whether `raw' is an origin and nothing else, as a browser sends one in + Origin and writes one into a WebAuthn response. */ +export function isOrigin (raw) +{ + return URL.canParse(raw) && new URL(raw).origin === raw; +} + +/* Whether a page on `host' may use passkeys for `rpId': WebAuthn takes + an RP ID that is the host or a domain it is under. */ +export function onRpId (host, rpId) +{ + return host === rpId || host.endsWith(`.${rpId}`); +} diff --git a/wasm/web/accounts.mjs b/wasm/web/accounts.mjs index ec16eeb8..55d9fa17 100644 --- a/wasm/web/accounts.mjs +++ b/wasm/web/accounts.mjs @@ -25,9 +25,11 @@ * POST login { key } { session, account } * GET me session { account } * POST handle { handle, key } session { account } - * POST key { key } session { key, session } + * POST key { key } session { key, session, + * passkeysRemoved } * POST logout session {} * POST delete { key, handle } session? {} + * .../passkey/... (passkeys.mjs) * * A session goes as `Authorization: Bearer s_...', never as a cookie, so * no other site's page can send one for its visitor; a failure is `{ @@ -62,6 +64,10 @@ export const HANDLE_KEPT_MS = 30 * DAY_MS; const BODY_MAX_BYTES = 1024; +/* A WebAuthn response: an attestation, with a public key and an + authenticator's own credential id, which may run to a KiB alone. */ +const RESPONSE_MAX_BYTES = 16 * 1024; + /* A session's last use is written at most this often: it only has to be good to the day for a year's lapse, and a write per hello and per minute's check of every open room is a write the disk waits on. */ @@ -128,9 +134,28 @@ const MIGRATIONS = [ account_id INTEGER NOT NULL, until INTEGER NOT NULL ) STRICT;`, + `-- Passkeys (passkeys.mjs). \`id' is the credential id, base64url; + -- \`transports' a JSON array. An account's \`user_handle' is the + -- WebAuthn user id its passkeys are made under, kept past the last of + -- them, so that a passkey made later replaces the ones an + -- authenticator still holds rather than sitting beside them. + ALTER TABLE accounts ADD COLUMN user_handle TEXT; + CREATE TABLE credentials ( + id TEXT PRIMARY KEY, + account_id INTEGER NOT NULL, + public_key BLOB NOT NULL, + counter INTEGER NOT NULL, + transports TEXT NOT NULL, + created_at INTEGER NOT NULL, + last_used_at INTEGER, + label TEXT NOT NULL + ) STRICT; + CREATE INDEX credentials_account ON credentials (account_id);`, ]; const COLUMNS = 'id, handle, handle_folded, created_at, renamed_at, banned'; +const CREDENTIALS = 'SELECT c.*, a.user_handle FROM credentials c ' + + 'JOIN accounts a ON a.id = c.account_id'; /* What is stored of a key or a session: its SHA-256, in hex. */ export function secretHash (secret) @@ -209,6 +234,23 @@ export class AccountStore 'renamed_at = coalesce(?, renamed_at) WHERE id = ?'), setBanned: q('UPDATE accounts SET banned = ? WHERE id = ?'), remove: q('DELETE FROM accounts WHERE id = ?'), + userHandle: q('SELECT user_handle FROM accounts WHERE id = ?'), + claimUserHandle: q('UPDATE accounts SET user_handle = ' + + 'coalesce(user_handle, ?) WHERE id = ?'), + addCredential: q('INSERT OR IGNORE INTO credentials (id, ' + + 'account_id, public_key, counter, ' + + 'transports, created_at, label) ' + + 'VALUES (?, ?, ?, ?, ?, ?, ?)'), + credential: q(`${CREDENTIALS} WHERE c.id = ?`), + credentials: q(`${CREDENTIALS} WHERE c.account_id = ? ` + + 'ORDER BY c.created_at, c.id'), + useCredential: q('UPDATE credentials SET counter = ?, ' + + 'last_used_at = ? WHERE id = ? AND ' + + '(counter < ? OR (counter = 0 AND ? = 0))'), + removeCredential: q('DELETE FROM credentials ' + + 'WHERE id = ? AND account_id = ?'), + removeCredentials: q('DELETE FROM credentials ' + + 'WHERE account_id = ?'), }; this.db.exec(`PRAGMA busy_timeout = ${Number(busyMs)};`); @@ -277,9 +319,10 @@ export class AccountStore this.q.keptBy.get(folded, now)?.account_id ?? null; } - /* A new account and its first session, or null if the handle is - somebody's; under the write lock, so nobody takes it in between. */ - create ({ handle, keyHash, sessionHash, now }) + /* A new account and its first session, and its first passkey if + `credential', or null if the handle is somebody's; under the write + lock, so nobody takes it in between. */ + create ({ handle, keyHash, sessionHash, now, credential = null }) { const folded = foldName(handle); @@ -292,10 +335,60 @@ export class AccountStore now).lastInsertRowid); this.q.addSession.run(sessionHash, id, now); + + if (credential !== null) + { + this.q.claimUserHandle.run(credential.userHandle, id); + + if (!this.addCredential(id, credential)) + throw passkeyTaken(); + } + return this.byId(id); }); } + /* The account's WebAuthn user handle; `fresh' becomes it if the + account has none yet. */ + userHandle (id, fresh) + { + this.q.claimUserHandle.run(fresh, id); + return this.q.userHandle.get(id).user_handle; + } + + /* False if the credential id is taken already: an authenticator picks + it, so it may be anyone's choosing. */ + addCredential (accountId, c) + { + return this.q.addCredential.run( + c.id, accountId, c.publicKey, c.counter, + JSON.stringify(c.transports), c.createdAt, c.label).changes === 1; + } + + credential (id) + { + return credentialOf(this.q.credential.get(id)); + } + + credentials (accountId) + { + return this.q.credentials.all(accountId).map(credentialOf); + } + + /* A login's new count, or false if it has not gone up since the last + one -- a cloned authenticator, or a replay. One that never counts + stays at 0. */ + useCredential (id, counter, now) + { + return this.q.useCredential.run(counter, now, id, counter, + counter).changes === 1; + } + + removeCredential (id, accountId) + { + return this.q.removeCredential.run(id, accountId).changes === 1; + } + addSession (sessionHash, id, now) { this.q.addSession.run(sessionHash, id, now); @@ -342,14 +435,16 @@ export class AccountStore this.q.lapsed.run(now); } - /* A new key, every session ended, and `sessionHash' the one left. */ + /* A new key, every session ended, and `sessionHash' the one left; + how many passkeys went with the old key. */ replaceKey (id, keyHash, sessionHash, now) { - this.transaction(() => + return this.transaction(() => { this.q.setKey.run(keyHash, id); this.endSessions(id); this.q.addSession.run(sessionHash, id, now); + return Number(this.q.removeCredentials.run(id).changes); }); } @@ -400,6 +495,7 @@ export class AccountStore const was = this.byId(id); this.endSessions(id); + this.q.removeCredentials.run(id); this.q.remove.run(id); if (free) @@ -427,8 +523,18 @@ function accountOf (row) }; } +function credentialOf (row) +{ + return row === undefined ? null : { + id: row.id, accountId: row.account_id, userHandle: row.user_handle, + publicKey: row.public_key, counter: row.counter, + transports: JSON.parse(row.transports), createdAt: row.created_at, + lastUsedAt: row.last_used_at, label: row.label, + }; +} + /* An account as its owner is shown it. */ -function infoOf (account) +export function infoOf (account) { return { handle: account.handle, @@ -456,10 +562,12 @@ const unauthorized = () => { 'WWW-Authenticate': 'Bearer' }); const badKey = (message = 'no account has that key') => new ApiError(401, 'bad_key', message); -const banned = () => +export const banned = () => new ApiError(403, 'banned', 'this account is banned'); -const taken = (handle) => +export const taken = (handle) => new ApiError(409, 'handle_taken', `${JSON.stringify(handle)} is taken`); +export const passkeyTaken = () => + new ApiError(409, 'passkey_taken', 'that passkey is registered already'); /* One token bucket per key, least recently seen first. */ class RateLimiter @@ -730,7 +838,8 @@ export class Accounts /* A new key for the current one, ending every other session: a session alone that could make one would be a stolen browser's way - to take the account and then delete it. */ + to take the account and then delete it. Every passkey goes too: + whoever had the old key could have added any of them. */ /* The caller's session goes too, for a new one: a copy of it taken before would otherwise outlive the key that made it. */ replaceKey (client, authorization, body) @@ -745,11 +854,12 @@ export class Accounts const session = newSession(); const sessionHash = secretHash(session); - this.store.replaceKey(account.id, secretHash(key), sessionHash, - this.now()); + const passkeysRemoved = this.store.replaceKey( + account.id, secretHash(key), sessionHash, this.now()); + this.onSessionsEnded({ account: account.id, except: sessionHash }, 'the account\'s key was replaced'); - return { key, session }; + return { key, session, passkeysRemoved }; } logout (client, authorization) @@ -887,7 +997,7 @@ export class Accounts } } -function handleOf (raw) +export function handleOf (raw) { const handle = normalizeName(raw); @@ -919,10 +1029,12 @@ function bearer (authorization) origin is refused, and one with none (curl) is let through. The relay serves none of this without one (relay.mjs); `trustProxy' how many proxies in front append to X-Forwarded-For. Only - count proxies that set it, or a client picks its own rate limit. */ + count proxies that set it, or a client picks its own rate limit. + `passkeys' is a Passkeys (passkeys.mjs), or null for none. */ export function accountRoutes (accounts, { corsOrigin = null, trustProxy = 0, - log = accounts.log } = {}) + log = accounts.log, + passkeys = null } = {}) { /* Behind a proxy that sends no X-Forwarded-For, every client is the proxy's address and shares its buckets: a few registrations would @@ -937,6 +1049,7 @@ export function accountRoutes (accounts, { corsOrigin = null, '/key': ['POST', (c, a, body) => accounts.replaceKey(c, a, body)], '/logout': ['POST', (c, a) => accounts.logout(c, a)], '/delete': ['POST', (c, a, body) => accounts.remove(c, a, body)], + ...passkeys?.routes(), }; return async (req, res) => @@ -982,7 +1095,9 @@ export function accountRoutes (accounts, { corsOrigin = null, return; } - const [method, run] = route; + /* A route's third entry: whether it takes a WebAuthn + response. */ + const [method, run, large = false] = route; if (req.method !== method) throw new ApiError(405, 'method_not_allowed', `use ${method}`, @@ -1022,9 +1137,12 @@ export function accountRoutes (accounts, { corsOrigin = null, const client = clientKey(forwarded ?? req.socket.remoteAddress ?? 'unknown'); - const body = method === 'POST' ? await readJson(req) : {}; + const body = method === 'POST' + ? await readJson(req, large ? RESPONSE_MAX_BYTES + : BODY_MAX_BYTES) + : {}; - send(200, run(client, req.headers.authorization, body)); + send(200, await run(client, req.headers.authorization, body)); } catch (e) { @@ -1040,18 +1158,18 @@ export function accountRoutes (accounts, { corsOrigin = null, }; } -/* A JSON object of at most BODY_MAX_BYTES, or none at all; past the cap - the rest is left unread and the connection goes with it. */ -function readJson (req) +/* A JSON object of at most `maxBytes', or none at all; past the cap the + rest is left unread and the connection goes with it. */ +function readJson (req, maxBytes) { const tooLarge = () => new ApiError(413, 'too_large', - `the body is over ${BODY_MAX_BYTES} bytes`, + `the body is over ${maxBytes} bytes`, { Connection: 'close' }); return new Promise((resolve, reject) => { - if (Number(req.headers['content-length']) > BODY_MAX_BYTES) + if (Number(req.headers['content-length']) > maxBytes) { reject(tooLarge()); return; @@ -1064,7 +1182,7 @@ function readJson (req) { size += chunk.length; - if (size > BODY_MAX_BYTES) + if (size > maxBytes) { reject(tooLarge()); req.pause(); @@ -1107,7 +1225,8 @@ export const ADMIN_USAGE = `usage: relay.mjs admin unaffected, and the old handle is free at once) ban end its sessions and refuse it until unbanned unban let it log in again - revoke end its sessions; its key still logs in + revoke end its sessions; its key and passkeys still + log in delete [--free] delete it; its handles stay nobody's for 30 days, as when its owner deletes it, unless --free frees them now`; diff --git a/wasm/web/accountstest.mjs b/wasm/web/accountstest.mjs index fa7c1654..9d5033f5 100644 --- a/wasm/web/accountstest.mjs +++ b/wasm/web/accountstest.mjs @@ -31,9 +31,16 @@ * refuse past their buckets, per client, per /48 and across everyone; and * the routes answer a CORS preflight and refuse a body that is too big. * + * Passkeys are made and used by an authenticator in this script, as the + * WebAuthn spec says one answers: registering, logging in, adding and + * removing, and the refusals -- another origin or RP ID, a challenge + * replayed, expired or issued for something else, a count that went back, + * a banned account. + * * Exit status is the number of failures. */ +import crypto from 'node:crypto'; import fs from 'node:fs'; import http from 'node:http'; import os from 'node:os'; @@ -43,6 +50,7 @@ import { foldName, normalizeKey, normalizeName } from './account.js'; import { AccountStore, Accounts, HANDLE_KEPT_MS, RENAME_EVERY_MS, SESSION_TTL_MS, accountRoutes, clientKey, forwardedAddress, newKey, runAdmin } from './accounts.mjs'; +import { CHALLENGE_TTL_MS, Passkeys, passkeyConfig } from './passkeys.mjs'; import { KEY_WORDS } from './wordlist.mjs'; let failures = 0; @@ -60,6 +68,7 @@ function check (cond, what) const DAY_MS = 24 * 60 * 60 * 1000; const PAGE = 'https://page.example.org'; +const RP_ID = 'example.org'; /* Limits nothing below reaches, but for the test of them. */ const ROOMY = { burst: 1000, refillMs: 1000 }; @@ -76,8 +85,10 @@ async function serve (store, { limits = roomy, trustProxy = 0 } = {}) const accounts = new Accounts({ store, now: () => clock.now, limits, onSessionsEnded: (e) => ended.push(e), log: (line) => logged.push(line) }); + const passkeys = new Passkeys({ accounts, rpId: RP_ID, + rpName: 'test', origin: PAGE }); const server = http.createServer( - accountRoutes(accounts, { corsOrigin: PAGE, trustProxy })); + accountRoutes(accounts, { corsOrigin: PAGE, trustProxy, passkeys })); await new Promise((r) => server.listen(0, '127.0.0.1', r)); @@ -103,7 +114,7 @@ async function serve (store, { limits = roomy, trustProxy = 0 } = {}) body: text === '' ? null : JSON.parse(text) }; }; - return { clock, ended, logged, call, base, + return { clock, ended, logged, call, base, passkeys, close: () => server.close() }; } @@ -385,6 +396,421 @@ async function serve (store, { limits = roomy, trustProxy = 0 } = {}) } } +/* ---- passkeys ---- */ + +const sha256 = (b) => crypto.createHash('sha256').update(b).digest(); +const b64 = (b) => Buffer.from(b).toString('base64url'); + +/* The CBOR an attestation needs: integers, byte and text strings, and + maps. */ +function cbor (v) +{ + const head = (major, n) => (n < 24 ? Buffer.from([major << 5 | n]) + : n < 256 ? Buffer.from([major << 5 | 24, n]) + : Buffer.from([major << 5 | 25, n >> 8, n & 255])); + + if (typeof v === 'number') + return v >= 0 ? head(0, v) : head(1, -1 - v); + + if (typeof v === 'string') + return Buffer.concat([head(3, Buffer.byteLength(v)), Buffer.from(v)]); + + if (v instanceof Uint8Array) + return Buffer.concat([head(2, v.length), v]); + + const entries = v instanceof Map ? [...v] : Object.entries(v); + + return Buffer.concat([head(5, entries.length), + ...entries.flatMap(([k, x]) => [cbor(k), cbor(x)])]); +} + +/* One ES256 passkey, answering as the browser library hands the page an + authenticator's answer: attestation `none', user present and + verified. */ +class Authenticator +{ + constructor () + { + const { privateKey, publicKey } = + crypto.generateKeyPairSync('ec', { namedCurve: 'P-256' }); + const { x, y } = publicKey.export({ format: 'jwk' }); + + this.key = privateKey; + this.cose = cbor(new Map([[1, 2], [3, -7], [-1, 1], + [-2, Buffer.from(x, 'base64url')], + [-3, Buffer.from(y, 'base64url')]])); + this.id = b64(crypto.randomBytes(16)); + this.counter = 0; + } + + authData (flags, counter, rpId, attested = Buffer.alloc(0)) + { + const count = Buffer.alloc(4); + + count.writeUInt32BE(counter); + return Buffer.concat([sha256(rpId), Buffer.from([flags]), count, + attested]); + } + + clientData (type, challenge, origin) + { + return Buffer.from(JSON.stringify({ type, challenge, origin, + crossOrigin: false })); + } + + create (options, { origin = PAGE, rpId = RP_ID } = {}) + { + const raw = Buffer.from(this.id, 'base64url'); + const length = Buffer.from([raw.length >> 8, raw.length & 255]); + + this.user = options.user.id; + return { + id: this.id, rawId: this.id, type: 'public-key', + clientExtensionResults: {}, + response: { + clientDataJSON: b64(this.clientData( + 'webauthn.create', options.challenge, origin)), + attestationObject: b64(cbor({ + fmt: 'none', attStmt: {}, + authData: this.authData(0x45, 0, rpId, Buffer.concat([ + Buffer.alloc(16), length, raw, this.cose])) })), + transports: ['internal'], + }, + }; + } + + get (options, { origin = PAGE, rpId = RP_ID, + counter = ++this.counter } = {}) + { + const authData = this.authData(0x05, counter, rpId); + const clientData = this.clientData('webauthn.get', options.challenge, + origin); + + return { + id: this.id, rawId: this.id, type: 'public-key', + clientExtensionResults: {}, + response: { + clientDataJSON: b64(clientData), + authenticatorData: b64(authData), + signature: b64(crypto.sign('sha256', Buffer.concat( + [authData, sha256(clientData)]), this.key)), + userHandle: this.user, + }, + }; + } +} + +{ + const store = new AccountStore(':memory:'); + const s = await serve(store); + const route = (r, opts) => s.call(`passkey/${r}`, opts); + const register = async (handle, a, how) => + { + const options = (await route('register-options', + { body: { handle } })).body; + + return route('register-verify', + { body: { response: a.create(options, how) } }); + }; + const login = async (a, how) => + { + const options = (await route('login-options')).body; + + return route('login-verify', + { body: { response: a.get(options, how) } }); + }; + + try + { + const a = new Authenticator(); + const options = (await route('register-options', + { body: { handle: 'Pat' } })).body; + + check(options.rp?.id === RP_ID && options.user?.name === 'Pat' && + options.authenticatorSelection?.residentKey === 'required', + 'a passkey is asked for on the site\'s domain, resident'); + + /* Over a KiB, as an RSA key or a long credential id makes one. */ + const pat = await route('register-verify', { body: { + response: a.create(options), padding: 'x'.repeat(2000) } }); + + check(pat.status === 200 && pat.body.account.handle === 'Pat' && + normalizeKey(pat.body.key) === pat.body.key && + /^s_[0-9a-f]{32}$/.test(pat.body.session), + 'registering with a passkey hands over a key, a session and ' + + 'the handle'); + + const taken = await route('register-options', + { body: { handle: 'pat' } }); + + check(taken.status === 409 && taken.body.error === 'handle_taken', + 'and a taken handle is said before a passkey is made'); + + const first = await login(a); + const me = await s.call('me', { method: 'GET', + session: first.body.session }); + + check(first.status === 200 && me.body.account.handle === 'Pat' && + first.body.session !== pat.body.session, + 'the passkey logs in to a session of the account\'s'); + check((await s.call('login', { body: { key: pat.body.key } })) + .status === 200, 'and so does the key'); + + const lo = (await route('login-options')).body; + const answer = a.get(lo); + + await route('login-verify', { body: { response: answer } }); + + const replayed = await route('login-verify', + { body: { response: answer } }); + + check(replayed.status === 400 && + replayed.body.error === 'bad_challenge', + 'a login answered once cannot be replayed'); + + const stale = (await route('login-options')).body; + + s.clock.now += CHALLENGE_TTL_MS + 1; + + const expired = await route('login-verify', + { body: { response: a.get(stale) } }); + + check(expired.body.error === 'bad_challenge', + 'nor answered once it has expired'); + + const forRegister = (await route('register-options', + { body: { handle: 'Ro' } })).body; + const crossed = await route('login-verify', { + body: { response: a.get(forRegister) } }); + + check(crossed.body.error === 'bad_challenge', + 'nor with a challenge issued to register'); + + for (const [how, what] of [ + [{ origin: 'https://elsewhere.example.org' }, 'another origin'], + [{ rpId: 'elsewhere.example.net' }, 'another RP ID'], + [{ counter: 1 }, 'a count that went back']]) + { + const r = await login(a, how); + + check(r.status === 401 && r.body.error === 'bad_passkey', + `a login from ${what} is refused`); + } + + const elsewhere = await register('Ely', new Authenticator(), + { origin: 'https://example.net' }); + + check(elsewhere.body.error === 'bad_passkey', + 'and so is a passkey made from another origin'); + + /* Adding takes the key, and is bound to the session it was + asked for under. */ + const session = first.body.session; + const b = new Authenticator(); + const keyless = await route('add-options', { session, body: {} }); + const addOptions = (await route('add-options', { + session, body: { key: pat.body.key } })).body; + + const other = (await s.call('register', + { body: { handle: 'Oz' } })).body; + const stolen = await route('add-verify', { + session: other.session, + body: { response: b.create(addOptions) } }); + + check(keyless.status === 401 && keyless.body.error === 'bad_key' && + addOptions.excludeCredentials?.[0]?.id === a.id && + addOptions.user.id === options.user.id, + 'adding a passkey takes the key, and names the ones the ' + + 'account has'); + const sibling = (await s.call('login', { + body: { key: pat.body.key } })).body.session; + const borrowed = await route('add-verify', { + session: sibling, + body: { response: b.create((await route('add-options', { + session, body: { key: pat.body.key } })).body) } }); + + check(stolen.body.error === 'bad_challenge' && + borrowed.body.error === 'bad_challenge', + 'and only the session that asked can add it: not another ' + + 'account\'s, nor another of the same account\'s'); + + const again = (await route('add-options', { + session, body: { key: pat.body.key } })).body; + const added = await route('add-verify', { + session, body: { response: b.create(again) } }); + const list = await route('list', { method: 'GET', session }); + + check(added.status === 200 && + list.body.passkeys.map((p) => p.id).join() === + [a.id, b.id].join() && + list.body.passkeys[0].lastUsedAt === + s.clock.now - CHALLENGE_TTL_MS - 1 && + list.body.passkeys[1].lastUsedAt === null, + 'an added passkey is listed beside the first, with when ' + + 'each was last used'); + + /* Two logins answered with one count, at once: each verifies + against the count before either is written. */ + const [l1, l2] = await Promise.all([route('login-options'), + route('login-options')]); + const count = a.counter + 1; + const both = await Promise.all([l1, l2].map((o) => + route('login-verify', { body: { response: a.get(o.body, { + counter: count }) } }))); + + a.counter = count; + check(both.map((r) => r.status).sort().join() === '200,401', + 'of two logins with one count at once, one gets in'); + + /* No number of challenges asked for pushes out another's. */ + const waiting = (await route('login-options')).body; + + for (let i = 0; i < 12000; i++) + await s.passkeys.loginOptions(`10.1.${i % 200}.1`); + + check((await route('login-verify', { + body: { response: a.get(waiting) } })).status === 200, + 'a login is still answerable after twelve thousand more ' + + 'challenges'); + + /* One challenge, spelled three ways, is still one try. */ + const spelled = (await route('login-options')).body.challenge; + const spellings = []; + + for (const challenge of [spelled, `${spelled}=`, `${spelled}.`]) + spellings.push((await route('login-verify', { + body: { response: a.get({ challenge }) } })).status); + + check(spellings.join(' ') === '200 400 400', + `a challenge padded or with a stray character is the one ` + + `already answered: ${spellings.join(' ')}`); + + const misnamed = (await route('register-options', + { body: { handle: 'Mo' } })).body; + const renamed = new Authenticator().create(misnamed); + + renamed.id = renamed.rawId = b64(crypto.randomBytes(16)); + check((await route('register-verify', { + body: { response: renamed } })).body.error === 'bad_passkey', + 'a passkey whose id is not the one its authenticator signed ' + + 'is refused'); + + for (const transports of ['usb', null, ['usb', 'usb', 'warp']]) + { + const t = new Authenticator(); + const handle = `T${JSON.stringify(transports).length}`; + const o = (await route('register-options', + { body: { handle } })).body; + const response = t.create(o); + + response.response.transports = transports; + + const r = await route('register-verify', { body: { response } }); + const kept = r.status === 200 && store.credential(t.id).transports; + + check(Array.isArray(kept) && kept.length <= + (Array.isArray(transports) ? 1 : 0), + `transports ${JSON.stringify(transports)} are kept as ` + + JSON.stringify(kept)); + } + + const dup = await register('Dup', a); + + check(dup.status === 409 && dup.body.error === 'passkey_taken', + 'a credential id already registered is not taken again'); + + /* Removing takes a session alone, and ends none. */ + const removed = await route('remove', { session, body: { id: a.id } }); + const gone = await login(a); + const unknown = await route('remove', { + session: other.session, body: { id: b.id } }); + + check(removed.status === 200 && gone.status === 401 && + (await login(b)).status === 200 && + (await s.call('me', { method: 'GET', session })).status === 200, + 'a removed passkey logs in no more; the others and the ' + + 'session do'); + check(unknown.status === 404, + 'and nobody removes another account\'s'); + + const patId = store.byHandle('pat').id; + + store.setBanned(patId, true); + + const bannedLogin = await login(b); + + check(bannedLogin.status === 403 && + bannedLogin.body.error === 'banned', + 'a banned account\'s passkey is refused'); + + store.setBanned(patId, false); + + const fresh = (await login(b)).body.session; + + await s.call('delete', { session: fresh, + body: { key: pat.body.key, handle: 'Pat' } }); + check(store.credentials(patId).length === 0 && + (await login(b)).status === 401, + 'and a deleted account\'s passkeys go with it'); + + /* A new key takes every passkey with the old one: whoever had it + could have added any of them. */ + const c = new Authenticator(); + const kit = (await register('Kit', c)).body; + const replaced = await s.call('key', { session: kit.session, + body: { key: kit.key } }); + const left = await route('list', { method: 'GET', + session: replaced.body.session }); + + check(replaced.status === 200 && replaced.body.passkeysRemoved === 1 && + left.body.passkeys.length === 0 && + (await login(c)).body.error === 'unknown_passkey', + 'replacing the key removes the passkeys, which log in no more'); + + const readd = (await route('add-options', { + session: replaced.body.session, + body: { key: replaced.body.key } })).body; + + check(readd.user.id === c.user, + 'and one added after is made under the user handle they ' + + 'were, so an authenticator replaces its old one'); + } + finally + { + s.close(); + } + + for (const [env, want] of [ + [{}, null], + [{ PASSKEY_RP_ID: RP_ID, CORS_ORIGIN: PAGE }, RP_ID], + [{ PASSKEY_RP_ID: 'page.example.org', CORS_ORIGIN: PAGE }, + 'page.example.org'], + [{ PASSKEY_RP_ID: 'localhost', CORS_ORIGIN: 'http://localhost:8080' }, + 'localhost'], + [{ PASSKEY_RP_ID: RP_ID }, 'refused'], + [{ PASSKEY_RP_ID: RP_ID, CORS_ORIGIN: '*' }, 'refused'], + [{ PASSKEY_RP_ID: RP_ID, CORS_ORIGIN: 'https://example.net' }, + 'refused'], + [{ PASSKEY_RP_ID: RP_ID, CORS_ORIGIN: 'https://badexample.org' }, + 'refused'], + [{ PASSKEY_RP_ID: RP_ID, CORS_ORIGIN: `${PAGE}/` }, 'refused'], + [{ PASSKEY_RP_ID: RP_ID, CORS_ORIGIN: `${PAGE}/jam` }, 'refused']]) + { + let got; + + try + { + got = passkeyConfig(env)?.rpId ?? null; + } + catch + { + got = 'refused'; + } + + check(got === want, `passkeys from ${JSON.stringify(env)}: ${want}`); + } +} + /* ---- the admin commands, on a file ---- */ { @@ -548,6 +974,10 @@ async function serve (store, { limits = roomy, trustProxy = 0 } = {}) /* Two each, three a /48 and six in all. */ check(await statuses(['10.0.0.1', '10.0.0.1', '10.0.0.1']) === '200 200 429', 'a client is refused past its own bucket'); + check((await s.call('passkey/register-options', { + headers: from('10.0.0.1'), body: { handle: 'Probe' } })) + .status === 429, + 'and asking whether a handle is taken is one of them'); check(await statuses(['2001:db8:1:1::1', '2001:db8:1:1::2', '2001:db8:1:1::3', '2001:db8:1:2::1', '2001:db8:1:3::1']) === diff --git a/wasm/web/accountui.js b/wasm/web/accountui.js index 605e70a1..c2be59a6 100644 --- a/wasm/web/accountui.js +++ b/wasm/web/accountui.js @@ -18,8 +18,9 @@ /* * accountui.js -- the room page's account dialog: create an account, log - * in with a key, and once logged in change the handle, replace the key, - * log out or delete the account. Opened from the join card. + * in with a passkey or a key, and once logged in add and remove passkeys, + * change the handle, replace the key, log out or delete the account. + * Opened from the join card. * * The key is the account and the relay picks it, so the dialog's real job * is getting the key into a password manager. Managers save what is @@ -30,10 +31,18 @@ * a manager offer to save it. Show, Copy and Download cover people without * one. * + * Where the relay and the browser have passkeys, a passkey is the way in + * and the key is for when it is lost: an account is made with both at + * once, and the key is saved the same way. + * * The page keeps the session and the handle beside it, never the key. */ -import { ACCOUNT_API, apiOriginOf, normalizeName } from './account.js'; +import { WebAuthnAbortService, browserSupportsWebAuthn, + browserSupportsWebAuthnAutofill, startAuthentication, + startRegistration } from '@simplewebauthn/browser'; + +import { ACCOUNT_API, apiOriginOf, normalizeName, onRpId } from './account.js'; /* A session is kept under the relay it is for: one relay's is never another's to see, nor to end. */ @@ -42,6 +51,13 @@ const STORE = 'thinksynth:account:'; /* A request slower than this is a relay that is not answering. */ const REQUEST_MS = 10000; +/* The autofill offer's challenge lapses after five minutes on the relay + (passkeys.mjs, CHALLENGE_TTL_MS), so a fresh one is asked for before + then; and again after one fails, shortly the first time and twice as + long each time after, up to the same four minutes. */ +const AUTOFILL_REARM_MS = 4 * 60 * 1000; +const AUTOFILL_RETRY_MS = 2000; + function load (origin) { try @@ -86,18 +102,18 @@ function client (origin) { const call = async (route, { body, session } = {}) => { + const get = route === '/me' || route === '/passkey/list'; let res; try { res = await fetch(`${origin}${ACCOUNT_API}${route}`, { - method: route === '/me' ? 'GET' : 'POST', + method: get ? 'GET' : 'POST', headers: { - ...(route === '/me' - ? {} : { 'Content-Type': 'application/json' }), + ...(get ? {} : { 'Content-Type': 'application/json' }), ...(session ? { Authorization: `Bearer ${session}` } : {}), }, - body: route === '/me' ? undefined : JSON.stringify(body ?? {}), + body: get ? undefined : JSON.stringify(body ?? {}), signal: AbortSignal.timeout(REQUEST_MS), }); } @@ -127,9 +143,59 @@ function client (origin) logout: (session) => call('/logout', { session }), remove: (session, key, handle) => call('/delete', { session, body: { key, handle } }), + + /* Each passkey ceremony: the relay's options, the browser's + answer to them, and the answer back to the relay. */ + registerPasskey: async (handle) => call('/passkey/register-verify', { + body: { response: await startRegistration({ optionsJSON: + await call('/passkey/register-options', + { body: { handle } }) }) } }), + addPasskey: async (session, key) => call('/passkey/add-verify', { + session, body: { response: await startRegistration({ optionsJSON: + await call('/passkey/add-options', + { session, body: { key } }) }) } }), + /* `still' says whether the ceremony is still wanted once the + options are in: starting one aborts any other that is out. */ + loginPasskey: async (useBrowserAutofill, still = () => true) => + { + const optionsJSON = await call('/passkey/login-options'); + + if (!still()) + throw new DOMException('no longer wanted', 'AbortError'); + + const response = await startAuthentication({ useBrowserAutofill, + optionsJSON }); + + try + { + return await call('/passkey/login-verify', + { body: { response } }); + } + catch (e) + { + e.picked = true; + + /* Where the browser can, it stops offering one that no + account has. */ + if (e.code === 'unknown_passkey') + PublicKeyCredential.signalUnknownCredential?.({ + rpId: optionsJSON.rpId, credentialId: response.id }) + .catch(() => {}); + + throw e; + } + }, + passkeys: (session) => call('/passkey/list', { session }), + removePasskey: (session, id) => + call('/passkey/remove', { session, body: { id } }), }; } +/* A passkey ceremony the person called off, or another took over: nothing + to say about it. */ +const calledOff = (e) => e.name === 'NotAllowedError' || + e.name === 'AbortError'; + /* A failed request, in the words of the person who made it. */ function failed (e) { @@ -151,6 +217,14 @@ function failed (e) return 'Type your handle to delete the account.'; case 'banned': return 'This account is banned.'; + case 'unknown_passkey': + return 'No account has that passkey; it may have been removed.'; + case 'bad_passkey': + return 'That passkey was not accepted.'; + case 'bad_challenge': + return 'That took too long; try again.'; + case 'passkey_taken': + return 'That passkey is registered already.'; default: return e.message; } @@ -191,7 +265,7 @@ function keyForm (id, handle, autocomplete, submitText) el('label', {}, 'Handle ', user), el('label', {}, 'Key ', key), submit); - return { form, key, submit }; + return { form, user, key, submit }; } /* A key as a text file, for whoever has no password manager. */ @@ -227,6 +301,7 @@ export function createAccounts ({ open, dialog, relays, onChange }) let origin = null; let kept = null; let api = null; + let passkeys = false; const status = el('p', { className: 'hint', role: 'status' }); const body = el('div', { className: 'accountbody' }); @@ -260,7 +335,21 @@ export function createAccounts ({ open, dialog, relays, onChange }) close(); }); - const show = (...nodes) => body.replaceChildren(...nodes); + /* A passkey offered in autofill is the logged-out screen's only. */ + dialog.addEventListener('close', + () => WebAuthnAbortService.cancelCeremony()); + + const show = (...nodes) => + { + const prefilled = body.querySelector('#account-passkey-key'); + + /* A key filled in for adding a passkey goes with the screen. */ + if (prefilled !== null) + prefilled.value = ''; + + WebAuthnAbortService.cancelCeremony(); + body.replaceChildren(...nodes); + }; const changed = (k) => { kept = k; @@ -269,8 +358,12 @@ export function createAccounts ({ open, dialog, relays, onChange }) onChange(k?.handle ?? null); }; - /* While a request is out, its button is not pressed again. */ - const busy = async (b, run) => + /* While a request is out, its button is not pressed again. + `refused' is what to say when a passkey ceremony the button started + ends in NotAllowedError: the browser's word for the person calling + it off, and for a timeout or an authenticator that cannot make a + resident passkey alike. */ + const busy = async (b, run, refused = null) => { b.disabled = true; @@ -280,7 +373,10 @@ export function createAccounts ({ open, dialog, relays, onChange }) } catch (e) { - say(failed(e)); + if (e.name === 'NotAllowedError' && refused !== null) + say(refused); + else if (!calledOff(e)) + say(failed(e)); } finally { @@ -288,11 +384,20 @@ export function createAccounts ({ open, dialog, relays, onChange }) } }; + const loggedInBy = (res) => + { + changed({ session: res.session, handle: res.account.handle }); + say(`Logged in as ${res.account.handle}.`); + + /* Gone on success: what a password manager watches for. */ + loggedIn(res); + }; + function loggedOut () { const handle = el('input', { id: 'account-handle', maxLength: 64, autocomplete: 'off' }); - const create = button('Create account', () => busy(create, async () => + const make = (b, withPasskey) => busy(b, async () => { const h = normalizeName(handle.value); @@ -302,43 +407,128 @@ export function createAccounts ({ open, dialog, relays, onChange }) return; } - const res = await api.register(h); + const res = withPasskey ? await api.registerPasskey(h) + : await api.register(h); changed({ session: res.session, handle: res.account.handle }); say(''); - saveKey(res.account.handle, res.key, true, () => loggedIn(res)); - })); + saveKey(res.account.handle, res.key, + withPasskey ? 'recovery' : 'new', () => loggedIn(res)); + }, withPasskey ? 'No passkey was made: it was canceled, or this ' + + 'device cannot make one. Create with a key only ' + + 'makes the account without one.' + : null); const login = keyForm('login', '', 'current-password', 'Log in'); - - login.form.onsubmit = (e) => + let rearm; + let retries = 0; + let ceremony = false; + + /* Asked again after each wait: an offer started under a ceremony + of the screen's own would abort it, and one past the screen + would be offered on another. */ + const offering = () => !ceremony && dialog.open && + body.contains(login.form); + + /* A passkey offered beside the handle as the browser fills it in, + where it can, for as long as this screen is up. A ceremony of + this screen's own aborts it, and puts it back once over. */ + const arm = () => { - e.preventDefault(); - busy(login.submit, async () => - { - const res = await api.login(login.key.value); + clearTimeout(rearm); - changed({ session: res.session, handle: res.account.handle }); - say(`Logged in as ${res.account.handle}.`); + if (!passkeys || !offering()) + return; - /* Gone on success: what a password manager watches for. */ - loggedIn(res); + rearm = setTimeout(arm, AUTOFILL_REARM_MS); + browserSupportsWebAuthnAutofill().then(async (can) => + { + if (!can || !offering()) + { + clearTimeout(rearm); + return; + } + + login.user.autocomplete = 'username webauthn'; + loggedInBy(await api.loginPasskey(true, offering)); + }).catch((e) => + { + /* Any failure once the person picked a passkey is said; + the offer's own failure to be made is not, over + whatever the status line was saying. */ + if (e.picked) + say(failed(e)); + + /* Not for an abort: whatever aborted it re-arms. */ + if (e.name !== 'AbortError') + { + clearTimeout(rearm); + rearm = setTimeout(arm, Math.min( + AUTOFILL_RETRY_MS * 2 ** retries++, + AUTOFILL_REARM_MS)); + } }); }; + /* A ceremony of the screen's own: no offer is made while it is + out, and one is made again once it is over. */ + const own = async (run) => + { + ceremony = true; + clearTimeout(rearm); + + try + { + await run(); + } + finally + { + ceremony = false; + arm(); + } + }; + const create = button('Create account', + () => own(() => make(create, passkeys))); + const keyOnly = button('Create with a key only', + () => make(keyOnly, false)); + const withPasskey = button('Log in with a passkey', + () => own(() => busy(withPasskey, + async () => loggedInBy( + await api.loginPasskey(false)), + 'No passkey was used: it was ' + + 'canceled, it took too long, or ' + + 'none here is for this site.'))); + + login.form.onsubmit = (e) => + { + e.preventDefault(); + busy(login.submit, + async () => loggedInBy(await api.login(login.key.value))); + }; show(el('p', { textContent: 'An account is a handle the room knows you by, which nobody ' + - 'else can take. There is no email or password: the relay ' + - 'gives you a key of eight words, and the key is the ' + - 'account. Keep it in your password manager. Without one ' + + 'else can take. There is no email or password: ' + + (passkeys ? 'a passkey logs you in, and the relay gives you ' + + 'a key of eight words for when the passkey is ' + + 'lost. ' + : 'the relay gives you a key of eight words, and ' + + 'the key is the account. ') + + 'Keep the key in your password manager. Without an account ' + 'you join as a guest.' }), section('Create an account', - el('label', {}, 'Handle ', handle), create), - section('Log in', login.form)); + el('label', {}, 'Handle ', handle), + ...(passkeys ? [el('div', { className: 'row' }, + create, keyOnly)] + : [create])), + section('Log in', ...(passkeys ? [withPasskey] : []), + login.form)); + arm(); } /* A key just issued, in a form a password manager will save, with - Show, Copy and Download beside it. `then' goes on once saved. */ - function saveKey (handle, key, isNew, then) + Show, Copy and Download beside it: a new account's (`new'), one's + beside its first passkey (`recovery'), or a replacement + (`replaced'). `then' goes on once saved. */ + function saveKey (handle, key, kind, then) { const form = keyForm('save', handle, 'new-password', 'Save key'); const note = el('span', { className: 'hint' }); @@ -376,13 +566,20 @@ export function createAccounts ({ open, dialog, relays, onChange }) showKey.textContent = shown ? 'Show' : 'Hide'; }); - show(section(isNew ? 'Your account key' : 'Your new key', + show(section({ new: 'Your account key', recovery: 'Your recovery key', + replaced: 'Your new key' }[kind], el('p', { className: 'accountwarn', textContent: - (isNew ? '' : 'The old key no longer works, and every other ' + - 'browser is logged out. ') + - 'This key is the only way into your account, here or on any ' + - 'other browser, and it cannot be recovered. Save it in your ' + - 'password manager now.' }), + kind === 'recovery' + ? 'Your passkey logs you in. This key is the way in ' + + 'without it, here or on any other browser, and it ' + + 'cannot be shown again or recovered. Save it in your ' + + 'password manager now.' + : (kind === 'new' ? '' : 'The old key no longer works, ' + + 'and every other browser is ' + + 'logged out. ') + + 'This key is the only way into your account, here or ' + + 'on any other browser, and it cannot be recovered. ' + + 'Save it in your password manager now.' }), form.form, el('div', { className: 'row' }, showKey, button('Copy', () => navigator.clipboard.writeText(key).then( @@ -394,7 +591,8 @@ export function createAccounts ({ open, dialog, relays, onChange }) form.submit.focus(); } - function loggedIn ({ account }) + /* `key' is one just issued, for the passkey form to add with. */ + function loggedIn ({ account }, key = '') { const session = kept.session; const waiting = account.renameAt > Date.now(); @@ -455,13 +653,20 @@ export function createAccounts ({ open, dialog, relays, onChange }) busy(replace.submit, async () => { const res = await api.replaceKey(session, replace.key.value); + const lost = res.passkeysRemoved > 0; /* The session this page had ended with the old key. */ changed({ session: res.session, handle: account.handle }); - say(''); - saveKey(account.handle, res.key, false, - () => loggedIn({ account })); + say(lost ? 'Your passkeys went with the old key.' : ''); + saveKey(account.handle, res.key, 'replaced', () => + { + loggedIn({ account }, res.key); + + if (lost && passkeys) + say('Your passkeys went with the old key. Add one ' + + 'below: the new key is filled in.'); + }); }); }; remove.form.onsubmit = (e) => @@ -479,6 +684,7 @@ export function createAccounts ({ open, dialog, relays, onChange }) show(section(`Logged in as ${account.handle}`, el('p', { textContent: 'Since ' + new Date( account.createdAt).toLocaleDateString() })), + ...(passkeys ? [passkeyPart(session, account, key)] : []), section('Change handle', el('p', { className: 'hint', textContent: waiting ? 'You can change it again on ' + new Date( @@ -489,7 +695,10 @@ export function createAccounts ({ open, dialog, relays, onChange }) section('Key', el('p', { className: 'hint', textContent: 'A new key needs the current one, and logs out ' + - 'every other browser.' }), + 'every other browser' + (passkeys + ? ' and removes your passkeys, since whoever ' + + 'had the old key could have added one.' + : '.') }), replace.form), section('Log out', el('p', { className: 'hint', textContent: @@ -502,6 +711,60 @@ export function createAccounts ({ open, dialog, relays, onChange }) remove.form)); } + /* The account's passkeys, and a form to add one: with the key, as + the relay asks, so a borrowed browser cannot add a way in of its + own. */ + function passkeyPart (session, account, key) + { + const day = (ms) => new Date(ms).toLocaleDateString(); + const list = el('ul', { className: 'passkeys' }); + const add = keyForm('passkey', account.handle, 'current-password', + 'Add a passkey'); + const row = (p) => + { + const remove = button('Remove', () => busy(remove, async () => + { + await api.removePasskey(session, p.id); + say('Passkey removed. Your key still logs in.'); + fill(); + })); + + const used = p.lastUsedAt === null + ? 'not used yet' : `last used ${day(p.lastUsedAt)}`; + + return el('li', {}, `${p.label}, added ${day(p.createdAt)}, ` + + `${used} `, remove); + }; + const fill = () => api.passkeys(session).then( + ({ passkeys: all }) => list.replaceChildren( + ...(all.length === 0 ? [el('li', { textContent: 'None yet.' })] + : all.map(row))), + (e) => say(failed(e))); + + add.key.value = key; + add.form.onsubmit = (e) => + { + e.preventDefault(); + busy(add.submit, async () => + { + await api.addPasskey(session, add.key.value); + say('Passkey added.'); + add.key.value = ''; + fill(); + }, 'No passkey was added: it was canceled, or this device ' + + 'cannot make one.'); + }; + fill(); + + return section('Passkeys', + el('p', { className: 'hint', textContent: + 'A passkey logs you in without the key. Adding ' + + 'one takes the key; removing one leaves the key ' + + 'working, and logs nobody out: a new key, below, ' + + 'logs out every other browser.' }), + list, add.form); + } + open.addEventListener('click', async () => { say(''); @@ -561,6 +824,7 @@ export function createAccounts ({ open, dialog, relays, onChange }) origin = home; kept = home === null ? null : load(home); api = null; + passkeys = false; open.hidden = true; /* Whether the relay has accounts, as its health line says. Only @@ -582,6 +846,13 @@ export function createAccounts ({ open, dialog, relays, onChange }) api = client(origin); open.hidden = false; + /* WebAuthn refuses a page off the RP ID's domain: 127.0.0.1 + for a relay on localhost, or a copy of the site served + elsewhere. */ + passkeys = typeof health.passkeys === 'string' && + onRpId(location.hostname, health.passkeys) && + browserSupportsWebAuthn(); + if (kept === null) return; diff --git a/wasm/web/jamtest.mjs b/wasm/web/jamtest.mjs index e4baa2de..8ca65b17 100644 --- a/wasm/web/jamtest.mjs +++ b/wasm/web/jamtest.mjs @@ -80,7 +80,10 @@ * * Last, one page makes an account in the account dialog and logs in with * its key on a reload, and the other joins as a guest: each shows the - * handle as it is and the guest marked as one. + * handle as it is and the guest marked as one. A Chromium page served as + * localhost, the passkeys' RP ID, makes an account with a passkey from a + * virtual authenticator and logs back in with it on a reload; Firefox has + * no virtual authenticator Playwright can drive, so it does not. * * Live rather than offline, because two peers have to agree on a clock * and an offline context has none. A headless browser has no sound card, @@ -580,8 +583,16 @@ async function sequenceTogether (pages) else if (nowLines.length !== wasLines.length || changed.length !== 1 || chainOf(changedAt[0]) !== 'chain snare {' || !/^\s*cells\s*=\s*"..x.x.......x...";/.test(changed[0])) + { + /* Where B skipped an edit, its log says which way. */ + const logged = (await B.page.evaluate( + () => document.getElementById('log').textContent)) + .split('\n').filter(Boolean).slice(-12); + fail(`the click is not the snare's cells in ${pieceFile}: ` + - JSON.stringify(changed)); + `${JSON.stringify(changed)}; ${B.label}'s log ends\n ` + + logged.join('\n ')); + } else ok(`and ${pieceFile} carries it on both pages: ${changed[0].trim()}`); } @@ -1961,6 +1972,170 @@ async function accountsTogether (pages) await A.page.evaluate(() => localStorage.clear()); } +/* An account made with a passkey and logged back in with it, in a page of + * its own on localhost -- a valid RP ID where 127.0.0.1 is not -- with + * Chromium's virtual authenticator standing in for the person's. That one + * answers whatever asks while its presence is simulated, the login form's + * autofill offer included; the button is tried with autofill taken away. + */ +async function passkeysTogether (browser) +{ + const context = await browser.newContext(); + const page = await context.newPage(); + const dialog = page.locator('#accountdialog'); + const cdp = await context.newCDPSession(page); + const listed = () => page.waitForFunction( + () => [...document.querySelectorAll('#accountdialog .passkeys li')] + .map((li) => li.firstChild.textContent).join('|'), + null, { timeout: 10000 }).then((h) => h.jsonValue(), () => ''); + const loggedIn = () => page.waitForSelector('#account-newhandle', + { timeout: 10000 }) + .then(() => true, () => false); + const sessionOf = () => page.evaluate(() => + { + const kept = Object.entries(localStorage).find( + ([k]) => k.startsWith('thinksynth:account:')); + + return kept ? JSON.parse(kept[1]).session : null; + }); + const said = async () => 'the dialog says "' + + await page.textContent('#accountdialog [role=status]') + + '", the log ends ' + JSON.stringify((await page.evaluate( + () => document.getElementById('log').textContent)) + .split('\n').filter(Boolean).slice(-6)); + const logOut = async () => + { + await dialog.getByRole('button', { name: 'Log out' }).click(); + await page.waitForSelector('#account-login-key'); + }; + + page.on('pageerror', (e) => errors.push(`passkeys: ${e.message}`)); + + try + { + await cdp.send('WebAuthn.enable'); + + const { authenticatorId } = await cdp.send( + 'WebAuthn.addVirtualAuthenticator', { options: { + protocol: 'ctap2', transport: 'internal', hasResidentKey: true, + hasUserVerification: true, isUserVerified: true, + automaticPresenceSimulation: true } }); + const present = (enabled) => cdp.send( + 'WebAuthn.setAutomaticPresenceSimulation', + { authenticatorId, enabled }); + + await page.goto(`http://localhost:${sitePort}/jam.html?panes=0` + + `&room=jampasskeys&piece=${HANDS_PIECE}`); + await page.click('#account', { timeout: 10000 }); + await page.fill('#account-handle', 'Pia'); + await dialog.getByRole('button', { name: 'Create account' }).click(); + await page.waitForSelector('#account-save-key'); + + const key = await page.inputValue('#account-save-key'); + const recovery = await dialog.getByRole('heading', + { name: 'Your recovery key' }) + .isVisible(); + + await dialog.getByRole('button', { name: 'Save key' }).click(); + + const made = await listed(); + + if (key.split('-').length === 8 && recovery && + /^Passkey.*, added .*, not used yet $/.test(made)) + ok('an account is made with a passkey, and the key shown as ' + + 'its recovery key'); + else + fail(`made with a passkey: key "${key}", recovery heading ` + + `${recovery}, passkeys "${made}"`); + + /* The authenticator answers the offer as soon as it is made, so + the logged-out screen may be gone before anything here sees it: + what says it happened is a new session. */ + const before = await sessionOf(); + + await dialog.getByRole('button', { name: 'Log out' }).click(); + + const after = await page.waitForFunction((was) => + { + const kept = Object.entries(localStorage).find( + ([k]) => k.startsWith('thinksynth:account:')); + const now = kept && JSON.parse(kept[1]).session; + + return now && now !== was && + document.getElementById('account-newhandle') !== null + ? now : null; + }, before, { timeout: 15000 }).then((h) => h.jsonValue(), + () => null); + + if (after !== null) + ok('the passkey the login form\'s autofill offers logs in'); + else + fail(`the autofill offer never logged in: ${await said()}`); + + /* Away while the offer is still out, and back once the page has + none to make. */ + await page.addInitScript(() => + { + delete PublicKeyCredential.isConditionalMediationAvailable; + }); + await present(false); + await logOut(); + await page.reload(); + await present(true); + await page.click('#account', { timeout: 10000 }); + await dialog.getByRole('button', { name: 'Log in with a passkey' }) + .click(); + + const back = await loggedIn(); + const used = await listed(); + const name = await page.inputValue('#name'); + + if (back && /, last used /.test(used) && !used.includes('|') && + name === 'Pia') + ok('so does the button, on a reload, and the passkey is listed ' + + 'as used'); + else + fail(`logged in with the passkey: the name box holds ` + + `"${name}", passkeys "${used}"; ${await said()}`); + + /* A new key takes the passkeys, and fills itself in to add one + with. */ + await page.fill('#account-replace-key', key); + await dialog.getByRole('button', { name: 'Replace key' }).click(); + await page.waitForSelector('#account-save-key'); + + const newKey = await page.inputValue('#account-save-key'); + + await dialog.getByRole('button', { name: 'Save key' }).click(); + + const emptied = await listed(); + const filled = await page.inputValue('#account-passkey-key'); + + await dialog.getByRole('button', { name: 'Add a passkey' }).click(); + + const readded = await page.waitForFunction( + () => /not used yet/.test(document.querySelector( + '#accountdialog .passkeys')?.textContent), + null, { timeout: 10000 }).then(() => true, () => false); + + if (emptied === 'None yet.' && filled === newKey && readded) + ok('a new key removes the passkeys, and adds one with itself'); + else + fail(`after a new key: passkeys "${emptied}", the add form ` + + `${filled === newKey ? 'filled' : 'not filled'}, ` + + `${readded ? '' : 'none '}added again`); + } + catch (e) + { + fail(`passkeys threw: ${e.message.split('\n')[0]}; ` + + await said().catch(() => 'the page is gone')); + } + finally + { + await context.close(); + } +} + /* A stage's parameter, typed into the popover beside its box. * * The panel is the module's description of the stage (src/StagePanel.cpp) @@ -2307,10 +2482,17 @@ if (!fs.existsSync(path.join(build, 'jam.js'))) process.exit(1); } -const relayServer = await relay({ port: 0, host: '127.0.0.1', tree: top, - corsOrigin: '*' }); -const relayUrl = `ws://127.0.0.1:${relayServer.address().port}`; -const site = await serve(build, 0, '127.0.0.1', relayUrl); +/* The site first, telling the relay's URL once there is one: the relay + takes a passkey's answer only from the page's origin. */ +let relayUrl = null; +const site = await serve(build, 0, '127.0.0.1', () => relayUrl); +const sitePort = site.address().port; +const relayServer = await relay({ + port: 0, host: '127.0.0.1', tree: top, corsOrigin: '*', + passkeys: { rpId: 'localhost', rpName: 'jamtest', + origin: `http://localhost:${sitePort}` } }); + +relayUrl = `ws://127.0.0.1:${relayServer.address().port}`; /* The document rather than the tiled layout. Both are the page -- panes.js adopts what is in the markup and puts it back, and below 60em or under a finger the tiled one is not offered at all -- and what is under test @@ -2731,6 +2913,7 @@ try /* ---- as an account, and a guest ---- */ await accountsTogether(pages); + await passkeysTogether(browsers[0]); for (const e of errors) fail(`page error: ${e}`); diff --git a/wasm/web/package-lock.json b/wasm/web/package-lock.json index 911e7f76..25f4b64e 100644 --- a/wasm/web/package-lock.json +++ b/wasm/web/package-lock.json @@ -6,6 +6,8 @@ "": { "name": "thinksynth-web-tests", "dependencies": { + "@simplewebauthn/browser": "^14.0.0", + "@simplewebauthn/server": "^14.0.3", "cairo-canvas2d": "^0.1.0", "codemirror": "^6.0.2", "mullion": "0.3.0", @@ -544,6 +546,18 @@ "node": ">=18" } }, + "node_modules/@hexagon/base64": { + "version": "1.1.28", + "resolved": "https://registry.npmjs.org/@hexagon/base64/-/base64-1.1.28.tgz", + "integrity": "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==", + "license": "MIT" + }, + "node_modules/@levischuck/tiny-cbor": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@levischuck/tiny-cbor/-/tiny-cbor-0.2.11.tgz", + "integrity": "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==", + "license": "MIT" + }, "node_modules/@lezer/common": { "version": "1.5.2", "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz", @@ -574,6 +588,280 @@ "integrity": "sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==", "license": "MIT" }, + "node_modules/@peculiar/asn1-android": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-android/-/asn1-android-2.10.0.tgz", + "integrity": "sha512-IHKdYL5MRW76DOQCv2HSE/vUMOf2jqH4E7yhtQYe/xx+RxpPiXfkUdhex0N4/s5crsnXCGftRNzy4RJn1s6CZA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.10.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-asym-key": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-asym-key/-/asn1-asym-key-2.10.0.tgz", + "integrity": "sha512-1R8xUTeqcUTSMLFTTCzJyFuKiq3y25EQKs9DezUetJ8qMqbJyxo3XkzykiwwT/Xzlt5/vRzS/sW4M1r+pq1FTg==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-pkcs8": "^2.10.0", + "@peculiar/asn1-schema": "^2.10.0", + "@peculiar/asn1-x509": "^2.10.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-cms": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.10.0.tgz", + "integrity": "sha512-CkX0H4NCIOMHOU3rh2xXZywinYZ/EnJlaOlJiGI0e5L4XjFqHf4iH30LMbXWChVppdA9ljbanjtzQFOhDNfTWg==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.10.0", + "@peculiar/asn1-x509": "^2.10.0", + "@peculiar/asn1-x509-attr": "^2.10.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-csr": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.10.0.tgz", + "integrity": "sha512-jTPTr/9rxKM+niQLMF3jiAMb0lWHUhUHIuSOhdGCIfpB4FAQYf9SoiXOgxP8bS/68IB+bj+1OHvVnDVCQfVUZw==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.10.0", + "@peculiar/asn1-x509": "^2.10.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-ecc": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.10.0.tgz", + "integrity": "sha512-GFd3iOjFrWX+QWH2R2dO5QSJyyRGv9CIBKtRlGlPNCvb2RvmCbBDexe91MJgV76c69d998Xsa+CvuQ98seoiPg==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.10.0", + "@peculiar/asn1-x509": "^2.10.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-pfx": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.10.0.tgz", + "integrity": "sha512-1y3QK9ZH1IPleAMmRoJlPS10eGkAWnULETW8zDFNUK1ffqpG7zmDY+kYBMYQgwlcQZ2iitLm2z2EBP0xzRXpaA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.10.0", + "@peculiar/asn1-pkcs8": "^2.10.0", + "@peculiar/asn1-rsa": "^2.10.0", + "@peculiar/asn1-schema": "^2.10.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-pkcs8": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.10.0.tgz", + "integrity": "sha512-Ri+BZT9bnwqlHWmhs7lvWjvJRxKev2hA2+4EbZajgeYWCbR8hyv0znF4DhsFouOhMj2nSDV/iGJ6DMQtwITnCw==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.10.0", + "@peculiar/asn1-x509": "^2.10.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-pkcs9": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.10.0.tgz", + "integrity": "sha512-XIXsbDQFezYk6fudczkuvawkRD4GNpISGCqfYhdkJlvcGF/RFknU+0DDJagOaJqBf+PdPzk4J9oMqDGcvfR9HQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.10.0", + "@peculiar/asn1-pfx": "^2.10.0", + "@peculiar/asn1-pkcs8": "^2.10.0", + "@peculiar/asn1-schema": "^2.10.0", + "@peculiar/asn1-x509": "^2.10.0", + "@peculiar/asn1-x509-attr": "^2.10.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-rsa": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.10.0.tgz", + "integrity": "sha512-4Jvmwlh3gZAhNZ4/u7JSyLuce9hofOFZ4o3PYKAccCImGnyaT5CMym/ATgvAvqpOYFNW531oPZvJlEBhJy9VfA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.10.0", + "@peculiar/asn1-x509": "^2.10.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-schema": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.10.0.tgz", + "integrity": "sha512-GhokD41lV4gQrrLYm3wCkHfBOnJrnhDMgt4XeMW8gzfE1UdJqIuSwsE+ggf82XBjUXRJylcm+KIGQFa4utIVLw==", + "license": "MIT", + "dependencies": { + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-x509": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.10.0.tgz", + "integrity": "sha512-ucNVg8+ANveTpMN3fy9lA2alryONdXc2A4cEG2hMniWbvQt+YOZoe8BI80YYNO8FBcuDY1qbDQ4uQGQVraHxGA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.10.0", + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-x509-attr": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.10.0.tgz", + "integrity": "sha512-/85GtKOKmgvuSJNlaFfwGWNdRSZZ+hpF02NyM01XiCdzpaZONiBltDyfluFPvFx966CR+ZHNSG1jniwpy07oGg==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.10.0", + "@peculiar/asn1-x509": "^2.10.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-x509-post-quantum": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-post-quantum/-/asn1-x509-post-quantum-2.10.0.tgz", + "integrity": "sha512-cXKcZ30wKbUkRK5RK4Z3W1imewXvHAB9apEAuIqJ0OOkOQCpJrwLOuN38QlsEXqOI+3gtUv8Kf3UZohUmCbf3w==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-asym-key": "^2.10.0", + "@peculiar/asn1-schema": "^2.10.0", + "@peculiar/asn1-x509": "^2.10.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz", + "integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/x509": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-2.1.0.tgz", + "integrity": "sha512-IYbg1R03CSQGWwl24kGyqrdVtixNSbRaDvBg1r5wyYjTP+VwPQkka1BzTgU5+vxiuwqg04OxdvdJ1xYYFIdUSA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.9.4", + "@peculiar/asn1-csr": "^2.9.4", + "@peculiar/asn1-ecc": "^2.9.4", + "@peculiar/asn1-pkcs9": "^2.9.4", + "@peculiar/asn1-rsa": "^2.9.4", + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "@peculiar/asn1-x509-post-quantum": "^2.9.4", + "pvtsutils": "^1.3.6", + "tslib": "^2.8.1", + "tsyringe": "^4.10.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@simplewebauthn/browser": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-14.0.0.tgz", + "integrity": "sha512-1odWVqeEBTl7lJ9zMKLEsmTlnyrDO5iRcTvfMKKk1WThUnp/i8JJdffdj2icP+tty159s4PgwE3BiMoEW9NFow==", + "license": "MIT" + }, + "node_modules/@simplewebauthn/server": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-14.0.3.tgz", + "integrity": "sha512-D39/0fiDiYmGV+nraY9s63D1Ho2AX/waMLy22/9gUadTUuvbAnstfb4bpbHYbwZuV5ZLDYN1SmzDKuiHzKAA4Q==", + "license": "MIT", + "dependencies": { + "@hexagon/base64": "^1.1.27", + "@levischuck/tiny-cbor": "^0.2.2", + "@peculiar/asn1-android": "^2.6.0", + "@peculiar/asn1-ecc": "^2.6.1", + "@peculiar/asn1-rsa": "^2.6.1", + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.1", + "@peculiar/asn1-x509-post-quantum": "^2.9.4", + "@peculiar/x509": "^2.1.0", + "reflect-metadata": "^0.2.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/asn1js": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz", + "integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==", + "license": "BSD-3-Clause", + "dependencies": { + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.5", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/cairo-canvas2d": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/cairo-canvas2d/-/cairo-canvas2d-0.1.0.tgz", @@ -712,6 +1000,30 @@ "node": ">=20" } }, + "node_modules/pvtsutils": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", + "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/pvutils": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.2.0.tgz", + "integrity": "sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/reflect-metadata": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", + "license": "Apache-2.0" + }, "node_modules/shotbox": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/shotbox/-/shotbox-0.3.0.tgz", @@ -731,6 +1043,30 @@ "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", "license": "MIT" }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tsyringe": { + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz", + "integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==", + "license": "MIT", + "dependencies": { + "tslib": "^1.9.3" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/tsyringe/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, "node_modules/w3c-keyname": { "version": "2.2.8", "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", diff --git a/wasm/web/package.json b/wasm/web/package.json index 6e23e6c4..864acaba 100644 --- a/wasm/web/package.json +++ b/wasm/web/package.json @@ -27,6 +27,8 @@ "shotbox": "0.3.0" }, "dependencies": { + "@simplewebauthn/browser": "^14.0.0", + "@simplewebauthn/server": "^14.0.3", "cairo-canvas2d": "^0.1.0", "codemirror": "^6.0.2", "mullion": "0.3.0", diff --git a/wasm/web/passkeys.mjs b/wasm/web/passkeys.mjs new file mode 100644 index 00000000..aeab2728 --- /dev/null +++ b/wasm/web/passkeys.mjs @@ -0,0 +1,445 @@ +/* + * Copyright (C) 2004-2026 Metaphonic Labs + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by the + * Free Software Foundation; either version 2 of the License, or (at your + * option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General + * Public License along with this program; if not, write to the + * Free Software Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. + */ + +/* + * passkeys.mjs -- WebAuthn passkeys for the relay's accounts. A passkey + * logs in to the same session a key does, so nothing past the login knows + * which it was. The routes, under /api/account/passkey/: + * + * POST register-options { handle } options + * POST register-verify { response } { key, session, account } + * POST add-options { key } session options + * POST add-verify { response } session { passkey } + * POST login-options options + * POST login-verify { response } { session, account } + * GET list session { passkeys } + * POST remove { id } session {} + * + * Registering makes the account, its first passkey and its key at once; + * the key is then the way back in for whoever loses the passkey. Adding + * a passkey takes the key, as everything that could hand the account to + * someone else does: a passkey added from a borrowed browser would outlast + * every session the owner ends. Removing one takes a session alone, since + * the key still logs in. + * + * The relying party is the site the page is served from, not the relay: + * WebAuthn binds a passkey to an RP ID, a domain the page's own origin + * must be on. Every response must also come from that one origin. + */ + +import crypto from 'node:crypto'; + +import { generateAuthenticationOptions, generateRegistrationOptions, + verifyAuthenticationResponse, + verifyRegistrationResponse } from '@simplewebauthn/server'; + +import { foldName, isOrigin, onRpId } from './account.js'; +import { ApiError, banned, handleOf, infoOf, newKey, newSession, + passkeyTaken, secretHash, taken } from './accounts.mjs'; + +/* How long a ceremony may take, from its options to its response. */ +export const CHALLENGE_TTL_MS = 5 * 60 * 1000; + + +/* EdDSA, ES256 and RS256: the library would offer ML-DSA too where Node + has it, whose keys alone are past a request's body cap. */ +const ALGORITHMS = [-8, -7, -257]; + +const TRANSPORTS = ['ble', 'cable', 'hybrid', 'internal', 'nfc', + 'smart-card', 'usb']; + +const badPasskey = () => + new ApiError(401, 'bad_passkey', 'the passkey could not be verified'); +const unknownPasskey = () => + new ApiError(401, 'unknown_passkey', 'no account has that passkey'); +/* What an add's challenge is good for besides its purpose: the account + and the session that asked. A new key ends every session, so it voids + every add asked for under the old one too. */ +const boundTo = (account, sessionHash) => `${account.id}\n${sessionHash}`; + +const badChallenge = () => + new ApiError(400, 'bad_challenge', 'that sign-in has expired; try again'); + +/* The passkeys of a relay's environment, or null for none: PASSKEY_RP_ID + names the site's domain, and CORS_ORIGIN, the page's origin -- just an + origin, as a browser writes one into every response -- must be on it. + Throws for a pair that could never work. */ +export function passkeyConfig ({ PASSKEY_RP_ID: rpId, + PASSKEY_RP_NAME: rpName, + CORS_ORIGIN: origin }) +{ + if (!rpId) + return null; + + if (!isOrigin(origin) || !onRpId(new URL(origin).hostname, rpId)) + throw new Error(`PASSKEY_RP_ID ${rpId} needs CORS_ORIGIN to be the ` + + 'page\'s origin, on that domain'); + + return { rpId, rpName: rpName || 'thinksynth', origin }; +} + +/* A passkey as its owner is shown it. */ +function shownOf (c) +{ + return { id: c.id, label: c.label, createdAt: c.createdAt, + lastUsedAt: c.lastUsedAt }; +} + +export class Passkeys +{ + /* `origin' is the page's, which every response must name. */ + constructor ({ accounts, rpId, rpName, origin }) + { + this.accounts = accounts; + this.store = accounts.store; + this.rpId = rpId; + this.rpName = rpName; + this.origin = origin; + + /* Challenges are signed rather than kept, so no number of them + asked for can push out anyone else's: a challenge carries what + it was issued for and until when, under an HMAC by a key that + lives as long as the process. Only the ones answered are + kept, until they lapse, so each is good for one try; that + takes a verify request, which the register and key limits + bound. */ + this.secret = crypto.randomBytes(32); + this.used = new Map(); /* challenge -> until */ + } + + routes () + { + return { + '/passkey/register-options': + ['POST', (c, a, body) => this.registerOptions(c, body)], + '/passkey/register-verify': + ['POST', (c, a, body) => this.registerVerify(c, body), true], + '/passkey/add-options': + ['POST', (c, a, body) => this.addOptions(c, a, body)], + '/passkey/add-verify': + ['POST', (c, a, body) => this.addVerify(c, a, body), true], + '/passkey/login-options': + ['POST', (c) => this.loginOptions(c)], + '/passkey/login-verify': + ['POST', (c, a, body) => this.loginVerify(c, body), true], + '/passkey/list': ['GET', (c, a) => this.list(c, a)], + '/passkey/remove': + ['POST', (c, a, body) => this.remove(c, a, body)], + }; + } + + /* The handle is asked after now, so a taken one is said before the + person makes a passkey for it; it is held only once registered. + On the register limits, so asking after handles is no cheaper here + than registering, and so is every account made: a verify needs a + challenge from here, and each is good for one try. */ + async registerOptions (client, body) + { + this.accounts.limits.register.take(client); + + const handle = handleOf(body.handle); + + if (this.store.holder(foldName(handle), this.accounts.now()) !== null) + throw taken(handle); + + const userHandle = crypto.randomBytes(16).toString('base64url'); + + return this.creation(handle, userHandle, [], + this.issue('register', { handle, userHandle })); + } + + async registerVerify (client, body) + { + this.accounts.limits.key.take(client); + + const { challenge, handle, userHandle } = + this.take(body.response, 'register'); + const info = await this.registered(body.response, challenge); + const key = newKey(); + const session = newSession(); + const now = this.accounts.now(); + + this.accounts.prune(now); + + const account = this.store.create({ + handle, keyHash: secretHash(key), sessionHash: secretHash(session), + now, credential: this.credentialOf(info, userHandle, now) }); + + if (account === null) + throw taken(handle); + + return { key, session, account: infoOf(account) }; + } + + async addOptions (client, authorization, body) + { + const { account, sessionHash } = + this.accounts.session(client, authorization); + + this.accounts.limits.key.take(client); + this.accounts.ownKey(account, body.key); + + const existing = this.store.credentials(account.id); + + /* One user handle an account, for good, so an authenticator + holding one of its passkeys -- even one removed, or gone with + an old key -- replaces it rather than keeping two. */ + const userHandle = this.store.userHandle( + account.id, crypto.randomBytes(16).toString('base64url')); + + return this.creation(account.handle, userHandle, existing, + this.issue('add', { userHandle }, + boundTo(account, sessionHash))); + } + + async addVerify (client, authorization, body) + { + const { account, sessionHash } = + this.accounts.session(client, authorization); + const entry = this.take(body.response, 'add', + boundTo(account, sessionHash)); + const info = await this.registered(body.response, entry.challenge); + + /* Asked again: the session may have ended while that was out. */ + this.accounts.session(client, authorization); + + const now = this.accounts.now(); + const credential = this.credentialOf(info, entry.userHandle, now); + + if (!this.store.addCredential(account.id, credential)) + throw passkeyTaken(); + + return { passkey: shownOf(credential) }; + } + + /* Discoverable: no credentials named, so the browser offers whichever + of this site's passkeys it holds, and nothing says which accounts + exist. */ + async loginOptions (client) + { + this.accounts.limits.session.take(client); + + return generateAuthenticationOptions({ + rpID: this.rpId, timeout: CHALLENGE_TTL_MS, + userVerification: 'preferred', challenge: this.issue('login') }); + } + + async loginVerify (client, body) + { + this.accounts.limits.key.take(client); + + const response = body.response; + const { challenge } = this.take(response, 'login'); + const stored = typeof response.id === 'string' + ? this.store.credential(response.id) : null; + + if (stored === null) + throw unknownPasskey(); + + /* As the spec asks: an authenticator's user handle, where it names + one, is the one the passkey was made under. The signature is + what says the passkey is this one. */ + const userHandle = response.response?.userHandle; + + if ((userHandle ?? null) !== null && userHandle !== stored.userHandle) + throw badPasskey(); + + const { authenticationInfo: info } = await this.verified( + verifyAuthenticationResponse, { + response, expectedChallenge: challenge, + credential: { id: stored.id, publicKey: stored.publicKey, + counter: stored.counter, + transports: stored.transports } }); + + const account = this.store.byId(stored.accountId); + + if (account === null) + throw unknownPasskey(); + + if (account.banned) + throw banned(); + + const now = this.accounts.now(); + + /* The counter checked again as it is written: two logins verified + against one count are one replay. */ + if (!this.store.useCredential(stored.id, info.newCounter, now)) + throw badPasskey(); + + const session = newSession(); + + this.accounts.prune(now); + this.store.addSession(secretHash(session), account.id, now); + return { session, account: infoOf(account) }; + } + + list (client, authorization) + { + const { account } = this.accounts.session(client, authorization); + + return { passkeys: this.store.credentials(account.id).map(shownOf) }; + } + + remove (client, authorization, body) + { + const { account } = this.accounts.session(client, authorization); + + if (typeof body.id !== 'string' || + !this.store.removeCredential(body.id, account.id)) + throw new ApiError(404, 'not_found', 'no such passkey'); + + return {}; + } + + creation (handle, userHandle, existing, challenge) + { + return generateRegistrationOptions({ + rpName: this.rpName, rpID: this.rpId, challenge, + userName: handle, userDisplayName: handle, + userID: Buffer.from(userHandle, 'base64url'), + timeout: CHALLENGE_TTL_MS, attestationType: 'none', + excludeCredentials: existing.map(({ id, transports }) => + ({ id, transports })), + + /* Resident, or a login naming no credential could not find + it. */ + authenticatorSelection: { residentKey: 'required', + userVerification: 'preferred' }, + supportedAlgorithmIDs: ALGORITHMS, + }); + } + + /* A challenge for `purpose', carrying `fields', good for + CHALLENGE_TTL_MS: an HMAC, then the JSON it is of. `bound' is what + the response must come with besides -- for an add, the account and + session -- signed and not carried. */ + issue (purpose, fields = {}, bound = '') + { + const json = Buffer.from(JSON.stringify({ + purpose, until: this.accounts.now() + CHALLENGE_TTL_MS, + nonce: crypto.randomBytes(16).toString('base64url'), ...fields })); + + return Buffer.concat([this.mac(json, bound), json]); + } + + mac (json, bound) + { + return crypto.createHmac('sha256', this.secret) + .update(json).update('\n').update(bound).digest(); + } + + /* What the challenge a response answers carries, if this relay issued + it for `purpose' and `bound' and it has not lapsed nor been + answered before. It is spent whatever comes of the response. */ + take (response, purpose, bound = '') + { + const now = this.accounts.now(); + let challenge; + let fields; + + for (const [c, until] of this.used) + { + if (until > now) + break; + + this.used.delete(c); + } + + try + { + challenge = JSON.parse(Buffer.from( + response.response.clientDataJSON, 'base64url') + .toString('utf8')).challenge; + + const bytes = Buffer.from(challenge, 'base64url'); + const json = bytes.subarray(32); + + /* Read strictly: Node's base64url skips padding and stray + characters, and a challenge spelled two ways would be two + tries. */ + if (bytes.toString('base64url') === challenge && + crypto.timingSafeEqual(bytes.subarray(0, 32), + this.mac(json, bound))) + fields = JSON.parse(json.toString('utf8')); + } + catch + { + /* Refused below. */ + } + + if (fields?.purpose !== purpose || !(fields.until > now) || + this.used.has(challenge)) + throw badChallenge(); + + this.used.set(challenge, fields.until); + return { ...fields, challenge }; + } + + /* The library takes the id the browser names on trust; the one the + authenticator signed is what is kept. */ + async registered (response, challenge) + { + const info = (await this.verified(verifyRegistrationResponse, { + response, expectedChallenge: challenge, + supportedAlgorithmIDs: ALGORITHMS })).registrationInfo; + + if (info.credential.id !== response.id) + throw badPasskey(); + + return info; + } + + /* A response checked against the page's origin and the RP ID. User + verification is asked for, not required, as the options say: a + security key without a PIN logs in whoever holds it, as the key + does whoever has it (RELAY.md). */ + async verified (verify, opts) + { + try + { + const out = await verify({ ...opts, expectedOrigin: this.origin, + expectedRPID: this.rpId, + requireUserVerification: false }); + + if (out.verified) + return out; + } + catch + { + /* Refused below, whatever was wrong. */ + } + + throw badPasskey(); + } + + credentialOf (info, userHandle, now) + { + const { id, publicKey, counter, transports } = info.credential; + + return { + id, userHandle, publicKey, counter, createdAt: now, + lastUsedAt: null, + + /* The browser's word, passed on unread. */ + transports: TRANSPORTS.filter((t) => Array.isArray(transports) && + transports.includes(t)), + label: info.credentialDeviceType === 'multiDevice' + ? 'Synced passkey' : 'Passkey on one device', + }; + } +} diff --git a/wasm/web/relay.mjs b/wasm/web/relay.mjs index bdd140c0..01f3eef5 100644 --- a/wasm/web/relay.mjs +++ b/wasm/web/relay.mjs @@ -43,7 +43,8 @@ * piece * WS /doc/ the Yjs document, y-websocket's protocol * WS /room/ JSON: presence, seats, clock, signalling, chat - * /api/account/... accounts: handles, keys, sessions (accounts.mjs) + * /api/account/... accounts: handles, keys, sessions (accounts.mjs), + * and passkeys (passkeys.mjs) * * Two sockets per peer rather than one: y-websocket's framing is its * own, and the JSON side is easier to read on the wire and in a harness @@ -76,9 +77,10 @@ import * as syncProtocol from 'y-protocols/sync'; import * as decoding from 'lib0/decoding'; import * as encoding from 'lib0/encoding'; -import { ACCOUNT_API, normalizeName, shownName } from './account.js'; +import { ACCOUNT_API, isOrigin, normalizeName, shownName } from './account.js'; import { AccountStore, Accounts, ADMIN_USAGE, accountRoutes, runAdmin } from './accounts.mjs'; +import { Passkeys, passkeyConfig } from './passkeys.mjs'; import { RELAY, TRANSPORT_LEAD } from './commands.js'; import { DEFAULT_PIECE, dspNames, files, hashOfFiles, hasSeen, meta, pieceName, putFile, readSeen, seenOf, snapshot } from './doc.js'; @@ -1221,10 +1223,11 @@ class Room /* The server. Resolves with it listening; `address().port' says where. `db' is the accounts' file, or ':memory:'; `corsOrigin' and - `trustProxy' are accountRoutes'. The two times are for a harness. */ + `trustProxy' are accountRoutes'; `passkeys' is passkeyConfig's, or null + for none. The two times are for a harness. */ export function relay ({ port = 8787, host = '0.0.0.0', tree = path.join(here, '..', '..'), db = ':memory:', - corsOrigin = null, trustProxy = 0, + corsOrigin = null, trustProxy = 0, passkeys = null, ticketMs = TICKET_MS, heartbeatMs = HEARTBEAT_MS, sessionCheckMs = SESSION_CHECK_MS } = {}) { @@ -1244,7 +1247,11 @@ export function relay ({ port = 8787, host = '0.0.0.0', a.sessionHash !== ended.except, why); }, }); - const api = accountRoutes(accounts, { corsOrigin, trustProxy }); + /* Passkeys are an account's, and there are none without accounts. */ + const keys = corsOrigin === null || passkeys === null + ? null : new Passkeys({ accounts, ...passkeys }); + const api = accountRoutes(accounts, { corsOrigin, trustProxy, + passkeys: keys }); const room = (name, seedWith) => { @@ -1283,6 +1290,9 @@ export function relay ({ port = 8787, host = '0.0.0.0', /* Only a page at CORS_ORIGIN can use them, so without it there are none to offer. */ accounts: corsOrigin !== null, + + /* The RP ID, for the page to tell whether it is on it. */ + passkeys: keys?.rpId ?? null, rooms: [...rooms].map(([name, r]) => ({ name, peers: r.peers.size, piece: pieceName(r.doc), playing: r.playing !== null })), @@ -1451,7 +1461,8 @@ if (process.argv[1] !== undefined && /* DB names the accounts' file; CORS_ORIGIN the page's origin, without which there are no accounts; TRUST_PROXY how many proxies in front - append to X-Forwarded-For (1 behind nginx alone). */ + append to X-Forwarded-For (1 behind nginx alone); PASSKEY_RP_ID the + site's domain, for passkeys (passkeyConfig). */ const opts = { db: process.env.DB || path.join(here, 'relay.db'), corsOrigin: process.env.CORS_ORIGIN || null, trustProxy: Number(process.env.TRUST_PROXY ?? 0) }; @@ -1476,9 +1487,7 @@ if (process.argv[1] !== undefined && /* An origin is what a browser sends in Origin, and nothing else: one with a path or a slash after it matches no request at all, and `*' would let any site spend its visitors' registrations here. */ - if (opts.corsOrigin !== null && - (!URL.canParse(opts.corsOrigin) || - new URL(opts.corsOrigin).origin !== opts.corsOrigin)) + if (opts.corsOrigin !== null && !isOrigin(opts.corsOrigin)) { process.stderr.write(`relay.mjs: CORS_ORIGIN is ${opts.corsOrigin}; ` + 'it is scheme://host[:port], nothing after\n'); @@ -1491,6 +1500,16 @@ if (process.argv[1] !== undefined && process.exit(2); } + try + { + opts.passkeys = passkeyConfig(process.env); + } + catch (e) + { + process.stderr.write(`relay.mjs: ${e.message}\n`); + process.exit(2); + } + for (let i = 0; i < args.length; i++) { if (args[i] === '--port' && i + 1 < args.length) diff --git a/wasm/web/relaytest.mjs b/wasm/web/relaytest.mjs index 365b387b..6e79b8f5 100644 --- a/wasm/web/relaytest.mjs +++ b/wasm/web/relaytest.mjs @@ -755,7 +755,7 @@ try check(listed?.peers === 2 && listed.piece === 'airports.gen' && listed.playing === false, 'the health line lists the room, its two people and its piece'); - check(health.accounts === false, + check(health.accounts === false && health.passkeys === null, 'and offers no accounts with no page origin to serve them to'); /* Nor any routes for them, from a page or not, and a hello's session diff --git a/wasm/web/serve.mjs b/wasm/web/serve.mjs index 736504ed..e1a3d93b 100644 --- a/wasm/web/serve.mjs +++ b/wasm/web/serve.mjs @@ -58,7 +58,9 @@ const TYPES = { '.css': 'text/css', }; -/* Resolves with the listening server. */ +/* Resolves with the listening server. `relay' is the relay's URL, or a + function asked for it on each request, for a harness whose relay needs + this server's port before it can start. */ export function serve (root, port = 8080, host = '127.0.0.1', relay = null) { root = path.resolve(root); @@ -72,7 +74,8 @@ export function serve (root, port = 8080, host = '127.0.0.1', relay = null) { res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); - res.end(JSON.stringify({ relay }) + '\n'); + res.end(JSON.stringify({ relay: typeof relay === 'function' + ? relay() : relay }) + '\n'); return; } diff --git a/wasm/web/style.css b/wasm/web/style.css index 9c5b068c..3a94f4e4 100644 --- a/wasm/web/style.css +++ b/wasm/web/style.css @@ -908,6 +908,7 @@ body:has(#joinrow:not([hidden])) #roomhead #accountdialog h2 { margin: 0; font-size: 1em; } #accountdialog p { margin: 0; } #accountdialog .accountwarn { color: var(--warn); } +#accountdialog .passkeys { margin: 0; padding-left: 1.2em; } #accountdialog label {