feat(app): a reset of the keys without the phrase, its banner, and the keys' proof key for a password that signs in (ADR 0020, D31–D35)
All checks were successful
CI / check (push) Successful in 31m14s
CI / deploy (push) Has been skipped

SimpleLogin's sheet, its password and its recovery phrase both lost:
under the lost-password step's phrase field, Lost your recovery phrase
too? says what stays and what is lost and asks for new keys
(`keys.startReset`) with the sign-in of a moment ago, which the device
then drops. The next sign-in says when while it waits, and once due
offers new keys, the sign-up's own password and phrase steps
(`keys.completeReset`), or the password, which calls it off (D31).

Every app's home shows `KeysResetBanner` where "too old" shows, and the
account section's SimpleLogin row the same words (`KeysResetNotice`),
each with Call it off, the password's proof alone
(`keys.callOffResets`, D32), and, once due on a device that holds the
keys, Finish here, which wraps the same keys anew (D33, D35). Both show
only a reset of an account that signs in with SimpleLogin alone; ADR
0017's resets show in its own section, which now shares `AlertBox` with
them.

An account whose password signs in has no Forgot your password? there,
and is asked its password and a code once while its keys lack the proof
key, at a sign-in or on adding SimpleLogin (`password.setProofKey`,
D34). `PasswordUnlock` carries the proof key, which its forgetting
leaves, and the keys' password proves for a purpose given.

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-24 22:37:17 +02:00
commit 1b79214764
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
31 changed files with 2711 additions and 248 deletions

View file

@ -153,6 +153,7 @@ class _HomeState() extends State<_Home> {
body: Column( body: Column(
children: [ children: [
AppMismatchBanner(onMore: () => context.push(RoutePaths.settings)), AppMismatchBanner(onMore: () => context.push(RoutePaths.settings)),
const KeysResetBanner(),
Expanded( Expanded(
child: loaded == null || today == null child: loaded == null || today == null
? const Center(child: AppLoadingIndicator()) ? const Center(child: AppLoadingIndicator())

View file

@ -1,3 +1,4 @@
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_todo_flutter/l10n/app_localizations.dart'; import 'package:halkyon_todo_flutter/l10n/app_localizations.dart';
import 'package:halkyon_todo_flutter/src/account/account_sheet.dart'; import 'package:halkyon_todo_flutter/src/account/account_sheet.dart';
import 'package:halkyon_ui/halkyon_ui.dart'; import 'package:halkyon_ui/halkyon_ui.dart';
@ -5,7 +6,8 @@ import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart'; import 'package:material_ui/material_ui.dart';
/// A signed-in person's home: nothing yet, as Todo keeps nothing until its /// A signed-in person's home: nothing yet, as Todo keeps nothing until its
/// first model, and the way to the account. /// first model, and the way to the account; a reset of the person's keys
/// waiting above it (`KeysResetBanner`).
class const HomeScreen({super.key}) extends StatelessWidget { class const HomeScreen({super.key}) extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -21,12 +23,19 @@ class const HomeScreen({super.key}) extends StatelessWidget {
), ),
], ],
), ),
body: Center( body: Column(
child: EmptyListMessage( children: [
icon: LucideIcons.listChecks, const KeysResetBanner(),
title: l10n.homeEmptyTitle, Expanded(
hint: l10n.homeEmptyHint, child: Center(
), child: EmptyListMessage(
icon: LucideIcons.listChecks,
title: l10n.homeEmptyTitle,
hint: l10n.homeEmptyHint,
),
),
),
],
), ),
); );
} }

View file

