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

@ -24,6 +24,17 @@ import 'package:serverpod/serverpod.dart';
// hour, never another 72, and refusals within an hour already counted buy
// nothing. A signed-in device shows the waiting resets (`SignInWays`),
// 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);
@ -35,6 +46,10 @@ const resetLapsesAfter = Duration(days: 7);
/// hold-outs moved it.
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.
/// For a reset of the authenticators only while it isn't carried out (the
/// 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
/// owner is held out from now until [heldOutUntil] and no hold-out counted
/// before covered, where that hold-out begins before the reset takes
/// effect (see above). Each reset's row keeps until when hold-outs were
/// counted, under a row lock, so refusals at once count once.
/// effect, never past [resetWaitsAtMost] after it was asked (see above).
/// Each reset's row keeps until when hold-outs were counted, under a row
/// lock, so refusals at once count once.
Future<void> pushOutResets(
Session session,
UuidValue account,
@ -137,15 +153,19 @@ Future<void> pushOutResets(
}
final counted = reset.heldOutUntil;
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;
}
final pushed = reset.takesEffectAt.add(until.difference(from));
// UPDATE "account_reset" SET "takesEffectAt" = $1, "heldOutUntil" = $2
// WHERE "id" = $3
await AccountReset.db.updateRow(
session,
reset.copyWith(
takesEffectAt: reset.takesEffectAt.add(until.difference(from)),
takesEffectAt: pushed.isAfter(latest) ? latest : pushed,
heldOutUntil: until,
),
columns: (t) => [t.takesEffectAt, t.heldOutUntil],