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
11 changed files with 41748 additions and 65 deletions
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -15,3 +15,4 @@
|
|||
20261020211224310-email-code-purpose
|
||||
20261021100319502-session-ended-warning
|
||||
20261021110159448-second-factor-paths
|
||||
20261021134802955-reset-hold-outs
|
||||
|
|
|
|||
|
|
@ -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