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

@ -155,6 +155,27 @@ final class PasswordDevice {
);
}
/// The owner's proof for [challenge] (a change's): the device's answer
/// with its password, or the [privateValue] of another, and a
/// [totpCode] or [recoveryCode].
OwnerProof ownerProof(
PasswordChallenge challenge, {
String? totpCode,
String? recoveryCode,
BigInt? privateValue,
String host = testHost,
}) {
final reply = answer(challenge, privateValue: privateValue, host: host);
return OwnerProof(
challenge: challenge.challenge,
clientValue: reply.clientValue,
clientProof: reply.clientProof,
host: host,
totpCode: totpCode,
recoveryCode: recoveryCode,
);
}
/// The signature proving the recovery phrase for [challenge] on [host]:
/// for the new [password] it sets, or for an authenticator's reset
/// without one; by the phrase's proof key, or [keys]' where given.