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
21 changed files with 2711 additions and 248 deletions

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_failed.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_sheet.dart';
export 'src/simplelogin/widget/simplelogin_standing_scope.dart';

View file

@ -266,6 +266,32 @@
"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.",
"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.",
"simpleLoginDenied": "Die Anmeldung bei SimpleLogin wurde abgelehnt, also wurde nichts geteilt.",
"simpleLoginNoBrowser": "Es gibt keinen Browser, der die Seite von SimpleLogin öffnen kann.",

View file

@ -600,6 +600,67 @@
"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.",
"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.",
"simpleLoginDenied": "SimpleLogin's sign-in was declined, so nothing was shared.",
"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.'**
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.
///
/// In en, this message translates to:

View file

@ -1002,6 +1002,105 @@ class HalkyonAppLocalizationsDe extends HalkyonAppLocalizations {
String get simpleLoginForgotDoesNotFit =>
'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
String get simpleLoginUnavailable =>
'Diese Instanz bietet die Anmeldung mit SimpleLogin nicht an.';

View file

@ -983,6 +983,103 @@ class HalkyonAppLocalizationsEn extends HalkyonAppLocalizations {
@override
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
String get simpleLoginUnavailable =>
'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
/// 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({
/// The salt and costs they were derived with.
required final Uint8List salt,
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]).
required final Uint8List _srpSecret,
@ -204,14 +210,15 @@ abstract interface class AccountCrypto() {
Future<NewKeysMaterial> newKeys(String password, {UnlockedAccountKeys? keys});
/// The proof of [password] for [challenge] on [instanceUrl], signed by
/// the password's proof key for unlocking the keys
/// (`KeyProofPurpose.unlockKeys`), with what it unlocked for the
/// the password's proof key for [purpose] (unlocking the keys, or calling
/// 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
/// the unlock once the keys are open.
/// the unlock once done with it.
Future<KeysPasswordProof> proveKeysPassword(
String password,
protocol.KeysChallenge challenge, {
required String instanceUrl,
srp.KeyProofPurpose purpose = srp.KeyProofPurpose.unlockKeys,
});
/// 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(
salt: salt,
costs: costs,
// Copied by proofPublicKey already.
proofKey: unlocked.proofPublicKey,
srpSecret: unlocked.srpSecret.extractBytes(),
keyEncryptionKey: unlocked.keyEncryptionKey.extractBytes(),
);
@ -286,6 +288,7 @@ class SodiumAccountCrypto() implements AccountCrypto {
String password,
protocol.KeysChallenge challenge, {
required String instanceUrl,
srp.KeyProofPurpose purpose = srp.KeyProofPurpose.unlockKeys,
}) => Isolate.run(
() => _guarded(() async {
final sodium = await SodiumSumoInit.init();
@ -306,7 +309,7 @@ class SodiumAccountCrypto() implements AccountCrypto {
challenge.challenge.toBytes(),
Uint8List.sublistView(challenge.nonce),
instanceUrl: instanceUrl,
purpose: srp.KeyProofPurpose.unlockKeys,
purpose: purpose,
),
secretKey: pair.secretKey,
),
@ -323,6 +326,7 @@ class SodiumAccountCrypto() implements AccountCrypto {
unlock: PasswordUnlock(
salt: salt,
costs: challenge.costs,
proofKey: _own(pair.publicKey),
srpSecret: unlocked.srpSecret.extractBytes(),
keyEncryptionKey: unlocked.keyEncryptionKey.extractBytes(),
),

View file

@ -244,79 +244,30 @@ class _SessionEndedWarningState() extends State<_SessionEndedWarning> {
@override
Widget build(BuildContext context) {
final l10n = HalkyonAppLocalizations.of(context);
final theme = Theme.of(context);
final colors = theme.colorScheme;
return BlocBuilder<SecurityStepCubit<bool>, SecurityStepState<bool>>(
bloc: _step,
builder: (context, state) => Padding(
padding: const EdgeInsets.only(top: 8),
child: DecoratedBox(
decoration: BoxDecoration(
color: colors.errorContainer,
borderRadius: BorderRadius.circular(12),
builder: (context, state) => AlertBox(
title: l10n.passwordWarningTitle,
lines: [
l10n.passwordWarningBody(passwordMoment(context, widget._ended)),
],
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
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),
return AlertBox(
title: l10n.passwordResetsTitle,
lines: [
if (authenticatorAt case final at?)
l10n.passwordResetsAuthenticator(passwordMoment(context, at)),
if (accountAt case final at?)
l10n.passwordResetsAccount(passwordMoment(context, at)),
],
actions: [
TextButton(
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.
class const SheetProblem(final String text, {super.key})
extends StatelessWidget {

View file

@ -1,3 +1,4 @@
import 'package:clock/clock.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.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.
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.
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.
mend,
/// Signed in, or SimpleLogin added to the account.
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,
}
@ -63,6 +81,17 @@ final class const SimpleLoginState({
/// Whether the sign-in is someone new's, whose account is yet to be.
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 List<int> checked = const [],
}) with Equatable {
@ -76,6 +105,10 @@ final class const SimpleLoginState({
String? handle,
String? signedInAs,
bool? newcomer,
bool? passwordSignsIn,
DateTime? resetAt,
bool clearResetAt = false,
bool? resetting,
String? phrase,
List<int>? checked,
}) => SimpleLoginState(
@ -87,6 +120,9 @@ final class const SimpleLoginState({
handle: handle ?? this.handle,
signedInAs: signedInAs ?? this.signedInAs,
newcomer: newcomer ?? this.newcomer,
passwordSignsIn: passwordSignsIn ?? this.passwordSignsIn,
resetAt: clearResetAt ? null : resetAt ?? this.resetAt,
resetting: resetting ?? this.resetting,
phrase: phrase ?? this.phrase,
checked: checked ?? this.checked,
);
@ -105,6 +141,9 @@ final class const SimpleLoginState({
handle,
signedInAs,
newcomer,
passwordSignsIn,
resetAt,
resetting,
phrase,
checked,
];
@ -115,11 +154,13 @@ final class const SimpleLoginState({
/// twice, the recovery phrase shown once with three of its words typed
/// 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
/// recovery phrase and a new password), or, for one without keys, the steps
/// making them. With [_link], SimpleLogin is added to the
/// account of the device's person instead, who keeps everything. Nothing is
/// the device's until the last step: no account exists whose phrase was
/// never written down.
/// recovery phrase and a new password; both lost, a reset to new keys 72
/// hours later, D31), or, for one without keys, the steps making them; for
/// one whose password signs in too, that password and a code first, once
/// (D34). With [_link], SimpleLogin is added to the account of the device's
/// 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(
final SimpleLoginRepository _simpleLogin, {
final bool _link = false,
@ -156,34 +197,55 @@ class SimpleLoginCubit(
if (isClosed) {
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) {
null => state.copyWith(step: SimpleLoginStep.closed, newcomer: false),
SimpleLoginNewcomer(:final suggestedHandle) => state.copyWith(
null => arrived.copyWith(step: SimpleLoginStep.closed),
SimpleLoginNewcomer(:final suggestedHandle) => arrived.copyWith(
step: SimpleLoginStep.newcomer,
newcomer: true,
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.
SimpleLoginAccount(hasKeys: true) when _link => state.copyWith(
SimpleLoginAccount(hasKeys: true) when _link => arrived.copyWith(
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,
newcomer: false,
signedInAs: handle,
),
SimpleLoginAccount(handle: null) => state.copyWith(
SimpleLoginAccount(handle: null) => arrived.copyWith(
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.
Future<void> cancelled() => _simpleLogin.cancel();
@ -296,6 +358,8 @@ class SimpleLoginCubit(
handle: state.handle,
made: made,
),
final SimpleLoginAccount account when state.resetting =>
await _simpleLogin.completeReset(account, made: made),
final SimpleLoginAccount account => await _simpleLogin.setFirstKeys(
account,
made: made,
@ -326,6 +390,56 @@ class SimpleLoginCubit(
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
/// phrase opens wrapped under it, and the account signed in.
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/base/base_calls.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/password/data/password_gateway.dart'
show OwnerAnswer;
import 'package:halkyon_app/src/password/data/serverpod_password_gateway.dart'
show passwordProblemOf;
import 'package:halkyon_app/src/password/model/password_failed.dart';
@ -35,8 +39,16 @@ class ServerpodSimpleLoginGateway(
_failing(() async => (await _base.instance.basics()).simpleLogin);
@override
Future<bool> hasSimpleLogin() =>
_failing(() async => (await _base.account.signInWays()).simpleLogin);
Future<({bool simpleLogin, DateTime? keysResetAt})> standing() =>
_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
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) =>
SessionGrant(session.authUserId.uuid, session);
@ -191,6 +256,10 @@ class ServerpodSimpleLoginGateway(
},
hasKeys: completed.hasKeys,
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/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_base_client/halkyon_base_client.dart' as protocol;
import 'package:halkyon_oidc_flutter/halkyon_oidc_flutter.dart' show OidcStart;
@ -18,8 +22,10 @@ abstract interface class SimpleLoginGateway() {
/// Whether the instance offers SimpleLogin.
Future<bool> offered();
/// Whether the device's person's account signs in with SimpleLogin.
Future<bool> hasSimpleLogin();
/// Whether the device's person's account signs in with SimpleLogin, and
/// 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:
/// the attempt, SimpleLogin's page and the state. With [link], as the
@ -107,4 +113,37 @@ abstract interface class SimpleLoginGateway() {
String? handle,
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:developer';
import 'package:flutter/foundation.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_standing.dart';
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';
/// 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
/// 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 {
if (_auth.status is! SignedIn || !await _gateway.offered()) {
return const SimpleLoginUnavailable();
if (_auth.status case SignedIn(:final authUserId)
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
@ -283,6 +303,87 @@ class SimpleLoginRepository(
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
/// 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

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
/// 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
/// 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;
/// 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.
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 {
@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 [];
}
/// The person's account signs in with SimpleLogin.
final class const SimpleLoginLinked() extends SimpleLoginStanding {
/// The person's account signs in with SimpleLogin; [resetAt] where a reset
/// 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
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

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),
),
),
);
}
}

View file

@ -4,6 +4,7 @@ import 'package:halkyon_app/src/app/app_scope.dart';
import 'package:halkyon_app/src/instance/instance_scope.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/keys_reset.dart';
import 'package:halkyon_app/src/simplelogin/widget/simplelogin_sheet.dart';
import 'package:halkyon_app/src/simplelogin/widget/simplelogin_standing_scope.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
@ -12,9 +13,10 @@ import 'package:material_ui/material_ui.dart';
/// The SimpleLogin part of an app's account section (ADR 0020, D21), which
/// each app places above `AccountSecuritySection`: for someone without
/// SimpleLogin a heading and Add SimpleLogin; once added, one more way in,
/// a row without heading lined up with the others. Nothing where the
/// instance offers no SimpleLogin or nobody is signed in. [gapAbove] is
/// the space above its heading, none above the row.
/// a row without heading lined up with the others, and a reset of the keys
/// waiting below it (D35). Nothing where the instance offers no SimpleLogin
/// or nobody is signed in. [gapAbove] is the space above its heading, none
/// above the row.
class const SimpleLoginSection({
final double gapAbove = 0,
super.key,
@ -35,14 +37,20 @@ class const _SimpleLoginSection({required final double gapAbove})
return switch (context.watch<SimpleLoginStandingCubit>().state) {
SimpleLoginChecking() ||
SimpleLoginUnavailable() => const SizedBox.shrink(),
SimpleLoginLinked() => ListTile(
contentPadding: EdgeInsets.zero,
title: Text(l10n.simpleLoginSectionTitle),
subtitle: Text(l10n.simpleLoginSignsYouIn),
trailing: Icon(
LucideIcons.circleCheck,
color: theme.colorScheme.primary,
),
final SimpleLoginLinked linked => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ListTile(
contentPadding: EdgeInsets.zero,
title: Text(l10n.simpleLoginSectionTitle),
subtitle: Text(l10n.simpleLoginSignsYouIn),
trailing: Icon(
LucideIcons.circleCheck,
color: theme.colorScheme.primary,
),
),
if (linked.resetAt != null) KeysResetNotice(linked),
],
),
SimpleLoginMissing() => Padding(
padding: EdgeInsets.only(top: gapAbove),

View file

@ -1,11 +1,13 @@
import 'dart:async';
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/handle/bloc/handle_check_cubit.dart';
import 'package:halkyon_app/src/handle/widget/handle_field.dart';
import 'package:halkyon_app/src/password/model/password_failed.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_cubit.dart';
import 'package:halkyon_app/src/simplelogin/widget/simplelogin_texts.dart';
@ -53,6 +55,7 @@ class _SimpleLoginState() extends State<_SimpleLogin> {
final _password = TextEditingController();
final _again = TextEditingController();
final _phrase = TextEditingController();
final _code = TextEditingController();
final _words = [for (var i = 0; i < 3; i++) TextEditingController()];
/// What the instance says of the handle typed, asked as it is typed (ADR
@ -78,6 +81,7 @@ class _SimpleLoginState() extends State<_SimpleLogin> {
_password.dispose();
_again.dispose();
_phrase.dispose();
_code.dispose();
for (final word in _words) {
word.dispose();
}
@ -104,11 +108,18 @@ class _SimpleLoginState() extends State<_SimpleLogin> {
_cubit.phraseChecked([for (final word in _words) word.text]);
case SimpleLoginStep.unlock:
_cubit.unlockEntered(_password.text);
case SimpleLoginStep.proofKey:
_cubit.proofKeyEntered(_password.text, _code.text);
case SimpleLoginStep.forgot:
_cubit.forgotEntered(_phrase.text, _password.text, _again.text);
case SimpleLoginStep.resetAsk:
_cubit.resetAsked();
case SimpleLoginStep.resetAsked:
_cubit.finished();
case SimpleLoginStep.mend:
_cubit.mendEntered(_handle.text, _phrase.text);
case SimpleLoginStep.newcomer ||
SimpleLoginStep.resetDue ||
SimpleLoginStep.done ||
SimpleLoginStep.closed:
break;
@ -127,6 +138,9 @@ class _SimpleLoginState() extends State<_SimpleLogin> {
case SimpleLoginStep.password || SimpleLoginStep.unlock:
_password.clear();
_again.clear();
case SimpleLoginStep.proofKey:
_password.clear();
_code.clear();
case SimpleLoginStep.forgot:
_phrase.clear();
_password.clear();
@ -141,7 +155,11 @@ class _SimpleLoginState() extends State<_SimpleLogin> {
_phrase.clear();
case SimpleLoginStep.handle:
_handleChecked().typed(_handle.text);
case SimpleLoginStep.browser || SimpleLoginStep.phrase:
case SimpleLoginStep.browser ||
SimpleLoginStep.phrase ||
SimpleLoginStep.resetAsk ||
SimpleLoginStep.resetAsked ||
SimpleLoginStep.resetDue:
break;
}
}
@ -167,6 +185,11 @@ class _SimpleLoginState() extends State<_SimpleLogin> {
return SheetScaffold(
title: switch (state.step) {
SimpleLoginStep.newcomer => l10n.simpleLoginNewcomerTitle,
// A device that holds the keys keeps them (D33).
SimpleLoginStep.password when state.resetting && widget.link =>
l10n.simpleLoginForgotTitle,
SimpleLoginStep.password when state.resetting =>
l10n.simpleLoginResetTitle,
SimpleLoginStep.handle || SimpleLoginStep.password
when state.newcomer =>
l10n.passwordCreateTitle,
@ -178,7 +201,11 @@ class _SimpleLoginState() extends State<_SimpleLogin> {
SimpleLoginStep.phrase => l10n.passwordPhraseTitle,
SimpleLoginStep.phraseCheck => l10n.passwordPhraseCheckTitle,
SimpleLoginStep.unlock => l10n.simpleLoginUnlockTitle,
SimpleLoginStep.proofKey => l10n.simpleLoginProofKeyTitle,
SimpleLoginStep.forgot => l10n.simpleLoginForgotTitle,
SimpleLoginStep.resetAsk => l10n.simpleLoginResetTitle,
SimpleLoginStep.resetAsked => l10n.simpleLoginResetAskedTitle,
SimpleLoginStep.resetDue => l10n.simpleLoginResetDueTitle,
SimpleLoginStep.mend => l10n.simpleLoginMendTitle,
_ when widget.link => l10n.simpleLoginAddTitle,
_ => l10n.simpleLoginTitle,
@ -200,6 +227,7 @@ class _SimpleLoginState() extends State<_SimpleLogin> {
child: Text(HalkyonUiLocalizations.of(context).cancel),
),
SimpleLoginStep.newcomer ||
SimpleLoginStep.resetDue ||
SimpleLoginStep.done ||
SimpleLoginStep.closed => null,
_ => FilledButton(
@ -211,8 +239,17 @@ class _SimpleLoginState() extends State<_SimpleLogin> {
SimpleLoginStep.phrase => l10n.passwordPhraseWrittenDown,
SimpleLoginStep.phraseCheck when state.newcomer =>
l10n.simpleLoginCreate,
SimpleLoginStep.phraseCheck when state.resetting =>
l10n.simpleLoginResetFinish,
SimpleLoginStep.unlock => l10n.simpleLoginOpen,
SimpleLoginStep.proofKey when widget.link =>
l10n.passwordContinue,
SimpleLoginStep.proofKey => l10n.simpleLoginOpen,
SimpleLoginStep.forgot => l10n.passwordSignInAction,
SimpleLoginStep.resetAsk => l10n.simpleLoginResetAsk,
SimpleLoginStep.resetAsked => HalkyonUiLocalizations.of(
context,
).close,
SimpleLoginStep.mend => l10n.simpleLoginMend,
_ => l10n.passwordContinue,
}),
@ -275,7 +312,11 @@ class _SimpleLoginState() extends State<_SimpleLogin> {
: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SheetIntro(l10n.simpleLoginPasswordIntro),
SheetIntro(
state.resetting && widget.link
? l10n.simpleLoginResetKeepIntro
: l10n.simpleLoginPasswordIntro,
),
PasswordField(
controller: _password,
label: l10n.passwordNewPassword,
@ -332,6 +373,15 @@ class _SimpleLoginState() extends State<_SimpleLogin> {
final handle? => l10n.simpleLoginUnlockIntroAs(handle),
null => l10n.simpleLoginUnlockIntro,
}),
if (state.resetAt case final at? when !_due(at)) ...[
AlertBox(
title: l10n.passwordResetsTitle,
lines: [
l10n.simpleLoginResetWaiting(passwordMoment(context, at)),
],
),
const SizedBox(height: 16),
],
PasswordField(
controller: _password,
label: l10n.passwordPassword,
@ -341,12 +391,48 @@ class _SimpleLoginState() extends State<_SimpleLogin> {
onSubmitted: (_) => _submit(state),
errorText: problem,
),
Align(
alignment: AlignmentDirectional.centerStart,
child: TextButton(
onPressed: enabled ? _cubit.forgotChosen : null,
child: Text(l10n.passwordForgot),
// One whose password signs in too resets it as ADR 0017 has it,
// by mail, where it signs in with it (D34).
if (!state.passwordSignsIn)
Align(
alignment: AlignmentDirectional.centerStart,
child: TextButton(
onPressed: enabled ? _cubit.forgotChosen : null,
child: Text(l10n.passwordForgot),
),
),
if (state.resetAt case final at? when _due(at))
Align(
alignment: AlignmentDirectional.centerStart,
child: TextButton(
onPressed: enabled ? _cubit.resetDueShownAgain : null,
child: Text(l10n.passwordBack),
),
),
],
),
SimpleLoginStep.proofKey => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SheetIntro(switch (state.signedInAs) {
final handle? when !widget.link => l10n.simpleLoginProofKeyIntroAs(
handle,
),
_ => l10n.simpleLoginProofKeyIntro,
}),
PasswordField(
controller: _password,
label: l10n.passwordPassword,
enabled: enabled,
autofocus: true,
),
const SizedBox(height: 8),
CodeField(
controller: _code,
kind: CodeKind.totp,
enabled: enabled,
errorText: problem,
onSubmitted: (_) => _submit(state),
),
],
),
@ -360,7 +446,13 @@ class _SimpleLoginState() extends State<_SimpleLogin> {
autofocus: true,
errorText: _phraseProblem(state.problem) ? problem : null,
),
const SizedBox(height: 8),
Align(
alignment: AlignmentDirectional.centerStart,
child: TextButton(
onPressed: enabled ? _cubit.phraseLost : null,
child: Text(l10n.simpleLoginPhraseLost),
),
),
PasswordField(
controller: _password,
label: l10n.passwordNewPassword,
@ -391,6 +483,44 @@ class _SimpleLoginState() extends State<_SimpleLogin> {
),
],
),
SimpleLoginStep.resetAsk => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SheetIntro(l10n.simpleLoginResetIntro(widget.instance)),
SheetIntro(l10n.simpleLoginResetIntroDevices),
if (problem case final problem?) SheetProblem(problem),
Align(
alignment: AlignmentDirectional.centerStart,
child: TextButton(
onPressed: enabled ? _cubit.forgotChosen : null,
child: Text(l10n.passwordBack),
),
),
],
),
SimpleLoginStep.resetAsked => SheetIntro(
l10n.simpleLoginResetAskedIntro(
passwordMoment(context, state.resetAt!),
),
),
SimpleLoginStep.resetDue => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SheetIntro(l10n.simpleLoginResetDueIntro),
_Choice(
icon: LucideIcons.keyRound,
title: l10n.simpleLoginResetNewKeys,
hint: l10n.simpleLoginResetNewKeysHint,
onTap: _cubit.newKeysChosen,
),
_Choice(
icon: LucideIcons.lockOpen,
title: l10n.simpleLoginResetHavePassword,
hint: l10n.simpleLoginResetHavePasswordHint,
onTap: _cubit.passwordChosen,
),
],
),
SimpleLoginStep.mend => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
@ -415,6 +545,9 @@ class _SimpleLoginState() extends State<_SimpleLogin> {
}
}
/// Whether a reset taking effect [at] is due.
bool _due(DateTime at) => !at.isAfter(clock.now());
/// Whether [problem] is the recovery phrase's, shown at its field.
bool _phraseProblem(Exception? problem) => switch (problem) {
PasswordFailed(

View file

@ -72,17 +72,35 @@ class FakeAccountCrypto() implements AccountCrypto {
static ByteData passwordKeyOf(String password) =>
ByteData.sublistView(utf8.encode('password key of $password'));
/// Whether [proof] is signed by the password's proof key whose public
/// half is [passwordKey].
static bool provesPassword(protocol.KeysProof proof, ByteData? passwordKey) {
/// The signature of [password]'s proof key for [purpose], as the fake
/// makes it.
static ByteData passwordSignatureOf(
String password, {
srp.KeyProofPurpose purpose = srp.KeyProofPurpose.unlockKeys,
}) => ByteData.sublistView(
utf8.encode('signed by password $password for ${purpose.name}'),
);
/// Whether [proof] is signed for [purpose] by the password's proof key
/// whose public half is [passwordKey].
static bool provesPassword(
protocol.KeysProof proof,
ByteData? passwordKey, {
srp.KeyProofPurpose purpose = srp.KeyProofPurpose.unlockKeys,
}) {
if (passwordKey == null) {
return false;
}
final signed = utf8.decode(Uint8List.sublistView(proof.signature));
final key = utf8.decode(Uint8List.sublistView(passwordKey));
return signed.startsWith('signed by password ') &&
key ==
'password key of ${signed.substring('signed by password '.length)}';
if (!key.startsWith('password key of ')) {
return false;
}
final expected = passwordSignatureOf(
key.substring('password key of '.length),
purpose: purpose,
);
return utf8.decode(Uint8List.sublistView(proof.signature)) ==
utf8.decode(Uint8List.sublistView(expected));
}
static String _words(String phrase) =>
@ -167,6 +185,7 @@ class FakeAccountCrypto() implements AccountCrypto {
final unlock = PasswordUnlock(
salt: salt,
costs: costs,
proofKey: Uint8List.sublistView(passwordKeyOf(password)),
srpSecret: Uint8List.fromList(utf8.encode(password)),
keyEncryptionKey: Uint8List.fromList(utf8.encode(password)),
);
@ -293,6 +312,7 @@ class FakeAccountCrypto() implements AccountCrypto {
String password,
protocol.KeysChallenge challenge, {
required String instanceUrl,
srp.KeyProofPurpose purpose = srp.KeyProofPurpose.unlockKeys,
}) async {
final unlock = await this.unlock(
password,
@ -302,9 +322,7 @@ class FakeAccountCrypto() implements AccountCrypto {
return (
proof: protocol.KeysProof(
challenge: challenge.challenge,
signature: ByteData.sublistView(
utf8.encode('signed by password $password'),
),
signature: passwordSignatureOf(password, purpose: purpose),
instanceUrl: instanceUrl,
),
unlock: unlock,

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