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

@ -270,7 +270,9 @@ Future<Map<String, Object?>?> _answer(
person: arguments.id('person'),
authenticator: arguments.id('authenticator'),
code: arguments.string('code'),
existingCode: arguments.optionalString('existingCode'),
proof: arguments.has('proof')
? arguments.model('proof', OwnerProof.fromJson)
: null,
);
return {'recoveryCodes': codes};
case RegisterOperation.authenticatorsOf:
@ -288,14 +290,16 @@ Future<Map<String, Object?>?> _answer(
session,
person: arguments.id('person'),
authenticator: arguments.id('authenticator'),
code: arguments.optionalString('code'),
proof: arguments.has('proof')
? arguments.model('proof', OwnerProof.fromJson)
: null,
);
return const {};
case RegisterOperation.newRecoveryCodes:
final codes = await register.newRecoveryCodes(
session,
person: arguments.id('person'),
code: arguments.string('code'),
proof: arguments.model('proof', OwnerProof.fromJson),
);
return {'recoveryCodes': codes};
case RegisterOperation.resetAuthenticator:
@ -361,32 +365,18 @@ Future<Map<String, Object?>?> _answer(
await register.changePassword(
session,
person: arguments.id('person'),
challenge: arguments.id('challenge'),
clientValue: arguments.bytes('clientValue'),
clientProof: arguments.bytes('clientProof'),
totpCode: arguments.optionalString('totpCode'),
recoveryCode: arguments.optionalString('recoveryCode'),
proof: arguments.model('proof', OwnerProof.fromJson),
password: arguments.model('password', NewPassword.fromJson),
keepSession: arguments.optionalId('keepSession'),
host: arguments.string('host'),
address: arguments.string('address'),
language: arguments.string('language'),
);
return const {};
case RegisterOperation.setRecoveryKeys:
await register.setRecoveryKeys(
session,
person: arguments.id('person'),
challenge: arguments.id('challenge'),
clientValue: arguments.bytes('clientValue'),
clientProof: arguments.bytes('clientProof'),
totpCode: arguments.optionalString('totpCode'),
recoveryCode: arguments.optionalString('recoveryCode'),
proof: arguments.model('proof', OwnerProof.fromJson),
recoveryKeys: arguments.model('recoveryKeys', WrappedKeys.fromJson),
recoveryProofKey: arguments.bytes('recoveryProofKey'),
host: arguments.string('host'),
address: arguments.string('address'),
language: arguments.string('language'),
);
return const {};
}