@ -1,6 +1,6 @@
# ADR 0020: Signing in with SimpleLogin only # ADR 0020: Signing in with SimpleLogin only
- **Status:** decided 23 Oct 2026, by the developer (D1–D20 below; D21–D34 on 24 - **Status:** decided 23 Oct 2026, by the developer (D1–D20 below; D21–D35 on 24
Oct), the *Proposal* with it (D15), as D18 to D20 change it and D24 drops the Oct), the *Proposal* with it (D15), as D18 to D20 change it and D24 drops the
cut-off's machinery (D8's code, D10, D22, 10's `ANONYMOUS_SESSIONS_END`, 11, step 5). cut-off's machinery (D8's code, D10, D22, 10's `ANONYMOUS_SESSIONS_END`, 11, step 5).
- **Built (23 Oct 2026), step 1:** the forwarding page (3), - **Built (23 Oct 2026), step 1:** the forwarding page (3),
@ -125,6 +125,20 @@
says whether the password signs in too (`passwordSignsIn`), whether the keys have the says whether the password signs in too (`passwordSignsIn`), whether the keys have the
proof key (`hasProofKey`) and when a reset takes effect (`accountResetAt`). No proof key (`hasProofKey`) and when a reset takes effect (`accountResetAt`). No
migration: the reset is ADR 0017's `account_reset` row of kind `account`. migration: the reset is ADR 0017's `account_reset` row of kind `account`.
Then the app's half (D31–D35): under the lost-password step's phrase field, Lost your
recovery phrase too? explains what stays and what is lost and asks for new keys with
the sign-in of a moment ago, which the device then drops; the next sign-in says when
while it waits, and once due offers new keys (the sign-up's password and phrase steps)
or the password. An account whose password signs in has no Forgot your password? there
and is asked its password and a code once, while its keys lack the proof key (D34),
with two Argon2id runs (the proof, then the unlock), as it happens once. Every app's
home shows `KeysResetBanner` where "too old" shows, and the account section's
SimpleLogin row the same words (`KeysResetNotice`), each with Call it off (the
password, D32) and, once due on a device that holds the keys, Finish here (D33); both
show only a reset of an account that signs in with SimpleLogin alone, as ADR 0017's
resets show in its own section. The security section's boxes and this one share
`AlertBox`. Left for later: an account whose password signs in, on an instance that
offers SimpleLogin alone, has no password sign-in to reset a lost password from.
- **Builds on:** ADR 0010 (email sign-in), ADR 0011 (profile pages), ADR 0016 (the - **Builds on:** ADR 0010 (email sign-in), ADR 0011 (profile pages), ADR 0016 (the
register), ADR 0017 (a password, TOTP and the keys). register), ADR 0017 (a password, TOTP and the keys).
- **Changes:** ADR 0017's way in on an instance that offers SimpleLogin (its keys, their - **Changes:** ADR 0017's way in on an instance that offers SimpleLogin (its keys, their
@ -346,6 +360,11 @@ against app.simplelogin.io on 23 Oct:
its keys through `keys.unlock` but changes nothing through `keys`: its changes and its keys through `keys.unlock` but changes nothing through `keys`: its changes and
resets stay ADR 0017's, which keep SRP's verifier in step. `simpleLogin.complete` says resets stay ADR 0017's, which keep SRP's verifier in step. `simpleLogin.complete` says
whether the password signs in and has the proof key. whether the password signs in and has the proof key.
- **D35 The banner on each app's home and in the account section** (24 Oct,
recommended, over the account section alone, as ADR 0017's resets show, which the
owner sees only on opening it, and over the home alone): at the top of the home,
where an app too old for its instance shows, and under the SimpleLogin row, each with
Call it off.
- **Also (23 Oct):** the developer's own account moves by adding SimpleLogin to it while - **Also (23 Oct):** the developer's own account moves by adding SimpleLogin to it while
signed in with GitHub; the instance's SimpleLogin app goes up for approval once signed in with GitHub; the instance's SimpleLogin app goes up for approval once
halkyon.app/learn exists, before learn.halkyon.eu switches. halkyon.app/learn exists, before learn.halkyon.eu switches.

View file

@ -101,6 +101,7 @@ export 'src/simplelogin/data/simplelogin_repository.dart';
export 'src/simplelogin/model/simplelogin_arrival.dart'; export 'src/simplelogin/model/simplelogin_arrival.dart';
export 'src/simplelogin/model/simplelogin_failed.dart'; export 'src/simplelogin/model/simplelogin_failed.dart';
export 'src/simplelogin/model/simplelogin_standing.dart'; export 'src/simplelogin/model/simplelogin_standing.dart';
export 'src/simplelogin/widget/keys_reset.dart';
export 'src/simplelogin/widget/simplelogin_section.dart'; export 'src/simplelogin/widget/simplelogin_section.dart';
export 'src/simplelogin/widget/simplelogin_sheet.dart'; export 'src/simplelogin/widget/simplelogin_sheet.dart';
export 'src/simplelogin/widget/simplelogin_standing_scope.dart'; export 'src/simplelogin/widget/simplelogin_standing_scope.dart';

View file

@ -266,6 +266,32 @@
"simpleLoginForgotTitle": "Ein neues Passwort", "simpleLoginForgotTitle": "Ein neues Passwort",
"simpleLoginForgotIntro": "Deine Wiederherstellungsphrase öffnet deine Schlüssel, die dann dein neues Passwort schützt. Alles bleibt; deine anderen Geräte werden abgemeldet.", "simpleLoginForgotIntro": "Deine Wiederherstellungsphrase öffnet deine Schlüssel, die dann dein neues Passwort schützt. Alles bleibt; deine anderen Geräte werden abgemeldet.",
"simpleLoginForgotDoesNotFit": "Die Wiederherstellungsphrase stimmt nicht.", "simpleLoginForgotDoesNotFit": "Die Wiederherstellungsphrase stimmt nicht.",
"simpleLoginResetWaiting": "Neue Schlüssel wurden angefordert: Ab dem {date} kann eine Anmeldung mit SimpleLogin sie einrichten. Öffnest du deine Schlüssel mit deinem Passwort, ist es abgesagt.",
"simpleLoginPhraseLost": "Auch die Wiederherstellungsphrase verloren?",
"simpleLoginResetTitle": "Neue Schlüssel",
"simpleLoginResetIntro": "Ohne dein Passwort oder deine Wiederherstellungsphrase kann niemand deine Schlüssel öffnen, auch {instance} nicht. Neue lassen sich in 72 Stunden einrichten, wenn du dich wieder mit SimpleLogin anmeldest. Dein Handle, dein Profil und was du veröffentlicht hast, bleiben; was mit deinen alten Schlüsseln verschlüsselt ist, geht verloren, außer du schließt auf einem Gerät ab, das noch angemeldet ist und sie behält.",
"simpleLoginResetIntroDevices": "Bis dahin zeigen deine angemeldeten Geräte das Zurücksetzen, und dein Passwort sagt es dort ab, falls jemand anderes es angefordert hat.",
"simpleLoginResetAsk": "Neue Schlüssel anfordern",
"simpleLoginResetAskedTitle": "Neue Schlüssel angefordert",
"simpleLoginResetAskedIntro": "Melde dich ab dem {date} wieder mit SimpleLogin an, um sie einzurichten. Findest du vorher dein Passwort oder deine Wiederherstellungsphrase, sagt das Öffnen deiner Schlüssel damit das Zurücksetzen ab.",
"simpleLoginResetDueTitle": "Deine neuen Schlüssel",
"simpleLoginResetDueIntro": "Die angeforderten neuen Schlüssel lassen sich jetzt einrichten. Oder öffne deine alten Schlüssel mit deinem Passwort, was das Zurücksetzen absagt.",
"simpleLoginResetNewKeys": "Neue Schlüssel einrichten",
"simpleLoginResetNewKeysHint": "Ein neues Passwort und eine neue Wiederherstellungsphrase. Was mit deinen alten Schlüsseln verschlüsselt ist, geht verloren, außer du schließt auf einem Gerät ab, das noch angemeldet ist.",
"simpleLoginResetHavePassword": "Ich habe mein Passwort",
"simpleLoginResetHavePasswordHint": "Deine Schlüssel öffnen sich und bleiben.",
"simpleLoginResetKeepIntro": "Wähle ein neues Passwort: Deine Schlüssel bleiben, unter ihm und einer neuen Wiederherstellungsphrase, sodass nichts verloren geht. Deine anderen Geräte werden abgemeldet.",
"simpleLoginResetFinish": "Schlüssel einrichten",
"simpleLoginProofKeyTitle": "Dein Passwort und ein Code",
"simpleLoginProofKeyIntroAs": "Mit SimpleLogin als @{handle} angemeldet. Du meldest dich bei deinem Konto auch mit einem Passwort an: Gib es und einen Code aus deiner Authenticator-App einmal ein, dann öffnet dein Passwort deine Schlüssel künftig nach SimpleLogin.",
"simpleLoginProofKeyIntro": "Du meldest dich bei deinem Konto auch mit einem Passwort an: Gib es und einen Code aus deiner Authenticator-App einmal ein, dann öffnet dein Passwort deine Schlüssel künftig nach SimpleLogin.",
"simpleLoginResetBanner": "Mit deinem SimpleLogin-Konto wurden neue Schlüssel angefordert: Ab dem {date} kann sie einrichten, wer es hat. Warst du das nicht, sag es mit deinem Passwort ab.",
"simpleLoginResetBannerDue": "Mit deinem SimpleLogin-Konto wurden neue Schlüssel angefordert, und wer es hat, kann sie jetzt einrichten. Warst du das nicht, sag es mit deinem Passwort ab.",
"simpleLoginResetBannerDueHere": "Mit deinem SimpleLogin-Konto wurden neue Schlüssel angefordert, die sich jetzt einrichten lassen. Warst du das, schließ hier ab: Deine Schlüssel bleiben. Wenn nicht, sag es mit deinem Passwort ab.",
"simpleLoginResetFinishHere": "Hier abschließen",
"simpleLoginCallOffIntro": "Dein Passwort sagt das Zurücksetzen deiner Schlüssel ab.",
"simpleLoginResetCalledOff": "Zurücksetzen abgesagt.",
"simpleLoginResetFinished": "Neues Passwort und neue Wiederherstellungsphrase gesetzt.",
"simpleLoginUnavailable": "Diese Instanz bietet die Anmeldung mit SimpleLogin nicht an.", "simpleLoginUnavailable": "Diese Instanz bietet die Anmeldung mit SimpleLogin nicht an.",
"simpleLoginDenied": "Die Anmeldung bei SimpleLogin wurde abgelehnt, also wurde nichts geteilt.", "simpleLoginDenied": "Die Anmeldung bei SimpleLogin wurde abgelehnt, also wurde nichts geteilt.",
"simpleLoginNoBrowser": "Es gibt keinen Browser, der die Seite von SimpleLogin öffnen kann.", "simpleLoginNoBrowser": "Es gibt keinen Browser, der die Seite von SimpleLogin öffnen kann.",

View file

@ -600,6 +600,67 @@
"simpleLoginForgotTitle": "A new password", "simpleLoginForgotTitle": "A new password",
"simpleLoginForgotIntro": "Your recovery phrase opens your keys, which your new password then protects. Everything stays; your other devices are signed out.", "simpleLoginForgotIntro": "Your recovery phrase opens your keys, which your new password then protects. Everything stays; your other devices are signed out.",
"simpleLoginForgotDoesNotFit": "The recovery phrase isn't right.", "simpleLoginForgotDoesNotFit": "The recovery phrase isn't right.",
"simpleLoginResetWaiting": "New keys were asked for: from {date}, a SimpleLogin sign-in can set them up. Your password opening your keys calls it off.",
"@simpleLoginResetWaiting": {
"placeholders": {
"date": {
"type": "String"
}
}
},
"simpleLoginPhraseLost": "Lost your recovery phrase too?",
"simpleLoginResetTitle": "New keys",
"simpleLoginResetIntro": "Without your password or your recovery phrase, nobody can open your keys, not even {instance}. New ones can be set up 72 hours from now, when you sign in with SimpleLogin again. Your handle, your profile and what you published stay; what was encrypted under your old keys is lost, unless you finish on a device still signed in, which keeps them.",
"@simpleLoginResetIntro": {
"placeholders": {
"instance": {
"type": "String"
}
}
},
"simpleLoginResetIntroDevices": "Meanwhile your signed-in devices show the reset, and your password calls it off there, should someone else have asked for it.",
"simpleLoginResetAsk": "Ask for new keys",
"simpleLoginResetAskedTitle": "New keys asked for",
"simpleLoginResetAskedIntro": "From {date}, sign in with SimpleLogin again to set them up. Should you find your password or your recovery phrase before then, opening your keys with it calls the reset off.",
"@simpleLoginResetAskedIntro": {
"placeholders": {
"date": {
"type": "String"
}
}
},
"simpleLoginResetDueTitle": "Your new keys",
"simpleLoginResetDueIntro": "The new keys you asked for can be set up now. Or open your old keys with your password, which calls the reset off.",
"simpleLoginResetNewKeys": "Set up new keys",
"simpleLoginResetNewKeysHint": "A new password and recovery phrase. What was encrypted under your old keys is lost, unless you finish on a device still signed in.",
"simpleLoginResetHavePassword": "I have my password",
"simpleLoginResetHavePasswordHint": "Your keys open, and stay.",
"simpleLoginResetKeepIntro": "Choose a new password: your keys stay, under it and a new recovery phrase, so nothing is lost. Your other devices are signed out.",
"simpleLoginResetFinish": "Set up the keys",
"simpleLoginProofKeyTitle": "Your password and a code",
"simpleLoginProofKeyIntroAs": "Signed in with SimpleLogin as @{handle}. Your account also signs in with a password: type it and a code from your authenticator once, and your password opens your keys after SimpleLogin from then on.",
"@simpleLoginProofKeyIntroAs": {
"placeholders": {
"handle": {
"type": "String"
}
}
},
"simpleLoginProofKeyIntro": "Your account also signs in with a password: type it and a code from your authenticator once, and your password opens your keys after SimpleLogin from then on.",
"simpleLoginResetBanner": "New keys were asked for with your SimpleLogin account: from {date}, whoever has it can set them up. Unless that was you, call it off with your password.",
"@simpleLoginResetBanner": {
"placeholders": {
"date": {
"type": "String"
}
}
},
"simpleLoginResetBannerDue": "New keys were asked for with your SimpleLogin account, and whoever has it can set them up now. Unless that was you, call it off with your password.",
"simpleLoginResetBannerDueHere": "New keys were asked for with your SimpleLogin account and can be set up now. If that was you, finish here: your keys stay. If not, call it off with your password.",
"simpleLoginResetFinishHere": "Finish here",
"simpleLoginCallOffIntro": "Your password calls off the reset of your keys.",
"simpleLoginResetCalledOff": "Reset called off.",
"simpleLoginResetFinished": "New password and recovery phrase set.",
"simpleLoginUnavailable": "This instance doesn't offer sign-in with SimpleLogin.", "simpleLoginUnavailable": "This instance doesn't offer sign-in with SimpleLogin.",
"simpleLoginDenied": "SimpleLogin's sign-in was declined, so nothing was shared.", "simpleLoginDenied": "SimpleLogin's sign-in was declined, so nothing was shared.",
"simpleLoginNoBrowser": "There's no browser to open SimpleLogin's page in.", "simpleLoginNoBrowser": "There's no browser to open SimpleLogin's page in.",

View file

@ -1699,6 +1699,162 @@ abstract class HalkyonAppLocalizations {
/// **'The recovery phrase isn\'t right.'** /// **'The recovery phrase isn\'t right.'**
String get simpleLoginForgotDoesNotFit; String get simpleLoginForgotDoesNotFit;
/// No description provided for @simpleLoginResetWaiting.
///
/// In en, this message translates to:
/// **'New keys were asked for: from {date}, a SimpleLogin sign-in can set them up. Your password opening your keys calls it off.'**
String simpleLoginResetWaiting(String date);
/// No description provided for @simpleLoginPhraseLost.
///
/// In en, this message translates to:
/// **'Lost your recovery phrase too?'**
String get simpleLoginPhraseLost;
/// No description provided for @simpleLoginResetTitle.
///
/// In en, this message translates to:
/// **'New keys'**
String get simpleLoginResetTitle;
/// No description provided for @simpleLoginResetIntro.
///
/// In en, this message translates to:
/// **'Without your password or your recovery phrase, nobody can open your keys, not even {instance}. New ones can be set up 72 hours from now, when you sign in with SimpleLogin again. Your handle, your profile and what you published stay; what was encrypted under your old keys is lost, unless you finish on a device still signed in, which keeps them.'**
String simpleLoginResetIntro(String instance);
/// No description provided for @simpleLoginResetIntroDevices.
///
/// In en, this message translates to:
/// **'Meanwhile your signed-in devices show the reset, and your password calls it off there, should someone else have asked for it.'**
String get simpleLoginResetIntroDevices;
/// No description provided for @simpleLoginResetAsk.
///
/// In en, this message translates to:
/// **'Ask for new keys'**
String get simpleLoginResetAsk;
/// No description provided for @simpleLoginResetAskedTitle.
///
/// In en, this message translates to:
/// **'New keys asked for'**
String get simpleLoginResetAskedTitle;
/// No description provided for @simpleLoginResetAskedIntro.
///
/// In en, this message translates to:
/// **'From {date}, sign in with SimpleLogin again to set them up. Should you find your password or your recovery phrase before then, opening your keys with it calls the reset off.'**
String simpleLoginResetAskedIntro(String date);
/// No description provided for @simpleLoginResetDueTitle.
///
/// In en, this message translates to:
/// **'Your new keys'**
String get simpleLoginResetDueTitle;
/// No description provided for @simpleLoginResetDueIntro.
///
/// In en, this message translates to:
/// **'The new keys you asked for can be set up now. Or open your old keys with your password, which calls the reset off.'**
String get simpleLoginResetDueIntro;
/// No description provided for @simpleLoginResetNewKeys.
///
/// In en, this message translates to:
/// **'Set up new keys'**
String get simpleLoginResetNewKeys;
/// No description provided for @simpleLoginResetNewKeysHint.
///
/// In en, this message translates to:
/// **'A new password and recovery phrase. What was encrypted under your old keys is lost, unless you finish on a device still signed in.'**
String get simpleLoginResetNewKeysHint;
/// No description provided for @simpleLoginResetHavePassword.
///
/// In en, this message translates to:
/// **'I have my password'**
String get simpleLoginResetHavePassword;
/// No description provided for @simpleLoginResetHavePasswordHint.
///
/// In en, this message translates to:
/// **'Your keys open, and stay.'**
String get simpleLoginResetHavePasswordHint;
/// No description provided for @simpleLoginResetKeepIntro.
///
/// In en, this message translates to:
/// **'Choose a new password: your keys stay, under it and a new recovery phrase, so nothing is lost. Your other devices are signed out.'**
String get simpleLoginResetKeepIntro;
/// No description provided for @simpleLoginResetFinish.
///
/// In en, this message translates to:
/// **'Set up the keys'**
String get simpleLoginResetFinish;
/// No description provided for @simpleLoginProofKeyTitle.
///
/// In en, this message translates to:
/// **'Your password and a code'**
String get simpleLoginProofKeyTitle;
/// No description provided for @simpleLoginProofKeyIntroAs.
///
/// In en, this message translates to:
/// **'Signed in with SimpleLogin as @{handle}. Your account also signs in with a password: type it and a code from your authenticator once, and your password opens your keys after SimpleLogin from then on.'**
String simpleLoginProofKeyIntroAs(String handle);
/// No description provided for @simpleLoginProofKeyIntro.
///
/// In en, this message translates to:
/// **'Your account also signs in with a password: type it and a code from your authenticator once, and your password opens your keys after SimpleLogin from then on.'**
String get simpleLoginProofKeyIntro;
/// No description provided for @simpleLoginResetBanner.
///
/// In en, this message translates to:
/// **'New keys were asked for with your SimpleLogin account: from {date}, whoever has it can set them up. Unless that was you, call it off with your password.'**
String simpleLoginResetBanner(String date);
/// No description provided for @simpleLoginResetBannerDue.
///
/// In en, this message translates to:
/// **'New keys were asked for with your SimpleLogin account, and whoever has it can set them up now. Unless that was you, call it off with your password.'**
String get simpleLoginResetBannerDue;
/// No description provided for @simpleLoginResetBannerDueHere.
///
/// In en, this message translates to:
/// **'New keys were asked for with your SimpleLogin account and can be set up now. If that was you, finish here: your keys stay. If not, call it off with your password.'**
String get simpleLoginResetBannerDueHere;
/// No description provided for @simpleLoginResetFinishHere.
///
/// In en, this message translates to:
/// **'Finish here'**
String get simpleLoginResetFinishHere;
/// No description provided for @simpleLoginCallOffIntro.
///
/// In en, this message translates to:
/// **'Your password calls off the reset of your keys.'**
String get simpleLoginCallOffIntro;
/// No description provided for @simpleLoginResetCalledOff.
///
/// In en, this message translates to:
/// **'Reset called off.'**
String get simpleLoginResetCalledOff;
/// No description provided for @simpleLoginResetFinished.
///
/// In en, this message translates to:
/// **'New password and recovery phrase set.'**
String get simpleLoginResetFinished;
/// No description provided for @simpleLoginUnavailable. /// No description provided for @simpleLoginUnavailable.
/// ///
/// In en, this message translates to: /// In en, this message translates to:

View file

@ -1002,6 +1002,105 @@ class HalkyonAppLocalizationsDe extends HalkyonAppLocalizations {
String get simpleLoginForgotDoesNotFit => String get simpleLoginForgotDoesNotFit =>
'Die Wiederherstellungsphrase stimmt nicht.'; 'Die Wiederherstellungsphrase stimmt nicht.';
@override
String simpleLoginResetWaiting(String date) {
return 'Neue Schlüssel wurden angefordert: Ab dem $date kann eine Anmeldung mit SimpleLogin sie einrichten. Öffnest du deine Schlüssel mit deinem Passwort, ist es abgesagt.';
}
@override
String get simpleLoginPhraseLost =>
'Auch die Wiederherstellungsphrase verloren?';
@override
String get simpleLoginResetTitle => 'Neue Schlüssel';
@override
String simpleLoginResetIntro(String instance) {
return 'Ohne dein Passwort oder deine Wiederherstellungsphrase kann niemand deine Schlüssel öffnen, auch $instance nicht. Neue lassen sich in 72 Stunden einrichten, wenn du dich wieder mit SimpleLogin anmeldest. Dein Handle, dein Profil und was du veröffentlicht hast, bleiben; was mit deinen alten Schlüsseln verschlüsselt ist, geht verloren, außer du schließt auf einem Gerät ab, das noch angemeldet ist und sie behält.';
}
@override
String get simpleLoginResetIntroDevices =>
'Bis dahin zeigen deine angemeldeten Geräte das Zurücksetzen, und dein Passwort sagt es dort ab, falls jemand anderes es angefordert hat.';
@override
String get simpleLoginResetAsk => 'Neue Schlüssel anfordern';
@override
String get simpleLoginResetAskedTitle => 'Neue Schlüssel angefordert';
@override
String simpleLoginResetAskedIntro(String date) {
return 'Melde dich ab dem $date wieder mit SimpleLogin an, um sie einzurichten. Findest du vorher dein Passwort oder deine Wiederherstellungsphrase, sagt das Öffnen deiner Schlüssel damit das Zurücksetzen ab.';
}
@override
String get simpleLoginResetDueTitle => 'Deine neuen Schlüssel';
@override
String get simpleLoginResetDueIntro =>
'Die angeforderten neuen Schlüssel lassen sich jetzt einrichten. Oder öffne deine alten Schlüssel mit deinem Passwort, was das Zurücksetzen absagt.';
@override
String get simpleLoginResetNewKeys => 'Neue Schlüssel einrichten';
@override
String get simpleLoginResetNewKeysHint =>
'Ein neues Passwort und eine neue Wiederherstellungsphrase. Was mit deinen alten Schlüsseln verschlüsselt ist, geht verloren, außer du schließt auf einem Gerät ab, das noch angemeldet ist.';
@override
String get simpleLoginResetHavePassword => 'Ich habe mein Passwort';
@override
String get simpleLoginResetHavePasswordHint =>
'Deine Schlüssel öffnen sich und bleiben.';
@override
String get simpleLoginResetKeepIntro =>
'Wähle ein neues Passwort: Deine Schlüssel bleiben, unter ihm und einer neuen Wiederherstellungsphrase, sodass nichts verloren geht. Deine anderen Geräte werden abgemeldet.';
@override
String get simpleLoginResetFinish => 'Schlüssel einrichten';
@override
String get simpleLoginProofKeyTitle => 'Dein Passwort und ein Code';
@override
String simpleLoginProofKeyIntroAs(String handle) {
return 'Mit SimpleLogin als @$handle angemeldet. Du meldest dich bei deinem Konto auch mit einem Passwort an: Gib es und einen Code aus deiner Authenticator-App einmal ein, dann öffnet dein Passwort deine Schlüssel künftig nach SimpleLogin.';
}
@override
String get simpleLoginProofKeyIntro =>
'Du meldest dich bei deinem Konto auch mit einem Passwort an: Gib es und einen Code aus deiner Authenticator-App einmal ein, dann öffnet dein Passwort deine Schlüssel künftig nach SimpleLogin.';
@override
String simpleLoginResetBanner(String date) {
return 'Mit deinem SimpleLogin-Konto wurden neue Schlüssel angefordert: Ab dem $date kann sie einrichten, wer es hat. Warst du das nicht, sag es mit deinem Passwort ab.';
}
@override
String get simpleLoginResetBannerDue =>
'Mit deinem SimpleLogin-Konto wurden neue Schlüssel angefordert, und wer es hat, kann sie jetzt einrichten. Warst du das nicht, sag es mit deinem Passwort ab.';
@override
String get simpleLoginResetBannerDueHere =>
'Mit deinem SimpleLogin-Konto wurden neue Schlüssel angefordert, die sich jetzt einrichten lassen. Warst du das, schließ hier ab: Deine Schlüssel bleiben. Wenn nicht, sag es mit deinem Passwort ab.';
@override
String get simpleLoginResetFinishHere => 'Hier abschließen';
@override
String get simpleLoginCallOffIntro =>
'Dein Passwort sagt das Zurücksetzen deiner Schlüssel ab.';
@override
String get simpleLoginResetCalledOff => 'Zurücksetzen abgesagt.';
@override
String get simpleLoginResetFinished =>
'Neues Passwort und neue Wiederherstellungsphrase gesetzt.';
@override @override
String get simpleLoginUnavailable => String get simpleLoginUnavailable =>
'Diese Instanz bietet die Anmeldung mit SimpleLogin nicht an.'; 'Diese Instanz bietet die Anmeldung mit SimpleLogin nicht an.';

View file

@ -983,6 +983,103 @@ class HalkyonAppLocalizationsEn extends HalkyonAppLocalizations {
@override @override
String get simpleLoginForgotDoesNotFit => 'The recovery phrase isn\'t right.'; String get simpleLoginForgotDoesNotFit => 'The recovery phrase isn\'t right.';
@override
String simpleLoginResetWaiting(String date) {
return 'New keys were asked for: from $date, a SimpleLogin sign-in can set them up. Your password opening your keys calls it off.';
}
@override
String get simpleLoginPhraseLost => 'Lost your recovery phrase too?';
@override
String get simpleLoginResetTitle => 'New keys';
@override
String simpleLoginResetIntro(String instance) {
return 'Without your password or your recovery phrase, nobody can open your keys, not even $instance. New ones can be set up 72 hours from now, when you sign in with SimpleLogin again. Your handle, your profile and what you published stay; what was encrypted under your old keys is lost, unless you finish on a device still signed in, which keeps them.';
}
@override
String get simpleLoginResetIntroDevices =>
'Meanwhile your signed-in devices show the reset, and your password calls it off there, should someone else have asked for it.';
@override
String get simpleLoginResetAsk => 'Ask for new keys';
@override
String get simpleLoginResetAskedTitle => 'New keys asked for';
@override
String simpleLoginResetAskedIntro(String date) {
return 'From $date, sign in with SimpleLogin again to set them up. Should you find your password or your recovery phrase before then, opening your keys with it calls the reset off.';
}
@override
String get simpleLoginResetDueTitle => 'Your new keys';
@override
String get simpleLoginResetDueIntro =>
'The new keys you asked for can be set up now. Or open your old keys with your password, which calls the reset off.';
@override
String get simpleLoginResetNewKeys => 'Set up new keys';
@override
String get simpleLoginResetNewKeysHint =>
'A new password and recovery phrase. What was encrypted under your old keys is lost, unless you finish on a device still signed in.';
@override
String get simpleLoginResetHavePassword => 'I have my password';
@override
String get simpleLoginResetHavePasswordHint => 'Your keys open, and stay.';
@override
String get simpleLoginResetKeepIntro =>
'Choose a new password: your keys stay, under it and a new recovery phrase, so nothing is lost. Your other devices are signed out.';
@override
String get simpleLoginResetFinish => 'Set up the keys';
@override
String get simpleLoginProofKeyTitle => 'Your password and a code';
@override
String simpleLoginProofKeyIntroAs(String handle) {
return 'Signed in with SimpleLogin as @$handle. Your account also signs in with a password: type it and a code from your authenticator once, and your password opens your keys after SimpleLogin from then on.';
}
@override
String get simpleLoginProofKeyIntro =>
'Your account also signs in with a password: type it and a code from your authenticator once, and your password opens your keys after SimpleLogin from then on.';
@override
String simpleLoginResetBanner(String date) {
return 'New keys were asked for with your SimpleLogin account: from $date, whoever has it can set them up. Unless that was you, call it off with your password.';
}
@override
String get simpleLoginResetBannerDue =>
'New keys were asked for with your SimpleLogin account, and whoever has it can set them up now. Unless that was you, call it off with your password.';
@override
String get simpleLoginResetBannerDueHere =>
'New keys were asked for with your SimpleLogin account and can be set up now. If that was you, finish here: your keys stay. If not, call it off with your password.';
@override
String get simpleLoginResetFinishHere => 'Finish here';
@override
String get simpleLoginCallOffIntro =>
'Your password calls off the reset of your keys.';
@override
String get simpleLoginResetCalledOff => 'Reset called off.';
@override
String get simpleLoginResetFinished =>
'New password and recovery phrase set.';
@override @override
String get simpleLoginUnavailable => String get simpleLoginUnavailable =>
'This instance doesn\'t offer sign-in with SimpleLogin.'; 'This instance doesn\'t offer sign-in with SimpleLogin.';

View file

@ -6,12 +6,18 @@ import 'package:halkyon_base_client/srp.dart' as srp;
/// What a password unlocks on the device (ADR 0017), for one account's salt /// What a password unlocks on the device (ADR 0017), for one account's salt
/// and costs: SRP's private value and the key that wraps the account's /// and costs: SRP's private value and the key that wraps the account's
/// private keys, kept only while a sign-in, reset or change needs them. /// private keys, kept only while a sign-in, reset or change needs them, and
/// the public half of its proof key.
final class PasswordUnlock({ final class PasswordUnlock({
/// The salt and costs they were derived with. /// The salt and costs they were derived with.
required final Uint8List salt, required final Uint8List salt,
required final protocol.PasswordCosts costs, required final protocol.PasswordCosts costs,
/// The public half of the password's proof key, which the keys of an
/// account signing in with SimpleLogin take in place of SRP's verifier
/// (ADR 0020, D19, D34): public, so [forget] leaves it.
required final Uint8List proofKey,
/// SRP's x, 32 bytes, big-endian ([srpSecret]). /// SRP's x, 32 bytes, big-endian ([srpSecret]).
required final Uint8List _srpSecret, required final Uint8List _srpSecret,
@ -204,14 +210,15 @@ abstract interface class AccountCrypto() {
Future<NewKeysMaterial> newKeys(String password, {UnlockedAccountKeys? keys}); Future<NewKeysMaterial> newKeys(String password, {UnlockedAccountKeys? keys});
/// The proof of [password] for [challenge] on [instanceUrl], signed by /// The proof of [password] for [challenge] on [instanceUrl], signed by
/// the password's proof key for unlocking the keys /// the password's proof key for [purpose] (unlocking the keys, or calling
/// (`KeyProofPurpose.unlockKeys`), with what it unlocked for the /// off a reset of them, ADR 0020, D32), with what it unlocked for the
/// challenge's salt and costs: one Argon2id for both. The caller forgets /// challenge's salt and costs: one Argon2id for both. The caller forgets
/// the unlock once the keys are open. /// the unlock once done with it.
Future<KeysPasswordProof> proveKeysPassword( Future<KeysPasswordProof> proveKeysPassword(
String password, String password,
protocol.KeysChallenge challenge, { protocol.KeysChallenge challenge, {
required String instanceUrl, required String instanceUrl,
srp.KeyProofPurpose purpose = srp.KeyProofPurpose.unlockKeys,
}); });
/// A new password for the keys of an account that signs in with /// A new password for the keys of an account that signs in with

View file

@ -80,6 +80,8 @@ class SodiumAccountCrypto() implements AccountCrypto {
return PasswordUnlock( return PasswordUnlock(
salt: salt, salt: salt,
costs: costs, costs: costs,
// Copied by proofPublicKey already.
proofKey: unlocked.proofPublicKey,
srpSecret: unlocked.srpSecret.extractBytes(), srpSecret: unlocked.srpSecret.extractBytes(),
keyEncryptionKey: unlocked.keyEncryptionKey.extractBytes(), keyEncryptionKey: unlocked.keyEncryptionKey.extractBytes(),
); );
@ -286,6 +288,7 @@ class SodiumAccountCrypto() implements AccountCrypto {
String password, String password,
protocol.KeysChallenge challenge, { protocol.KeysChallenge challenge, {
required String instanceUrl, required String instanceUrl,
srp.KeyProofPurpose purpose = srp.KeyProofPurpose.unlockKeys,
}) => Isolate.run( }) => Isolate.run(
() => _guarded(() async { () => _guarded(() async {
final sodium = await SodiumSumoInit.init(); final sodium = await SodiumSumoInit.init();
@ -306,7 +309,7 @@ class SodiumAccountCrypto() implements AccountCrypto {
challenge.challenge.toBytes(), challenge.challenge.toBytes(),
Uint8List.sublistView(challenge.nonce), Uint8List.sublistView(challenge.nonce),
instanceUrl: instanceUrl, instanceUrl: instanceUrl,
purpose: srp.KeyProofPurpose.unlockKeys, purpose: purpose,
), ),
secretKey: pair.secretKey, secretKey: pair.secretKey,
), ),
@ -323,6 +326,7 @@ class SodiumAccountCrypto() implements AccountCrypto {
unlock: PasswordUnlock( unlock: PasswordUnlock(
salt: salt, salt: salt,
costs: challenge.costs, costs: challenge.costs,
proofKey: _own(pair.publicKey),
srpSecret: unlocked.srpSecret.extractBytes(), srpSecret: unlocked.srpSecret.extractBytes(),
keyEncryptionKey: unlocked.keyEncryptionKey.extractBytes(), keyEncryptionKey: unlocked.keyEncryptionKey.extractBytes(),
), ),

View file

@ -244,79 +244,30 @@ class _SessionEndedWarningState() extends State<_SessionEndedWarning> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = HalkyonAppLocalizations.of(context); final l10n = HalkyonAppLocalizations.of(context);
final theme = Theme.of(context);
final colors = theme.colorScheme;
return BlocBuilder<SecurityStepCubit<bool>, SecurityStepState<bool>>( return BlocBuilder<SecurityStepCubit<bool>, SecurityStepState<bool>>(
bloc: _step, bloc: _step,
builder: (context, state) => Padding( builder: (context, state) => AlertBox(
padding: const EdgeInsets.only(top: 8), title: l10n.passwordWarningTitle,
child: DecoratedBox( lines: [
decoration: BoxDecoration( l10n.passwordWarningBody(passwordMoment(context, widget._ended)),
color: colors.errorContainer, ],
borderRadius: BorderRadius.circular(12), problem: switch (state.problem) {
final failed? => passwordProblemText(context, failed),
null => null,
},
actions: [
TextButton(
onPressed: state.busy
? null
: () => _step.run(() async {
await AppScope.of(
context,
).passwords.dismissSessionEndedWarning();
return true;
}),
child: Text(l10n.passwordWarningDismiss),
), ),
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.passwordWarningTitle,
style: theme.textTheme.titleSmall?.copyWith(
color: colors.onErrorContainer,
),
),
),
],
),
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.only(right: 8),
child: Text(
l10n.passwordWarningBody(
passwordMoment(context, widget._ended),
),
style: theme.textTheme.bodyMedium?.copyWith(
color: colors.onErrorContainer,
),
),
),
if (state.problem case final failed?)
Padding(
padding: const EdgeInsets.only(top: 8, right: 8),
child: Text(
passwordProblemText(context, failed),
style: theme.textTheme.bodySmall?.copyWith(
color: colors.error,
),
),
),
Align(
alignment: AlignmentDirectional.centerEnd,
child: TextButton(
onPressed: state.busy
? null
: () => _step.run(() async {
await AppScope.of(
context,
).passwords.dismissSessionEndedWarning();
return true;
}),
child: Text(l10n.passwordWarningDismiss),
),
),
],
),
),
),
), ),
); );
} }
@ -629,66 +580,20 @@ class const _ResetsWaiting({
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = HalkyonAppLocalizations.of(context); final l10n = HalkyonAppLocalizations.of(context);
final theme = Theme.of(context); return AlertBox(
final colors = theme.colorScheme; title: l10n.passwordResetsTitle,
final body = theme.textTheme.bodyMedium?.copyWith( lines: [
color: colors.onErrorContainer, if (authenticatorAt case final at?)
); l10n.passwordResetsAuthenticator(passwordMoment(context, at)),
return Padding( if (accountAt case final at?)
padding: const EdgeInsets.only(top: 8), l10n.passwordResetsAccount(passwordMoment(context, at)),
child: DecoratedBox( ],
decoration: BoxDecoration( actions: [
color: colors.errorContainer, TextButton(
borderRadius: BorderRadius.circular(12), onPressed: onCallOff,
child: Text(l10n.passwordResetsCallOff),
), ),
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),
),
),
],
),
),
),
); );
} }
} }

