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

@ -533,16 +533,20 @@ void main() {
authenticator: spare.authenticator,
code: codeOf(spare),
),
refused(PasswordRefusal.wrongExistingCode),
refused(PasswordRefusal.wrongCredentials),
);
now = now.add(totpPeriod);
Future<OwnerProof> ownerProof() async => device.ownerProof(
await remote.beginPasswordChange(session, person: annaOnTodo),
totpCode: codeOf(enrolment),
);
expect(
await remote.confirmAuthenticator(
session,
person: annaOnTodo,
authenticator: spare.authenticator,
code: codeOf(spare),
existingCode: codeOf(enrolment),
proof: await ownerProof(),
),
isEmpty,
);
@ -551,7 +555,7 @@ void main() {
session,
person: annaOnTodo,
authenticator: spare.authenticator,
code: codeOf(enrolment),
proof: await ownerProof(),
);
final ways = await remote.signInWaysOf(session, annaOnTodo);
expect(ways.password && ways.authenticator, isTrue);
@ -601,7 +605,7 @@ void main() {
await remote.newRecoveryCodes(
session,
person: annaOnTodo,
code: codeOf(enrolment, at: now),
proof: await ownerProof(),
),
hasLength(recoveryCodeCount),
);
@ -749,19 +753,15 @@ void main() {
session,
person: annaOnTodo,
);
final answer = device.answer(challenge);
await remote.changePassword(
session,
person: annaOnTodo,
challenge: challenge.challenge,
clientValue: answer.clientValue,
clientProof: answer.clientProof,
totpCode: codeOf(enrolment),
proof: device.ownerProof(
challenge,
totpCode: codeOf(enrolment),
),
password: device.newPassword(),
keepSession: annasPhone,
host: testHost,
address: 'anna@example.org',
language: 'en',
);
expect(members.sessionsTold.single, (
_todo,
@ -780,21 +780,17 @@ void main() {
session,
person: annaOnTodo,
);
final answer = device.answer(challenge);
await remote.setRecoveryKeys(
session,
person: annaOnTodo,
challenge: challenge.challenge,
clientValue: answer.clientValue,
clientProof: answer.clientProof,
totpCode: codeOf(enrolment),
proof: device.ownerProof(
challenge,
totpCode: codeOf(enrolment),
),
recoveryKeys: fresh,
recoveryProofKey: ByteData.sublistView(
device.recoveryProofKey,
),
host: testHost,
address: 'anna@example.org',
language: 'en',
);
}