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
11 changed files with 1022 additions and 18 deletions

View file

@ -225,5 +225,11 @@
"passwordWarningTitle": "Jemand hat dein Passwort ausprobiert",
"passwordWarningBody": "Am {date} hat ein bei deinem Konto angemeldetes Gerät zehnmal hintereinander ein falsches Passwort oder einen falschen Code gesendet und wurde abgemeldet. Warst du das nicht, hat vielleicht jemand anderes dieses Gerät oder seine Sitzung: Ändere dein Passwort.",
"passwordWarningDismiss": "Ausblenden",
"passwordWarningLater": "Später erinnern"
"passwordWarningLater": "Später erinnern",
"passwordResetsTitle": "Ein Zurücksetzen steht an",
"passwordResetsAuthenticator": "Am {date} werden deine Authenticator-Apps entfernt: Jemand hat es mit deinem Passwort angefordert. Warst du das nicht, sag es ab und ändere dein Passwort.",
"passwordResetsAccount": "Am {date} bekommt dein Konto ein neues Passwort und neue Schlüssel, und was mit den alten verschlüsselt ist, geht verloren: Jemand hat es mit einem Code per Mail angefordert. Warst du das nicht, sag es ab.",
"passwordResetsCallOff": "Absagen",
"passwordCallOffTitle": "Zurücksetzen absagen",
"passwordCallOffIntro": "Dein Passwort und ein Code sagen jedes anstehende Zurücksetzen ab."
}

View file

@ -479,5 +479,25 @@
}
},
"passwordWarningDismiss": "Dismiss",
"passwordWarningLater": "Remind me later"
"passwordWarningLater": "Remind me later",
"passwordResetsTitle": "A reset is waiting",
"passwordResetsAuthenticator": "Your authenticators will be removed on {date}: someone asked with your password. If that wasn't you, call it off and change your password.",
"@passwordResetsAuthenticator": {
"placeholders": {
"date": {
"type": "String"
}
}
},
"passwordResetsAccount": "Your account will get a new password and new keys on {date}, and what is encrypted under the old ones will be lost: someone asked with a code by mail. If that wasn't you, call it off.",
"@passwordResetsAccount": {
"placeholders": {
"date": {
"type": "String"
}
}
},
"passwordResetsCallOff": "Call it off",
"passwordCallOffTitle": "Call off the reset",
"passwordCallOffIntro": "Your password and a code call off every reset waiting."
}

View file

@ -1458,6 +1458,42 @@ abstract class HalkyonAppLocalizations {
/// In en, this message translates to:
/// **'Remind me later'**
String get passwordWarningLater;
/// No description provided for @passwordResetsTitle.
///
/// In en, this message translates to:
/// **'A reset is waiting'**
String get passwordResetsTitle;
/// No description provided for @passwordResetsAuthenticator.
///
/// In en, this message translates to:
/// **'Your authenticators will be removed on {date}: someone asked with your password. If that wasn\'t you, call it off and change your password.'**
String passwordResetsAuthenticator(String date);
/// No description provided for @passwordResetsAccount.
///
/// In en, this message translates to:
/// **'Your account will get a new password and new keys on {date}, and what is encrypted under the old ones will be lost: someone asked with a code by mail. If that wasn\'t you, call it off.'**
String passwordResetsAccount(String date);
/// No description provided for @passwordResetsCallOff.
///
/// In en, this message translates to:
/// **'Call it off'**
String get passwordResetsCallOff;
/// No description provided for @passwordCallOffTitle.
///
/// In en, this message translates to:
/// **'Call off the reset'**
String get passwordCallOffTitle;
/// No description provided for @passwordCallOffIntro.
///
/// In en, this message translates to:
/// **'Your password and a code call off every reset waiting.'**
String get passwordCallOffIntro;
}
class _HalkyonAppLocalizationsDelegate

View file

@ -846,4 +846,27 @@ class HalkyonAppLocalizationsDe extends HalkyonAppLocalizations {
@override
String get passwordWarningLater => 'Später erinnern';
@override
String get passwordResetsTitle => 'Ein Zurücksetzen steht an';
@override
String passwordResetsAuthenticator(String date) {
return 'Am $date werden deine Authenticator-Apps entfernt: Jemand hat es mit deinem Passwort angefordert. Warst du das nicht, sag es ab und ändere dein Passwort.';
}
@override
String passwordResetsAccount(String date) {
return 'Am $date bekommt dein Konto ein neues Passwort und neue Schlüssel, und was mit den alten verschlüsselt ist, geht verloren: Jemand hat es mit einem Code per Mail angefordert. Warst du das nicht, sag es ab.';
}
@override
String get passwordResetsCallOff => 'Absagen';
@override
String get passwordCallOffTitle => 'Zurücksetzen absagen';
@override
String get passwordCallOffIntro =>
'Dein Passwort und ein Code sagen jedes anstehende Zurücksetzen ab.';
}