View file

@ -226,6 +226,80 @@ class const SheetIntro(final String text, {super.key}) extends StatelessWidget {
} }
} }
/// A warning in the error container's colours, as an account's section and
/// a sheet show one: the shield and [title], [lines] below it, a [problem]
/// in the error colour, and [actions] at the end.
class const AlertBox({
required final String title,
required final List<String> lines,
final String? problem,
final List<Widget> actions = const [],
super.key,
}) extends StatelessWidget {
@override
Widget build(BuildContext 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: EdgeInsets.fromLTRB(16, 12, 8, actions.isEmpty ? 12 : 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Icon(LucideIcons.shieldAlert, color: colors.onErrorContainer),
const SizedBox(width: 12),
Expanded(
child: Text(
title,
style: theme.textTheme.titleSmall?.copyWith(
color: colors.onErrorContainer,
),
),
),
],
),
for (final line in lines)
Padding(
padding: const EdgeInsets.only(top: 8, right: 8),
child: Text(line, style: body),
),
if (problem case final problem?)
Padding(
padding: const EdgeInsets.only(top: 8, right: 8),
child: Text(
problem,
style: theme.textTheme.bodySmall?.copyWith(
color: colors.error,
),
),
),
if (actions.isNotEmpty)
Align(
alignment: AlignmentDirectional.centerEnd,
child: OverflowBar(
alignment: MainAxisAlignment.end,
children: actions,
),
),
],
),
),
),
);
}
}
/// A refusal shown in a sheet, in the error colour. /// A refusal shown in a sheet, in the error colour.
class const SheetProblem(final String text, {super.key}) class const SheetProblem(final String text, {super.key})
extends StatelessWidget { extends StatelessWidget {

View file

@ -1,3 +1,4 @@
import 'package:clock/clock.dart';
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_app/src/handle/model/handle_problem.dart'; import 'package:halkyon_app/src/handle/model/handle_problem.dart';
@ -31,16 +32,33 @@ enum SimpleLoginStep() {
/// The password that opens the account's keys. /// The password that opens the account's keys.
unlock, unlock,
/// The password, which signs in too, and a code, which give the keys the
/// proof key that password makes (D34): asked once, and again after a
/// new password of ADR 0017's.
proofKey,
/// The recovery phrase and a new password, twice, for a password lost. /// The recovery phrase and a new password, twice, for a password lost.
forgot, forgot,
/// What a reset of the keys means, the password and the phrase lost
/// (D31), before it is asked for.
resetAsk,
/// The reset asked for, and when it takes effect.
resetAsked,
/// The reset due: new keys, or the old ones opened with the password,
/// which calls it off.
resetDue,
/// The handle and the recovery phrase, which link the account again. /// The handle and the recovery phrase, which link the account again.
mend, mend,
/// Signed in, or SimpleLogin added to the account. /// Signed in, or SimpleLogin added to the account.
done, done,
/// The browser closed before SimpleLogin came back: nothing happened. /// The browser closed before SimpleLogin came back, or a reset of the
/// keys was asked for: nobody is signed in.
closed, closed,
} }
@ -63,6 +81,17 @@ final class const SimpleLoginState({
/// Whether the sign-in is someone new's, whose account is yet to be. /// Whether the sign-in is someone new's, whose account is yet to be.
final bool newcomer = false, final bool newcomer = false,
/// Whether the account's password signs in too: its resets are ADR
/// 0017's, by mail (D34).
final bool passwordSignsIn = false,
/// When a reset of the account's keys takes effect (D31).
final DateTime? resetAt,
/// Whether the password and the phrase being made are for the keys a
/// reset gives (D31, D33).
final bool resetting = false,
final String phrase = '', final String phrase = '',
final List<int> checked = const [], final List<int> checked = const [],
}) with Equatable { }) with Equatable {
@ -76,6 +105,10 @@ final class const SimpleLoginState({
String? handle, String? handle,
String? signedInAs, String? signedInAs,
bool? newcomer, bool? newcomer,
bool? passwordSignsIn,
DateTime? resetAt,
bool clearResetAt = false,
bool? resetting,
String? phrase, String? phrase,
List<int>? checked, List<int>? checked,
}) => SimpleLoginState( }) => SimpleLoginState(
@ -87,6 +120,9 @@ final class const SimpleLoginState({
handle: handle ?? this.handle, handle: handle ?? this.handle,
signedInAs: signedInAs ?? this.signedInAs, signedInAs: signedInAs ?? this.signedInAs,
newcomer: newcomer ?? this.newcomer, newcomer: newcomer ?? this.newcomer,
passwordSignsIn: passwordSignsIn ?? this.passwordSignsIn,
resetAt: clearResetAt ? null : resetAt ?? this.resetAt,
resetting: resetting ?? this.resetting,
phrase: phrase ?? this.phrase, phrase: phrase ?? this.phrase,
checked: checked ?? this.checked, checked: checked ?? this.checked,
); );
@ -105,6 +141,9 @@ final class const SimpleLoginState({
handle, handle,
signedInAs, signedInAs,
newcomer, newcomer,
passwordSignsIn,
resetAt,
resetting,
phrase, phrase,
checked, checked,
]; ];
@ -115,11 +154,13 @@ final class const SimpleLoginState({
/// twice, the recovery phrase shown once with three of its words typed /// twice, the recovery phrase shown once with three of its words typed
/// back) or theirs linked again with its handle and recovery phrase (D7); /// back) or theirs linked again with its handle and recovery phrase (D7);
/// for an account, its password, which opens its keys (or, for one lost, the /// for an account, its password, which opens its keys (or, for one lost, the
/// recovery phrase and a new password), or, for one without keys, the steps /// recovery phrase and a new password; both lost, a reset to new keys 72
/// making them. With [_link], SimpleLogin is added to the /// hours later, D31), or, for one without keys, the steps making them; for
/// account of the device's person instead, who keeps everything. Nothing is /// one whose password signs in too, that password and a code first, once
/// the device's until the last step: no account exists whose phrase was /// (D34). With [_link], SimpleLogin is added to the account of the device's
/// never written down. /// person instead, who keeps everything, and finishes a reset due there
/// with the keys the device holds (D33). Nothing is the device's until the
/// last step: no account exists whose phrase was never written down.
class SimpleLoginCubit( class SimpleLoginCubit(
final SimpleLoginRepository _simpleLogin, { final SimpleLoginRepository _simpleLogin, {
final bool _link = false, final bool _link = false,
@ -156,34 +197,55 @@ class SimpleLoginCubit(
if (isClosed) { if (isClosed) {
return; return;
} }
final arrived = state.copyWith(
newcomer: arrival is SimpleLoginNewcomer,
passwordSignsIn: switch (arrival) {
SimpleLoginAccount(:final passwordSignsIn) => passwordSignsIn,
_ => false,
},
resetAt: switch (arrival) {
SimpleLoginAccount(:final resetAt) => resetAt,
_ => null,
},
clearResetAt: switch (arrival) {
SimpleLoginAccount(resetAt: _?) => false,
_ => true,
},
resetting: false,
);
emit(switch (arrival) { emit(switch (arrival) {
null => state.copyWith(step: SimpleLoginStep.closed, newcomer: false), null => arrived.copyWith(step: SimpleLoginStep.closed),
SimpleLoginNewcomer(:final suggestedHandle) => state.copyWith( SimpleLoginNewcomer(:final suggestedHandle) => arrived.copyWith(
step: SimpleLoginStep.newcomer, step: SimpleLoginStep.newcomer,
newcomer: true,
handle: suggestedHandle ?? '', handle: suggestedHandle ?? '',
), ),
SimpleLoginAccount(hasKeys: true, proofKeyMissing: true, :final handle) =>
arrived.copyWith(step: SimpleLoginStep.proofKey, signedInAs: handle),
// The device holds the keys, which stay under the new password.
SimpleLoginAccount(hasKeys: true, :final resetAt?)
when _link && _due(resetAt) =>
arrived.copyWith(step: SimpleLoginStep.password, resetting: true),
// The device has the keys of its person already. // The device has the keys of its person already.
SimpleLoginAccount(hasKeys: true) when _link => state.copyWith( SimpleLoginAccount(hasKeys: true) when _link => arrived.copyWith(
step: SimpleLoginStep.done, step: SimpleLoginStep.done,
newcomer: false,
), ),
SimpleLoginAccount(hasKeys: true, :final handle) => state.copyWith( SimpleLoginAccount(hasKeys: true, :final resetAt?, :final handle)
when _due(resetAt) =>
arrived.copyWith(step: SimpleLoginStep.resetDue, signedInAs: handle),
SimpleLoginAccount(hasKeys: true, :final handle) => arrived.copyWith(
step: SimpleLoginStep.unlock, step: SimpleLoginStep.unlock,
newcomer: false,
signedInAs: handle, signedInAs: handle,
), ),
SimpleLoginAccount(handle: null) => state.copyWith( SimpleLoginAccount(handle: null) => arrived.copyWith(
step: SimpleLoginStep.handle, step: SimpleLoginStep.handle,
newcomer: false,
),
SimpleLoginAccount() => state.copyWith(
step: SimpleLoginStep.password,
newcomer: false,
), ),
SimpleLoginAccount() => arrived.copyWith(step: SimpleLoginStep.password),
}); });
}); });
/// Whether a reset taking effect [at] is due.
static bool _due(DateTime at) => !at.isAfter(clock.now());
/// Stops waiting for the browser: the sign-in ends as closed. /// Stops waiting for the browser: the sign-in ends as closed.
Future<void> cancelled() => _simpleLogin.cancel(); Future<void> cancelled() => _simpleLogin.cancel();
@ -296,6 +358,8 @@ class SimpleLoginCubit(
handle: state.handle, handle: state.handle,
made: made, made: made,
), ),
final SimpleLoginAccount account when state.resetting =>
await _simpleLogin.completeReset(account, made: made),
final SimpleLoginAccount account => await _simpleLogin.setFirstKeys( final SimpleLoginAccount account => await _simpleLogin.setFirstKeys(
account, account,
made: made, made: made,
@ -326,6 +390,56 @@ class SimpleLoginCubit(
state.copyWith(step: SimpleLoginStep.unlock, clearProblem: true), state.copyWith(step: SimpleLoginStep.unlock, clearProblem: true),
); );
/// The password, which signs in too, and the authenticator's [code]: the
/// keys get the proof key the password makes (D34), then open with it;
/// on the device's person's account, which holds them, SimpleLogin is
/// added.
Future<void> proofKeyEntered(String password, String code) => _run(() async {
final account = _arrival! as SimpleLoginAccount;
await _simpleLogin.addProofKey(account, password: password, code: code);
if (_link) {
emit(state.copyWith(step: SimpleLoginStep.done));
} else {
await _enter(await _simpleLogin.unlock(account, password));
}
});
/// The recovery phrase is lost too: what a reset of the keys means.
void phraseLost() => emit(
state.copyWith(step: SimpleLoginStep.resetAsk, clearProblem: true),
);
/// Asks for the reset of the keys (D31): when it takes effect, shown;
/// nobody is signed in.
Future<void> resetAsked() => _run(() async {
final at = await _simpleLogin.startReset(_arrival! as SimpleLoginAccount);
emit(state.copyWith(step: SimpleLoginStep.resetAsked, resetAt: at));
});
/// Done with the sheet once the reset was asked for.
void finished() => emit(state.copyWith(step: SimpleLoginStep.closed));
/// The reset due: new keys, made with a new password and phrase.
void newKeysChosen() => emit(
state.copyWith(
step: SimpleLoginStep.password,
resetting: true,
passwordsDiffer: false,
clearProblem: true,
),
);
/// The reset due, the password found: it opens the keys, calling the
/// reset off.
void passwordChosen() => emit(
state.copyWith(step: SimpleLoginStep.unlock, clearProblem: true),
);
/// Back to the choice a reset due offers.
void resetDueShownAgain() => emit(
state.copyWith(step: SimpleLoginStep.resetDue, clearProblem: true),
);
/// The recovery phrase and the new password, typed twice: the keys the /// The recovery phrase and the new password, typed twice: the keys the
/// phrase opens wrapped under it, and the account signed in. /// phrase opens wrapped under it, and the account signed in.
Future<void> forgotEntered( Future<void> forgotEntered(

View file

@ -1,7 +1,11 @@
import 'dart:typed_data';
import 'package:halkyon_app/src/auth/model/session_grant.dart'; import 'package:halkyon_app/src/auth/model/session_grant.dart';
import 'package:halkyon_app/src/base/base_calls.dart'; import 'package:halkyon_app/src/base/base_calls.dart';
import 'package:halkyon_app/src/handle/data/handle_refusals.dart'; import 'package:halkyon_app/src/handle/data/handle_refusals.dart';
import 'package:halkyon_app/src/handle/model/handle_problem.dart'; import 'package:halkyon_app/src/handle/model/handle_problem.dart';
import 'package:halkyon_app/src/password/data/password_gateway.dart'
show OwnerAnswer;
import 'package:halkyon_app/src/password/data/serverpod_password_gateway.dart' import 'package:halkyon_app/src/password/data/serverpod_password_gateway.dart'
show passwordProblemOf; show passwordProblemOf;
import 'package:halkyon_app/src/password/model/password_failed.dart'; import 'package:halkyon_app/src/password/model/password_failed.dart';
@ -35,8 +39,16 @@ class ServerpodSimpleLoginGateway(
_failing(() async => (await _base.instance.basics()).simpleLogin); _failing(() async => (await _base.instance.basics()).simpleLogin);
@override @override
Future<bool> hasSimpleLogin() => Future<({bool simpleLogin, DateTime? keysResetAt})> standing() =>
_failing(() async => (await _base.account.signInWays()).simpleLogin); _failing(() async {
final ways = await _base.account.signInWays();
return (
simpleLogin: ways.simpleLogin,
// One whose password signs in resets as ADR 0017 has it, which
// its account section shows.
keysResetAt: ways.keys && !ways.password ? ways.accountResetAt : null,
);
});
@override @override
Future<OidcStart> begin( Future<OidcStart> begin(
@ -171,6 +183,59 @@ class ServerpodSimpleLoginGateway(
), ),
); );
@override
Future<DateTime> startReset(
protocol.UuidValue attempt, {
SessionGrant? as,
}) => _failing(
() async => (await _calls.asGrant(
as,
(base) => base.keys.startReset(attempt: attempt),
)).takesEffectAt,
);
@override
Future<void> completeReset(
protocol.UuidValue attempt,
protocol.KeysSetup setup, {
SessionGrant? as,
}) => _failing(
() => _calls.asGrant(
as,
(base) => base.keys.completeReset(attempt: attempt, setup: setup),
),
);
@override
Future<void> callOffResets(protocol.KeysProof proof) =>
_failing(() => _base.keys.callOffResets(proof: proof));
@override
Future<protocol.PasswordChallenge> beginOwnerProof({SessionGrant? as}) =>
_failing(() => _calls.asGrant(as, (base) => base.password.beginChange()));
@override
Future<void> setProofKey(
OwnerAnswer owner,
ByteData proofKey, {
SessionGrant? as,
}) => _failing(
() => _calls.asGrant(
as,
(base) => base.password.setProofKey(
proof: protocol.OwnerProof(
challenge: owner.challenge.challenge,
clientValue: owner.answer.clientValue,
clientProof: owner.answer.clientProof,
instanceUrl: instanceUrl,
totpCode: owner.totpCode,
recoveryCode: owner.recoveryCode,
),
proofKey: proofKey,
),
),
);
static SessionGrant _grantOf(AuthSuccess session) => static SessionGrant _grantOf(AuthSuccess session) =>
SessionGrant(session.authUserId.uuid, session); SessionGrant(session.authUserId.uuid, session);
@ -191,6 +256,10 @@ class ServerpodSimpleLoginGateway(
}, },
hasKeys: completed.hasKeys, hasKeys: completed.hasKeys,
handle: completed.handle, handle: completed.handle,
passwordSignsIn: completed.passwordSignsIn,
proofKeyMissing: completed.passwordSignsIn && !completed.hasProofKey,
// One whose password signs in resets as ADR 0017 has it, by mail.
resetAt: completed.passwordSignsIn ? null : completed.accountResetAt,
), ),
}; };
} }

