#409 · Passkeys: a password reset removes them; stricter RSA key size; one count per sign-in
- timeline-app
- pull request
- api
- security
Hardening follow-up to passkey support (#402). ## Changes **A password reset removes the account's passkeys** (`server/account-security.mjs`) - `reset` deletes the account's `oc_passkeys` rows in the same transaction that ends its sessions, API keys, desktop sign-ins and challenges. A passkey can sign in on its own (`PASSKEY_SIGN_IN`, on by default), so it should not outlast a reset. - Two-factor authentication stays on, as documented ("does not disable MFA"). When passkeys were the account's only second factor, `mfa_enabled` stays true with no authenticator app and no passkeys. Then: - the two-factor step offers no passkey (`challengePasskey: false`) and accepts a recovery code; - the account page shows two-factor On, Authenticator app None (Set up), the empty passkey list and the recovery codes; confirmation forms ask for a "Recovery code"; - adding a passkey or an app is confirmed with a recovery code and keeps the existing codes. - the Security card shows a notice: two-factor is on but only the recovery codes can answer it, with the number left, and links that open the forms to add a passkey or an authenticator app (`recoveryCodesOnly` in `platform/lib/second-factor.js`, from the data `GET /api/auth/account` already returns). - The reset email, the account notice and the reset response say passkeys were removed and must be added again. When passkeys were the only factor, the notice also says to sign in with a recovery code. - Updated: `docs/authentication.md`, the OpenAPI description of `POST /api/auth/password/reset`, the reset page (`platform/components/account.jsx`) and the `oc_passkeys` schema comment. - An ordinary password change while signed in is unchanged and keeps passkeys. Whether it should also remove them could be decided later. **RSA key size** (`server/webauthn.mjs`) - RS256 keys are now checked by `createPublicKey(...).asymmetricKeyDetails.modulusLength`, the size node:crypto actually reads. The old check used the byte length of the encoded modulus, which leading zeros could inflate. - Keys from 2048 to 8192 bits are accepted. The 8192-bit upper bound is new and keeps verification cheap. **Sign-in attempts** (`server/passkeys.mjs`) - `POST /api/auth/passkeys/sign-in/options` has its own per-address limit (`passkey-options: `, ten a minute). `POST /api/auth/passkeys/sign-in` keeps the password sign-in's per-address limit, so one passkey sign-in now counts once against it instead of twice. The OpenAPI and docs text are updated. ## Tests - `test/passkeys.test.mjs`: a 1024-bit RSA modulus padded to 256 bytes is refused, and a 2048-bit one with a leading zero is accepted. It fails without the fix. - `test/passkeys-postgres.mjs`, on an account of its own: - passkey sign-in refused for a suspended account and for an unconfirmed address; - another signed-in account gets 404 renaming or removing the passkey; - a desktop Bearer session gets 403 from PATCH, DELETE and register/options; - PATCH and DELETE with a missing or wrong CSRF token get 403; - a sign-in challenge does not answer the two-factor step or confirm a change, and an `assert` challenge (two-factor step or confirmation) does not sign in; - six passkey sign-ins in a row from one address succeed. This fails without the rate-limit change. - After a reset: the old passkey no longer signs in, the list is empty, two-factor stays on, the step takes a recovery code, and a passkey can be added again with a recovery code. This fails without the fix: the old passkey still signs in. - `test/passkeys.test.mjs`: `recoveryCodesOnly` is true only with two-factor on, no app and no passkeys. - Run locally: `test/passkeys-postgres.mjs`, `test/account-security-postgres.mjs`, `test/postgres.mjs` and `npm test` (404/404) all pass. This PR merges cleanly with #406 (`git merge-tree`). It doesn't take that PR's changes.