View file

@ -829,6 +829,29 @@ class HalkyonAppLocalizationsEn extends HalkyonAppLocalizations {
@override
String get passwordWarningLater => 'Remind me later';
@override
String get passwordResetsTitle => 'A reset is waiting';
@override
String passwordResetsAuthenticator(String date) {
return 'Your authenticators will be removed on $date: someone asked with your password. If that wasn\'t you, call it off and change your password.';
}
@override
String passwordResetsAccount(String date) {
return 'Your account will get a new password and new keys on $date, and what is encrypted under the old ones will be lost: someone asked with a code by mail. If that wasn\'t you, call it off.';
}
@override
String get passwordResetsCallOff => 'Call it off';
@override
String get passwordCallOffTitle => 'Call off the reset';
@override
String get passwordCallOffIntro =>
'Your password and a code call off every reset waiting.';
}
/// The translations for English, as used in the United Kingdom (`en_GB`).

View file

@ -25,6 +25,11 @@ typedef PasswordWays = ({
/// When a session of the account was last ended for ten wrong proofs in
/// a row, until the warning is dismissed; null for none.
DateTime? sessionEndedForProofsAt,
/// When a reset of the authenticators, and of the account, waiting takes
/// effect; null for none (ADR 0017, the full reading's H1).
DateTime? authenticatorResetAt,
DateTime? accountResetAt,
});
/// What a signed-in owner proves to change how their account is secured
@ -121,6 +126,10 @@ abstract interface class PasswordGateway() {
/// New recovery codes for the [owner]'s proof.
Future<List<String>> newRecoveryCodes(OwnerAnswer owner);
/// Calls off every reset of the signed-in person's account waiting, for
/// the [owner]'s proof.
Future<void> callOffResets(OwnerAnswer owner);
/// How the signed-in person signs in.
Future<PasswordWays> ways();

View file

@ -330,6 +330,17 @@ class PasswordRepository(
await _ownerAnswer(password, totpCode: code),
);
/// Calls off every reset of the signed-in person's account waiting, for
/// their [password] and a [code] (the full reading's H1: the section shows
/// them, as no wait holds back a signed-in owner's proof).
Future<void> callOffResets({
required String password,
required String code,
}) async {
await _gateway.callOffResets(await _ownerAnswer(password, totpCode: code));
_changed();
}
/// The owner's proof (`OwnerProof`): [password] (or [unlocked], unlocked
/// at a sign-in already) answering a change's challenge, of the signed-in
/// session or [as], with a [totpCode] or [recoveryCode] where given.
@ -377,6 +388,8 @@ class PasswordRepository(
hasPassword: ways.password,
secondFactorWorks: ways.secondFactorWorks,
sessionEndedAt: ways.sessionEndedForProofsAt,
authenticatorResetAt: ways.authenticatorResetAt,
accountResetAt: ways.accountResetAt,
authenticators: ways.password
? await _gateway.authenticators()
: const <AccountAuthenticator>[],

View file

@ -182,6 +182,11 @@ class ServerpodPasswordGateway(
() => _base.authenticator.newRecoveryCodes(proof: _proofOf(owner)!),
);
@override
Future<void> callOffResets(OwnerAnswer owner) => _failing(
() => _base.password.callOffResets(proof: _proofOf(owner)!),
);
/// [owner] as the instance takes it, bound to the [host] addressed.
protocol.OwnerProof? _proofOf(OwnerAnswer? owner) => switch (owner) {
null => null,
@ -206,6 +211,8 @@ class ServerpodPasswordGateway(
authenticator: ways.authenticator,
secondFactorWorks: ways.secondFactorWorks,
sessionEndedForProofsAt: ways.sessionEndedForProofsAt,
authenticatorResetAt: ways.authenticatorResetAt,
accountResetAt: ways.accountResetAt,
);
});

View file

@ -29,6 +29,12 @@ final class const AccountSecurity({
/// owner's warning until dismissed or a new password; null for none.
final DateTime? sessionEndedAt,
/// When a reset of the authenticators, and of the account, waiting takes
/// effect (the full reading's H1): the section shows them, with a way to
/// call them off; null for none.
final DateTime? authenticatorResetAt,
final DateTime? accountResetAt,
/// Their authenticators, the oldest first; empty without a password.
final List<AccountAuthenticator> authenticators = const [],
@ -48,6 +54,8 @@ final class const AccountSecurity({
hasPassword,
secondFactorWorks,
sessionEndedAt,
authenticatorResetAt,
accountResetAt,
authenticators,
keysHere,
];

View file

@ -64,6 +64,14 @@ class const _AccountSecurity() extends StatelessWidget {
Text(l10n.passwordSectionTitle, style: theme.textTheme.titleSmall),
if (security.sessionEndedAt case final ended?)
_SessionEndedWarning(ended),
if (security.authenticatorResetAt != null ||
security.accountResetAt != null)
_ResetsWaiting(
authenticatorAt: security.authenticatorResetAt,
accountAt: security.accountResetAt,
onCallOff: () =>
_show(context, (context) => const _CallOffResets()),
),
if (security.gitHub && security.gitHubEnds != null)
Padding(
padding: const EdgeInsets.only(top: 8),
@ -605,6 +613,151 @@ class _AddAuthenticatorState() extends State<_AddAuthenticator> {
}
}
/// The resets of the account waiting, each with when it takes effect, and
/// a way to call them off (the full reading's H1: an owner held out of
/// signing in by codes made to wait still calls them off here).
class const _ResetsWaiting({
required final DateTime? authenticatorAt,
required final DateTime? accountAt,
required final VoidCallback onCallOff,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = HalkyonAppLocalizations.of(context);
final theme = Theme.of(context);
final colors = theme.colorScheme;
final body = theme.textTheme.bodyMedium?.copyWith(
color: colors.onErrorContainer,
);
return Padding(
padding: const EdgeInsets.only(top: 8),
child: DecoratedBox(
decoration: BoxDecoration(
color: colors.errorContainer,
borderRadius: BorderRadius.circular(12),
),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 8, 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Icon(LucideIcons.shieldAlert, color: colors.onErrorContainer),
const SizedBox(width: 12),
Expanded(
child: Text(
l10n.passwordResetsTitle,
style: theme.textTheme.titleSmall?.copyWith(
color: colors.onErrorContainer,
),
),
),
],
),
if (authenticatorAt case final at?)
Padding(
padding: const EdgeInsets.only(top: 8, right: 8),
child: Text(
l10n.passwordResetsAuthenticator(
passwordMoment(context, at),
),
style: body,
),
),
if (accountAt case final at?)
Padding(
padding: const EdgeInsets.only(top: 8, right: 8),
child: Text(
l10n.passwordResetsAccount(passwordMoment(context, at)),
style: body,
),
),
Align(
alignment: AlignmentDirectional.centerEnd,
child: TextButton(
onPressed: onCallOff,
child: Text(l10n.passwordResetsCallOff),
),
),
],
),
),
),
);
}
}
/// Calls off every reset waiting, for the password and a code.
class const _CallOffResets() extends StatefulWidget {
@override
State<_CallOffResets> createState() => _CallOffResetsState();
}
class _CallOffResetsState() extends State<_CallOffResets> {
final _password = TextEditingController();
final _code = TextEditingController();
late final _step = SecurityStepCubit<bool>();
@override
void dispose() {
_password.dispose();
_code.dispose();
_step.close();
super.dispose();
}
Future<void> _submit() async {
final passwords = _passwordsOf(context);
final navigator = Navigator.of(context);
if (await _step.run(() async {
await passwords.callOffResets(
password: _password.text,
code: _code.text,
);
return true;
})) {
navigator.pop();
}
}
@override
Widget build(BuildContext context) {
final l10n = HalkyonAppLocalizations.of(context);
return BlocBuilder<SecurityStepCubit<bool>, SecurityStepState<bool>>(
bloc: _step,
builder: (context, state) => SheetScaffold(
title: l10n.passwordCallOffTitle,
busy: state.busy,
body: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SheetIntro(l10n.passwordCallOffIntro),
PasswordField(
controller: _password,
label: l10n.passwordCurrent,
autofocus: true,
),
const SizedBox(height: 8),
CodeField(
controller: _code,
kind: CodeKind.totp,
errorText: _problemOf(context, state),
onSubmitted: (_) => _submit(),
),
],
),
footer: FilledButton(
onPressed: state.busy ? null : _submit,
child: state.busy
? const ButtonLoadingIndicator()
: Text(l10n.passwordResetsCallOff),
),
),
);
}
}
/// New recovery codes for the password and a code: shown once.
class const _NewRecoveryCodes() extends StatefulWidget {
@override

View file

@ -559,6 +559,10 @@ class FakePasswordGateway(final AuthGateway _auth) implements PasswordGateway {
},
);
@override
Future<void> callOffResets(OwnerAnswer owner) =>
_call('callOffResets', () => _ownerProved(owner));
@override
Future<PasswordWays> ways() => _call('ways', () {
final person = _signedIn();
@ -572,6 +576,11 @@ class FakePasswordGateway(final AuthGateway _auth) implements PasswordGateway {
(account?.finished ?? false) &&
(account?.authenticatorReset?.isAfter(clock.now()) ?? true),
sessionEndedForProofsAt: account?.sessionEndedForProofsAt,
// Not carried out: one of its authenticators still confirmed.
authenticatorResetAt: (account?.finished ?? false)
? account?.authenticatorReset
: null,
accountResetAt: account?.accountReset,
);
});