fix(auth): every proof counts toward the address as it is checked

The ceiling of 30 proofs an hour per address was looked at only when a
sign-in began and counted only wrong passwords (the re-review's second
finding): sign-ins begun ahead from many networks got 60 wrong passwords
checked in an hour, and a right password, not counted, could be told from
a wrong one by whether the next sign-in still began. Every proof for an
address, of a password or of the recovery phrase, is now recorded before it
is checked, whatever its outcome, and refused past 30 an hour
(tooManyAttempts); the check at begin stays as an early refusal.

A change's proof is no longer counted toward the address (the fifth
finding: strangers' wrong passwords held a signed-in owner out of changing
the password, a new phrase and deletion). Its challenge has a purpose of
its own (change), counted per account as it begins (10 an hour), taken by
no sign-in, and a change takes no sign-in's challenge.

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:44:02 +02:00
commit 24d46a8503
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
2 changed files with 246 additions and 37 deletions

View file

@ -290,6 +290,89 @@ void main() {
expect(members.sessionsTold, isEmpty);
});
test("is held back by no stranger's wrong passwords for the "
"owner's address", () async {
for (var i = 0; i < 30; i++) {
await atNow(() async {
final challenge = await endpoints.passwordSignIn.begin(
notSignedIn(),
address: 'anna@example.org',
);
// From many networks, as a stranger would.
final answer = device.answer(
challenge,
privateValue: BigInt.from(4242),
);
await expectLater(
endpoints.passwordSignIn.complete(
notSignedIn(),
challenge: challenge.challenge,
clientValue: answer.clientValue,
clientProof: answer.clientProof,
totpCode: '000000',
host: testHost,
address: 'anna@example.org',
language: 'en',
),
refused(PasswordRefusal.wrongCredentials),
);
});
if (i % 10 == 9) {
// Past a network's ten an hour: the next hour, from scratch.
now = now.add(const Duration(minutes: 1));
await RateLimitedRequestAttempt.db.deleteWhere(
session,
where: (t) => t.source.equals('begins_per_address_and_network'),
);
}
}
await change();
});
test("takes a change's challenge alone, and a sign-in takes none of "
'those', () async {
final signInChallenge = await atNow(
() => endpoints.passwordSignIn.begin(
notSignedIn(),
address: 'anna@example.org',
),
);
await expectLater(
atNow(() async {
final answer = device.answer(signInChallenge);
await endpoints.password.change(
onPhone(),
challenge: signInChallenge.challenge,
clientValue: answer.clientValue,
clientProof: answer.clientProof,
totpCode: codeOf(enrolment, at: now),
password: device.newPassword(),
host: testHost,
);
}),
refused(PasswordRefusal.challengeExpired),
);
final changeChallenge = await atNow(
() => endpoints.password.beginChange(onPhone()),
);
await expectLater(
atNow(() async {
final answer = device.answer(changeChallenge);
await endpoints.passwordSignIn.complete(
notSignedIn(),
challenge: changeChallenge.challenge,
clientValue: answer.clientValue,
clientProof: answer.clientProof,
totpCode: codeOf(enrolment, at: now),
host: testHost,
address: 'anna@example.org',
language: 'en',
);
}),
refused(PasswordRefusal.challengeExpired),
);
});
test('is begun only by someone with a password', () async {
final ben = await newAnonymousPerson(session);
final bensLaptop = await signInNewDevice(session, ben);

View file

@ -2017,6 +2017,98 @@ void main() {
);
});
test('counts every proof as it is checked: of sign-ins begun ahead '
'from many networks, at most 30 an hour are checked', () async {
await annaComplete();
final challenges = [
for (var network = 0; network < 6; network++)
for (var i = 0; i < 10; i++)
await atNow(
() => beginPasswordSignIn(
session,
address: 'anna@example.org',
network: '203.0.113.$network',
),
),
];
final reasons = <PasswordRefusal>[];
for (final challenge in challenges) {
final answer = device.answer(
challenge,
privateValue: BigInt.from(4242),
);
try {
await atNow(
() => endpoints.passwordSignIn.complete(
notSignedIn(),
challenge: challenge.challenge,
clientValue: answer.clientValue,
clientProof: answer.clientProof,
totpCode: '000000',
host: testHost,
address: 'anna@example.org',
language: 'en',
),
);
} on PasswordRefusedException catch (refusal) {
reasons.add(refusal.reason);
}
}
expect(
reasons.where((r) => r == PasswordRefusal.wrongCredentials),
hasLength(30),
);
expect(
reasons.where((r) => r == PasswordRefusal.tooManyAttempts),
hasLength(30),
);
});
test('counts a right password as a wrong one: the ceiling tells '
'neither', () async {
await annaComplete();
Future<void> proveFrom(String network, {required bool right}) =>
atNow(() async {
final challenge = await beginPasswordSignIn(
session,
address: 'anna@example.org',
network: network,
);
final answer = device.answer(
challenge,
privateValue: right ? null : BigInt.from(4242),
);
await expectLater(
endpoints.passwordSignIn.complete(
notSignedIn(),
challenge: challenge.challenge,
clientValue: answer.clientValue,
clientProof: answer.clientProof,
totpCode: '000000',
host: testHost,
address: 'anna@example.org',
language: 'en',
),
refused(PasswordRefusal.wrongCredentials),
);
});
for (var i = 0; i < 29; i++) {
await proveFrom('203.0.113.${i ~/ 10}', right: false);
}
// The right password with a wrong code: counted all the same.
await proveFrom('198.51.100.1', right: true);
await expectLater(
atNow(
() => beginPasswordSignIn(
session,
address: 'anna@example.org',
network: '198.51.100.2',
),
),
refused(PasswordRefusal.tooManyAttempts),
);
});
test(
'counts at most 5 proofs begun for an address from a network '
'an hour; another network begins its own, and failed proofs '