View file

@ -1,5 +1,9 @@
import 'dart:typed_data';
import 'package:halkyon_app/src/auth/model/session_grant.dart'; import 'package:halkyon_app/src/auth/model/session_grant.dart';
import 'package:halkyon_app/src/handle/model/handle_problem.dart'; import 'package:halkyon_app/src/handle/model/handle_problem.dart';
import 'package:halkyon_app/src/password/data/password_gateway.dart'
show OwnerAnswer;
import 'package:halkyon_app/src/simplelogin/model/simplelogin_arrival.dart'; import 'package:halkyon_app/src/simplelogin/model/simplelogin_arrival.dart';
import 'package:halkyon_base_client/halkyon_base_client.dart' as protocol; import 'package:halkyon_base_client/halkyon_base_client.dart' as protocol;
import 'package:halkyon_oidc_flutter/halkyon_oidc_flutter.dart' show OidcStart; import 'package:halkyon_oidc_flutter/halkyon_oidc_flutter.dart' show OidcStart;
@ -18,8 +22,10 @@ abstract interface class SimpleLoginGateway() {
/// Whether the instance offers SimpleLogin. /// Whether the instance offers SimpleLogin.
Future<bool> offered(); Future<bool> offered();
/// Whether the device's person's account signs in with SimpleLogin. /// Whether the device's person's account signs in with SimpleLogin, and
Future<bool> hasSimpleLogin(); /// when a reset of its keys takes effect (D31): one asked for an account
/// whose password opens its keys alone, null for none.
Future<({bool simpleLogin, DateTime? keysResetAt})> standing();
/// Begins a sign-in that comes [back] by the loopback or the app's scheme: /// Begins a sign-in that comes [back] by the loopback or the app's scheme:
/// the attempt, SimpleLogin's page and the state. With [link], as the /// the attempt, SimpleLogin's page and the state. With [link], as the
@ -107,4 +113,37 @@ abstract interface class SimpleLoginGateway() {
String? handle, String? handle,
SessionGrant? as, SessionGrant? as,
}); });
/// Asks for a reset of the account's keys, its password and its recovery
/// phrase lost (D31), with its SimpleLogin sign-in under [attempt] of the
/// last ten minutes, which it takes: when the reset takes effect, kept
/// where one was asked for already.
Future<DateTime> startReset(protocol.UuidValue attempt, {SessionGrant? as});
/// Carries out the reset of the account's keys, due now, with its
/// SimpleLogin sign-in under [attempt] of the last ten minutes: the keys
/// [setup] describes replace its password, keys and phrase, and every
/// other session of the account ends.
Future<void> completeReset(
protocol.UuidValue attempt,
protocol.KeysSetup setup, {
SessionGrant? as,
});
/// Calls off a reset of the device's person's keys with their password's
/// [proof] for it (D32), for a challenge of [beginUnlock].
Future<void> callOffResets(protocol.KeysProof proof);
/// The first step of the owner's proof for an account whose password
/// signs in (ADR 0017): SRP's challenge, with the salt and costs.
Future<protocol.PasswordChallenge> beginOwnerProof({SessionGrant? as});
/// Gives the keys of an account whose password signs in the proof key
/// that password makes ([proofKey], its public half), with the owner's
/// proof [owner] binding it (D34): its keys then open after SimpleLogin.
Future<void> setProofKey(
OwnerAnswer owner,
ByteData proofKey, {
SessionGrant? as,
});
} }

