feat(app): a lost password with the recovery phrase in SimpleLogin's sheet (ADR 0020, 7)

An account that signs in with SimpleLogin opens its keys with its
password. Where that is lost, the unlock step's "Forgot your password?"
opens one step: the recovery phrase, and a new password twice. There is
no address and no code: the SimpleLogin sign-in of a moment ago stands
for them (D20).

- The device opens the keys with the phrase (`keys.beginPhrase`) and
  wraps them under the new password (`AccountCrypto.newKeysPassword`).
  It then signs the phrase's proof bound to that password
  (`signKeysResetProof`), which `keys.resetPassword` takes with the
  sign-in. The keys stay, and every other session ends.
- A phrase that doesn't open the keys is refused on the device, and the
  instance gets no proof to count. Two passwords that differ are
  refused before anything is sent.
- The gateway's `beginPhrase` and `resetPassword` run on the account's
  own session, not yet the device's. The repository's `resetPassword`
  ties them together, and the cubit and sheet have their `forgot` step.
  Three texts are new, in English and German.
- halkyon_base_server exports `isKeysPassword` for the interop test,
  which checks against the register's checks, with real libsodium, what
  the device sends.

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 21:25:47 +02:00
commit 9e9f5bf406
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
15 changed files with 756 additions and 2 deletions

View file

@ -263,6 +263,9 @@
"simpleLoginMend": "Verknüpfen",
"simpleLoginWrongPassword": "Das Passwort stimmt nicht.",
"simpleLoginMendDoesNotFit": "Das Handle oder die Wiederherstellungsphrase stimmt nicht.",
"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.",
"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

@ -597,6 +597,9 @@
"simpleLoginMend": "Link",
"simpleLoginWrongPassword": "The password isn't right.",
"simpleLoginMendDoesNotFit": "The handle or the recovery phrase isn't right.",
"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.",
"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

