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
4 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)