fix(auth): a reset waits while its owner is held out, and devices show it

The full reading's H1: an owner could be kept from calling a reset off, and
the reset then went through. Whoever knew the password and had the mailbox
asked an authenticators' reset and kept the codes waiting, so the owner's
sign-ins were refused until it took effect; whoever had the mailbox filled
the address's proofs ceiling from three networks and completed an account
reset. Now every hold-out of the owner after a reset was asked (a wait
that begins, an attempt a wait refuses, a sign-in or proof refused by the
address's ceiling) moves the account's resets not carried out to take
effect no sooner than 72 hours after it ends (pushOutResets). A reset
carried out (the password alone signing in until an authenticator is
confirmed) is never pushed, nor a lapsed one brought back.

Signed-in devices are told too: SignInWays carries when a waiting reset
of each kind takes effect, the account section shows them with Call it
off, which takes the owner's proof (password and code; password.
callOffResets, forwarded by members) and calls every reset off, as no wait
holds a signed-in owner's proof back. Texts in English and German.

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:17:30 +02:00
commit 9d0d037c86
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
15 changed files with 1022 additions and 18 deletions

View file

@ -21,7 +21,7 @@ export 'src/halkyon_base.dart';
export 'src/instance/data_location.dart';
export 'src/instance/source_code.dart';
export 'src/password/account_resets.dart'
show forgetLapsedResets, resetLapsesAfter;
show forgetLapsedResets, resetDelay, resetLapsesAfter;
export 'src/password/authenticators.dart'
show
forgetUnconfirmedAuthenticators,
@ -36,7 +36,6 @@ export 'src/password/passwords.dart'
newPasswordCosts,
passwordGroup,
passwordMethod,
resetDelay,
signInChallengeLifetime,
signsInWithPassword,
wrappedKeysLength;

View file

@ -19,3 +19,9 @@ fields:
### a row, the owner's warning until they dismiss it or set a new
### password (ADR 0017, the fourth reading); null for none.
sessionEndedForProofsAt: DateTime?
### When a reset of the authenticators waiting (or due, not carried out)
### takes effect, so a signed-in device shows it and its owner calls it
### off with their proof (ADR 0017, the full reading's H1); null for none.
authenticatorResetAt: DateTime?
### When a reset of the account waiting (or due) takes effect, alike.
accountResetAt: DateTime?

View file

