fix(auth): limits by address and network, so nobody holds an owner out

Anyone who knew an address could spend its whole hour: 10 sign-ins, 5
phrase proofs and 3 codes, each new code voiding the last (the second
reading's M3). Now:
- sign-ins begun count per address and network (10 an hour), phrase
  proofs likewise (5), and networks as before (30);
- failed proofs count per address from every network: past 30 an hour
  (wrong passwords, phrases' signatures by another key, an address nobody
  has alike) no sign-in or proof begins for it anywhere, so guessing from
  many networks makes at most 30 guesses an hour, while holding an owner
  out takes failing without end from at least three; longer windows
  would outlive the hourly pruning of the limits' notes;
- a signed-in change of password begins 10 times an hour per account;
- codes by mail count per address and network (3 an hour), per address
  from every network (10, no mailbox flooded) and per network (10), and
  up to 3 are valid at once, a fourth voiding the oldest, so codes others
  ask for don't void the one its owner is typing.

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 21:36:02 +02:00
commit 26488b40ce
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -2,6 +2,8 @@ import 'dart:typed_data';
import 'package:clock/clock.dart';
import 'package:halkyon_base_server/base.dart';
import 'package:halkyon_base_server/src/password/passwords.dart'
show beginKeyProof, beginPasswordSignIn;
import 'package:halkyon_base_server/src/srp/srp_math.dart';
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_auth_idp_server/core.dart';
@ -668,51 +670,85 @@ void main() {
);
});
test('counts at most 10 sign-ins begun for an address and 30 from a '
'network in an hour, an unknown address as any', () async {
test('counts sign-ins begun per address and network, 10 an hour, '
'and per network, 30; another network signs the address in all '
'the same, an unknown address counted as any', () async {
Future<void> begin(String address, String network) => atNow(
() => beginPasswordSignIn(
session,
address: address,
network: network,
),
);
for (final address in ['anna@example.org', 'nobody@example.org']) {
for (var i = 0; i < 10; i++) {
await atNow(
() => endpoints.passwordSignIn.begin(
notSignedIn(),
address: address,
),
await begin(address, '203.0.113.1');
}
await expectLater(
begin(address, '203.0.113.1'),
refused(PasswordRefusal.tooManyAttempts),
);
// Whoever filled the first network's count holds no other back.
await begin(address, '198.51.100.7');
}
for (var i = 0; i < 30; i++) {
await begin('third$i@example.org', '192.0.2.9');
}
await expectLater(
begin('fresh@example.org', '192.0.2.9'),
refused(PasswordRefusal.tooManyAttempts),
);
now = now.add(const Duration(hours: 1, seconds: 1));
await begin('anna@example.org', '203.0.113.1');
});
test('counts failed proofs per address from every network: past 30 '
'an hour, no sign-in begins for it anywhere, an address nobody '
'has alike', () async {
final complete = await annaComplete();
Future<void> failFrom(String address, String network) => atNow(
() async {
final challenge = await beginPasswordSignIn(
session,
address: address,
network: network,
);
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,
),
refused(PasswordRefusal.wrongCredentials),
);
},
);
for (final address in ['anna@example.org', 'nobody@example.org']) {
for (var i = 0; i < 30; i++) {
// Ten from each network, within its own count.
await failFrom(address, '203.0.113.${i ~/ 10}');
}
await expectLater(
atNow(
() => endpoints.passwordSignIn.begin(
notSignedIn(),
() => beginPasswordSignIn(
session,
address: address,
network: '198.51.100.7',
),
),
refused(PasswordRefusal.tooManyAttempts),
);
}
for (var i = 0; i < 10; i++) {
await atNow(
() => endpoints.passwordSignIn.begin(
notSignedIn(),
address: 'third$i@example.org',
),
);
}
await expectLater(
atNow(
() => endpoints.passwordSignIn.begin(
notSignedIn(),
address: 'fresh@example.org',
),
),
refused(PasswordRefusal.tooManyAttempts),
);
// An hour on, the count is past: the owner signs in.
now = now.add(const Duration(hours: 1, seconds: 1));
await atNow(
() => endpoints.passwordSignIn.begin(
notSignedIn(),
address: 'anna@example.org',
),
);
await signIn(totpCode: codeOf(complete.enrolment, at: now));
});
test('takes each recovery code once, in place of a code', () async {
@ -1771,25 +1807,65 @@ void main() {
);
});
test('counts at most 5 proofs begun for an address an hour', () async {
for (var i = 0; i < 5; i++) {
await atNow(
() => endpoints.accountRecovery.beginKeyProof(
notSignedIn(),
address: 'nobody@example.org',
test(
'counts at most 5 proofs begun for an address from a network '
'an hour; another network begins its own, and failed proofs '
"count toward the address's ceiling as failed sign-ins do",
() async {
final complete = await annaComplete();
Future<KeyProofChallenge> begin(String network) => atNow(
() => beginKeyProof(
session,
address: 'anna@example.org',
network: network,
),
);
}
await expectLater(
atNow(
() => endpoints.accountRecovery.beginKeyProof(
notSignedIn(),
address: 'nobody@example.org',
for (var i = 0; i < 5; i++) {
await begin('203.0.113.1');
}
await expectLater(
begin('203.0.113.1'),
refused(PasswordRefusal.tooManyAttempts),
);
// A proof by another key fails, and counts.
for (var i = 0; i < 30; i++) {
final challenge = await begin('198.51.100.${i ~/ 5}');
final newPassword = device.newPassword();
final signature = await device.sign(
challenge,
keys: await otherSigningKeys(),
password: newPassword,
);
await expectLater(
atNow(
() => endpoints.accountRecovery.resetPassword(
notSignedIn(),
challenge: challenge.challenge,
signature: signature,
password: newPassword,
totpCode: codeOf(complete.enrolment, at: now),
host: testHost,
),
),
refused(PasswordRefusal.wrongCredentials),
);
}
await expectLater(
begin('192.0.2.9'),
refused(PasswordRefusal.tooManyAttempts),
);
await expectLater(
atNow(
() => beginPasswordSignIn(
session,
address: 'anna@example.org',
network: '192.0.2.9',
),
),
),
refused(PasswordRefusal.tooManyAttempts),
);
});
refused(PasswordRefusal.tooManyAttempts),
);
},
);
});
group('a lost password without the phrase', () {