fix(auth): a code is counted before it is checked

Codes sent at once were all checked before the tenth refused began a wait
(the re-review's first finding: 30 codes from three networks, all checked),
so whoever knew the password could open sign-ins ahead and guess far more
than ten before codes waited. Every attempt at a code for an account is now
counted before its code is checked, one at a time per account (a
transaction holding an advisory lock on the account's key), and the tenth
in a row begins the wait as it is counted: of codes sent at once at most
ten are checked, the rest refused (codesWait). A code accepted clears it
all, that wait too.

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 22:41:20 +02:00
commit ae41937e0b
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -844,6 +844,56 @@ void main() {
expect(mailbox.codesWait, hasLength(2));
});
test('sent at once are counted before they are checked: ten '
'checked, the rest wait', () async {
await annaComplete();
final challenges = [
for (var network = 0; network < 3; network++)
for (var i = 0; i < 10; i++)
await atNow(
() => beginPasswordSignIn(
session,
address: 'anna@example.org',
network: '203.0.113.$network',
),
),
];
final reasons = await Future.wait([
for (final (i, challenge) in challenges.indexed)
atNow(() async {
final answer = device.answer(challenge);
try {
await endpoints.passwordSignIn.complete(
notSignedIn(),
challenge: challenge.challenge,
clientValue: answer.clientValue,
clientProof: answer.clientProof,
totpCode: '${100000 + i}',
host: testHost,
address: 'anna@example.org',
language: 'en',
);
return null;
} on PasswordRefusedException catch (refusal) {
return refusal.reason;
}
}),
]);
expect(
reasons.where((r) => r == PasswordRefusal.wrongCredentials),
hasLength(10),
);
expect(
reasons.where((r) => r == PasswordRefusal.codesWait),
hasLength(20),
);
final row = await SecondFactorFailures.db.findFirstRow(session);
expect(row?.failures, 10);
expect(row?.waits, 1);
});
test('wait a day at the most', () {
expect(codeWait(0), const Duration(hours: 1));
expect(codeWait(1), const Duration(hours: 2));