@ -23,6 +23,8 @@ abstract class SignInWays
bool? passwordOnly,
bool? secondFactorWorks,
this.sessionEndedForProofsAt,
this.authenticatorResetAt,
this.accountResetAt,
}) : password = password ?? false,
authenticator = authenticator ?? false,
passwordOnly = passwordOnly ?? false,
@ -36,6 +38,8 @@ abstract class SignInWays
bool? passwordOnly,
bool? secondFactorWorks,
DateTime? sessionEndedForProofsAt,
DateTime? authenticatorResetAt,
DateTime? accountResetAt,
}) = _SignInWaysImpl;
factory SignInWays.fromJson(Map<String, dynamic> jsonSerialization) {
@ -62,6 +66,16 @@ abstract class SignInWays
: _is.DateTimeJsonExtension.fromJson(
jsonSerialization['sessionEndedForProofsAt'],
),
authenticatorResetAt: jsonSerialization['authenticatorResetAt'] == null
? null
: _is.DateTimeJsonExtension.fromJson(
jsonSerialization['authenticatorResetAt'],
),
accountResetAt: jsonSerialization['accountResetAt'] == null
? null
: _is.DateTimeJsonExtension.fromJson(
jsonSerialization['accountResetAt'],
),
);
}
@ -90,6 +104,14 @@ abstract class SignInWays
/// password (ADR 0017, the fourth reading); null for none.
DateTime? sessionEndedForProofsAt;
/// When a reset of the authenticators waiting (or due, not carried out)
/// takes effect, so a signed-in device shows it and its owner calls it
/// off with their proof (ADR 0017, the full reading's H1); null for none.
DateTime? authenticatorResetAt;
/// When a reset of the account waiting (or due) takes effect, alike.
DateTime? accountResetAt;
/// Returns a shallow copy of this [SignInWays]
/// with some or all fields replaced by the given arguments.
@_is.useResult
@ -101,6 +123,8 @@ abstract class SignInWays
bool? passwordOnly,
bool? secondFactorWorks,
DateTime? sessionEndedForProofsAt,
DateTime? authenticatorResetAt,
DateTime? accountResetAt,
});
@override
Map<String, dynamic> toJson() {
@ -114,6 +138,9 @@ abstract class SignInWays
'secondFactorWorks': secondFactorWorks,
if (sessionEndedForProofsAt != null)
'sessionEndedForProofsAt': sessionEndedForProofsAt?.toJson(),
if (authenticatorResetAt != null)
'authenticatorResetAt': authenticatorResetAt?.toJson(),
if (accountResetAt != null) 'accountResetAt': accountResetAt?.toJson(),
};
}
@ -129,6 +156,9 @@ abstract class SignInWays
'secondFactorWorks': secondFactorWorks,
if (sessionEndedForProofsAt != null)
'sessionEndedForProofsAt': sessionEndedForProofsAt?.toJson(),
if (authenticatorResetAt != null)
'authenticatorResetAt': authenticatorResetAt?.toJson(),
if (accountResetAt != null) 'accountResetAt': accountResetAt?.toJson(),
};
}
@ -149,6 +179,8 @@ class _SignInWaysImpl extends SignInWays {
bool? passwordOnly,
bool? secondFactorWorks,
DateTime? sessionEndedForProofsAt,
DateTime? authenticatorResetAt,
DateTime? accountResetAt,
}) : super._(
gitHub: gitHub,
email: email,
@ -157,6 +189,8 @@ class _SignInWaysImpl extends SignInWays {
passwordOnly: passwordOnly,
secondFactorWorks: secondFactorWorks,
sessionEndedForProofsAt: sessionEndedForProofsAt,
authenticatorResetAt: authenticatorResetAt,
accountResetAt: accountResetAt,
);
/// Returns a shallow copy of this [SignInWays]
@ -171,6 +205,8 @@ class _SignInWaysImpl extends SignInWays {
bool? passwordOnly,
bool? secondFactorWorks,
Object? sessionEndedForProofsAt = _Undefined,
Object? authenticatorResetAt = _Undefined,
Object? accountResetAt = _Undefined,
}) {
return SignInWays(
gitHub: gitHub ?? this.gitHub,
@ -182,6 +218,12 @@ class _SignInWaysImpl extends SignInWays {
sessionEndedForProofsAt: sessionEndedForProofsAt is DateTime?
? sessionEndedForProofsAt
: this.sessionEndedForProofsAt,
authenticatorResetAt: authenticatorResetAt is DateTime?
? authenticatorResetAt
: this.authenticatorResetAt,
accountResetAt: accountResetAt is DateTime?
? accountResetAt
: this.accountResetAt,
);
}
}

View file

