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

View file

@ -225,5 +225,11 @@
"passwordWarningTitle": "Jemand hat dein Passwort ausprobiert", "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.", "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", "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", "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: /// In en, this message translates to:
/// **'Remind me later'** /// **'Remind me later'**
String get passwordWarningLater; 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 class _HalkyonAppLocalizationsDelegate

View file

@ -846,4 +846,27 @@ class HalkyonAppLocalizationsDe extends HalkyonAppLocalizations {
@override @override
String get passwordWarningLater => 'Später erinnern'; 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 @override
String get passwordWarningLater => 'Remind me later'; 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`). /// 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 /// When a session of the account was last ended for ten wrong proofs in
/// a row, until the warning is dismissed; null for none. /// a row, until the warning is dismissed; null for none.
DateTime? sessionEndedForProofsAt, 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 /// 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. /// New recovery codes for the [owner]'s proof.
Future<List<String>> newRecoveryCodes(OwnerAnswer owner); 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. /// How the signed-in person signs in.
Future<PasswordWays> ways(); Future<PasswordWays> ways();

View file

@ -330,6 +330,17 @@ class PasswordRepository(
await _ownerAnswer(password, totpCode: code), 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 /// The owner's proof (`OwnerProof`): [password] (or [unlocked], unlocked
/// at a sign-in already) answering a change's challenge, of the signed-in /// at a sign-in already) answering a change's challenge, of the signed-in
/// session or [as], with a [totpCode] or [recoveryCode] where given. /// session or [as], with a [totpCode] or [recoveryCode] where given.
@ -377,6 +388,8 @@ class PasswordRepository(
hasPassword: ways.password, hasPassword: ways.password,
secondFactorWorks: ways.secondFactorWorks, secondFactorWorks: ways.secondFactorWorks,
sessionEndedAt: ways.sessionEndedForProofsAt, sessionEndedAt: ways.sessionEndedForProofsAt,
authenticatorResetAt: ways.authenticatorResetAt,
accountResetAt: ways.accountResetAt,
authenticators: ways.password authenticators: ways.password
? await _gateway.authenticators() ? await _gateway.authenticators()
: const <AccountAuthenticator>[], : const <AccountAuthenticator>[],

View file

@ -182,6 +182,11 @@ class ServerpodPasswordGateway(
() => _base.authenticator.newRecoveryCodes(proof: _proofOf(owner)!), () => _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. /// [owner] as the instance takes it, bound to the [host] addressed.
protocol.OwnerProof? _proofOf(OwnerAnswer? owner) => switch (owner) { protocol.OwnerProof? _proofOf(OwnerAnswer? owner) => switch (owner) {
null => null, null => null,
@ -206,6 +211,8 @@ class ServerpodPasswordGateway(
authenticator: ways.authenticator, authenticator: ways.authenticator,
secondFactorWorks: ways.secondFactorWorks, secondFactorWorks: ways.secondFactorWorks,
sessionEndedForProofsAt: ways.sessionEndedForProofsAt, 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. /// owner's warning until dismissed or a new password; null for none.
final DateTime? sessionEndedAt, 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. /// Their authenticators, the oldest first; empty without a password.
final List<AccountAuthenticator> authenticators = const [], final List<AccountAuthenticator> authenticators = const [],
@ -48,6 +54,8 @@ final class const AccountSecurity({
hasPassword, hasPassword,
secondFactorWorks, secondFactorWorks,
sessionEndedAt, sessionEndedAt,
authenticatorResetAt,
accountResetAt,
authenticators, authenticators,
keysHere, keysHere,
]; ];

View file

@ -64,6 +64,14 @@ class const _AccountSecurity() extends StatelessWidget {
Text(l10n.passwordSectionTitle, style: theme.textTheme.titleSmall), Text(l10n.passwordSectionTitle, style: theme.textTheme.titleSmall),
if (security.sessionEndedAt case final ended?) if (security.sessionEndedAt case final ended?)
_SessionEndedWarning(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) if (security.gitHub && security.gitHubEnds != null)
Padding( Padding(
padding: const EdgeInsets.only(top: 8), 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. /// New recovery codes for the password and a code: shown once.
class const _NewRecoveryCodes() extends StatefulWidget { class const _NewRecoveryCodes() extends StatefulWidget {
@override @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 @override
Future<PasswordWays> ways() => _call('ways', () { Future<PasswordWays> ways() => _call('ways', () {
final person = _signedIn(); final person = _signedIn();
@ -572,6 +576,11 @@ class FakePasswordGateway(final AuthGateway _auth) implements PasswordGateway {
(account?.finished ?? false) && (account?.finished ?? false) &&
(account?.authenticatorReset?.isAfter(clock.now()) ?? true), (account?.authenticatorReset?.isAfter(clock.now()) ?? true),
sessionEndedForProofsAt: account?.sessionEndedForProofsAt, sessionEndedForProofsAt: account?.sessionEndedForProofsAt,
// Not carried out: one of its authenticators still confirmed.
authenticatorResetAt: (account?.finished ?? false)
? account?.authenticatorReset
: null,
accountResetAt: account?.accountReset,
); );
}); });

View file

@ -98,6 +98,36 @@ void main() {
expect(anna.sessionEndedForProofsAt, isNull); expect(anna.sessionEndedForProofsAt, isNull);
expect(cubit.state.security?.sessionEndedAt, isNull); expect(cubit.state.security?.sessionEndedAt, isNull);
}); });
test('carries the resets waiting, each with when it takes effect, '
'until called off with the password and a code', () async {
anna
..authenticatorReset = DateTime.utc(2026, 10, 24, 12)
..accountReset = DateTime.utc(2026, 10, 25, 12);
final cubit = AccountSecurityCubit(passwords);
addTearDown(cubit.close);
await cubit.load();
expect(
cubit.state.security?.authenticatorResetAt,
DateTime.utc(2026, 10, 24, 12),
);
expect(
cubit.state.security?.accountResetAt,
DateTime.utc(2026, 10, 25, 12),
);
await expectLater(
passwords.callOffResets(password: 'correct horse', code: '000000'),
throwsA(isA<PasswordFailed>()),
);
await passwords.callOffResets(
password: 'correct horse',
code: fakeTotpCode,
);
await pumpEventQueue();
expect(cubit.state.security?.authenticatorResetAt, isNull);
expect(cubit.state.security?.accountResetAt, isNull);
});
}); });
group('a step of the account section', () { group('a step of the account section', () {

View file

@ -689,6 +689,48 @@ void main() {
return anna; return anna;
} }
testWidgets('shows the resets waiting, called off only with the '
'password and a code', (tester) async {
final anna = await annaSignedIn();
anna
..authenticatorReset = DateTime.utc(2026, 10, 24, 12)
..accountReset = DateTime.utc(2026, 10, 25, 12);
await show(tester);
expect(find.text('A reset is waiting'), findsOneWidget);
expect(
find.textContaining('Your authenticators will be removed on'),
findsOneWidget,
);
expect(
find.textContaining('Your account will get a new password'),
findsOneWidget,
);
await tester.tap(find.widgetWithText(TextButton, 'Call it off'));
await tester.pumpAndSettle();
await tester.enterText(field('Current password'), 'correct horse');
await tester.enterText(
field('Code from your authenticator app'),
'000000',
);
await tapButton(tester, 'Call it off');
expect(
find.text("The address, the password or the code isn't right."),
findsOneWidget,
);
expect(anna.accountReset, isNotNull);
await tester.enterText(
field('Code from your authenticator app'),
fakeTotpCode,
);
await tapButton(tester, 'Call it off');
expect(find.text('A reset is waiting'), findsNothing);
expect(anna.authenticatorReset, isNull);
expect(anna.accountReset, isNull);
});
testWidgets('removes an authenticator only with the password and a code, ' testWidgets('removes an authenticator only with the password and a code, '
'and never the last', (tester) async { 'and never the last', (tester) async {
final anna = await annaSignedIn(); final anna = await annaSignedIn();

View file

@ -23,6 +23,8 @@ abstract class SignInWays
bool? passwordOnly, bool? passwordOnly,
bool? secondFactorWorks, bool? secondFactorWorks,
this.sessionEndedForProofsAt, this.sessionEndedForProofsAt,
this.authenticatorResetAt,
this.accountResetAt,
}) : password = password ?? false, }) : password = password ?? false,
authenticator = authenticator ?? false, authenticator = authenticator ?? false,
passwordOnly = passwordOnly ?? false, passwordOnly = passwordOnly ?? false,
@ -36,6 +38,8 @@ abstract class SignInWays
bool? passwordOnly, bool? passwordOnly,
bool? secondFactorWorks, bool? secondFactorWorks,
DateTime? sessionEndedForProofsAt, DateTime? sessionEndedForProofsAt,
DateTime? authenticatorResetAt,
DateTime? accountResetAt,
}) = _SignInWaysImpl; }) = _SignInWaysImpl;
factory SignInWays.fromJson(Map<String, dynamic> jsonSerialization) { factory SignInWays.fromJson(Map<String, dynamic> jsonSerialization) {
@ -62,6 +66,16 @@ abstract class SignInWays
: _isc.DateTimeJsonExtension.fromJson( : _isc.DateTimeJsonExtension.fromJson(
jsonSerialization['sessionEndedForProofsAt'], jsonSerialization['sessionEndedForProofsAt'],
), ),
authenticatorResetAt: jsonSerialization['authenticatorResetAt'] == null
? null
: _isc.DateTimeJsonExtension.fromJson(
jsonSerialization['authenticatorResetAt'],
),
accountResetAt: jsonSerialization['accountResetAt'] == null
? null
: _isc.DateTimeJsonExtension.fromJson(
jsonSerialization['accountResetAt'],
),
); );
} }
@ -90,6 +104,14 @@ abstract class SignInWays
/// password (ADR 0017, the fourth reading); null for none. /// password (ADR 0017, the fourth reading); null for none.
DateTime? sessionEndedForProofsAt; 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] /// Returns a shallow copy of this [SignInWays]
/// with some or all fields replaced by the given arguments. /// with some or all fields replaced by the given arguments.
@_isc.useResult @_isc.useResult
@ -101,6 +123,8 @@ abstract class SignInWays
bool? passwordOnly, bool? passwordOnly,
bool? secondFactorWorks, bool? secondFactorWorks,
DateTime? sessionEndedForProofsAt, DateTime? sessionEndedForProofsAt,
DateTime? authenticatorResetAt,
DateTime? accountResetAt,
}); });
@override @override
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
@ -114,6 +138,9 @@ abstract class SignInWays
'secondFactorWorks': secondFactorWorks, 'secondFactorWorks': secondFactorWorks,
if (sessionEndedForProofsAt != null) if (sessionEndedForProofsAt != null)
'sessionEndedForProofsAt': sessionEndedForProofsAt?.toJson(), 'sessionEndedForProofsAt': sessionEndedForProofsAt?.toJson(),
if (authenticatorResetAt != null)
'authenticatorResetAt': authenticatorResetAt?.toJson(),
if (accountResetAt != null) 'accountResetAt': accountResetAt?.toJson(),
}; };
} }
@ -129,6 +156,9 @@ abstract class SignInWays
'secondFactorWorks': secondFactorWorks, 'secondFactorWorks': secondFactorWorks,
if (sessionEndedForProofsAt != null) if (sessionEndedForProofsAt != null)
'sessionEndedForProofsAt': sessionEndedForProofsAt?.toJson(), '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? passwordOnly,
bool? secondFactorWorks, bool? secondFactorWorks,
DateTime? sessionEndedForProofsAt, DateTime? sessionEndedForProofsAt,
DateTime? authenticatorResetAt,
DateTime? accountResetAt,
}) : super._( }) : super._(
gitHub: gitHub, gitHub: gitHub,
email: email, email: email,
@ -157,6 +189,8 @@ class _SignInWaysImpl extends SignInWays {
passwordOnly: passwordOnly, passwordOnly: passwordOnly,
secondFactorWorks: secondFactorWorks, secondFactorWorks: secondFactorWorks,
sessionEndedForProofsAt: sessionEndedForProofsAt, sessionEndedForProofsAt: sessionEndedForProofsAt,
authenticatorResetAt: authenticatorResetAt,
accountResetAt: accountResetAt,
); );
/// Returns a shallow copy of this [SignInWays] /// Returns a shallow copy of this [SignInWays]
@ -171,6 +205,8 @@ class _SignInWaysImpl extends SignInWays {
bool? passwordOnly, bool? passwordOnly,
bool? secondFactorWorks, bool? secondFactorWorks,
Object? sessionEndedForProofsAt = _Undefined, Object? sessionEndedForProofsAt = _Undefined,
Object? authenticatorResetAt = _Undefined,
Object? accountResetAt = _Undefined,
}) { }) {
return SignInWays( return SignInWays(
gitHub: gitHub ?? this.gitHub, gitHub: gitHub ?? this.gitHub,
@ -182,6 +218,12 @@ class _SignInWaysImpl extends SignInWays {
sessionEndedForProofsAt: sessionEndedForProofsAt is DateTime? sessionEndedForProofsAt: sessionEndedForProofsAt is DateTime?
? sessionEndedForProofsAt ? sessionEndedForProofsAt
: this.sessionEndedForProofsAt, : this.sessionEndedForProofsAt,
authenticatorResetAt: authenticatorResetAt is DateTime?
? authenticatorResetAt
: this.authenticatorResetAt,
accountResetAt: accountResetAt is DateTime?
? accountResetAt
: this.accountResetAt,
); );
} }
} }

View file

@ -489,6 +489,18 @@ class EndpointPassword extends _isc.EndpointRef {
}, },
); );
/// 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]).
_ida.Future<void> callOffResets({required _ikq9b2yz.OwnerProof proof}) =>
caller.callServerEndpoint<void>(
'halkyon_base.password',
'callOffResets',
{'proof': proof},
);
/// A new recovery phrase: [recoveryKeys], the caller's keys wrapped under /// A new recovery phrase: [recoveryKeys], the caller's keys wrapped under
/// it on the device, replace the old wrapping, and [recoveryProofKey], the /// 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 /// public half of its proof key, the old one, with the owner's [proof], as

View file

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

View file

@ -19,3 +19,9 @@ fields:
### a row, the owner's warning until they dismiss it or set a new ### a row, the owner's warning until they dismiss it or set a new
### password (ADR 0017, the fourth reading); null for none. ### password (ADR 0017, the fourth reading); null for none.
sessionEndedForProofsAt: DateTime? 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? passwordOnly,
bool? secondFactorWorks, bool? secondFactorWorks,
this.sessionEndedForProofsAt, this.sessionEndedForProofsAt,
this.authenticatorResetAt,
this.accountResetAt,
}) : password = password ?? false, }) : password = password ?? false,
authenticator = authenticator ?? false, authenticator = authenticator ?? false,
passwordOnly = passwordOnly ?? false, passwordOnly = passwordOnly ?? false,
@ -36,6 +38,8 @@ abstract class SignInWays
bool? passwordOnly, bool? passwordOnly,
bool? secondFactorWorks, bool? secondFactorWorks,
DateTime? sessionEndedForProofsAt, DateTime? sessionEndedForProofsAt,
DateTime? authenticatorResetAt,
DateTime? accountResetAt,
}) = _SignInWaysImpl; }) = _SignInWaysImpl;
factory SignInWays.fromJson(Map<String, dynamic> jsonSerialization) { factory SignInWays.fromJson(Map<String, dynamic> jsonSerialization) {
@ -62,6 +66,16 @@ abstract class SignInWays
: _is.DateTimeJsonExtension.fromJson( : _is.DateTimeJsonExtension.fromJson(
jsonSerialization['sessionEndedForProofsAt'], 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. /// password (ADR 0017, the fourth reading); null for none.
DateTime? sessionEndedForProofsAt; 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] /// Returns a shallow copy of this [SignInWays]
/// with some or all fields replaced by the given arguments. /// with some or all fields replaced by the given arguments.
@_is.useResult @_is.useResult
@ -101,6 +123,8 @@ abstract class SignInWays
bool? passwordOnly, bool? passwordOnly,
bool? secondFactorWorks, bool? secondFactorWorks,
DateTime? sessionEndedForProofsAt, DateTime? sessionEndedForProofsAt,
DateTime? authenticatorResetAt,
DateTime? accountResetAt,
}); });
@override @override
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
@ -114,6 +138,9 @@ abstract class SignInWays
'secondFactorWorks': secondFactorWorks, 'secondFactorWorks': secondFactorWorks,
if (sessionEndedForProofsAt != null) if (sessionEndedForProofsAt != null)
'sessionEndedForProofsAt': sessionEndedForProofsAt?.toJson(), 'sessionEndedForProofsAt': sessionEndedForProofsAt?.toJson(),
if (authenticatorResetAt != null)
'authenticatorResetAt': authenticatorResetAt?.toJson(),
if (accountResetAt != null) 'accountResetAt': accountResetAt?.toJson(),
}; };
} }
@ -129,6 +156,9 @@ abstract class SignInWays
'secondFactorWorks': secondFactorWorks, 'secondFactorWorks': secondFactorWorks,
if (sessionEndedForProofsAt != null) if (sessionEndedForProofsAt != null)
'sessionEndedForProofsAt': sessionEndedForProofsAt?.toJson(), '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? passwordOnly,
bool? secondFactorWorks, bool? secondFactorWorks,
DateTime? sessionEndedForProofsAt, DateTime? sessionEndedForProofsAt,
DateTime? authenticatorResetAt,
DateTime? accountResetAt,
}) : super._( }) : super._(
gitHub: gitHub, gitHub: gitHub,
email: email, email: email,
@ -157,6 +189,8 @@ class _SignInWaysImpl extends SignInWays {
passwordOnly: passwordOnly, passwordOnly: passwordOnly,
secondFactorWorks: secondFactorWorks, secondFactorWorks: secondFactorWorks,
sessionEndedForProofsAt: sessionEndedForProofsAt, sessionEndedForProofsAt: sessionEndedForProofsAt,
authenticatorResetAt: authenticatorResetAt,
accountResetAt: accountResetAt,
); );
/// Returns a shallow copy of this [SignInWays] /// Returns a shallow copy of this [SignInWays]
@ -171,6 +205,8 @@ class _SignInWaysImpl extends SignInWays {
bool? passwordOnly, bool? passwordOnly,
bool? secondFactorWorks, bool? secondFactorWorks,
Object? sessionEndedForProofsAt = _Undefined, Object? sessionEndedForProofsAt = _Undefined,
Object? authenticatorResetAt = _Undefined,
Object? accountResetAt = _Undefined,
}) { }) {
return SignInWays( return SignInWays(
gitHub: gitHub ?? this.gitHub, gitHub: gitHub ?? this.gitHub,
@ -182,6 +218,12 @@ class _SignInWaysImpl extends SignInWays {
sessionEndedForProofsAt: sessionEndedForProofsAt is DateTime? sessionEndedForProofsAt: sessionEndedForProofsAt is DateTime?
? sessionEndedForProofsAt ? sessionEndedForProofsAt
: this.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'], 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( 'setRecoveryKeys': _is.MethodConnector(
name: 'setRecoveryKeys', name: 'setRecoveryKeys',
params: { params: {

View file

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

Some files were not shown because too many files have changed in this diff Show more