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
2 changed files with 161 additions and 12 deletions

View file

@ -393,18 +393,21 @@ class EndpointAuthenticator extends _isc.EndpointRef {
},
);
/// Confirms [authenticator] with a [code] from it; the account's first
/// returns its recovery codes, shown once. Refusals as
/// Confirms [authenticator] with a [code] from it, and, where the account
/// has a confirmed authenticator already, an [existingCode] from that one;
/// the account's first returns its recovery codes, shown once. Refusals as
/// `confirmAuthenticator`'s.
_ida.Future<List<String>> confirm({
required _isc.UuidValue authenticator,
required String code,
String? existingCode,
}) => caller.callServerEndpoint<List<String>>(
'halkyon_base.authenticator',
'confirm',
{
'authenticator': authenticator,
'code': code,
'existingCode': existingCode,
},
);

View file

@ -69,7 +69,12 @@ enum PasswordRefusal implements _isc.SerializableModel {
notDue,
/// No such reset is waiting, or the address isn't the account's.
noReset;
noReset,
/// Adding an authenticator to an account that has one confirmed takes a
/// code from one of those, which is missing or isn't right: a session
/// alone adds no second factor.
wrongExistingCode;
static PasswordRefusal fromJson(String name) {
switch (name) {
@ -105,6 +110,8 @@ enum PasswordRefusal implements _isc.SerializableModel {
return PasswordRefusal.notDue;
case 'noReset':
return PasswordRefusal.noReset;
case 'wrongExistingCode':
return PasswordRefusal.wrongExistingCode;
default:
throw ArgumentError(
'Value "$name" cannot be converted to "PasswordRefusal"',