@ -1681,6 +1681,24 @@ abstract class HalkyonAppLocalizations {
/// **'The handle or the recovery phrase isn\'t right.'**
String get simpleLoginMendDoesNotFit;
/// No description provided for @simpleLoginForgotTitle.
///
/// In en, this message translates to:
/// **'A new password'**
String get simpleLoginForgotTitle;
/// No description provided for @simpleLoginForgotIntro.
///
/// In en, this message translates to:
/// **'Your recovery phrase opens your keys, which your new password then protects. Everything stays; your other devices are signed out.'**
String get simpleLoginForgotIntro;
/// No description provided for @simpleLoginForgotDoesNotFit.
///
/// In en, this message translates to:
/// **'The recovery phrase isn\'t right.'**
String get simpleLoginForgotDoesNotFit;
/// No description provided for @simpleLoginUnavailable.
///
/// In en, this message translates to:

View file

@ -991,6 +991,17 @@ class HalkyonAppLocalizationsDe extends HalkyonAppLocalizations {
String get simpleLoginMendDoesNotFit =>
'Das Handle oder die Wiederherstellungsphrase stimmt nicht.';
@override
String get simpleLoginForgotTitle => 'Ein neues Passwort';
@override
String get simpleLoginForgotIntro =>
'Deine Wiederherstellungsphrase öffnet deine Schlüssel, die dann dein neues Passwort schützt. Alles bleibt; deine anderen Geräte werden abgemeldet.';
@override
String get simpleLoginForgotDoesNotFit =>
'Die Wiederherstellungsphrase stimmt nicht.';
@override
String get simpleLoginUnavailable =>
'Diese Instanz bietet die Anmeldung mit SimpleLogin nicht an.';

View file

@ -973,6 +973,16 @@ class HalkyonAppLocalizationsEn extends HalkyonAppLocalizations {
String get simpleLoginMendDoesNotFit =>
'The handle or the recovery phrase isn\'t right.';
@override
String get simpleLoginForgotTitle => 'A new password';
@override
String get simpleLoginForgotIntro =>
'Your recovery phrase opens your keys, which your new password then protects. Everything stays; your other devices are signed out.';
@override
String get simpleLoginForgotDoesNotFit => 'The recovery phrase isn\'t right.';
@override
String get simpleLoginUnavailable =>
'This instance doesn\'t offer sign-in with SimpleLogin.';

View file

@ -214,6 +214,27 @@ abstract interface class AccountCrypto() {
required String instanceUrl,
});
/// A new password for the keys of an account that signs in with
/// SimpleLogin ([keys], which stay): the salt, costs, the public half of
/// its proof key and the keys wrapped under it, as [newPassword] without
/// SRP's verifier (ADR 0020, D19).
Future<protocol.KeysPassword> newKeysPassword(
String password,
UnlockedAccountKeys keys,
);
/// The signature proving the recovery [phrase] for [challenge] on
/// [instanceUrl], for the new keys [password] it sets in place of a lost
/// one (`KeyProofPurpose.passwordReset`): by the phrase's own proof key,
/// as [signKeyProof]'s. Refused (`PasswordProblem.notAPhrase`) for what
/// is no recovery phrase.
Future<ByteData> signKeysResetProof(
String phrase,
protocol.KeyProofChallenge challenge, {
required String instanceUrl,
required protocol.KeysPassword password,
});
/// The signature proving the recovery [phrase] for [challenge] on
/// [instanceUrl], to link the SimpleLogin sign-in under [attempt] in
/// place of a lost one (ADR 0020, D7; `KeyProofPurpose.mendLink`): by the

View file

@ -21,6 +21,19 @@ Uint8List newPasswordDigestOf(protocol.NewPassword password) =>
keysCipherText: Uint8List.sublistView(password.passwordKeys.cipherText),
);
/// The digest of a new [password] for the keys of an account that signs in
/// with SimpleLogin (its salt, costs, proof key and wrapping).
Uint8List newKeysPasswordDigestOf(protocol.KeysPassword password) =>
srp.newKeysPasswordDigest(
salt: Uint8List.sublistView(password.salt),
algorithm: password.costs.algorithm,
opsLimit: password.costs.opsLimit,
memLimit: password.costs.memLimit,
proofKey: Uint8List.sublistView(password.passwordProofKey),
keysNonce: Uint8List.sublistView(password.passwordKeys.nonce),
keysCipherText: Uint8List.sublistView(password.passwordKeys.cipherText),
);
/// The digest of a new recovery phrase: the keys wrapped under it
/// ([recoveryKeys]) and its proof key's public half ([proofKey]).
Uint8List newPhraseDigestOf(

View file

@ -333,6 +333,74 @@ class SodiumAccountCrypto() implements AccountCrypto {
}),
);
@override
Future<protocol.KeysPassword> newKeysPassword(
String password,
UnlockedAccountKeys keys,
) => Isolate.run(
() => _guarded(() async {
checkNewPassword(password);
final sodium = await SodiumSumoInit.init();
final opened = _keysOf(sodium, keys);
final salt = _own(newPasswordSalt(sodium));
final unlocked = derivePasswordKeys(
sodium,
password: password,
salt: salt,
);
try {
return protocol.KeysPassword(
salt: ByteData.sublistView(salt),
costs: _costsOf(PasswordKdf.current),
// Copied by proofPublicKey already.
passwordProofKey: ByteData.sublistView(unlocked.proofPublicKey),
passwordKeys: _wrappedOf(
wrapAccountKeys(sodium, opened, unlocked.keyEncryptionKey),
),
);
} finally {
unlocked.dispose();
opened.dispose();
}
}),
);
@override
Future<ByteData> signKeysResetProof(
String phrase,
protocol.KeyProofChallenge challenge, {
required String instanceUrl,
required protocol.KeysPassword password,
}) => Isolate.run(
() => _guarded(() async {
final sodium = await SodiumSumoInit.init();
final KeyPair proof;
try {
proof = recoveryProofKeysOf(sodium, phrase);
} on KeyRefused {
throw const PasswordFailed(PasswordProblem.notAPhrase);
}
try {
return ByteData.sublistView(
_own(
sodium.crypto.sign.detached(
message: srp.keyProofMessage(
challenge.challenge.toBytes(),
Uint8List.sublistView(challenge.nonce),
instanceUrl: instanceUrl,
purpose: srp.KeyProofPurpose.passwordReset,
digest: newKeysPasswordDigestOf(password),
),
secretKey: proof.secretKey,
),
),
);
} finally {
proof.secretKey.dispose();
}
}),
);
@override
Future<ByteData> signMendProof(
String phrase,

View file

@ -31,6 +31,9 @@ enum SimpleLoginStep() {
/// The password that opens the account's keys.
unlock,
/// The recovery phrase and a new password, twice, for a password lost.
forgot,
/// The handle and the recovery phrase, which link the account again.
mend,
@ -111,8 +114,9 @@ final class const SimpleLoginState({
/// browser, then, for someone new, an account created (a handle, a password
/// 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 without
/// keys, the steps making them. With [_link], SimpleLogin is added to 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
/// 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.
@ -308,6 +312,43 @@ class SimpleLoginCubit(
);
});
/// The password is lost: the recovery phrase and a new one instead.
void forgotChosen() => emit(
state.copyWith(
step: SimpleLoginStep.forgot,
passwordsDiffer: false,
clearProblem: true,
),
);
/// Back to the password.
void unlockShownAgain() => emit(
state.copyWith(step: SimpleLoginStep.unlock, 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(
String phrase,
String password,
String again,
) async {
if (password != again) {
emit(state.copyWith(passwordsDiffer: true, clearProblem: true));
return;
}
emit(state.copyWith(passwordsDiffer: false));
await _run(() async {
await _enter(
await _simpleLogin.resetPassword(
_arrival! as SimpleLoginAccount,
phrase: phrase,
newPassword: password,
),
);
});
}
/// The handle and the recovery phrase: the account linked again.
Future<void> mendEntered(String handle, String phrase) => _run(() async {
final typed = handleOf(handle);

View file

@ -142,6 +142,22 @@ class ServerpodSimpleLoginGateway(
() => _calls.asGrant(as, (base) => base.keys.unlock(proof)),
);
@override
Future<protocol.KeyProofChallenge> beginPhrase({SessionGrant? as}) =>
_failing(() => _calls.asGrant(as, (base) => base.keys.beginPhrase()));
@override
Future<void> resetPassword(
protocol.KeysOwnerProof proof,
protocol.KeysPassword password, {
SessionGrant? as,
}) => _failing(
() => _calls.asGrant(
as,
(base) => base.keys.resetPassword(proof: proof, password: password),
),
);
@override
Future<void> setKeys(
protocol.UuidValue attempt,

View file

@ -84,6 +84,20 @@ abstract interface class SimpleLoginGateway() {
SessionGrant? as,
});
/// The first step of proving the account's recovery phrase, for a lost
/// password: the keys wrapped under the phrase, and a nonce to sign with
/// its proof key.
Future<protocol.KeyProofChallenge> beginPhrase({SessionGrant? as});
/// Sets [password] in place of the lost one with the phrase's [proof]
/// and the SimpleLogin sign-in under its attempt of the last ten
/// minutes: the keys stay, and every other session of the account ends.
Future<void> resetPassword(
protocol.KeysOwnerProof proof,
protocol.KeysPassword password, {
SessionGrant? as,
});
/// Gives an account without keys the keys [setup] describes, and
/// [handle] where it has none, with its SimpleLogin sign-in under
/// [attempt] of the last ten minutes.

View file

@ -244,6 +244,45 @@ class SimpleLoginRepository(
}
}
/// Sets [newPassword] for [account]'s lost one with its recovery
/// [phrase] (ADR 0020, 7), the keys staying: the phrase opens them on the
/// device first, so one that doesn't fit spends none of the instance's
/// tries, and its proof binds the new password and the sign-in. Every
/// other session of the account ends.
Future<PendingSimpleLogin> resetPassword(
SimpleLoginAccount account, {
required String phrase,
required String newPassword,
}) async {
final challenge = await _gateway.beginPhrase(as: account.grant);
final keys = await _crypto.openWithPhrase(
phrase,
challenge.recoveryKeys,
encryptionPublicKey: Uint8List.sublistView(
challenge.encryptionPublicKey,
),
signingPublicKey: Uint8List.sublistView(challenge.signingPublicKey),
);
final password = await _crypto.newKeysPassword(newPassword, keys);
final signature = await _crypto.signKeysResetProof(
phrase,
challenge,
instanceUrl: _gateway.instanceUrl,
password: password,
);
await _gateway.resetPassword(
protocol.KeysOwnerProof(
challenge: challenge.challenge,
signature: signature,
instanceUrl: _gateway.instanceUrl,
attempt: account.attempt,
),
password,
as: account.grant,
);
return PendingSimpleLogin(grant: account.grant, keys: keys);
}
/// 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,6 +5,7 @@ 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_widgets.dart';
import 'package:halkyon_app/src/simplelogin/bloc/simplelogin_cubit.dart';
import 'package:halkyon_app/src/simplelogin/widget/simplelogin_texts.dart';
@ -103,6 +104,8 @@ class _SimpleLoginState() extends State<_SimpleLogin> {
_cubit.phraseChecked([for (final word in _words) word.text]);
case SimpleLoginStep.unlock:
_cubit.unlockEntered(_password.text);
case SimpleLoginStep.forgot:
_cubit.forgotEntered(_phrase.text, _password.text, _again.text);
case SimpleLoginStep.mend:
_cubit.mendEntered(_handle.text, _phrase.text);
case SimpleLoginStep.newcomer ||
@ -124,6 +127,10 @@ class _SimpleLoginState() extends State<_SimpleLogin> {
case SimpleLoginStep.password || SimpleLoginStep.unlock:
_password.clear();
_again.clear();
case SimpleLoginStep.forgot:
_phrase.clear();
_password.clear();
_again.clear();
case SimpleLoginStep.phraseCheck:
for (final word in _words) {
word.clear();
@ -147,6 +154,13 @@ class _SimpleLoginState() extends State<_SimpleLogin> {
listener: _stepChanged,
builder: (context, state) {
final problem = switch (state.problem) {
// The phrase alone is asked for here, with no handle.
PasswordFailed(
problem: PasswordProblem.phraseDoesNotFit ||
PasswordProblem.wrongCredentials,
)
when state.step == SimpleLoginStep.forgot =>
l10n.simpleLoginForgotDoesNotFit,
final failed? => simpleLoginFailureText(context, failed),
null => null,
};
@ -164,6 +178,7 @@ class _SimpleLoginState() extends State<_SimpleLogin> {
SimpleLoginStep.phrase => l10n.passwordPhraseTitle,
SimpleLoginStep.phraseCheck => l10n.passwordPhraseCheckTitle,
SimpleLoginStep.unlock => l10n.simpleLoginUnlockTitle,
SimpleLoginStep.forgot => l10n.simpleLoginForgotTitle,
SimpleLoginStep.mend => l10n.simpleLoginMendTitle,
_ when widget.link => l10n.simpleLoginAddTitle,
_ => l10n.simpleLoginTitle,
@ -197,6 +212,7 @@ class _SimpleLoginState() extends State<_SimpleLogin> {
SimpleLoginStep.phraseCheck when state.newcomer =>
l10n.simpleLoginCreate,
SimpleLoginStep.unlock => l10n.simpleLoginOpen,
SimpleLoginStep.forgot => l10n.passwordSignInAction,
SimpleLoginStep.mend => l10n.simpleLoginMend,
_ => l10n.passwordContinue,
}),
@ -325,6 +341,54 @@ 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),
),
),
],
),
SimpleLoginStep.forgot => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SheetIntro(l10n.simpleLoginForgotIntro),
PhraseField(
controller: _phrase,
enabled: enabled,
autofocus: true,
errorText: _phraseProblem(state.problem) ? problem : null,
),
const SizedBox(height: 8),
PasswordField(
controller: _password,
label: l10n.passwordNewPassword,
showsMinimum: true,
isNew: true,
enabled: enabled,
),
const SizedBox(height: 8),
PasswordField(
controller: _again,
label: l10n.passwordRepeat,
isNew: true,
enabled: enabled,
textInputAction: TextInputAction.done,
onSubmitted: (_) => _submit(state),
errorText: state.passwordsDiffer
? l10n.passwordsDiffer
: _phraseProblem(state.problem)
? null
: problem,
),
Align(
alignment: AlignmentDirectional.centerStart,
child: TextButton(
onPressed: enabled ? _cubit.unlockShownAgain : null,
child: Text(l10n.passwordBack),
),
),
],
),
SimpleLoginStep.mend => Column(
@ -351,6 +415,17 @@ class _SimpleLoginState() extends State<_SimpleLogin> {
}
}
/// Whether [problem] is the recovery phrase's, shown at its field.
bool _phraseProblem(Exception? problem) => switch (problem) {
PasswordFailed(
problem: PasswordProblem.notAPhrase ||
PasswordProblem.phraseDoesNotFit ||
PasswordProblem.wrongCredentials,
) =>
true,
_ => false,
};
/// One of the ways on for someone new.
class const _Choice({
required final IconData icon,

View file

@ -311,6 +311,28 @@ class FakeAccountCrypto() implements AccountCrypto {
);
}
@override
Future<protocol.KeysPassword> newKeysPassword(
String password,
UnlockedAccountKeys keys,
) async {
_checkNew(password);
return protocol.KeysPassword(
salt: ByteData(16),
costs: _costs,
passwordProofKey: passwordKeyOf(password),
passwordKeys: wrap(password, keys),
);
}
@override
Future<ByteData> signKeysResetProof(
String phrase,
protocol.KeyProofChallenge challenge, {
required String instanceUrl,
required protocol.KeysPassword password,
}) => signKeyProof(phrase, challenge, instanceUrl: instanceUrl);
@override
Future<ByteData> signMendProof(
String phrase,

View file

@ -359,6 +359,54 @@ class FakeSimpleLoginGateway(final AuthGateway _auth)
);
}
@override
Future<protocol.KeyProofChallenge> beginPhrase({SessionGrant? as}) async {
await _call('beginPhrase');
final person = _owner(as);
final keys = accounts[person]?.keys;
if (keys == null) {
throw const PasswordFailed(PasswordProblem.noPassword);
}
final challenge = _newId();
_challenges[challenge] = person;
return protocol.KeyProofChallenge(
challenge: challenge,
nonce: ByteData(32),
encryptionPublicKey: keys.encryptionPublicKey,
signingPublicKey: keys.signingPublicKey,
recoveryKeys: keys.recoveryKeys,
);
}
@override
Future<void> resetPassword(
protocol.KeysOwnerProof proof,
protocol.KeysPassword password, {
SessionGrant? as,
}) async {
await _call('resetPassword');
final person = _owner(as);
if (_signedIn[proof.attempt] != person) {
throw const SimpleLoginFailed(SimpleLoginProblem.startAgain);
}
if (_challenges.remove(proof.challenge) != person) {
throw const PasswordFailed(PasswordProblem.challengeExpired);
}
final account = accounts[person]!;
final keys = account.keys!;
if (!FakeAccountCrypto.proves(proof.signature, keys.recoveryProofKey)) {
throw const PasswordFailed(PasswordProblem.wrongCredentials);
}
// The sign-in is taken by the change, as the register takes it.
_signedIn.remove(proof.attempt);
account.keys = keys.copyWith(
salt: password.salt,
costs: password.costs,
passwordProofKey: password.passwordProofKey,
passwordKeys: password.passwordKeys,
);
}
@override
Future<void> setKeys(
protocol.UuidValue attempt,