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
16 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 /// Confirms [authenticator] with a [code] from it, and, where the account
/// returns its recovery codes, shown once. Refusals as /// has a confirmed authenticator already, an [existingCode] from that one;
/// the account's first returns its recovery codes, shown once. Refusals as
/// `confirmAuthenticator`'s. /// `confirmAuthenticator`'s.
_ida.Future<List<String>> confirm({ _ida.Future<List<String>> confirm({
required _isc.UuidValue authenticator, required _isc.UuidValue authenticator,
required String code, required String code,
String? existingCode,
}) => caller.callServerEndpoint<List<String>>( }) => caller.callServerEndpoint<List<String>>(
'halkyon_base.authenticator', 'halkyon_base.authenticator',
'confirm', 'confirm',
{ {
'authenticator': authenticator, 'authenticator': authenticator,
'code': code, 'code': code,
'existingCode': existingCode,
}, },
); );

View file

@ -69,7 +69,12 @@ enum PasswordRefusal implements _isc.SerializableModel {
notDue, notDue,
/// No such reset is waiting, or the address isn't the account's. /// 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) { static PasswordRefusal fromJson(String name) {
switch (name) { switch (name) {
@ -105,6 +110,8 @@ enum PasswordRefusal implements _isc.SerializableModel {
return PasswordRefusal.notDue; return PasswordRefusal.notDue;
case 'noReset': case 'noReset':
return PasswordRefusal.noReset; return PasswordRefusal.noReset;
case 'wrongExistingCode':
return PasswordRefusal.wrongExistingCode;
default: default:
throw ArgumentError( throw ArgumentError(
'Value "$name" cannot be converted to "PasswordRefusal"', 'Value "$name" cannot be converted to "PasswordRefusal"',

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 | | `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 | | `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 | | `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 | | `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 | | `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 | | `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. `wrongCredentials`; `setupIncomplete` and `notDue` come only after a right password.
At most 10 sign-ins begun per address and 30 per network an hour. 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 - **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 (issuer: the account domain); confirmed by a code; while the account has a confirmed
recovery codes; at most 10 per account; the last confirmed one stays; removing a 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). 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`. 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 - **Resets:** a lost authenticator: SRP's proof plus a mailed code or the recovery

View file

@ -633,6 +633,11 @@ class Endpoints extends _is.EndpointDispatch {
type: _is.getType<String>(), type: _is.getType<String>(),
nullable: false, nullable: false,
), ),
'existingCode': _is.ParameterDescription(
name: 'existingCode',
type: _is.getType<String?>(),
nullable: true,
),
}, },
call: call:
( (
@ -645,6 +650,7 @@ class Endpoints extends _is.EndpointDispatch {
session, session,
authenticator: params['authenticator'], authenticator: params['authenticator'],
code: params['code'], code: params['code'],
existingCode: params['existingCode'],
), ),
), ),
'list': _is.MethodConnector( 'list': _is.MethodConnector(

View file

@ -69,7 +69,12 @@ enum PasswordRefusal implements _is.SerializableModel {
notDue, notDue,
/// No such reset is waiting, or the address isn't the account's. /// 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) { static PasswordRefusal fromJson(String name) {
switch (name) { switch (name) {
@ -105,6 +110,8 @@ enum PasswordRefusal implements _is.SerializableModel {
return PasswordRefusal.notDue; return PasswordRefusal.notDue;
case 'noReset': case 'noReset':
return PasswordRefusal.noReset; return PasswordRefusal.noReset;
case 'wrongExistingCode':
return PasswordRefusal.wrongExistingCode;
default: default:
throw ArgumentError( throw ArgumentError(
'Value "$name" cannot be converted to "PasswordRefusal"', 'Value "$name" cannot be converted to "PasswordRefusal"',

View file

@ -23,18 +23,21 @@ class AuthenticatorEndpoint extends Endpoint {
label: label, label: label,
); );
/// Confirms [authenticator] with a [code] from it; the account's first /// Confirms [authenticator] with a [code] from it, and, where the account
/// returns its recovery codes, shown once. Refusals as /// has a confirmed authenticator already, an [existingCode] from that one;
/// the account's first returns its recovery codes, shown once. Refusals as
/// `confirmAuthenticator`'s. /// `confirmAuthenticator`'s.
Future<List<String>> confirm( Future<List<String>> confirm(
Session session, { Session session, {
required UuidValue authenticator, required UuidValue authenticator,
required String code, required String code,
String? existingCode,
}) => AccountRegister.of(session.serverpod).confirmAuthenticator( }) => AccountRegister.of(session.serverpod).confirmAuthenticator(
session, session,
person: session.authenticated!.authUserId, person: session.authenticated!.authUserId,
authenticator: authenticator, authenticator: authenticator,
code: code, code: code,
existingCode: existingCode,
); );
/// The caller's authenticators, the oldest first. /// The caller's authenticators, the oldest first.

View file

@ -125,15 +125,25 @@ String _labelOf(String label) {
/// codes, returned to be shown once; otherwise none. Confirming one /// codes, returned to be shown once; otherwise none. Confirming one
/// confirmed before only checks the code. /// confirmed before only checks the code.
/// ///
/// While the account has a confirmed authenticator, another counts only
/// with an [existingCode] from one of those too (each step's code once, as
/// signing in takes it; decided 20 Oct 2026): otherwise whoever holds a
/// session could add a second factor of their own, sign in with it and
/// remove the owner's. Without one (the first, or after a reset carried
/// out) none is asked for.
///
/// Refused ([PasswordRefusedException]) for an authenticator not the /// Refused ([PasswordRefusedException]) for an authenticator not the
/// person's ([PasswordRefusal.unknownAuthenticator]), a wrong or used code /// person's ([PasswordRefusal.unknownAuthenticator]), a wrong or used code
/// ([PasswordRefusal.wrongCode]), and beyond 10 codes tried an hour /// ([PasswordRefusal.wrongCode]), a missing, wrong or used code from one
/// confirmed before ([PasswordRefusal.wrongExistingCode]; checked only once
/// [code] is right, so a wrong one uses none), and beyond 10 tries an hour
/// ([PasswordRefusal.tooManyAttempts]). /// ([PasswordRefusal.tooManyAttempts]).
Future<List<String>> confirmAuthenticator( Future<List<String>> confirmAuthenticator(
Session session, { Session session, {
required UuidValue person, required UuidValue person,
required UuidValue authenticator, required UuidValue authenticator,
required String code, required String code,
String? existingCode,
}) async { }) async {
final keys = requirePasswordKeys(session.serverpod); final keys = requirePasswordKeys(session.serverpod);
await _countTry(session, person); await _countTry(session, person);
@ -153,6 +163,15 @@ Future<List<String>> confirmAuthenticator(
TotpAccepted(:final step) => step, TotpAccepted(:final step) => step,
TotpRefused() => throw refusedFor(PasswordRefusal.wrongCode), TotpRefused() => throw refusedFor(PasswordRefusal.wrongCode),
}; };
if (row.confirmedAt == null) {
final confirmedBefore = await confirmedAuthenticatorsOf(session, person);
final typed = existingCode?.trim() ?? '';
if (confirmedBefore.isNotEmpty &&
(typed.isEmpty ||
!await acceptTotpCode(session, keys, confirmedBefore, typed))) {
throw refusedFor(PasswordRefusal.wrongExistingCode);
}
}
return await session.db.transaction((transaction) async { return await session.db.transaction((transaction) async {
// UPDATE "totp_authenticator" SET "lastStep" = $1, "confirmedAt" = $2 // UPDATE "totp_authenticator" SET "lastStep" = $1, "confirmedAt" = $2
// WHERE "id" = $3 AND ("lastStep" IS NULL OR "lastStep" < $1) // WHERE "id" = $3 AND ("lastStep" IS NULL OR "lastStep" < $1)

View file

@ -43,3 +43,7 @@ values:
- notDue - notDue
### No such reset is waiting, or the address isn't the account's. ### 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

View file

@ -193,13 +193,15 @@ abstract interface class AccountRegister {
required String label, required String label,
}); });
/// Confirms [person]'s [authenticator]; the first one's recovery codes. /// Confirms [person]'s [authenticator], with [existingCode] from one
/// Refusals as `confirmAuthenticator`'s. /// confirmed before where the account has one; the first one's recovery
/// codes. Refusals as `confirmAuthenticator`'s.
Future<List<String>> confirmAuthenticator( Future<List<String>> confirmAuthenticator(
Session session, { Session session, {
required UuidValue person, required UuidValue person,
required UuidValue authenticator, required UuidValue authenticator,
required String code, required String code,
String? existingCode,
}); });
/// [person]'s authenticators, the oldest first. /// [person]'s authenticators, the oldest first.

View file

@ -263,6 +263,7 @@ Future<Map<String, Object?>?> _answer(
person: arguments.id('person'), person: arguments.id('person'),
authenticator: arguments.id('authenticator'), authenticator: arguments.id('authenticator'),
code: arguments.string('code'), code: arguments.string('code'),
existingCode: arguments.optionalString('existingCode'),
); );
return {'recoveryCodes': codes}; return {'recoveryCodes': codes};
case RegisterOperation.authenticatorsOf: case RegisterOperation.authenticatorsOf:

View file

@ -471,11 +471,13 @@ final class LocalRegister implements AccountRegister {
required UuidValue person, required UuidValue person,
required UuidValue authenticator, required UuidValue authenticator,
required String code, required String code,
String? existingCode,
}) => authenticators.confirmAuthenticator( }) => authenticators.confirmAuthenticator(
session, session,
person: person, person: person,
authenticator: authenticator, authenticator: authenticator,
code: code, code: code,
existingCode: existingCode,
); );
@override @override

View file

@ -381,12 +381,14 @@ final class RemoteRegister implements AccountRegister {
required UuidValue person, required UuidValue person,
required UuidValue authenticator, required UuidValue authenticator,
required String code, required String code,
String? existingCode,
}) async => _strings( }) async => _strings(
RegisterOperation.confirmAuthenticator, RegisterOperation.confirmAuthenticator,
(await _ask(RegisterOperation.confirmAuthenticator, { (await _ask(RegisterOperation.confirmAuthenticator, {
'person': person.uuid, 'person': person.uuid,
'authenticator': authenticator.uuid, 'authenticator': authenticator.uuid,
'code': code, 'code': code,
'existingCode': existingCode,
}))['recoveryCodes'], }))['recoveryCodes'],
); );

View file

@ -855,6 +855,7 @@ void main() {
asAnna(), asAnna(),
authenticator: second.authenticator, authenticator: second.authenticator,
code: codeOf(second, at: now), code: codeOf(second, at: now),
existingCode: codeOf(complete.enrolment, at: now),
), ),
); );
expect(codes, isEmpty); 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 { test("are another person's to nobody: unknown", () async {
final complete = await annaComplete(); final complete = await annaComplete();
final ben = await newAnonymousPerson(session); final ben = await newAnonymousPerson(session);
@ -930,6 +996,7 @@ void main() {
asAnna(), asAnna(),
authenticator: second.authenticator, authenticator: second.authenticator,
code: codeOf(second, at: now), code: codeOf(second, at: now),
existingCode: codeOf(complete.enrolment, at: now),
), ),
); );
nextStep(); nextStep();

View file

@ -289,6 +289,7 @@ final class _Register implements AccountRegister {
required UuidValue person, required UuidValue person,
required UuidValue authenticator, required UuidValue authenticator,
required String code, required String code,
String? existingCode,
}) => _answer('confirmAuthenticator', () { }) => _answer('confirmAuthenticator', () {
authenticated.add(person); authenticated.add(person);
return const ['K7QX-M2PD']; return const ['K7QX-M2PD'];

View file

@ -531,10 +531,32 @@ void main() {
name: 'Spare', name: 'Spare',
label: '', 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( await remote.removeAuthenticator(
session, session,
person: annaOnTodo, person: annaOnTodo,
authenticator: spare.authenticator, authenticator: spare.authenticator,
code: codeOf(enrolment),
); );
final ways = await remote.signInWaysOf(session, annaOnTodo); final ways = await remote.signInWaysOf(session, annaOnTodo);
expect(ways.password && ways.authenticator, isTrue); expect(ways.password && ways.authenticator, isTrue);

View file

@ -931,6 +931,7 @@ class _AuthenticatorEndpoint {
_ist.TestSessionBuilder sessionBuilder, { _ist.TestSessionBuilder sessionBuilder, {
required _is.UuidValue authenticator, required _is.UuidValue authenticator,
required String code, required String code,
String? existingCode,
}) async { }) async {
return _ist.callAwaitableFunctionAndHandleExceptions(() async { return _ist.callAwaitableFunctionAndHandleExceptions(() async {
var _localUniqueSession = var _localUniqueSession =
@ -946,6 +947,7 @@ class _AuthenticatorEndpoint {
parameters: _ist.testObjectToJson({ parameters: _ist.testObjectToJson({
'authenticator': authenticator, 'authenticator': authenticator,
'code': code, 'code': code,
'existingCode': existingCode,
}), }),
serializationManager: _serializationManager, serializationManager: _serializationManager,
); );