fix(auth): a reset takes effect 14 days after it was asked at the latest

Hold-outs move a waiting reset on by the time they hold its owner out, but
never past 14 days after it was asked (resetWaitsAtMost): whoever knows
only an address can fill its ceiling of proofs every hour, and kept up, no
reset, the owner's own recovery included, would ever take effect (the
short reading, M3; the cap decided by the developer on 21 Oct). Whoever
means to take an account over with its password and mailbox, or its
phrase, has to hold the owner out for all 14 days, each wait mailing them.

The 7-day lapse after a reset takes effect and the 30-day bound stay.

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:39:58 +02:00
commit 6dfa989eb3
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -1,7 +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;
show pushOutResets, resetLivesAtMost, resetWaitsAtMost;
import 'package:halkyon_base_server/src/password/passwords.dart'
show beginKeyProof, beginPasswordSignIn, completePasswordSignIn;
import 'package:serverpod/serverpod.dart';
@ -417,8 +417,8 @@ void main() {
});
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 {
"owner's own reset by the hours it held her out (the reading of "
'the fixes, A1)', () async {
await annaComplete();
// Anna lost her phone and has no recovery codes: she asks.
final asked = await askAuthenticatorReset();
@ -465,12 +465,47 @@ void main() {
);
});
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 {
test('knowing only the address, a hold-out kept up holds the '
"owner's reset back 14 days after she asked at the latest: a "
'burst then pushes it no further, and the password alone carries '
'it out once the hour is over (the short reading, M3)', () async {
await annaComplete();
final askedAt = now;
final asked = await askAuthenticatorReset();
final latest = askedAt.add(resetWaitsAtMost);
// Held out for 11 days less half an hour, on end.
await atNow(
() => pushOutResets(
session,
anna,
now.add(const Duration(days: 11, minutes: -30)),
),
);
expect(
(await resets()).single.takesEffectAt,
latest.subtract(const Duration(minutes: 30)),
);
expect(asked.takesEffectAt, askedAt.add(resetDelay));
// An hour before the latest, the stranger bursts: an hour's push
// would pass it.
now = latest.subtract(const Duration(hours: 1));
await strangerFillsCeiling();
expect((await resets()).single.takesEffectAt, latest);
// The hour over, Anna's password alone carries it out.
now = latest.add(const Duration(minutes: 2));
expect(
(await signInFrom('198.51.100.7')).needsAuthenticator,
isTrue,
);
});
test('held out for 40 days on end, a reset takes effect 14 days '
'after it was asked and lapses a week after that as ever: '
'never carried out, not shown, and asked again it waits anew '
'with a new mail', () async {
await annaComplete();
final askedAt = now;
final asked = await askAuthenticatorReset();
// Held out for 40 days on end.
await atNow(
() => pushOutResets(
session,
@ -478,13 +513,15 @@ void main() {
now.add(const Duration(days: 40)),
),
);
now = now.add(resetLivesAtMost - const Duration(minutes: 1));
// Still waiting the day before: not due.
final latest = askedAt.add(resetWaitsAtMost);
expect((await resets()).single.takesEffectAt, latest);
// Still waiting a minute before: not due.
now = latest.subtract(const Duration(minutes: 1));
await expectLater(
signInFrom('203.0.113.9'),
refused(PasswordRefusal.notDue),
);
now = now.add(const Duration(minutes: 2));
now = latest.add(resetLapsesAfter + const Duration(minutes: 1));
await expectLater(
signInFrom('203.0.113.9'),
refused(PasswordRefusal.wrongCredentials),