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

@ -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 {