fix(auth): adding an authenticator takes a code from one confirmed before

Enrolling and confirming an authenticator took only a session, so whoever
held one (a stolen token) could add a second factor of their own, then use
its codes to remove the owner's and make new recovery codes.

While the account has a confirmed authenticator, confirming another now
also takes a code from one of those (`existingCode`, each step's code once
as signing in takes it, within the same 10 tries an hour), refused as
`wrongExistingCode`; it is checked only once the new one's code is right,
so a wrong one uses none. The first authenticator, and the first after a
reset carried out, need none. Members forward it to the register.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-20 14:12:48 +02:00
commit 892fba97e8
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -17,7 +17,7 @@ Endpoints, under the nickname `base` in a host's client (`client.modules.base.em
| `anonymousIdp` | `login` (Serverpod's anonymous sign-in, at most 30 new accounts per network and hour) | anyone |
| `password` | `set`: the caller's password, with the address a mailed code proves unless they have one | signed in |
| `passwordSignIn` | `begin`, `complete`: SRP-6a's two steps with a code from an authenticator (or a recovery code); a session | without a session |
| `authenticator` | `enrol`, `confirm` (the first brings ten recovery codes), `list`, `remove`, `newRecoveryCodes` | signed in |
| `authenticator` | `enrol`, `confirm` (the first brings ten recovery codes; another takes a code from one confirmed before), `list`, `remove`, `newRecoveryCodes` | signed in |
| `accountRecovery` | `sendCode`, `resetAuthenticator`, `beginKeyProof`, `resetPassword` (with the recovery phrase), `startAccountReset`, `completeAccountReset` | without a session |
| `gitHubUpgrade` | `upgrade`: links a GitHub account to the caller, who becomes `identified`; refused where passwords are offered (deprecated) | signed in |
| `gitHubSignIn` | `signIn`: a session for whoever has that GitHub account, unless they sign in with a password; none once `GITHUB_SIGN_IN_ENDS` has come | without a session |
@ -189,8 +189,9 @@ Built on the three parts above, on the register (members forward every step thro
`wrongCredentials`; `setupIncomplete` and `notDue` come only after a right password.
At most 10 sign-ins begun per address and 30 per network an hour.
- **Authenticators:** a secret made on the register, shown once as an `otpauth://` URI
(issuer: the account domain); confirmed by a code; the first confirmed brings ten
recovery codes; at most 10 per account; the last confirmed one stays; removing a
(issuer: the account domain); confirmed by a code; while the account has a confirmed
one, another counts only with a code from that one too (`wrongExistingCode`), so a
session alone adds no second factor; the first confirmed brings ten recovery codes; at most 10 per account; the last confirmed one stays; removing a
confirmed one, or new recovery codes, takes a code (10 tries an hour per account).
Each step's code is accepted once, by a conditional update of `lastStep`.
- **Resets:** a lost authenticator: SRP's proof plus a mailed code or the recovery