fix(auth): a refusal past an address's ceiling answers before its resets move

The push of a waiting reset runs after the refusal, on a session of its
own, as the members' notices are delivered (the short reading's L1). In
the request, the same statements for every address still differed in
time: an account with a reset waiting wrote it, and a write's commit waits
for the database's log to reach the disk, so how long a refusal took told
whether the address had an account with a reset waiting. Running the same
work for every address would not have closed that; leaving it out of the
request does. Tests wait for it (holdOutsCounted).

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-21 17:41:37 +02:00
commit 372ed60eb5
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
2 changed files with 80 additions and 30 deletions

View file

@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:math'; import 'dart:math';
import 'dart:typed_data'; import 'dart:typed_data';
@ -20,6 +21,7 @@ import 'package:halkyon_base_server/src/srp/srp_math.dart';
import 'package:halkyon_base_server/src/srp/srp_server.dart'; import 'package:halkyon_base_server/src/srp/srp_server.dart';
import 'package:halkyon_base_server/src/totp/recovery_codes.dart'; import 'package:halkyon_base_server/src/totp/recovery_codes.dart';
import 'package:halkyon_base_server/src/totp/totp.dart'; import 'package:halkyon_base_server/src/totp/totp.dart';
import 'package:meta/meta.dart';
import 'package:serverpod/serverpod.dart'; import 'package:serverpod/serverpod.dart';
import 'package:serverpod_auth_idp_server/core.dart'; import 'package:serverpod_auth_idp_server/core.dart';
@ -150,7 +152,7 @@ final _keyProofsPerAddressAndNetwork = DatabaseRateLimiter(
Future<void> _refuseAddressProvingTooOften(Session session, String hash) async { Future<void> _refuseAddressProvingTooOften(Session session, String hash) async {
if (await _proofsPerAddress.countAttempts(session, key: hash) >= if (await _proofsPerAddress.countAttempts(session, key: hash) >=
_proofsPerAddress.config.maxAttempts) { _proofsPerAddress.config.maxAttempts) {
await _heldOut(session, hash); _heldOut(session, hash);
} }
} }
@ -158,26 +160,65 @@ Future<void> _refuseAddressProvingTooOften(Session session, String hash) async {
/// [hash]'s address past [_proofsPerAddress]' ceiling, the owner held out /// [hash]'s address past [_proofsPerAddress]' ceiling, the owner held out
/// for its hour: the account's resets are pushed out past it (the full /// for its hour: the account's resets are pushed out past it (the full
/// reading's H1), so whoever filled the ceiling can't carry one out /// reading's H1), so whoever filled the ceiling can't carry one out
/// meanwhile. The same queries for an address nobody has. /// meanwhile.
Future<Never> _heldOut(Session session, String hash) async { ///
// SELECT * FROM "email_identity" WHERE "addressHash" = $1 /// The push runs after the refusal, on a session of its own (the short
final identity = await EmailIdentity.db.findFirstRow( /// reading's L1): an account with a waiting reset writes it, and a write's
session, /// commit waits for the database's log to reach the disk, so done within
where: (t) => t.addressHash.equals(hash), /// the request, how long the refusal took would tell whether the address
); /// has an account with a reset waiting. Off the request's path, the
await pushOutResets( /// refusal answers alike for every address.
session, Never _heldOut(Session session, String hash) {
identity?.authUserId ?? UuidValue.fromString(_nobody), final pushing = _pushOutResetsOf(
session.serverpod,
hash,
clock.now().toUtc().add(_proofsWindow), clock.now().toUtc().add(_proofsWindow),
); );
_holdOuts.add(pushing);
unawaited(pushing.whenComplete(() => _holdOuts.remove(pushing)));
throw refusedFor(PasswordRefusal.tooManyAttempts); throw refusedFor(PasswordRefusal.tooManyAttempts);
} }
/// The pushes [_heldOut] began, until they end.
final _holdOuts = <Future<void>>{};
/// Pushes the resets of [hash]'s account, if any, out to [heldOutUntil]
/// ([pushOutResets]), on a session of its own: the request's closes first.
/// It logs, so a failure is written.
Future<void> _pushOutResetsOf(
Serverpod pod,
String hash,
DateTime heldOutUntil,
) async {
final session = await pod.createSession();
try {
// SELECT * FROM "email_identity" WHERE "addressHash" = $1
final identity = await EmailIdentity.db.findFirstRow(
session,
where: (t) => t.addressHash.equals(hash),
);
if (identity != null) {
await pushOutResets(session, identity.authUserId, heldOutUntil);
}
} on Object catch (error) {
session.log(
'Pushing resets out failed: ${error.runtimeType}',
level: LogLevel.warning,
);
} finally {
await session.close();
}
}
/// Waits for the pushes [_heldOut] began: tests do.
@visibleForTesting
Future<void> holdOutsCounted() => Future.wait([..._holdOuts]);
/// Counts a proof for [hash]'s address about to be checked; refused /// Counts a proof for [hash]'s address about to be checked; refused
/// ([PasswordRefusal.tooManyAttempts]) past [_proofsPerAddress]' ceiling. /// ([PasswordRefusal.tooManyAttempts]) past [_proofsPerAddress]' ceiling.
Future<void> _countProof(Session session, String hash) async { Future<void> _countProof(Session session, String hash) async {
if (!await _proofsPerAddress.tryRecordAttempt(session, key: hash)) { if (!await _proofsPerAddress.tryRecordAttempt(session, key: hash)) {
await _heldOut(session, hash); _heldOut(session, hash);
} }
} }

View file

@ -3,7 +3,11 @@ import 'package:halkyon_base_server/base.dart';
import 'package:halkyon_base_server/src/password/account_resets.dart' import 'package:halkyon_base_server/src/password/account_resets.dart'
show pushOutResets, resetLivesAtMost, resetWaitsAtMost; show pushOutResets, resetLivesAtMost, resetWaitsAtMost;
import 'package:halkyon_base_server/src/password/passwords.dart' import 'package:halkyon_base_server/src/password/passwords.dart'
show beginKeyProof, beginPasswordSignIn, completePasswordSignIn; show
beginKeyProof,
beginPasswordSignIn,
completePasswordSignIn,
holdOutsCounted;
import 'package:serverpod/serverpod.dart'; import 'package:serverpod/serverpod.dart';
import 'package:serverpod_auth_idp_server/core.dart'; import 'package:serverpod_auth_idp_server/core.dart';
import 'package:test/test.dart'; import 'package:test/test.dart';
@ -121,28 +125,33 @@ void main() {
return enrolment; return enrolment;
}); });
/// A sign-in of Anna's from [network], with [totp] or none. /// A sign-in of Anna's from [network], with [totp] or none; a refusal
/// past the address's ceiling once its push of her resets is done.
Future<PasswordProof> signInFrom( Future<PasswordProof> signInFrom(
String network, { String network, {
String? totp, String? totp,
PasswordDevice? with_, PasswordDevice? with_,
}) => atNow(() async { }) => atNow(() async {
final challenge = await beginPasswordSignIn( try {
session, final challenge = await beginPasswordSignIn(
address: 'anna@example.org', session,
network: network, address: 'anna@example.org',
); network: network,
final answer = (with_ ?? device).answer(challenge); );
return await completePasswordSignIn( final answer = (with_ ?? device).answer(challenge);
session, return await completePasswordSignIn(
challenge: challenge.challenge, session,
clientValue: answer.clientValue, challenge: challenge.challenge,
clientProof: answer.clientProof, clientValue: answer.clientValue,
totpCode: totp, clientProof: answer.clientProof,
instanceUrl: testInstanceUrl, totpCode: totp,
address: 'anna@example.org', instanceUrl: testInstanceUrl,
language: 'en', address: 'anna@example.org',
); language: 'en',
);
} finally {
await holdOutsCounted();
}
}); });
/// Whoever knows Anna's password and has her mailbox asks a reset of /// Whoever knows Anna's password and has her mailbox asks a reset of