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

@ -855,6 +855,7 @@ void main() {
asAnna(),
authenticator: second.authenticator,
code: codeOf(second, at: now),
existingCode: codeOf(complete.enrolment, at: now),
),
);
expect(codes, isEmpty);
@ -866,6 +867,71 @@ void main() {
);
});
test('take, once one is confirmed, a code from one confirmed before '
'to add another: a session alone adds none', () async {
final complete = await annaComplete();
final another = await endpoints.authenticator.enrol(
asAnna(),
name: 'Not hers',
label: '',
);
Future<List<String>> confirm({
String? code,
String? existingCode,
}) => atNow(
() => endpoints.authenticator.confirm(
asAnna(),
authenticator: another.authenticator,
code: code ?? codeOf(another, at: now),
existingCode: existingCode,
),
);
await expectLater(
confirm(),
refused(PasswordRefusal.wrongExistingCode),
);
await expectLater(
confirm(existingCode: '000000'),
refused(PasswordRefusal.wrongExistingCode),
);
// Its own code is no code from one confirmed before.
await expectLater(
confirm(existingCode: codeOf(another, at: now)),
refused(PasswordRefusal.wrongExistingCode),
);
// A wrong code from the new one takes nothing of the other's.
await expectLater(
confirm(
code: '000000',
existingCode: codeOf(complete.enrolment, at: now),
),
refused(PasswordRefusal.wrongCode),
);
expect(
(await endpoints.authenticator.list(asAnna())).map(
(authenticator) => authenticator.confirmed,
),
[true, false],
);
expect(
await confirm(existingCode: codeOf(complete.enrolment, at: now)),
isEmpty,
);
expect(
(await endpoints.authenticator.list(asAnna())).map(
(authenticator) => authenticator.confirmed,
),
[true, true],
);
// The code it took is used.
await expectLater(
signIn(totpCode: codeOf(complete.enrolment, at: now)),
refused(PasswordRefusal.wrongCredentials),
);
});
test("are another person's to nobody: unknown", () async {
final complete = await annaComplete();
final ben = await newAnonymousPerson(session);
@ -930,6 +996,7 @@ void main() {
asAnna(),
authenticator: second.authenticator,
code: codeOf(second, at: now),
existingCode: codeOf(complete.enrolment, at: now),
),
);
nextStep();