View file

@ -1,4 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:developer';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:halkyon_app/src/auth/data/auth_gateway.dart'; import 'package:halkyon_app/src/auth/data/auth_gateway.dart';
@ -13,6 +14,7 @@ import 'package:halkyon_app/src/simplelogin/model/simplelogin_arrival.dart';
import 'package:halkyon_app/src/simplelogin/model/simplelogin_failed.dart'; import 'package:halkyon_app/src/simplelogin/model/simplelogin_failed.dart';
import 'package:halkyon_app/src/simplelogin/model/simplelogin_standing.dart'; import 'package:halkyon_app/src/simplelogin/model/simplelogin_standing.dart';
import 'package:halkyon_base_client/halkyon_base_client.dart' as protocol; import 'package:halkyon_base_client/halkyon_base_client.dart' as protocol;
import 'package:halkyon_base_client/srp.dart' as srp;
import 'package:halkyon_oidc_flutter/halkyon_oidc_flutter.dart'; import 'package:halkyon_oidc_flutter/halkyon_oidc_flutter.dart';
/// Where SimpleLogin's forwarding page sends the browser back to, on the /// Where SimpleLogin's forwarding page sends the browser back to, on the
@ -89,14 +91,32 @@ class SimpleLoginRepository(
/// Where the device's person stands with SimpleLogin (D21): nothing to /// Where the device's person stands with SimpleLogin (D21): nothing to
/// show where the instance offers none or nobody is signed in; otherwise /// show where the instance offers none or nobody is signed in; otherwise
/// whether their account has it. /// whether their account has it, and a reset of its keys waiting (D35),
/// with whether the device holds them (D33).
Future<SimpleLoginStanding> standing() async { Future<SimpleLoginStanding> standing() async {
if (_auth.status is! SignedIn || !await _gateway.offered()) { if (_auth.status case SignedIn(:final authUserId)
return const SimpleLoginUnavailable(); when await _gateway.offered()) {
final (:simpleLogin, :keysResetAt) = await _gateway.standing();
if (!simpleLogin) {
return const SimpleLoginMissing();
}
return SimpleLoginLinked(
resetAt: keysResetAt,
keysHere: keysResetAt != null && await _keysKept(authUserId),
);
}
return const SimpleLoginUnavailable();
}
/// Whether [person]'s keys are in the keyring; not where it can't be read
/// (locked, its service gone), which says nothing else of the account.
Future<bool> _keysKept(String person) async {
try {
return await _keys.read(person) != null;
} on Object catch (error) {
log('The keyring could not be read: ${error.runtimeType}', name: 'Keys');
return false;
} }
return await _gateway.hasSimpleLogin()
? const SimpleLoginLinked()
: const SimpleLoginMissing();
} }
/// Signs in with SimpleLogin in the browser: someone new, or an account /// Signs in with SimpleLogin in the browser: someone new, or an account
@ -283,6 +303,87 @@ class SimpleLoginRepository(
return PendingSimpleLogin(grant: account.grant, keys: keys); return PendingSimpleLogin(grant: account.grant, keys: keys);
} }
/// Asks for a reset of [account]'s keys, its password and its recovery
/// phrase lost (D31), with the sign-in it arrived with, which this takes:
/// when the reset takes effect, kept where one was asked for already.
/// Nothing is the device's: the account has no keys to open until then.
Future<DateTime> startReset(SimpleLoginAccount account) =>
_gateway.startReset(account.attempt, as: account.grant);
/// Carries out the reset of [account]'s keys, due now: the keys [made]
/// ([makeKeys]: those the device holds where SimpleLogin signed in its
/// person, D33, new ones otherwise) replace its password, keys and
/// phrase. Every other session of the account ends.
Future<PendingSimpleLogin> completeReset(
SimpleLoginAccount account, {
required NewKeysMaterial made,
}) async {
await _gateway.completeReset(
account.attempt,
made.setup,
as: account.grant,
);
return PendingSimpleLogin(grant: account.grant, keys: made.keys);
}
/// Calls off the reset of the device's person's keys waiting, with their
/// [password]'s proof made for it (D32): whoever asked for it has their
/// SimpleLogin account, not their password.
Future<void> callOffReset(String password) async {
final challenge = await _gateway.beginUnlock();
final proven = await _crypto.proveKeysPassword(
password,
challenge,
instanceUrl: _gateway.instanceUrl,
purpose: srp.KeyProofPurpose.callOffResets,
);
// Nothing is opened with it.
proven.unlock.forget();
await _gateway.callOffResets(proven.proof);
_changed();
}
/// Gives [account]'s keys, whose password signs in (ADR 0017), the proof
/// key that [password] makes, with the owner's proof: the password and
/// the authenticator's [code] (D34). Its password then opens its keys
/// after SimpleLogin ([unlock]).
Future<void> addProofKey(
SimpleLoginAccount account, {
required String password,
required String code,
}) async {
final challenge = await _gateway.beginOwnerProof(as: account.grant);
final unlock = await _crypto.unlock(
password,
salt: Uint8List.sublistView(challenge.salt),
costs: challenge.costs,
);
final ByteData proofKey;
final SrpAnswer answer;
try {
proofKey = ByteData.sublistView(Uint8List.fromList(unlock.proofKey));
answer = await _crypto.answer(
unlock,
challenge,
instanceUrl: _gateway.instanceUrl,
purpose: srp.PasswordProofPurpose.proofKey,
digest: srp.proofKeyDigest(Uint8List.sublistView(proofKey)),
);
} finally {
unlock.forget();
}
await _gateway.setProofKey(
(
challenge: challenge,
answer: answer,
totpCode: code.replaceAll(' ', ''),
recoveryCode: null,
),
proofKey,
as: account.grant,
);
}
/// Links the SimpleLogin account of someone new here ([newcomer]) to the /// Links the SimpleLogin account of someone new here ([newcomer]) to the
/// account with [handle] in place of a lost one (D7), by its recovery /// account with [handle] in place of a lost one (D7), by its recovery
/// [phrase], which opens the keys on the device first: a phrase that /// [phrase], which opens the keys on the device first: a phrase that

