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
2 changed files with 300 additions and 131 deletions

View file

@ -244,8 +244,9 @@ void main() {
expect(await _addressesOf(session, anna), isEmpty);
});
test('a code past its 15 minutes, a code used, and one replaced by '
'a newer code', () async {
test('a code past its 15 minutes, a code used, and the oldest of '
'four: up to three are valid at once, so codes others ask for '
"don't void the one the owner is typing", () async {
final late = await endpoints.emailCode.sendCode(
asAnna(),
address: 'anna@example.org',
@ -264,75 +265,71 @@ void main() {
),
);
final replaced = await endpoints.emailCode.sendCode(
asAnna(),
address: 'anna@example.org',
language: 'en',
);
final replacedCode = mailbox.codeFor('anna@example.org');
final newer = await endpoints.emailCode.sendCode(
asAnna(),
address: 'anna@example.org',
language: 'en',
);
await expectLater(
proveAddressCode(
Future<(UuidValue, String)> ask(String network) async {
final request = await sendSignInCode(
session,
request: replaced,
code: replacedCode,
),
throwsA(_refusal(EmailSignInRefusal.codeExpired)),
);
final newerCode = mailbox.codeFor('anna@example.org');
await proveAddressCode(
session,
request: newer,
code: newerCode,
);
await expectLater(
proveAddressCode(
session,
request: newer,
code: newerCode,
),
throwsA(_refusal(EmailSignInRefusal.codeExpired)),
);
});
test('a fourth code for one address within an hour, and an '
'eleventh from one network', () async {
for (var i = 0; i < 3; i++) {
await endpoints.emailCode.sendCode(
asAnna(),
address: 'anna@example.org',
language: 'en',
network: network,
);
return (request, mailbox.codeFor('anna@example.org'));
}
final (owners, ownersCode) = await ask('203.0.113.1');
final (second, secondCode) = await ask('198.51.100.7');
final (third, thirdCode) = await ask('198.51.100.7');
// Three valid: the owner's still is.
await proveAddressCode(session, request: owners, code: ownersCode);
final (fourth, fourthCode) = await ask('198.51.100.7');
final (fifth, _) = await ask('192.0.2.9');
// Five asked for, three valid: the two oldest left went.
await expectLater(
proveAddressCode(session, request: second, code: secondCode),
throwsA(_refusal(EmailSignInRefusal.codeExpired)),
);
await proveAddressCode(session, request: third, code: thirdCode);
await proveAddressCode(session, request: fourth, code: fourthCode);
await expectLater(
proveAddressCode(session, request: fourth, code: fourthCode),
throwsA(_refusal(EmailSignInRefusal.codeExpired)),
);
expect(fifth, isNotNull);
});
test('a fourth code for one address from one network within an '
'hour, an eleventh for one address from any, and an eleventh '
'from one network', () async {
Future<void> ask(String address, String network) => sendSignInCode(
session,
address: address,
language: 'en',
network: network,
);
for (var i = 0; i < 3; i++) {
await ask('anna@example.org', '203.0.113.1');
}
await expectLater(
endpoints.emailCode.sendCode(
asAnna(),
address: 'Anna@Example.org',
language: 'en',
),
ask('Anna@Example.org', '203.0.113.1'),
throwsA(_refusal(EmailSignInRefusal.tooManyCodes)),
);
// Another network still gets one: whoever filled the first count
// holds back no other, the owner's among them.
for (var i = 0; i < 7; i++) {
await endpoints.emailCode.sendCode(
asAnna(),
address: 'anna$i@example.org',
language: 'en',
);
await ask('anna@example.org', '198.51.100.${i ~/ 3}');
}
// Ten mailed to one address in the hour: no more, from anywhere.
await expectLater(
endpoints.emailCode.sendCode(
asAnna(),
address: 'anna.else@example.org',
language: 'en',
),
ask('anna@example.org', '192.0.2.200'),
throwsA(_refusal(EmailSignInRefusal.tooManyCodes)),
);
expect(mailbox.sent, hasLength(10));
for (var i = 0; i < 10; i++) {
await ask('anna$i@example.org', '192.0.2.9');
}
await expectLater(
ask('anna.else@example.org', '192.0.2.9'),
throwsA(_refusal(EmailSignInRefusal.tooManyCodes)),
);
expect(mailbox.sent, hasLength(20));
});
test('saying how someone signs in when not signed in', () async {

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', () {