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:
parent
8d6c54ef22
commit
6dfa989eb3
2 changed files with 71 additions and 14 deletions
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Reference in a new issue