View file

@ -5,7 +5,8 @@ import 'package:halkyon_base_client/halkyon_base_client.dart' show UuidValue;
/// Where a SimpleLogin sign-in arrived once the browser came back (ADR /// Where a SimpleLogin sign-in arrived once the browser came back (ADR
/// 0020, 5): someone new here, or an account. Its attempt's ID stands for /// 0020, 5): someone new here, or an account. Its attempt's ID stands for
/// the sign-in a while longer: half an hour for someone new's sign-up, ten /// the sign-in a while longer: half an hour for someone new's sign-up, ten
/// minutes for an account's first keys or a lost password (D16, D20). /// minutes for an account's first keys, a lost password or a reset of its
/// keys (D16, D20, D31).
sealed class const SimpleLoginArrival() with Equatable; sealed class const SimpleLoginArrival() with Equatable;
/// Someone new here, whose SimpleLogin identity waits under [attempt] for /// Someone new here, whose SimpleLogin identity waits under [attempt] for
@ -33,7 +34,28 @@ final class const SimpleLoginAccount({
/// Its handle; null for one without, which chooses one with its keys. /// Its handle; null for one without, which chooses one with its keys.
required final String? handle, required final String? handle,
/// Whether its password signs in too (ADR 0017's): its resets are ADR
/// 0017's then, the mailed one for a password lost (D34).
final bool passwordSignsIn = false,
/// Whether its password, which signs in, has yet to get the keys' proof
/// key, which the password and a code set once (D34): until then, its
/// keys open on no device signed in with SimpleLogin.
final bool proofKeyMissing = false,
/// When a reset of its keys takes effect, asked with the password and
/// the phrase lost (D31): due once it is past. Null for none.
final DateTime? resetAt,
}) extends SimpleLoginArrival { }) extends SimpleLoginArrival {
@override @override
List<Object?> get props => [attempt, grant, hasKeys, handle]; List<Object?> get props => [
attempt,
grant,
hasKeys,
handle,
passwordSignsIn,
proofKeyMissing,
resetAt,
];
} }

