feat(app): sign in with a password and TOTP on the device

ADR 0017, step 3: halkyon_app's side, which Learn and Todo take.

- The account's cryptography on the device (`SodiumAccountCrypto`):
  halkyon_keys and SRP's client half, each step on an isolate of its
  own. sodium hands out results as views of native memory its
  finalizers free once the isolate that made them ends, so everything
  crossing back is copied first: a view read freed memory and crashed
  the process.
- `PasswordGateway` on the module's endpoints (steps without a session
  through a client holding none; an authenticator set up with a session
  granted but not yet the device's), and `PasswordRepository` running
  the flows: signing in (M2 checked, the keys opened before anything is
  trusted), signing up, a password for someone signed in, an account
  never finished, the authenticators, recovery codes, changing the
  password, a new recovery phrase, and getting back in.
- The unlocked keys in the system's keyring per instance and person
  (`SecureAccountKeyStore`), gone once another person signs in or
  nobody, and with the instance's removal.
- The screens: the start screen's Sign in and Create an account (and
  when GitHub sign-in ends), the sign-in sheet (codes, recovery codes,
  an account never finished, a lost authenticator), the account's setup
  (the recovery phrase shown once, three random words typed back before
  anything reaches the instance; the authenticator's QR code, drawn
  with pub.dev's qr; the recovery codes), getting back in, and the
  account section (`AccountSecuritySection`).
- `FileSaver` moves from Learn into halkyon_app, for the phrase and the
  codes in Todo too; `AppDependencies` names it and `passwords`.
- halkyon_keys rebuilds account keys from the secret keys a device kept;
  the key proof's message is in halkyon_base_client, checked against
  the register's.

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-20 15:42:45 +02:00
commit a4819c3d39
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -330,6 +330,517 @@ class HalkyonAppLocalizationsEn extends HalkyonAppLocalizations {
@override
String get syncMismatchMore => 'More';
@override
String get startSignIn => 'Sign in';
@override
String get startCreateAccount => 'Create an account';
@override
String startGitHubEnds(String date) {
return 'Sign-in with GitHub ends on $date.';
}
@override
String get passwordAddress => 'Email address';
@override
String get passwordPassword => 'Password';
@override
String get passwordNewPassword => 'New password';
@override
String get passwordCurrent => 'Current password';
@override
String get passwordRepeat => 'New password again';
@override
String get passwordsDiffer => 'The two passwords differ.';
@override
String get passwordShow => 'Show password';
@override
String get passwordHide => 'Hide password';
@override
String get passwordContinue => 'Continue';
@override
String get passwordBack => 'Back';
@override
String get passwordDone => 'Done';
@override
String get passwordCopy => 'Copy';
@override
String get passwordCopied => 'Copied.';
@override
String get passwordSave => 'Save as a file';
@override
String get passwordQrCode => 'QR code for your authenticator app';
@override
String get passwordMailCode => 'Code from the email';
@override
String passwordMailCodeSent(String address) {
return 'We emailed a code to $address. It\'s valid for 15 minutes.';
}
@override
String get passwordResendCode => 'Send a new code';
@override
String get passwordTotpCode => 'Code from your authenticator app';
@override
String get passwordRecoveryCode => 'Recovery code';
@override
String get passwordUseRecoveryCode => 'Use a recovery code instead';
@override
String get passwordUseTotpCode => 'Use your authenticator app instead';
@override
String get passwordUnlocking => 'Unlocking your password on this device…';
@override
String get passwordMakingKeys => 'Making your keys on this device…';
@override
String get passwordSignInTitle => 'Sign in';
@override
String get passwordSignInAction => 'Sign in';
@override
String passwordSignInIntro(String instance) {
return 'With the email address and password of your account on $instance.';
}
@override
String passwordSecondFactorIntro(String instance) {
return 'Enter the code your authenticator app shows for $instance.';
}
@override
String get passwordRecoveryCodeIntro =>
'Enter one of the recovery codes you kept. Each signs in once.';
@override
String get passwordForgot => 'Forgot your password?';
@override
String get passwordOtherPassword => 'Change the address or password';
@override
String get passwordLostAuthenticator => 'Lost your authenticator?';
@override
String get passwordLostAuthenticatorTitle => 'Lost authenticator';
@override
String get passwordLostAuthenticatorIntro =>
'With a recovery code you sign in as usual. Without one, ask to reset your authenticators: it takes effect 72 hours later, and signing in with a code before then stops it.';
@override
String get passwordResetWithMail => 'Reset with a code by email';
@override
String passwordResetWithMailHint(String address) {
return 'We email a code to $address.';
}
@override
String get passwordResetWithPhrase => 'Reset with your recovery phrase';
@override
String get passwordResetWithPhraseHint => 'The 24 words you wrote down.';
@override
String get passwordSignInWithoutCode => 'Sign in without a code';
@override
String get passwordSignInWithoutCodeHint =>
'Once a reset you asked for has taken effect.';
@override
String get passwordResetAction => 'Reset';
@override
String passwordAuthenticatorResetAt(String date) {
return 'Your authenticators are reset on $date. We emailed you: if it wasn\'t you, sign in with a code before then to stop it. After that, sign in with your password alone and set up a new authenticator.';
}
@override
String passwordUnfinishedIntro(String address) {
return 'Your account was never finished. We emailed a code to $address: with it, you sign in and set up an authenticator.';
}
@override
String get passwordPhraseField => 'Recovery phrase';
@override
String get passwordPhraseFieldHint =>
'The 24 words, with spaces between them';
@override
String get passwordPhraseFieldIntro =>
'Enter your recovery phrase: it proves the account is yours.';
@override
String get passwordAuthenticatorTitle => 'Set up an authenticator';
@override
String get passwordAuthenticatorIntro =>
'Every sign-in takes a code from an authenticator app, such as Aegis, 2FAS or Proton Authenticator. Scan this code with it, or type in the key.';
@override
String get passwordAuthenticatorKey => 'Key';
@override
String get passwordAuthenticatorOpen => 'Open in your authenticator app';
@override
String get passwordAuthenticatorNoApp =>
'No app on this device opens it. Scan the code or type in the key.';
@override
String get passwordAuthenticatorCodeIntro => 'Then enter the code it shows.';
@override
String get passwordExistingCode =>
'Code from an authenticator you added before';
@override
String get passwordRecoveryCodesTitle => 'Recovery codes';
@override
String get passwordRecoveryCodesIntro =>
'Each signs in once in place of a code from your authenticator, should you lose it. Keep them where only you find them: they\'re shown only now.';
@override
String get passwordCodesKept => 'I\'ve kept them';
@override
String get passwordPhraseTitle => 'Your recovery phrase';
@override
String get passwordPhraseIntro =>
'These 24 words open your account\'s keys should you forget your password: without them, what\'s encrypted is lost. Write them down in order, and keep them where only you find them. They\'re shown only now.';
@override
String get passwordPhraseWrittenDown => 'I\'ve written it down';
@override
String get passwordPhraseCheckTitle => 'Check your recovery phrase';
@override
String get passwordPhraseCheckIntro =>
'Type the words asked for, from what you wrote down.';
@override
String passwordPhraseWord(int number) {
return 'Word $number';
}
@override
String get passwordPhraseWrong =>
'These aren\'t the words. Look again at what you wrote down.';
@override
String get passwordPhraseShowAgain => 'Show the phrase again';
@override
String get passwordCreateTitle => 'Create an account';
@override
String passwordCreateIntro(String instance) {
return 'On $instance, with your email address, a password, and an authenticator app for the code every sign-in takes.';
}
@override
String get passwordSetUpTitle => 'Set up a password';
@override
String get passwordSetUpIntro =>
'Sign in with your email address, a password and an authenticator app, on this device and others. Everything you have stays.';
@override
String get passwordChooseIntro =>
'Choose a password. It never leaves this device: the instance can\'t read it, and can\'t reset it without what it protects being lost.';
@override
String get passwordFinishResetTitle => 'Finish the reset';
@override
String get passwordFinishResetIntro =>
'72 hours after you asked to reset your account, finish it here with a new code by email.';
@override
String get passwordFinishResetHavePhrase => 'I have my recovery phrase';
@override
String get passwordFinishResetKeepsKeys =>
'Your account keeps its keys: nothing encrypted is lost.';
@override
String get passwordFinishResetNewKeys =>
'Without your recovery phrase, your account gets new keys: what was encrypted with the old ones is lost.';
@override
String get passwordRecoveryTitle => 'Get back into your account';
@override
String get passwordRecoveryWithPhrase => 'I have my recovery phrase';
@override
String get passwordRecoveryWithPhraseHint =>
'Set a new password at once. Everything stays.';
@override
String get passwordRecoveryWithPhraseIntro =>
'Your recovery phrase opens your account\'s keys, which your new password then protects. A code from your authenticator, or a recovery code, confirms it.';
@override
String get passwordRecoveryNoCodeHint =>
'Lost your authenticator as well? Reset your account 72 hours later with your recovery phrase: it keeps everything.';
@override
String get passwordRecoveryWithoutPhrase => 'Reset my account';
@override
String get passwordRecoveryWithoutPhraseHint =>
'Takes effect 72 hours later. Without your recovery phrase, what\'s encrypted is lost.';
@override
String get passwordRecoveryStartIntro =>
'We email a code to your address. Your account can then be reset 72 hours later; signing in with your password before then stops it.';
@override
String get passwordRecoveryFinish => 'Finish a reset';
@override
String get passwordRecoveryFinishHint => '72 hours after you asked for it.';
@override
String passwordResetStarted(String date) {
return 'Your account can be reset from $date. We emailed you: if it wasn\'t you, sign in with your password before then to stop it.';
}
@override
String get passwordSectionTitle => 'Password and authenticator';
@override
String get passwordSectionFailed =>
'Couldn\'t read how your account signs in.';
@override
String passwordSectionGitHubEnds(String date) {
return 'Sign-in with GitHub ends on $date. Set up a password before then to keep your account.';
}
@override
String get passwordSectionSetUp => 'Set up a password';
@override
String get passwordSectionSetUpHint =>
'Sign in with your email address, a password and an authenticator app.';
@override
String get passwordSectionUnfinished =>
'Finish setting up: add an authenticator';
@override
String get passwordSectionUnfinishedHint =>
'Your password signs in once an authenticator is set up.';
@override
String get passwordSectionChange => 'Change password';
@override
String get passwordSectionKeysMissing =>
'Sign in on this device with your password to change it.';
@override
String get passwordSectionNotConfirmed => 'Not set up yet';
@override
String passwordSectionRemove(String name) {
return 'Remove $name?';
}
@override
String get passwordSectionRemoveIntro =>
'Enter a code from one of your authenticators to confirm.';
@override
String get passwordSectionRemoveAction => 'Remove';
@override
String get passwordSectionAddAuthenticator => 'Add an authenticator';
@override
String get passwordSectionRecoveryCodes => 'New recovery codes';
@override
String get passwordSectionRecoveryCodesHint => 'The old ones stop working.';
@override
String get passwordSectionNewPhrase => 'New recovery phrase';
@override
String get passwordSectionPhraseHint => 'Your old phrase stops working.';
@override
String get passwordChangeTitle => 'Change password';
@override
String get passwordChangeIntro =>
'Your other devices are signed out. Your keys stay: nothing is lost.';
@override
String get passwordChangeAction => 'Change password';
@override
String get passwordChanged =>
'Password changed. Your other devices are signed out.';
@override
String get passwordNewCodesIntro =>
'Enter a code from your authenticator app. Your old recovery codes stop working.';
@override
String get passwordNewPhraseIntro =>
'Your new recovery phrase. Write the 24 words down in order: once you confirm it, your old phrase opens nothing any more.';
@override
String get passwordPhraseKept =>
'New recovery phrase set. The old one no longer works.';
@override
String get passwordWrongCredentials =>
'The address, the password or the code isn\'t right.';
@override
String get passwordTooManyAttempts =>
'Too many attempts. Try again in an hour.';
@override
String get passwordSetupIncomplete =>
'Your account was never finished: set up an authenticator.';
@override
String passwordNotDue(String date) {
return 'This takes effect on $date.';
}
@override
String get passwordNotDueYet => 'This hasn\'t taken effect yet.';
@override
String get passwordNoReset => 'No reset waits for this address.';
@override
String get passwordExists =>
'This address\'s account has a password already. Sign in with it, or get back in if you\'ve forgotten it.';
@override
String get passwordAddressTaken => 'This address belongs to another account.';
@override
String get passwordNoAddress => 'Your account has no email address yet.';
@override
String get passwordNoPassword => 'This account has no password.';
@override
String get passwordWrongCode =>
'This code isn\'t right. Enter the one your app shows now.';
@override
String get passwordWrongExistingCode =>
'The code from your other authenticator isn\'t right.';
@override
String get passwordLastAuthenticator =>
'Your last authenticator can\'t go: every sign-in needs one.';
@override
String get passwordTooManyAuthenticators =>
'Your account has as many authenticators as it may. Remove one first.';
@override
String get passwordUnknownAuthenticator => 'This authenticator is gone.';
@override
String get passwordInvalidSetup =>
'The instance didn\'t accept what this device made. Update the app, or try again.';
@override
String get passwordChallengeExpired => 'That took too long. Try again.';
@override
String get passwordUnavailable => 'This instance offers no password sign-in.';
@override
String get passwordNotAnAddress => 'This isn\'t an email address.';
@override
String get passwordTooManyCodes =>
'Too many codes asked for. Try again in an hour.';
@override
String get passwordWrongMailCode => 'This code isn\'t right.';
@override
String get passwordTooManyMailTries =>
'Too many wrong codes. Ask for a new one.';
@override
String get passwordMailCodeExpired =>
'This code is no longer valid. Ask for a new one.';
@override
String get passwordMailFailed =>
'The instance couldn\'t send the email. Try again later.';
@override
String get passwordOtherAddressLinked =>
'Your account has another email address.';
@override
String get passwordNotAPhrase =>
'This isn\'t a recovery phrase: check every word and their number.';
@override
String get passwordPhraseDoesNotFit =>
'The address or the recovery phrase isn\'t right.';
@override
String get passwordUnusable =>
'This password can\'t be used: it\'s empty, or holds characters that aren\'t text.';
@override
String get passwordInstanceUnproven =>
'The instance couldn\'t prove it knows your password, so this device trusts nothing it says. Try again; if it happens again, the instance isn\'t the one it claims to be.';
@override
String get passwordKeysMissing =>
'Your account\'s keys aren\'t on this device. Sign out and in again with your password here.';
@override
String get passwordFailed =>
'Couldn\'t reach the instance. Check the connection and try again.';
}
/// The translations for English, as used in the United Kingdom (`en_GB`).