fix(auth): a hold-out moves a reset on only by its own time, and a reset lives 30 days at most

The reading of the full reading's fixes found that every refusal at the
address's ceiling moved a reset to 72 hours after it (A1): someone who
knows only the address kept the owner's own reset from ever taking effect
with one burst every three days, and kept a forgotten one alive (A2).

Now a hold-out that begins before a reset takes effect moves it on only by
the time it holds the owner out and no hold-out counted before did
(account_reset.heldOutUntil, under a row lock), so the owner gets 72 hours
outside hold-outs in total; and every reset not carried out lapses 30 days
after it was asked (resetLivesAtMost), whatever moved it, besides a week
after it took effect.

Migration (module, Learn's and Todo's servers, Learn's device schema):
ALTER TABLE "account_reset" ADD COLUMN "heldOutUntil" timestamp without
time zone; nullable, existing rows counted from now on.

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 15:50:29 +02:00
commit 274b796faa
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -1,5 +1,7 @@
import 'package:clock/clock.dart';
import 'package:halkyon_base_server/base.dart';
import 'package:halkyon_base_server/src/password/account_resets.dart'
show pushOutResets, resetLivesAtMost;
import 'package:halkyon_base_server/src/password/passwords.dart'
show beginKeyProof, beginPasswordSignIn, completePasswordSignIn;
import 'package:serverpod/serverpod.dart';
@ -259,9 +261,10 @@ void main() {
);
}
final waitEnds = mailbox.codesWait.single.until;
// Moved on by the hour the codes wait, as it overlaps the 72.
expect(
(await resets()).single.takesEffectAt,
waitEnds.add(resetDelay),
asked.takesEffectAt.add(waitEnds.difference(now)),
);
// Anna, held back while codes wait: her try pushes it no less.
nextStep();
@ -301,10 +304,13 @@ void main() {
signInFrom('198.51.100.7', totp: codeOf(enrolment, at: now)),
refused(PasswordRefusal.tooManyAttempts),
);
// Moved on by the hour the owner is held out, as it overlaps the
// 72: every refusal of that hour counted once.
expect(
(await resets()).single.takesEffectAt,
heldOutAt.add(const Duration(hours: 1)).add(resetDelay),
asked.takesEffectAt.add(const Duration(hours: 1)),
);
expect(heldOutAt, now);
now = asked.takesEffectAt;
final other = await PasswordDevice.make(seed: 5);
await expectLater(
@ -367,6 +373,133 @@ void main() {
expect((await resets()).single.takesEffectAt, asked.takesEffectAt);
});
/// Someone who knows only Anna's address fills its ceiling of
/// proofs from three networks (30 wrong passwords), then begins once
/// more: held out, she is for the hour.
Future<void> strangerFillsCeiling() async {
final wrong = await PasswordDevice.make(seed: 77);
for (final network in ['192.0.2.1', '192.0.2.2', '192.0.2.3']) {
for (var i = 0; i < 10; i++) {
await expectLater(
signInFrom(network, with_: wrong),
refused(PasswordRefusal.wrongCredentials),
);
}
}
await expectLater(
signInFrom('192.0.2.4', with_: wrong),
refused(PasswordRefusal.tooManyAttempts),
);
}
test('a hold-out moves a reset on only by the time it overlaps: '
'refusals within an hour already counted add nothing, and the '
'owner still gets 72 hours outside hold-outs', () async {
await annaComplete();
final asked = await askAuthenticatorReset();
// The proof asking it out of the address's hour.
now = now.add(const Duration(hours: 1, minutes: 1));
await strangerFillsCeiling();
final once = (await resets()).single.takesEffectAt;
expect(once, asked.takesEffectAt.add(const Duration(hours: 1)));
// Ten minutes on, still within the hour: refused again, moved on
// by the ten minutes alone.
now = now.add(const Duration(minutes: 10));
final wrong = await PasswordDevice.make(seed: 77);
await expectLater(
signInFrom('192.0.2.5', with_: wrong),
refused(PasswordRefusal.tooManyAttempts),
);
expect(
(await resets()).single.takesEffectAt,
once.add(const Duration(minutes: 10)),
);
});
test('knowing only the address, a burst every three days delays the '
"owner's own reset by the hours it held her out, never "
'for good (the reading of the fixes, A1)', () async {
await annaComplete();
// Anna lost her phone and has no recovery codes: she asks.
final asked = await askAuthenticatorReset();
var due = asked.takesEffectAt;
for (var round = 0; round < 3; round++) {
// An hour before it would take effect, the stranger bursts.
now = due.subtract(const Duration(hours: 1));
await strangerFillsCeiling();
final pushed = (await resets()).single.takesEffectAt;
expect(pushed, due.add(const Duration(hours: 1)));
due = pushed;
}
// Three hours later than first told, it is Anna's.
expect(due, asked.takesEffectAt.add(const Duration(hours: 3)));
now = due.add(const Duration(minutes: 2));
expect(
(await signInFrom('198.51.100.7')).needsAuthenticator,
isTrue,
);
});
test('knowing only the address keeps no forgotten reset alive: a '
'hold-out once it took effect moves nothing, and it lapses '
'as ever (the reading of the fixes, A2)', () async {
await annaComplete();
final asked = await askAuthenticatorReset();
now = asked.takesEffectAt.add(const Duration(days: 6));
await strangerFillsCeiling();
expect((await resets()).single.takesEffectAt, asked.takesEffectAt);
now = asked.takesEffectAt.add(
resetLapsesAfter + const Duration(minutes: 1),
);
// Whoever learnt the password carries out nothing.
await expectLater(
signInFrom('203.0.113.9'),
refused(PasswordRefusal.wrongCredentials),
);
expect(
await TotpAuthenticator.db.count(
session,
where: (t) => t.authUserId.equals(anna),
),
1,
);
});
test('a reset lapses 30 days after it was asked, however far '
'hold-outs moved it: never carried out, not shown, pruned, and '
'asked again it waits anew with a new mail', () async {
await annaComplete();
final asked = await askAuthenticatorReset();
// Held out for 40 days on end.
await atNow(
() => pushOutResets(
session,
anna,
now.add(const Duration(days: 40)),
),
);
now = now.add(resetLivesAtMost - const Duration(minutes: 1));
// Still waiting the day before: not due.
await expectLater(
signInFrom('203.0.113.9'),
refused(PasswordRefusal.notDue),
);
now = now.add(const Duration(minutes: 2));
await expectLater(
signInFrom('203.0.113.9'),
refused(PasswordRefusal.wrongCredentials),
);
final ways = await atNow(
() => endpoints.account.signInWays(asAnna()),
);
expect(ways.authenticatorResetAt, isNull);
expect(await resets(), isEmpty);
final again = await askAuthenticatorReset();
expect(again.takesEffectAt, now.add(resetDelay));
expect(mailbox.notices, hasLength(2));
expect(asked.takesEffectAt.isBefore(again.takesEffectAt), isTrue);
});
test("are shown to the owner's signed-in devices, each kind with "
'when it takes effect, none lapsed or carried out', () async {
await annaComplete();
@ -618,7 +751,21 @@ void main() {
createdAt: DateTime.utc(2025),
),
);
expect(await forgetLapsedResets(session, now: now), 2);
// Carl's account reset, asked 31 days ago and moved on by
// hold-outs past now: lapsed all the same.
final carl = await newAnonymousPerson(session);
await AccountReset.db.insertRow(
session,
AccountReset(
authUserId: carl,
kind: AccountResetKind.account,
takesEffectAt: now.add(const Duration(days: 5)),
createdAt: now.subtract(
resetLivesAtMost + const Duration(days: 1),
),
),
);
expect(await forgetLapsedResets(session, now: now), 3);
expect(await resets(), isEmpty);
expect(
await AccountReset.db.count(