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:
parent
a72b96d628
commit
274b796faa
1 changed files with 41748 additions and 65 deletions
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in a new issue