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:
Tizian 「ティツィアーン」 2026-10-21 15:50:29 +02:00
commit 274b796faa
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
11 changed files with 41748 additions and 65 deletions

View file

@ -23,6 +23,7 @@ abstract class AccountReset
required this.authUserId,
required this.kind,
required this.takesEffectAt,
this.heldOutUntil,
required this.createdAt,
});
@ -31,6 +32,7 @@ abstract class AccountReset
required _is.UuidValue authUserId,
required _itrk9fro.AccountResetKind kind,
required DateTime takesEffectAt,
DateTime? heldOutUntil,
required DateTime createdAt,
}) = _AccountResetImpl;
@ -46,6 +48,11 @@ abstract class AccountReset
takesEffectAt: _is.DateTimeJsonExtension.fromJson(
jsonSerialization['takesEffectAt'],
),
heldOutUntil: jsonSerialization['heldOutUntil'] == null
? null
: _is.DateTimeJsonExtension.fromJson(
jsonSerialization['heldOutUntil'],
),
createdAt: _is.DateTimeJsonExtension.fromJson(
jsonSerialization['createdAt'],
),
@ -68,6 +75,10 @@ abstract class AccountReset
/// When it may be carried out.
DateTime takesEffectAt;
/// Until when its owner's hold-outs were counted (`pushOutResets`), so
/// a hold-out moves it on only by the time no earlier one covered.
DateTime? heldOutUntil;
DateTime createdAt;
@override
@ -81,6 +92,7 @@ abstract class AccountReset
_is.UuidValue? authUserId,
_itrk9fro.AccountResetKind? kind,
DateTime? takesEffectAt,
DateTime? heldOutUntil,
DateTime? createdAt,
});
@override
@ -91,6 +103,7 @@ abstract class AccountReset
'authUserId': authUserId.toJson(),
'kind': kind.toJson(),
'takesEffectAt': takesEffectAt.toJson(),
if (heldOutUntil != null) 'heldOutUntil': heldOutUntil?.toJson(),
'createdAt': createdAt.toJson(),
};
}
@ -136,12 +149,14 @@ class _AccountResetImpl extends AccountReset {
required _is.UuidValue authUserId,
required _itrk9fro.AccountResetKind kind,
required DateTime takesEffectAt,
DateTime? heldOutUntil,
required DateTime createdAt,
}) : super._(
id: id,
authUserId: authUserId,
kind: kind,
takesEffectAt: takesEffectAt,
heldOutUntil: heldOutUntil,
createdAt: createdAt,
);
@ -154,6 +169,7 @@ class _AccountResetImpl extends AccountReset {
_is.UuidValue? authUserId,
_itrk9fro.AccountResetKind? kind,
DateTime? takesEffectAt,
Object? heldOutUntil = _Undefined,
DateTime? createdAt,
}) {
return AccountReset(
@ -161,6 +177,9 @@ class _AccountResetImpl extends AccountReset {
authUserId: authUserId ?? this.authUserId,
kind: kind ?? this.kind,
takesEffectAt: takesEffectAt ?? this.takesEffectAt,
heldOutUntil: heldOutUntil is DateTime?
? heldOutUntil
: this.heldOutUntil,
createdAt: createdAt ?? this.createdAt,
);
}
@ -189,6 +208,12 @@ class AccountResetUpdateTable extends _is.UpdateTable<AccountResetTable> {
value,
);
_is.ColumnValue<DateTime, DateTime> heldOutUntil(DateTime? value) =>
_is.ColumnValue(
table.heldOutUntil,
value,
);
_is.ColumnValue<DateTime, DateTime> createdAt(DateTime value) =>
_is.ColumnValue(
table.createdAt,
@ -212,6 +237,10 @@ class AccountResetTable extends _is.Table<int?> {
'takesEffectAt',
this,
);
heldOutUntil = _is.ColumnDateTime(
'heldOutUntil',
this,
);
createdAt = _is.ColumnDateTime(
'createdAt',
this,
@ -229,6 +258,10 @@ class AccountResetTable extends _is.Table<int?> {
/// When it may be carried out.
late final _is.ColumnDateTime takesEffectAt;
/// Until when its owner's hold-outs were counted (`pushOutResets`), so
/// a hold-out moves it on only by the time no earlier one covered.
late final _is.ColumnDateTime heldOutUntil;
late final _is.ColumnDateTime createdAt;
@override
@ -237,6 +270,7 @@ class AccountResetTable extends _is.Table<int?> {
authUserId,
kind,
takesEffectAt,
heldOutUntil,
createdAt,
];
}

View file

@ -337,6 +337,12 @@ class Protocol extends _is.DatabaseSerializationManager {
isNullable: false,
dartType: 'DateTime',
),
_isp.ColumnDefinition(
name: 'heldOutUntil',
columnType: _isp.ColumnType.timestampWithoutTimeZone,
isNullable: true,
dartType: 'DateTime?',
),
_isp.ColumnDefinition(
name: 'createdAt',
columnType: _isp.ColumnType.timestampWithoutTimeZone,

View file

@ -11,6 +11,9 @@ fields:
kind: AccountResetKind
### When it may be carried out.
takesEffectAt: DateTime
### Until when its owner's hold-outs were counted (`pushOutResets`), so
### a hold-out moves it on only by the time no earlier one covered.
heldOutUntil: DateTime?
createdAt: DateTime
indexes:
### One of each kind per account.

View file

@ -3,38 +3,44 @@ import 'package:halkyon_base_server/src/generated/protocol.dart';
import 'package:serverpod/serverpod.dart';
// Resets that wait (ADR 0017, D3), and how long they stay (the full
// reading's M2): a reset that took effect and was never carried out lapses
// a week later, as never asked, so an authenticator's reset asked and
// forgotten doesn't leave the account to the password alone for good, nor
// an account's reset to whoever holds the mailbox a year on. Asked again,
// it waits anew, with a new mail. A reset of the authenticators carried out
// never lapses: its row says the password alone signs in until an
// authenticator is confirmed, which removes it.
// reading's M2 and the reading of its fixes' A2): a reset that took effect
// and was never carried out lapses a week later, as never asked, and every
// reset not carried out lapses 30 days after it was asked, whatever moved
// it, so an authenticator's reset asked and forgotten doesn't leave the
// account to the password alone for good, nor an account's reset to
// whoever holds the mailbox a year on, nor can anyone keep one alive. Asked
// again, it waits anew, with a new mail. A reset of the authenticators
// carried out never lapses: its row says the password alone signs in until
// an authenticator is confirmed, which removes it.
//
// Nor does a reset take effect while its owner is held out of calling it
// off (the full reading's H1): whoever knows the password made the codes
// wait, or whoever knows the address filled its proofs' ceiling, and the
// owner's sign-in, which calls every reset off, was refused. Each such
// hold-out moves the account's resets not carried out to take effect no
// sooner than [resetDelay] after it ends ([pushOutResets]), so the owner
// always has that long, once let in, to call them off; and a signed-in
// device shows them (`SignInWays`), whose owner calls them off with their
// proof, which no wait holds back.
// Nor does a reset take effect before its owner had [resetDelay] outside
// hold-outs to call it off (the full reading's H1, as the reading of its
// fixes' A1 bounded it): whoever knows the password made the codes wait,
// or whoever knows the address filled its proofs' ceiling, and the owner's
// sign-in, which calls every reset off, was refused. Each such hold-out
// that begins before the reset takes effect moves it on by the time it
// holds the owner out and no hold-out counted before did
// ([pushOutResets]): a burst that holds the owner out for an hour buys an
// 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.
/// How long a reset waits before it takes effect (ADR 0017, D3): long
/// enough for the account's owner, told by mail, to call it off by signing
/// in.
const resetDelay = Duration(hours: 72);
/// How long a reset that took effect waits to be carried out before it
/// lapses.
const resetLapsesAfter = Duration(days: 7);
/// How long after it was asked a reset not carried out lapses, however far
/// hold-outs moved it.
const resetLivesAtMost = Duration(days: 30);
/// 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.
bool resetLapsed(AccountReset reset, DateTime now) =>
!reset.takesEffectAt.add(resetLapsesAfter).isAfter(now);
!reset.takesEffectAt.add(resetLapsesAfter).isAfter(now) ||
!reset.createdAt.add(resetLivesAtMost).isAfter(now);
/// Deletes the resets that lapsed at [now], and returns how many: an
/// account's, and an authenticators' reset not carried out (its account
@ -43,21 +49,23 @@ Future<int> forgetLapsedResets(
Session session, {
required DateTime now,
}) async {
final cutoff = now.toUtc().subtract(resetLapsesAfter);
// DELETE FROM "account_reset"
// WHERE "kind" = 'account' AND "takesEffectAt" <= $1 RETURNING *
final tookEffectBy = now.toUtc().subtract(resetLapsesAfter);
final askedBy = now.toUtc().subtract(resetLivesAtMost);
// DELETE FROM "account_reset" WHERE "kind" = 'account'
// AND ("takesEffectAt" <= $1 OR "createdAt" <= $2) RETURNING *
final accounts = await AccountReset.db.deleteWhere(
session,
where: (t) =>
t.kind.equals(AccountResetKind.account) & (t.takesEffectAt <= cutoff),
t.kind.equals(AccountResetKind.account) &
((t.takesEffectAt <= tookEffectBy) | (t.createdAt <= askedBy)),
);
// SELECT * FROM "account_reset"
// WHERE "kind" = 'authenticator' AND "takesEffectAt" <= $1
// SELECT * FROM "account_reset" WHERE "kind" = 'authenticator'
// AND ("takesEffectAt" <= $1 OR "createdAt" <= $2)
final candidates = await AccountReset.db.find(
session,
where: (t) =>
t.kind.equals(AccountResetKind.authenticator) &
(t.takesEffectAt <= cutoff),
((t.takesEffectAt <= tookEffectBy) | (t.createdAt <= askedBy)),
);
if (candidates.isEmpty) {
return accounts.length;
@ -86,46 +94,65 @@ Future<int> forgetLapsedResets(
return accounts.length + lapsed.length;
}
/// Moves [account]'s resets not carried out and not lapsed to take effect
/// no sooner than [resetDelay] after [heldOutUntil], when its owner, held
/// out of signing in meanwhile, can call them off again (the full reading's
/// H1). A reset of the authenticators carried out (none confirmed any more)
/// stays as it is: its owner signs in with the password alone meanwhile.
/// 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.
Future<void> pushOutResets(
Session session,
UuidValue account,
DateTime heldOutUntil, {
Transaction? transaction,
}) async {
final takesEffectAt = heldOutUntil.toUtc().add(resetDelay);
final notLapsedSince = clock.now().toUtc().subtract(resetLapsesAfter);
// SELECT count(*) FROM "totp_authenticator"
// WHERE "authUserId" = $1 AND "confirmedAt" IS NOT NULL
final notCarriedOut =
await TotpAuthenticator.db.count(
DateTime heldOutUntil,
) async {
final now = clock.now().toUtc();
final until = heldOutUntil.toUtc();
if (!until.isAfter(now)) {
return;
}
await session.db.transaction((transaction) async {
// SELECT * FROM "account_reset" WHERE "authUserId" = $1 FOR UPDATE
final rows = await AccountReset.db.find(
session,
where: (t) => t.authUserId.equals(account),
lockMode: LockMode.forUpdate,
transaction: transaction,
);
if (rows.isEmpty) {
return;
}
// SELECT count(*) FROM "totp_authenticator"
// WHERE "authUserId" = $1 AND "confirmedAt" IS NOT NULL
final notCarriedOut =
await TotpAuthenticator.db.count(
session,
where: (t) =>
t.authUserId.equals(account) & t.confirmedAt.notEquals(null),
transaction: transaction,
) >
0;
for (final reset in rows) {
if (resetLapsed(reset, now) ||
(reset.kind == AccountResetKind.authenticator && !notCarriedOut)) {
continue;
}
final counted = reset.heldOutUntil;
final from = counted != null && counted.isAfter(now) ? counted : now;
if (!until.isAfter(from) || !reset.takesEffectAt.isAfter(from)) {
continue;
}
// UPDATE "account_reset" SET "takesEffectAt" = $1, "heldOutUntil" = $2
// WHERE "id" = $3
await AccountReset.db.updateRow(
session,
where: (t) =>
t.authUserId.equals(account) & t.confirmedAt.notEquals(null),
reset.copyWith(
takesEffectAt: reset.takesEffectAt.add(until.difference(from)),
heldOutUntil: until,
),
columns: (t) => [t.takesEffectAt, t.heldOutUntil],
transaction: transaction,
) >
0;
// UPDATE "account_reset" SET "takesEffectAt" = $1
// WHERE "authUserId" = $2 AND "takesEffectAt" < $1
// AND "takesEffectAt" > $3 [AND "kind" = 'account']
await AccountReset.db.updateWhere(
session,
columnValues: (t) => [t.takesEffectAt(takesEffectAt)],
where: (t) {
final waiting =
t.authUserId.equals(account) &
(t.takesEffectAt < takesEffectAt) &
(t.takesEffectAt > notLapsedSince);
return notCarriedOut
? waiting
: waiting & t.kind.equals(AccountResetKind.account);
},
transaction: transaction,
);
);
}
});
}
/// When [account]'s resets waiting (or due, not carried out and not lapsed)

View file

@ -0,0 +1,33 @@
{
"__className__": "serverpod.DatabaseMigration",
"actions": [
{
"__className__": "serverpod.DatabaseMigrationAction",
"type": "alterTable",
"alterTable": {
"__className__": "serverpod.TableMigration",
"name": "account_reset",
"schema": "public",
"addColumns": [
{
"__className__": "serverpod.ColumnDefinition",
"name": "heldOutUntil",
"fieldName": "heldOutUntil",
"columnType": 4,
"isNullable": true,
"dartType": "DateTime?"
}
],
"deleteColumns": [],
"modifyColumns": [],
"addIndexes": [],
"deleteIndexes": [],
"addForeignKeys": [],
"deleteForeignKeys": [],
"warnings": []
}
}
],
"warnings": [],
"migrationApiVersion": 1
}

View file

@ -0,0 +1,41 @@
BEGIN;
--
-- ACTION ALTER TABLE
--
ALTER TABLE "account_reset" ADD COLUMN "heldOutUntil" timestamp without time zone;
--
-- MIGRATION VERSION FOR halkyon_base
--
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
VALUES ('halkyon_base', '20261021134802955-reset-hold-outs', now())
ON CONFLICT ("module")
DO UPDATE SET "version" = '20261021134802955-reset-hold-outs', "timestamp" = now();
--
-- MIGRATION VERSION FOR serverpod
--
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
VALUES ('serverpod', '20260824182259319', now())
ON CONFLICT ("module")
DO UPDATE SET "version" = '20260824182259319', "timestamp" = now();
--
-- MIGRATION VERSION FOR serverpod_auth_idp
--
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
VALUES ('serverpod_auth_idp', '20260924105404509', now())
ON CONFLICT ("module")
DO UPDATE SET "version" = '20260924105404509', "timestamp" = now();
--
-- MIGRATION VERSION FOR serverpod_auth_core
--
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
VALUES ('serverpod_auth_core', '20260924105232991', now())
ON CONFLICT ("module")
DO UPDATE SET "version" = '20260924105232991', "timestamp" = now();
COMMIT;

View file

@ -15,3 +15,4 @@
20261020211224310-email-code-purpose
20261021100319502-session-ended-warning
20261021110159448-second-factor-paths
20261021134802955-reset-hold-outs

View file

@ -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(