fix(auth): codes after the recovery phrase count apart from the password's

The full reading's M3: whoever knows the password made the account's codes
wait, and that wait refused the phrase's owner their reset too, so they were
held out of both without end. Codes now count per account and per way they
came (SecondFactorPath: after the password, after the phrase), each with
its own waits; a code accepted either way clears both.

Migration (module, Learn's and Todo's servers; Learn's device schema
rebuilds the server-only table):
  DROP INDEX "second_factor_failures_person_idx";
  ALTER TABLE "second_factor_failures" ADD COLUMN "path" text NOT NULL
    DEFAULT 'password'::text;
  CREATE UNIQUE INDEX "second_factor_failures_person_idx" ON
    "second_factor_failures" USING btree ("authUserId", "path");
Existing rows are the password's, unique per account, so unique per
account and way too.

Also the hosts' generated future calls, for the pruning's doc comment the
lapsed resets' commit changed.

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 13:06:26 +02:00
commit 4d1cd2045c
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
8 changed files with 41930 additions and 17 deletions

View file

@ -11,25 +11,30 @@
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:serverpod/serverpod.dart' as _is;
import '../password/second_factor_path.dart' as _iqz50kjy;
/// The codes an account's second factor refused in a row (ADR 0017; the
/// second reading's M4): whoever knows the password guesses codes, and
/// after every tenth refused in a row codes wait, an hour, then twice as
/// long each time. The row goes once a code is accepted.
/// long each time. One row per account and way the codes came
/// (`SecondFactorPath`, the full reading's M3); the rows go once a code is
/// accepted.
abstract class SecondFactorFailures
implements _is.TableRow<int?>, _is.ProtocolSerialization {
SecondFactorFailures._({
this.id,
required this.authUserId,
_iqz50kjy.SecondFactorPath? path,
required this.failures,
required this.waits,
this.waitUntil,
required this.updatedAt,
});
}) : path = path ?? _iqz50kjy.SecondFactorPath.password;
factory SecondFactorFailures({
int? id,
required _is.UuidValue authUserId,
_iqz50kjy.SecondFactorPath? path,
required int failures,
required int waits,
DateTime? waitUntil,
@ -44,6 +49,11 @@ abstract class SecondFactorFailures
authUserId: _is.UuidValueJsonExtension.fromJson(
jsonSerialization['authUserId'],
),
path: jsonSerialization['path'] == null
? null
: _iqz50kjy.SecondFactorPath.fromJson(
(jsonSerialization['path'] as String),
),
failures: jsonSerialization['failures'] as int,
waits: jsonSerialization['waits'] as int,
waitUntil: jsonSerialization['waitUntil'] == null
@ -65,6 +75,9 @@ abstract class SecondFactorFailures
/// The account: its person's ID (deleted with them, `deletePerson`).
_is.UuidValue authUserId;
/// Which way the codes came: after the password, or the phrase.
_iqz50kjy.SecondFactorPath path;
/// Codes refused in a row since the last accepted.
int failures;
@ -86,6 +99,7 @@ abstract class SecondFactorFailures
SecondFactorFailures copyWith({
int? id,
_is.UuidValue? authUserId,
_iqz50kjy.SecondFactorPath? path,
int? failures,
int? waits,
DateTime? waitUntil,
@ -97,6 +111,7 @@ abstract class SecondFactorFailures
'__className__': 'halkyon_base.SecondFactorFailures',
if (id != null) 'id': id,
'authUserId': authUserId.toJson(),
'path': path.toJson(),
'failures': failures,
'waits': waits,
if (waitUntil != null) 'waitUntil': waitUntil?.toJson(),
@ -143,6 +158,7 @@ class _SecondFactorFailuresImpl extends SecondFactorFailures {
_SecondFactorFailuresImpl({
int? id,
required _is.UuidValue authUserId,
_iqz50kjy.SecondFactorPath? path,
required int failures,
required int waits,
DateTime? waitUntil,
@ -150,6 +166,7 @@ class _SecondFactorFailuresImpl extends SecondFactorFailures {
}) : super._(
id: id,
authUserId: authUserId,
path: path,
failures: failures,
waits: waits,
waitUntil: waitUntil,
@ -163,6 +180,7 @@ class _SecondFactorFailuresImpl extends SecondFactorFailures {
SecondFactorFailures copyWith({
Object? id = _Undefined,
_is.UuidValue? authUserId,
_iqz50kjy.SecondFactorPath? path,
int? failures,
int? waits,
Object? waitUntil = _Undefined,
@ -171,6 +189,7 @@ class _SecondFactorFailuresImpl extends SecondFactorFailures {
return SecondFactorFailures(
id: id is int? ? id : this.id,
authUserId: authUserId ?? this.authUserId,
path: path ?? this.path,
failures: failures ?? this.failures,
waits: waits ?? this.waits,
waitUntil: waitUntil is DateTime? ? waitUntil : this.waitUntil,
@ -190,6 +209,13 @@ class SecondFactorFailuresUpdateTable
value,
);
_is.ColumnValue<_iqz50kjy.SecondFactorPath, _iqz50kjy.SecondFactorPath> path(
_iqz50kjy.SecondFactorPath value,
) => _is.ColumnValue(
table.path,
value,
);
_is.ColumnValue<int, int> failures(int value) => _is.ColumnValue(
table.failures,
value,
@ -221,6 +247,12 @@ class SecondFactorFailuresTable extends _is.Table<int?> {
'authUserId',
this,
);
path = _is.ColumnEnum(
'path',
this,
_is.EnumSerialization.byName,
hasDefault: true,
);
failures = _is.ColumnInt(
'failures',
this,
@ -244,6 +276,9 @@ class SecondFactorFailuresTable extends _is.Table<int?> {
/// The account: its person's ID (deleted with them, `deletePerson`).
late final _is.ColumnUuid authUserId;
/// Which way the codes came: after the password, or the phrase.
late final _is.ColumnEnum<_iqz50kjy.SecondFactorPath> path;
/// Codes refused in a row since the last accepted.
late final _is.ColumnInt failures;
@ -260,6 +295,7 @@ class SecondFactorFailuresTable extends _is.Table<int?> {
List<_is.Column> get columns => [
id,
authUserId,
path,
failures,
waits,
waitUntil,

View file

@ -0,0 +1,45 @@
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
/* To generate run: "serverpod generate" */
// ignore_for_file: implementation_imports
// ignore_for_file: library_private_types_in_public_api
// ignore_for_file: non_constant_identifier_names
// ignore_for_file: public_member_api_docs
// ignore_for_file: type_literal_in_constant_pattern
// ignore_for_file: use_super_parameters
// ignore_for_file: invalid_use_of_internal_member
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:serverpod/serverpod.dart' as _is;
/// Which way codes reach an account's second factor without a session
/// (ADR 0017; the full reading's M3): after the password proved right, or
/// after the recovery phrase did. Each counts its own refused codes, so
/// whoever knows the password and makes codes wait never holds back the
/// phrase's owner from their reset.
enum SecondFactorPath implements _is.SerializableModel {
/// A sign-in, or a reset of the authenticators asked with the password.
password,
/// A new password set with the recovery phrase.
phrase;
static SecondFactorPath fromJson(String name) {
switch (name) {
case 'password':
return SecondFactorPath.password;
case 'phrase':
return SecondFactorPath.phrase;
default:
throw ArgumentError(
'Value "$name" cannot be converted to "SecondFactorPath"',
);
}
}
@override
String toJson() => name;
@override
String toString() => name;
}

View file

@ -47,6 +47,7 @@ import 'password/password_signed_in.dart' as _ipk7gvzy;
import 'password/recovery_code.dart' as _iart5x0c;
import 'password/reset_pending.dart' as _i3y6qzi3;
import 'password/second_factor_failures.dart' as _i9cg650q;
import 'password/second_factor_path.dart' as _i4ivswjj;
import 'password/session_code_failures.dart' as _iqr11ieo;
import 'password/sign_in_challenge.dart' as _i9rg59hu;
import 'password/sign_in_challenge_purpose.dart' as _izijxzng;
@ -87,6 +88,7 @@ export 'password/password_signed_in.dart';
export 'password/recovery_code.dart';
export 'password/reset_pending.dart';
export 'password/second_factor_failures.dart';
export 'password/second_factor_path.dart';
export 'password/session_code_failures.dart';
export 'password/sign_in_challenge.dart';
export 'password/sign_in_challenge_purpose.dart';
@ -678,6 +680,13 @@ class Protocol extends _is.DatabaseSerializationManager {
isNullable: false,
dartType: 'UuidValue',
),
_isp.ColumnDefinition(
name: 'path',
columnType: _isp.ColumnType.text,
isNullable: false,
dartType: 'protocol:SecondFactorPath',
columnDefault: '\'password\'',
),
_isp.ColumnDefinition(
name: 'failures',
columnType: _isp.ColumnType.bigint,
@ -713,6 +722,10 @@ class Protocol extends _is.DatabaseSerializationManager {
type: _isp.IndexElementDefinitionType.column,
definition: 'authUserId',
),
_isp.IndexElementDefinition(
type: _isp.IndexElementDefinitionType.column,
definition: 'path',
),
],
type: 'btree',
isUnique: true,
@ -1109,6 +1122,9 @@ class Protocol extends _is.DatabaseSerializationManager {
if (t == _i9cg650q.SecondFactorFailures) {
return _i9cg650q.SecondFactorFailures.fromJson(data) as T;
}
if (t == _i4ivswjj.SecondFactorPath) {
return _i4ivswjj.SecondFactorPath.fromJson(data) as T;
}
if (t == _iqr11ieo.SessionCodeFailures) {
return _iqr11ieo.SessionCodeFailures.fromJson(data) as T;
}
@ -1263,6 +1279,10 @@ class Protocol extends _is.DatabaseSerializationManager {
: null)
as T;
}
if (t == _is.getType<_i4ivswjj.SecondFactorPath?>()) {
return (data != null ? _i4ivswjj.SecondFactorPath.fromJson(data) : null)
as T;
}
if (t == _is.getType<_iqr11ieo.SessionCodeFailures?>()) {
return (data != null
? _iqr11ieo.SessionCodeFailures.fromJson(data)
@ -1384,6 +1404,7 @@ class Protocol extends _is.DatabaseSerializationManager {
_iart5x0c.RecoveryCode => 'RecoveryCode',
_i3y6qzi3.ResetPending => 'ResetPending',
_i9cg650q.SecondFactorFailures => 'SecondFactorFailures',
_i4ivswjj.SecondFactorPath => 'SecondFactorPath',
_iqr11ieo.SessionCodeFailures => 'SessionCodeFailures',
_i9rg59hu.SignInChallenge => 'SignInChallenge',
_izijxzng.SignInChallengePurpose => 'SignInChallengePurpose',
@ -1469,6 +1490,8 @@ class Protocol extends _is.DatabaseSerializationManager {
return 'ResetPending';
case _i9cg650q.SecondFactorFailures():
return 'SecondFactorFailures';
case _i4ivswjj.SecondFactorPath():
return 'SecondFactorPath';
case _iqr11ieo.SessionCodeFailures():
return 'SessionCodeFailures';
case _i9rg59hu.SignInChallenge():
@ -1591,6 +1614,9 @@ class Protocol extends _is.DatabaseSerializationManager {
if (dataClassName == 'SecondFactorFailures') {
return deserialize<_i9cg650q.SecondFactorFailures>(data['data']);
}
if (dataClassName == 'SecondFactorPath') {
return deserialize<_i4ivswjj.SecondFactorPath>(data['data']);
}
if (dataClassName == 'SessionCodeFailures') {
return deserialize<_iqr11ieo.SessionCodeFailures>(data['data']);
}

View file

@ -156,6 +156,9 @@ Future<AccountSessionsEnded> resetPasswordWithPhrase(
totpCode: totpCode,
recoveryCode: recoveryCode,
notice: notice,
// Its own count (the full reading's M3): whoever knows the password
// and makes its codes wait never holds the phrase's owner back.
path: SecondFactorPath.phrase,
);
final endedBefore = await session.db.transaction((transaction) async {
await writeNewPassword(session, row, password, transaction: transaction);

View file

@ -950,7 +950,9 @@ typedef SecondFactorPassed = ({
/// no code while a reset waits, [PasswordRefusal.notDue]; while codes
/// wait, [PasswordRefusal.codesWait]; anything else,
/// [PasswordRefusal.wrongCredentials]. Every code is counted as it is
/// tried ([takeCodeAttempt]); [notice] tells the owner as a wait begins.
/// tried ([takeCodeAttempt]), apart for each [path] it came by (after the
/// password, or the recovery phrase: the full reading's M3); [notice] tells
/// the owner as a wait begins.
Future<SecondFactorPassed> secondFactor(
Session session,
PasswordKeys keys,
@ -958,6 +960,7 @@ Future<SecondFactorPassed> secondFactor(
String? totpCode,
String? recoveryCode,
required CodesWaitNotice notice,
SecondFactorPath path = SecondFactorPath.password,
}) async {
final now = clock.now().toUtc();
final confirmed = await confirmedAuthenticatorsOf(session, account);
@ -988,7 +991,7 @@ Future<SecondFactorPassed> secondFactor(
throw refusedFor(PasswordRefusal.setupIncomplete);
}
if (totp.isNotEmpty || recovery.isNotEmpty) {
final waitBegan = await takeCodeAttempt(session, account);
final waitBegan = await takeCodeAttempt(session, account, path: path);
if (totp.isNotEmpty
? await acceptTotpCode(session, keys, confirmed, totp)
: await _useRecoveryCode(session, keys, account, recovery)) {

View file

@ -1,13 +1,17 @@
### The codes an account's second factor refused in a row (ADR 0017; the
### second reading's M4): whoever knows the password guesses codes, and
### after every tenth refused in a row codes wait, an hour, then twice as
### long each time. The row goes once a code is accepted.
### long each time. One row per account and way the codes came
### (`SecondFactorPath`, the full reading's M3); the rows go once a code is
### accepted.
class: SecondFactorFailures
serverOnly: true
table: second_factor_failures
fields:
### The account: its person's ID (deleted with them, `deletePerson`).
authUserId: UuidValue
### Which way the codes came: after the password, or the phrase.
path: SecondFactorPath, default=password
### Codes refused in a row since the last accepted.
failures: int
### How many waits began since the last accepted code: the next lasts an
@ -17,7 +21,7 @@ fields:
waitUntil: DateTime?
updatedAt: DateTime
indexes:
### One row per account.
### One row per account and way.
second_factor_failures_person_idx:
fields: authUserId
fields: authUserId, path
unique: true

View file

@ -0,0 +1,12 @@
### Which way codes reach an account's second factor without a session
### (ADR 0017; the full reading's M3): after the password proved right, or
### after the recovery phrase did. Each counts its own refused codes, so
### whoever knows the password and makes codes wait never holds back the
### phrase's owner from their reset.
enum: SecondFactorPath
serialized: byName
values:
### A sign-in, or a reset of the authenticators asked with the password.
- password
### A new password set with the recovery phrase.
- phrase

View file

@ -10,7 +10,10 @@ import 'package:serverpod/serverpod.dart';
// password in a sign-in or a proof of the recovery phrase, and every
// recovery code there, counts as it is
// tried (before it is checked, so codes sent at once can't slip past), per
// account, from every network. After every [codeFailuresBeforeWait] tried
// account and way the code came (`SecondFactorPath`: after the password, or
// after the recovery phrase; the full reading's M3: whoever knows the
// password made codes wait, and the phrase's owner was held back from their
// reset too), from every network. After every [codeFailuresBeforeWait] tried
// in a row no code is taken for a while, an hour the first time and twice
// as long each time after, [longestCodeWait] at the most, and the owner is
// mailed as each wait begins: every call carrying such a code takes the
@ -51,22 +54,24 @@ const longestCodeWait = Duration(days: 1);
/// before a wait are checked.
Future<DateTime?> takeCodeAttempt(
Session session,
UuidValue account,
) {
UuidValue account, {
SecondFactorPath path = SecondFactorPath.password,
}) {
final now = clock.now().toUtc();
return session.db.transaction((transaction) async {
// SELECT pg_advisory_xact_lock($1)
await session.db.unsafeQuery(
'SELECT pg_advisory_xact_lock(@key)',
parameters: QueryParameters.named({
'key': _lockKeyOf('second_factor:${account.uuid}'),
'key': _lockKeyOf('second_factor:${account.uuid}:${path.name}'),
}),
transaction: transaction,
);
// SELECT * FROM "second_factor_failures" WHERE "authUserId" = $1
// SELECT * FROM "second_factor_failures"
// WHERE "authUserId" = $1 AND "path" = $2
final row = await SecondFactorFailures.db.findFirstRow(
session,
where: (t) => t.authUserId.equals(account),
where: (t) => t.authUserId.equals(account) & t.path.equals(path),
transaction: transaction,
);
final waiting = row?.waitUntil;
@ -83,6 +88,7 @@ Future<DateTime?> takeCodeAttempt(
final counted = SecondFactorFailures(
id: row?.id,
authUserId: account,
path: path,
failures: failures,
waits: begins ? waits + 1 : waits,
waitUntil: until ?? waiting,
@ -129,7 +135,7 @@ Duration codeWait(int waits) {
return wait > longestCodeWait ? longestCodeWait : wait;
}
/// Clears [account]'s refused codes: one was accepted.
/// Clears [account]'s refused codes, both ways': one was accepted.
Future<void> clearRefusedCodes(Session session, UuidValue account) =>
// DELETE FROM "second_factor_failures" WHERE "authUserId" = $1
SecondFactorFailures.db.deleteWhere(