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
2 changed files with 71 additions and 14 deletions

View file

@ -24,6 +24,17 @@ import 'package:serverpod/serverpod.dart';
// hour, never another 72, and refusals within an hour already counted buy // hour, never another 72, and refusals within an hour already counted buy
// nothing. A signed-in device shows the waiting resets (`SignInWays`), // nothing. A signed-in device shows the waiting resets (`SignInWays`),
// whose owner calls them off with their proof, which no wait holds back. // whose owner calls them off with their proof, which no wait holds back.
//
// Nor do hold-outs move a reset past [resetWaitsAtMost] after it was asked
// (the short reading's M3, decided by the developer on 21 Oct): whoever
// knows only the address can fill its ceiling every hour, and would
// otherwise keep any reset, the owner's own recovery too, from ever taking
// effect. Fourteen days bound what such a stranger delays; whoever means to
// take the account over with the password and the mailbox (or the phrase)
// has to hold its owner out for all of them, while every wait mails the
// owner. With the cap, a reset not carried out lapses a week after it took
// effect, within [resetLivesAtMost] (kept as the bound a reset never
// outlives).
const resetDelay = Duration(hours: 72); const resetDelay = Duration(hours: 72);
@ -35,6 +46,10 @@ const resetLapsesAfter = Duration(days: 7);
/// hold-outs moved it. /// hold-outs moved it.
const resetLivesAtMost = Duration(days: 30); const resetLivesAtMost = Duration(days: 30);
/// How long after it was asked a reset takes effect at the latest, however
/// long its owner is held out.
const resetWaitsAtMost = Duration(days: 14);
/// Whether [reset] lapsed at [now]: a week or more since it took effect. /// Whether [reset] lapsed at [now]: a week or more since it took effect.
/// For a reset of the authenticators only while it isn't carried out (the /// For a reset of the authenticators only while it isn't carried out (the
/// account still has a confirmed authenticator): the caller knows which. /// account still has a confirmed authenticator): the caller knows which.
@ -97,8 +112,9 @@ Future<int> forgetLapsedResets(
/// Moves [account]'s resets not carried out, nor lapsed, on by the time its /// Moves [account]'s resets not carried out, nor lapsed, on by the time its
/// owner is held out from now until [heldOutUntil] and no hold-out counted /// owner is held out from now until [heldOutUntil] and no hold-out counted
/// before covered, where that hold-out begins before the reset takes /// before covered, where that hold-out begins before the reset takes
/// effect (see above). Each reset's row keeps until when hold-outs were /// effect, never past [resetWaitsAtMost] after it was asked (see above).
/// counted, under a row lock, so refusals at once count once. /// Each reset's row keeps until when hold-outs were counted, under a row
/// lock, so refusals at once count once.
Future<void> pushOutResets( Future<void> pushOutResets(
Session session, Session session,
UuidValue account, UuidValue account,
@ -137,15 +153,19 @@ Future<void> pushOutResets(
} }
final counted = reset.heldOutUntil; final counted = reset.heldOutUntil;
final from = counted != null && counted.isAfter(now) ? counted : now; final from = counted != null && counted.isAfter(now) ? counted : now;
if (!until.isAfter(from) || !reset.takesEffectAt.isAfter(from)) { final latest = reset.createdAt.add(resetWaitsAtMost);
if (!until.isAfter(from) ||
!reset.takesEffectAt.isAfter(from) ||
!reset.takesEffectAt.isBefore(latest)) {
continue; continue;
} }
final pushed = reset.takesEffectAt.add(until.difference(from));
// UPDATE "account_reset" SET "takesEffectAt" = $1, "heldOutUntil" = $2 // UPDATE "account_reset" SET "takesEffectAt" = $1, "heldOutUntil" = $2
// WHERE "id" = $3 // WHERE "id" = $3
await AccountReset.db.updateRow( await AccountReset.db.updateRow(
session, session,
reset.copyWith( reset.copyWith(
takesEffectAt: reset.takesEffectAt.add(until.difference(from)), takesEffectAt: pushed.isAfter(latest) ? latest : pushed,
heldOutUntil: until, heldOutUntil: until,
), ),
columns: (t) => [t.takesEffectAt, t.heldOutUntil], columns: (t) => [t.takesEffectAt, t.heldOutUntil],

View file

@ -1,7 +1,7 @@
import 'package:clock/clock.dart'; import 'package:clock/clock.dart';
import 'package:halkyon_base_server/base.dart'; 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; 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;
import 'package:serverpod/serverpod.dart'; import 'package:serverpod/serverpod.dart';
@ -417,8 +417,8 @@ void main() {
}); });
test('knowing only the address, a burst every three days delays the ' test('knowing only the address, a burst every three days delays the '
"owner's own reset by the hours it held her out, never " "owner's own reset by the hours it held her out (the reading of "
'for good (the reading of the fixes, A1)', () async { 'the fixes, A1)', () async {
await annaComplete(); await annaComplete();
// Anna lost her phone and has no recovery codes: she asks. // Anna lost her phone and has no recovery codes: she asks.
final asked = await askAuthenticatorReset(); final asked = await askAuthenticatorReset();
@ -465,12 +465,47 @@ void main() {
); );
}); });
test('a reset lapses 30 days after it was asked, however far ' test('knowing only the address, a hold-out kept up holds the '
'hold-outs moved it: never carried out, not shown, pruned, and ' "owner's reset back 14 days after she asked at the latest: a "
'asked again it waits anew with a new mail', () async { '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(); 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(); final asked = await askAuthenticatorReset();
// Held out for 40 days on end.
await atNow( await atNow(
() => pushOutResets( () => pushOutResets(
session, session,
@ -478,13 +513,15 @@ void main() {
now.add(const Duration(days: 40)), now.add(const Duration(days: 40)),
), ),
); );
now = now.add(resetLivesAtMost - const Duration(minutes: 1)); final latest = askedAt.add(resetWaitsAtMost);
// Still waiting the day before: not due. expect((await resets()).single.takesEffectAt, latest);
// Still waiting a minute before: not due.
now = latest.subtract(const Duration(minutes: 1));
await expectLater( await expectLater(
signInFrom('203.0.113.9'), signInFrom('203.0.113.9'),
refused(PasswordRefusal.notDue), refused(PasswordRefusal.notDue),
); );
now = now.add(const Duration(minutes: 2)); now = latest.add(resetLapsesAfter + const Duration(minutes: 1));
await expectLater( await expectLater(
signInFrom('203.0.113.9'), signInFrom('203.0.113.9'),
refused(PasswordRefusal.wrongCredentials), refused(PasswordRefusal.wrongCredentials),