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

@ -531,10 +531,32 @@ void main() {
name: 'Spare',
label: '',
);
await expectLater(
remote.confirmAuthenticator(
session,
person: annaOnTodo,
authenticator: spare.authenticator,
code: codeOf(spare),
),
refused(PasswordRefusal.wrongExistingCode),
);
now = now.add(totpPeriod);
expect(
await remote.confirmAuthenticator(
session,
person: annaOnTodo,
authenticator: spare.authenticator,
code: codeOf(spare),
existingCode: codeOf(enrolment),
),
isEmpty,
);
now = now.add(totpPeriod);
await remote.removeAuthenticator(
session,
person: annaOnTodo,
authenticator: spare.authenticator,
code: codeOf(enrolment),
);
final ways = await remote.signInWaysOf(session, annaOnTodo);
expect(ways.password && ways.authenticator, isTrue);