View file

@ -17,10 +17,19 @@ final class const SimpleLoginUnavailable() extends SimpleLoginStanding {
List<Object?> get props => const []; List<Object?> get props => const [];
} }
/// The person's account signs in with SimpleLogin. /// The person's account signs in with SimpleLogin; [resetAt] where a reset
final class const SimpleLoginLinked() extends SimpleLoginStanding { /// of its keys waits (D31), which the device shows (D35).
final class const SimpleLoginLinked({
/// When the reset of the keys takes effect, due once it is past; null for
/// none.
final DateTime? resetAt,
/// Whether the device holds the keys, which a reset carried out here
/// keeps (D33).
final bool keysHere = false,
}) extends SimpleLoginStanding {
@override @override
List<Object?> get props => const []; List<Object?> get props => [resetAt, keysHere];
} }
/// The person's account doesn't sign in with SimpleLogin yet: they can add /// The person's account doesn't sign in with SimpleLogin yet: they can add

View file

@ -0,0 +1,186 @@
import 'package:clock/clock.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_app/l10n/halkyon_app_localizations.dart';
import 'package:halkyon_app/src/app/app_scope.dart';
import 'package:halkyon_app/src/password/bloc/account_security_cubit.dart';
import 'package:halkyon_app/src/password/widget/password_texts.dart';
import 'package:halkyon_app/src/password/widget/password_widgets.dart';
import 'package:halkyon_app/src/simplelogin/bloc/simplelogin_standing_cubit.dart';
import 'package:halkyon_app/src/simplelogin/model/simplelogin_standing.dart';
import 'package:halkyon_app/src/simplelogin/widget/simplelogin_section.dart';
import 'package:halkyon_app/src/simplelogin/widget/simplelogin_sheet.dart';
import 'package:halkyon_app/src/simplelogin/widget/simplelogin_standing_scope.dart';
import 'package:halkyon_app/src/simplelogin/widget/simplelogin_texts.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// A banner at the top of an app's home while a reset of the device's
/// person's keys waits (ADR 0020, D35), where `AppMismatchBanner` shows: no
/// mail reaches an account that signs in with SimpleLogin, so this is how
/// its owner learns of a reset someone who took over their SimpleLogin
/// account asked for, and calls it off with the password (D32). Once due,
/// a device that holds the keys finishes it, keeping them (D33). Nothing
/// otherwise; the account section says the same ([KeysResetNotice]).
class const KeysResetBanner({super.key}) extends StatelessWidget {
@override
Widget build(BuildContext context) =>
SimpleLoginStandingScope.around(context, const _KeysResetBanner());
}
class const _KeysResetBanner() extends StatelessWidget {
@override
Widget build(BuildContext context) {
final standing = context.watch<SimpleLoginStandingCubit>().state;
if (standing case SimpleLoginLinked(resetAt: _?)) {
final colors = Theme.of(context).colorScheme;
return MaterialBanner(
backgroundColor: colors.errorContainer,
leading: Icon(LucideIcons.shieldAlert, color: colors.onErrorContainer),
content: Text(
_keysResetText(context, standing),
style: TextStyle(color: colors.onErrorContainer),
),
// The text whole across the banner, alike with one action or two.
forceActionsBelow: true,
actions: _keysResetActions(context, standing),
);
}
return const SizedBox.shrink();
}
}
/// A reset of the keys waiting, in the SimpleLogin part of the account
/// section (D35), as [KeysResetBanner] has it.
class const KeysResetNotice(final SimpleLoginLinked _linked, {super.key})
extends StatelessWidget {
@override
Widget build(BuildContext context) => AlertBox(
title: HalkyonAppLocalizations.of(context).passwordResetsTitle,
lines: [_keysResetText(context, _linked)],
actions: _keysResetActions(context, _linked),
);
}
/// Whether the reset [linked] has waiting is due.
bool _due(SimpleLoginLinked linked) => !linked.resetAt!.isAfter(clock.now());
String _keysResetText(BuildContext context, SimpleLoginLinked linked) {
final l10n = HalkyonAppLocalizations.of(context);
return switch (_due(linked)) {
false => l10n.simpleLoginResetBanner(
passwordMoment(context, linked.resetAt!),
),
true when linked.keysHere => l10n.simpleLoginResetBannerDueHere,
true => l10n.simpleLoginResetBannerDue,
};
}
List<Widget> _keysResetActions(BuildContext context, SimpleLoginLinked linked) {
final l10n = HalkyonAppLocalizations.of(context);
return [
if (_due(linked) && linked.keysHere)
TextButton(
onPressed: () => _finishHere(context),
child: Text(l10n.simpleLoginResetFinishHere),
),
TextButton(
onPressed: () => _callOff(context),
child: Text(l10n.passwordResetsCallOff),
),
];
}
/// Finishes the reset due on this device, which holds the keys (D33): a
/// SimpleLogin sign-in, then a new password and phrase for the same keys.
Future<void> _finishHere(BuildContext context) async {
final messenger = ScaffoldMessenger.maybeOf(context);
final finished = HalkyonAppLocalizations.of(context).simpleLoginResetFinished;
if (await showSimpleLoginSheet(
context,
instance: simpleLoginInstanceName(context),
link: true,
)) {
messenger?.showSnackBar(SnackBar(content: Text(finished)));
}
}
Future<void> _callOff(BuildContext context) async {
final messenger = ScaffoldMessenger.maybeOf(context);
final calledOff = HalkyonAppLocalizations.of(
context,
).simpleLoginResetCalledOff;
if (await showAdaptiveSheet<bool>(
context,
swipeable: false,
builder: (context) => const _CallOff(),
) ??
false) {
messenger?.showSnackBar(SnackBar(content: Text(calledOff)));
}
}
/// Calls off the reset of the keys with the password alone (D32): whoever
/// asked for it has the SimpleLogin account, not the password.
class const _CallOff() extends StatefulWidget {
@override
State<_CallOff> createState() => _CallOffState();
}
class _CallOffState() extends State<_CallOff> {
final _password = TextEditingController();
late final _step = SecurityStepCubit<bool>();
@override
void dispose() {
_password.dispose();
_step.close();
super.dispose();
}
Future<void> _submit() async {
final simpleLogin = AppScope.of(context).simpleLogin;
final navigator = Navigator.of(context);
if (await _step.run(() async {
await simpleLogin.callOffReset(_password.text);
return true;
})) {
navigator.pop(true);
}
}
@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.simpleLoginCallOffIntro),
PasswordField(
controller: _password,
label: l10n.passwordPassword,
autofocus: true,
textInputAction: TextInputAction.done,
onSubmitted: (_) => _submit(),
errorText: switch (state.problem) {
final failed? => simpleLoginFailureText(context, failed),
null => null,
},
),
],
),
footer: FilledButton(
onPressed: state.busy ? null : _submit,
child: state.busy
? const ButtonLoadingIndicator()
: Text(l10n.passwordResetsCallOff),
),
),
);
}
}

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