@ -747,6 +747,25 @@ class Endpoints extends _is.EndpointDispatch {
password: params['password'],
),
),
'callOffResets': _is.MethodConnector(
name: 'callOffResets',
params: {
'proof': _is.ParameterDescription(
name: 'proof',
type: _is.getType<_ifhod73s.OwnerProof>(),
nullable: false,
),
},
call:
(
_is.Session session,
Map<String, dynamic> params,
) async => (endpoints['password'] as _ig7fyg1b.PasswordEndpoint)
.callOffResets(
session,
proof: params['proof'],
),
),
'setRecoveryKeys': _is.MethodConnector(
name: 'setRecoveryKeys',
params: {

View file

@ -27,6 +27,7 @@ password:
- set:
- beginChange:
- change:
- callOffResets:
- setRecoveryKeys:
- dismissSessionEndedWarning:
passwordSignIn:

View file

@ -1,3 +1,4 @@
import 'package:clock/clock.dart';
import 'package:halkyon_base_server/src/generated/protocol.dart';
import 'package:serverpod/serverpod.dart';
@ -9,6 +10,21 @@ import 'package:serverpod/serverpod.dart';
// 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.
/// 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.
@ -69,3 +85,80 @@ 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.
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(
session,
where: (t) =>
t.authUserId.equals(account) & t.confirmedAt.notEquals(null),
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)
/// take effect, each kind's; null for none.
Future<({DateTime? authenticator, DateTime? account})> waitingResets(
Session session,
UuidValue account,
) async {
final now = clock.now().toUtc();
// SELECT * FROM "account_reset" WHERE "authUserId" = $1
final rows = await AccountReset.db.find(
session,
where: (t) => t.authUserId.equals(account),
);
DateTime? whenOf(AccountResetKind kind) => rows
.where((reset) => reset.kind == kind && !resetLapsed(reset, now))
.firstOrNull
?.takesEffectAt;
final authenticatorAt = whenOf(AccountResetKind.authenticator);
return (
authenticator:
authenticatorAt != null &&
// SELECT count(*) FROM "totp_authenticator"
// WHERE "authUserId" = $1 AND "confirmedAt" IS NOT NULL
await TotpAuthenticator.db.count(
session,
where: (t) =>
t.authUserId.equals(account) &
t.confirmedAt.notEquals(null),
) >
0
? authenticatorAt
: null,
account: whenOf(AccountResetKind.account),
);
}

View file

@ -56,6 +56,21 @@ Future<void> changePassword(
await announceSessionsEnded(session, person);
}
/// Calls off every reset of [person]'s account waiting, with the owner's
/// [proof] (the full reading's H1): a signed-in device shows the resets
/// (`SignInWays`), and its owner, held out of signing in by codes made to
/// wait, calls them off all the same, as no wait holds a signed-in owner's
/// proof back. Refusals as [proveOwner]'s.
Future<void> callOffResets(
Session session, {
required UuidValue person,
required OwnerProof proof,
}) async {
final keys = requirePasswordKeys(session.serverpod);
// The owner's code, accepted, calls every reset off (`ownersCode`).
await proveOwner(session, keys, person: person, proof: proof);
}
/// Replaces the wrapping of [person]'s keys under their recovery phrase
/// with [recoveryKeys] (a new phrase, made on the device, which holds the
/// keys) and the phrase's proof key with [recoveryProofKey] (its public

View file

@ -100,6 +100,29 @@ class PasswordEndpoint extends Endpoint {
);
}
/// Calls off every reset of the caller's account waiting, with the
/// owner's [proof] (ADR 0017, the full reading's H1: a signed-in device
/// shows the resets, `SignInWays`), even while the account's codes wait;
/// counted against this session as every owner's proof is. Refusals as
/// `callOffResets`' ([PasswordRefusedException]).
Future<void> callOffResets(
Session session, {
required OwnerProof proof,
}) {
final bound = proof.copyWith(
host: deviceHostOn(session.serverpod, proof.host),
);
return asOwnerProofTry(
session,
bound,
() => AccountRegister.of(session.serverpod).callOffResets(
session,
person: session.authenticated!.authUserId,
proof: bound,
),
);
}
/// A new recovery phrase: [recoveryKeys], the caller's keys wrapped under
/// it on the device, replace the old wrapping, and [recoveryProofKey], the
/// public half of its proof key, the old one, with the owner's [proof], as

View file

@ -42,11 +42,6 @@ const passwordMethod = 'password';
/// second step; it is taken once, either way.
const signInChallengeLifetime = Duration(minutes: 2);
/// 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);
/// The group Halkyon signs in with.
final passwordGroup = SrpGroup.rfc5054Group3072;
@ -119,10 +114,13 @@ final _proofsPerAddress = DatabaseRateLimiter(
domain: 'password_sign_in',
source: 'proofs_per_address',
maxAttempts: 30,
timeframe: const Duration(hours: 1),
timeframe: _proofsWindow,
),
);
/// [_proofsPerAddress]' window: how long its ceiling holds an owner out.
const _proofsWindow = Duration(hours: 1);
/// How many sign-ins and proofs of the recovery phrase one network (IP
/// address) begins in an hour.
final _beginsPerNetwork = DatabaseRateLimiter(
@ -152,15 +150,34 @@ final _keyProofsPerAddressAndNetwork = DatabaseRateLimiter(
Future<void> _refuseAddressProvingTooOften(Session session, String hash) async {
if (await _proofsPerAddress.countAttempts(session, key: hash) >=
_proofsPerAddress.config.maxAttempts) {
throw refusedFor(PasswordRefusal.tooManyAttempts);
await _heldOut(session, hash);
}
}
/// Refuses ([PasswordRefusal.tooManyAttempts]) a proof or sign-in for
/// [hash]'s address past [_proofsPerAddress]' ceiling, the owner held out
/// for its hour: the account's resets are pushed out past it (the full
/// reading's H1), so whoever filled the ceiling can't carry one out
/// meanwhile. The same queries for an address nobody has.
Future<Never> _heldOut(Session session, String hash) async {
// SELECT * FROM "email_identity" WHERE "addressHash" = $1
final identity = await EmailIdentity.db.findFirstRow(
session,
where: (t) => t.addressHash.equals(hash),
);
await pushOutResets(
session,
identity?.authUserId ?? UuidValue.fromString(_nobody),
clock.now().toUtc().add(_proofsWindow),
);
throw refusedFor(PasswordRefusal.tooManyAttempts);
}
/// Counts a proof for [hash]'s address about to be checked; refused
/// ([PasswordRefusal.tooManyAttempts]) past [_proofsPerAddress]' ceiling.
Future<void> _countProof(Session session, String hash) async {
if (!await _proofsPerAddress.tryRecordAttempt(session, key: hash)) {
throw refusedFor(PasswordRefusal.tooManyAttempts);
await _heldOut(session, hash);
}
}

View file

@ -3,6 +3,7 @@ import 'dart:math';
import 'package:clock/clock.dart';
import 'package:halkyon_base_server/src/generated/protocol.dart';
import 'package:halkyon_base_server/src/password/account_resets.dart';
import 'package:serverpod/serverpod.dart';
// Codes guessed with the password known (ADR 0017; the second reading's M4):
@ -52,11 +53,36 @@ const longestCodeWait = Duration(days: 1);
/// One attempt at a time per account (a transaction holding an advisory
/// lock on the account's key), so of codes sent at once at most the ten
/// before a wait are checked.
///
/// While codes wait, the account's owner can't sign in to call a reset
/// off: a wait that begins, and an attempt a wait refuses, push the
/// account's resets out past it (`pushOutResets`, the full reading's H1).
Future<DateTime?> takeCodeAttempt(
Session session,
UuidValue account, {
SecondFactorPath path = SecondFactorPath.password,
}) {
}) async {
final taken = await _takeCodeAttempt(session, account, path);
final heldOutUntil = taken.refusedUntil ?? taken.began;
if (heldOutUntil != null) {
await pushOutResets(session, account, heldOutUntil);
}
if (taken.refusedUntil case final waiting?) {
throw PasswordRefusedException(
reason: PasswordRefusal.codesWait,
takesEffectAt: waiting,
);
}
return taken.began;
}
/// [takeCodeAttempt]'s count: the wait that refused the attempt, or the
/// wait it began.
Future<({DateTime? refusedUntil, DateTime? began})> _takeCodeAttempt(
Session session,
UuidValue account,
SecondFactorPath path,
) {
final now = clock.now().toUtc();
return session.db.transaction((transaction) async {
// SELECT pg_advisory_xact_lock($1)
@ -76,10 +102,7 @@ Future<DateTime?> takeCodeAttempt(
);
final waiting = row?.waitUntil;
if (waiting != null && waiting.isAfter(now)) {
throw PasswordRefusedException(
reason: PasswordRefusal.codesWait,
takesEffectAt: waiting,
);
return (refusedUntil: waiting, began: null);
}
final failures = (row?.failures ?? 0) + 1;
final waits = row?.waits ?? 0;
@ -109,7 +132,7 @@ Future<DateTime?> takeCodeAttempt(
transaction: transaction,
);
}
return until;
return (refusedUntil: null, began: until);
});
}

