fix(auth)!: a session alone never gets a code checked

The third reading found a stolen session guessing TOTP codes without end:
nine wrong codes for new recovery codes, then removing an unconfirmed
authenticator of its own, a success that cleared the session's count. As
Proton has it, every signed-in change of how an account is secured now
takes the owner's proof (OwnerProof): the password's SRP proof for a
change's challenge and a TOTP or recovery code. Adding an authenticator
once one is confirmed, removing a confirmed one, new recovery codes, a new
phrase, a new password and deleting take it; only the first authenticator
is confirmed with its own code alone, and removing an unconfirmed one
takes nothing. A proof given where none is taken is refused, and a
confirmed authenticator's code is never checked for a session alone, so
no success passes a proof unchecked.

A session's proofs count for it alone, before they are checked, cleared
only by one accepted; the tenth wrong in a row ends the session. They
never count toward the account's waits nor wait for them, so whoever
knows the password no longer holds a signed-in owner out of changing it
(the second finding), and a new password forgets those waits. Beginning a
change counts nothing against the account (the third finding): 30 an
hour per session, holding back that session alone. Deleting takes a TOTP
code wherever an authenticator is confirmed, a code by mail where no
second factor works (the fourth finding), which SignInWays now tells
(secondFactorWorks). Signed-in calls no longer take the address: nothing
there mails. wrongExistingCode goes, a wrong existing code being a wrong
proof.

The app follows in the next commit.

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-21 10:33:25 +02:00
commit a9459bb875
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -282,7 +282,7 @@ final class _Register implements AccountRegister {
required UuidValue person,
required UuidValue authenticator,
required String code,
String? existingCode,
OwnerProof? proof,
}) => _answer('confirmAuthenticator', () {
authenticated.add(person);
return const ['K7QX-M2PD'];
@ -299,14 +299,14 @@ final class _Register implements AccountRegister {
Session session, {
required UuidValue person,
required UuidValue authenticator,
String? code,
OwnerProof? proof,
}) => _answer('removeAuthenticator', () {});
@override
Future<List<String>> newRecoveryCodes(
Session session, {
required UuidValue person,
required String code,
required OwnerProof proof,
}) => _answer('newRecoveryCodes', () => const ['K7QX-M2PD']);
@override
@ -407,34 +407,21 @@ final class _Register implements AccountRegister {
Future<void> changePassword(
Session session, {
required UuidValue person,
required UuidValue challenge,
required ByteData clientValue,
required ByteData clientProof,
String? totpCode,
String? recoveryCode,
required OwnerProof proof,
required NewPassword password,
UuidValue? keepSession,
required String host,
required String address,
required String language,
}) => _answer('changePassword', () {
changed[person] = keepSession;
changedWith.add(proof);
});
@override
Future<void> setRecoveryKeys(
Session session, {
required UuidValue person,
required UuidValue challenge,
required ByteData clientValue,
required ByteData clientProof,
String? totpCode,
String? recoveryCode,
required OwnerProof proof,
required WrappedKeys recoveryKeys,
required ByteData recoveryProofKey,
required String host,
required String address,
required String language,
}) => _answer('setRecoveryKeys', () {});
@override
@ -450,6 +437,7 @@ final class _Register implements AccountRegister {
/// The proofs deletions came with, as the member bound them.
final deletedWith = <DeletionProof?>[];
final changedWith = <OwnerProof>[];
@override
Future<Set<UuidValue>> knownAccounts(
@ -859,8 +847,6 @@ void main() {
clientProof: ByteData(32),
totpCode: '123456',
host: 'LOCALHOST',
address: 'anna@example.org',
language: 'en',
),
);
@ -1047,10 +1033,13 @@ void main() {
await endpoints.password.change(
asBen(),
challenge: challenge.challenge,
clientValue: ByteData(384),
clientProof: ByteData(32),
totpCode: '123456',
proof: OwnerProof(
challenge: challenge.challenge,
clientValue: ByteData(384),
clientProof: ByteData(32),
host: 'LOCALHOST',
totpCode: '123456',
),
password: NewPassword(
salt: ByteData(16),
costs: newPasswordCosts,
@ -1060,13 +1049,57 @@ void main() {
cipherText: ByteData(112),
),
),
host: testHost,
address: 'anna@example.org',
language: 'en',
);
expect(register.asked, ['beginPasswordChange', 'changePassword']);
expect(register.changed, {ben: bensLaptop});
// Bound to the host the device addressed here.
expect(register.changedWith.single.host, testHost);
expect(register.changedWith.single.totpCode, '123456');
});
test("counts its own session's wrong proofs, which the register "
'refuses: the tenth in a row ends the session here', () async {
register.failing = PasswordRefusedException(
reason: PasswordRefusal.wrongCredentials,
);
Future<List<String>> newCodes() =>
endpoints.authenticator.newRecoveryCodes(
asBen(),
proof: OwnerProof(
challenge: const Uuid().v7obj(),
clientValue: ByteData(384),
clientProof: ByteData(32),
host: testHost,
totpCode: '000000',
),
);
for (var i = 0; i < 9; i++) {
await expectLater(
newCodes(),
throwsA(
isA<PasswordRefusedException>().having(
(refusal) => refusal.reason,
'reason',
PasswordRefusal.wrongCredentials,
),
),
);
}
await expectLater(
newCodes(),
throwsA(
isA<PasswordRefusedException>().having(
(refusal) => refusal.reason,
'reason',
PasswordRefusal.sessionEnded,
),
),
);
expect(
await ServerSideSession.db.findById(session, bensLaptop),
isNull,
);
});
test('a reset ends the sessions here made before the register ended '