View file

@ -318,6 +318,15 @@ abstract interface class AccountRegister {
UuidValue? keepSession,
});
/// Calls off every reset of [person]'s account waiting, with the owner's
/// [proof] (ADR 0017, the full reading's H1). Refusals as
/// `callOffResets`'.
Future<void> callOffResets(
Session session, {
required UuidValue person,
required OwnerProof proof,
});
/// Replaces the wrapping of [person]'s keys under their recovery phrase
/// and the phrase's proof key, with the owner's [proof]. Refusals as
/// `setRecoveryKeys`'.

View file

@ -381,6 +381,13 @@ Future<Map<String, Object?>?> _answer(
keepSession: arguments.optionalId('keepSession'),
);
return const {};
case RegisterOperation.callOffResets:
await register.callOffResets(
session,
person: arguments.id('person'),
proof: arguments.model('proof', OwnerProof.fromJson),
);
return const {};
case RegisterOperation.setRecoveryKeys:
await register.setRecoveryKeys(
session,

View file

@ -9,6 +9,8 @@ import 'package:halkyon_base_server/src/generated/protocol.dart';
import 'package:halkyon_base_server/src/halkyon_base.dart';
import 'package:halkyon_base_server/src/password/account_recovery.dart'
as recovery;
import 'package:halkyon_base_server/src/password/account_resets.dart'
as account_resets;
import 'package:halkyon_base_server/src/password/authenticators.dart'
as authenticators;
import 'package:halkyon_base_server/src/password/owner_proof_tries.dart'
@ -369,6 +371,7 @@ final class LocalRegister implements AccountRegister {
session,
where: (t) => t.authUserId.equals(person),
);
final resets = await account_resets.waitingResets(session, person);
return SignInWays(
// SELECT count(*) FROM "serverpod_auth_idp_github_account"
// WHERE "authUserId" = $1
@ -393,6 +396,8 @@ final class LocalRegister implements AccountRegister {
passwordOnly: await passwords.signsInWithPassword(session, person),
secondFactorWorks: await passwords.secondFactorWorks(session, person),
sessionEndedForProofsAt: password?.sessionEndedForProofsAt,
authenticatorResetAt: resets.authenticator,
accountResetAt: resets.account,
);
}
@ -640,6 +645,13 @@ final class LocalRegister implements AccountRegister {
keepSession: keepSession,
);
@override
Future<void> callOffResets(
Session session, {
required UuidValue person,
required OwnerProof proof,
}) => change.callOffResets(session, person: person, proof: proof);
@override
Future<void> setRecoveryKeys(
Session session, {

View file

@ -51,6 +51,7 @@ abstract final class RegisterOperation {
static const beginPasswordChange = 'begin-password-change';
static const changePassword = 'change-password';
static const setRecoveryKeys = 'set-recovery-keys';
static const callOffResets = 'call-off-resets';
}
/// A refusal the register answered, as its JSON: the exception the module's

View file

@ -604,6 +604,16 @@ final class RemoteRegister implements AccountRegister {
'keepSession': keepSession?.uuid,
});
@override
Future<void> callOffResets(
Session session, {
required UuidValue person,
required OwnerProof proof,
}) => _ask(RegisterOperation.callOffResets, {
'person': person.uuid,
'proof': proof.toJson(),
});
@override
Future<void> setRecoveryKeys(
Session session, {