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
21 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,

View file

@ -279,6 +279,108 @@ void main() {
},
);
test(
'a lost password: the new one is a password the register takes, '
"wrapping the same keys and proving itself, and the phrase's proof "
"checks against the phrase's own key, bound to that password",
() async {
const newPassword = 'a new horse battery';
final next = await crypto.newKeysPassword(newPassword, keys.keys);
final taken = register.KeysPassword.fromJson(next.toJson());
final other = register.KeysPassword.fromJson(
(await crypto.newKeysPassword(
'another horse battery',
keys.keys,
)).toJson(),
);
final nonce = Uint8List.fromList(List.filled(32, 7));
final signature = await crypto.signKeysResetProof(
keys.phrase,
protocol.KeyProofChallenge(
challenge: challengeId,
nonce: ByteData.sublistView(nonce),
encryptionPublicKey: keys.setup.encryptionPublicKey,
signingPublicKey: keys.setup.signingPublicKey,
recoveryKeys: keys.setup.recoveryKeys,
),
instanceUrl: instanceUrl,
password: next,
);
Future<bool> checks(
ByteData publicKey,
register.KeysPassword bound, {
register.KeyProofPurpose purpose =
register.KeyProofPurpose.passwordReset,
}) => register.isAccountSignature(
register.keyProofMessage(
challengeId,
nonce,
instanceUrl: 'https://learn.halkyon.eu/',
purpose: purpose,
digest: register.newKeysPasswordDigest(bound),
),
signature: Uint8List.sublistView(signature),
publicKey: Uint8List.sublistView(publicKey),
);
expect(register.isKeysPassword(taken), isTrue);
expect(await checks(keysSetup.recoveryProofKey, taken), isTrue);
expect(await checks(keysSetup.passwordProofKey, taken), isFalse);
expect(await checks(keysSetup.recoveryProofKey, other), isFalse);
expect(
await checks(
keysSetup.recoveryProofKey,
taken,
purpose: register.KeyProofPurpose.newPassword,
),
isFalse,
);
final proven = await crypto.proveKeysPassword(
newPassword,
protocol.KeysChallenge(
challenge: challengeId,
nonce: ByteData.sublistView(nonce),
salt: next.salt,
costs: next.costs,
),
instanceUrl: instanceUrl,
);
expect(
await register.isAccountSignature(
register.keyProofMessage(
challengeId,
nonce,
instanceUrl: 'https://learn.halkyon.eu/',
purpose: register.KeyProofPurpose.unlockKeys,
),
signature: Uint8List.sublistView(proven.proof.signature),
publicKey: Uint8List.sublistView(taken.passwordProofKey),
),
isTrue,
);
expect(
await crypto.openKeys(
proven.unlock,
next.passwordKeys,
encryptionPublicKey: keys.keys.encryptionPublicKey,
signingPublicKey: keys.keys.signingPublicKey,
),
keys.keys,
);
await expectLater(
crypto.newKeysPassword('too short', keys.keys),
throwsA(
isA<PasswordFailed>().having(
(failed) => failed.problem,
'problem',
PasswordProblem.passwordTooShort,
),
),
);
},
);
test(
"linking again: the phrase's proof checks against the phrase's own "
'key, bound to the sign-in it links',

View file

@ -259,6 +259,51 @@ void main() {
expect(keys.kept, {'person-9': material.keys});
});
test('with the password lost, opens them with the recovery phrase under '
'a new password typed twice, and goes back to the password when '
'asked', () async {
final material = await crypto.newKeys(password);
gateway.addAccount(
'person-9',
subject: 'simplelogin-anna',
handle: 'anna',
keys: material.setup,
);
final cubit = cubitFor();
await cubit.started();
cubit.forgotChosen();
expect(cubit.state, at(SimpleLoginStep.forgot));
cubit.unlockShownAgain();
expect(cubit.state, at(SimpleLoginStep.unlock));
cubit.forgotChosen();
const newPassword = 'a new horse battery';
await cubit.forgotEntered(fakePhrase, newPassword, 'a new horse');
expect(cubit.state, at(SimpleLoginStep.forgot));
expect(cubit.state.passwordsDiffer, isTrue);
expect(gateway.calls, isNot(contains('beginPhrase')));
await cubit.forgotEntered(otherFakePhrase, newPassword, newPassword);
expect(
cubit.state,
at(
SimpleLoginStep.forgot,
problem: const PasswordFailed(PasswordProblem.phraseDoesNotFit),
),
);
expect(cubit.state.passwordsDiffer, isFalse);
expect(auth.status, const SignedOut());
await cubit.forgotEntered(fakePhrase, newPassword, newPassword);
expect(cubit.state, at(SimpleLoginStep.done));
expect(
auth.status,
const SignedIn(authUserId: 'person-9', isIdentified: true),
);
expect(keys.kept, {'person-9': material.keys});
});
test('of before, without keys, makes them with the handle it '
'lacks', () async {
gateway.addAccount('person-9', subject: 'simplelogin-anna');

View file

@ -208,6 +208,81 @@ void main() {
expect(sessionOf('halkyon_base.keys/unlock'), contains('anna-token'));
});
test("sets a lost password on the account's own session, not yet the "
"device's: a sign-in taken or run out begins again, a phrase that "
"doesn't prove itself is the keys' refusal", () async {
final grant = SessionGrant(
'0191f0c4-2222-7000-8000-000000000002',
AuthSuccess(
authStrategy: 'session',
token: 'anna-token',
authUserId: UuidValue.withValidation(
'0191f0c4-2222-7000-8000-000000000002',
),
scopeNames: {'identified'},
),
);
Future<void> reset() => gateway.resetPassword(
KeysOwnerProof(
challenge: _Instance.attempt,
signature: ByteData(64),
instanceUrl: gateway.instanceUrl,
attempt: _Instance.attempt,
),
KeysPassword(
salt: ByteData(16),
costs: PasswordCosts(
algorithm: 'argon2id13',
opsLimit: 3,
memLimit: 64 * 1024 * 1024,
),
passwordProofKey: ByteData(32),
passwordKeys: WrappedKeys(
nonce: ByteData(24),
cipherText: ByteData(48),
),
),
as: grant,
);
instance.refusals['halkyon_base.keys/resetPassword'] = OidcRefusedException(
reason: OidcRefusal.unknownAttempt,
);
await expectLater(reset(), failsWith(SimpleLoginProblem.startAgain));
expect(
sessionOf('halkyon_base.keys/resetPassword'),
contains('anna-token'),
);
instance.calls.clear();
instance.refusals['halkyon_base.keys/resetPassword'] =
PasswordRefusedException(reason: PasswordRefusal.wrongCredentials);
await expectLater(
reset(),
throwsA(
isA<PasswordFailed>().having(
(failed) => failed.problem,
'problem',
PasswordProblem.wrongCredentials,
),
),
);
instance.refusals['halkyon_base.keys/beginPhrase'] =
PasswordRefusedException(reason: PasswordRefusal.noPassword);
await expectLater(
gateway.beginPhrase(as: grant),
throwsA(
isA<PasswordFailed>().having(
(failed) => failed.problem,
'problem',
PasswordProblem.noPassword,
),
),
);
expect(sessionOf('halkyon_base.keys/beginPhrase'), contains('anna-token'));
});
test("puts the instance's refusals of SimpleLogin as the app's problems", () {
expect(
{

View file

@ -298,6 +298,93 @@ void main() {
expect(keys.kept, isEmpty);
expect(crypto.unlocks.single.isForgotten, isTrue);
});
group('with the password lost (ADR 0020, 7)', () {
const newPassword = 'a new horse battery';
test('the recovery phrase opens them and a new password wraps them: '
"once entered, the session and the same keys are the device's, "
'and the new password opens them, the old one no longer', () async {
final pending = await simpleLogin.resetPassword(
account,
phrase: fakePhrase,
newPassword: newPassword,
);
expect(auth.status, const SignedOut());
await simpleLogin.enter(pending);
expect(auth.usedSessions, [account.grant]);
expect(keys.kept, {'person-9': material.keys});
await expectLater(
simpleLogin.unlock(account, password),
keysFailWith(PasswordProblem.wrongCredentials),
);
expect(
(await simpleLogin.unlock(account, newPassword)).keys,
material.keys,
);
});
test("refuses a phrase that doesn't open the keys on the device: the "
'instance gets no proof to count, and the password stays', () async {
await expectLater(
simpleLogin.resetPassword(
account,
phrase: otherFakePhrase,
newPassword: newPassword,
),
keysFailWith(PasswordProblem.phraseDoesNotFit),
);
await expectLater(
simpleLogin.resetPassword(
account,
phrase: 'not a phrase',
newPassword: newPassword,
),
keysFailWith(PasswordProblem.notAPhrase),
);
expect(gateway.calls, isNot(contains('resetPassword')));
expect(keys.kept, isEmpty);
expect(
(await simpleLogin.unlock(account, password)).keys,
material.keys,
);
});
test('refuses a new password too short on the device, before anything '
'is sent', () async {
await expectLater(
simpleLogin.resetPassword(
account,
phrase: fakePhrase,
newPassword: 'too short',
),
keysFailWith(PasswordProblem.passwordTooShort),
);
expect(gateway.calls, isNot(contains('resetPassword')));
});
test('takes the sign-in it was made with: once it is used, the change '
'begins again with SimpleLogin', () async {
await simpleLogin.resetPassword(
account,
phrase: fakePhrase,
newPassword: newPassword,
);
await expectLater(
simpleLogin.resetPassword(
account,
phrase: fakePhrase,
newPassword: 'yet another horse battery',
),
failsWith(SimpleLoginProblem.startAgain),
);
});
});
});
group('an account of before, without keys', () {

View file

@ -177,6 +177,42 @@ void main() {
expect(find.text('App of person-9'), findsOneWidget);
});
testWidgets('an account whose password was lost opens its keys with the '
'recovery phrase under a new password, a phrase that is not its own '
'refused', (tester) async {
final material = await dependencies.crypto.newKeys(password);
dependencies.simpleLoginGateway.addAccount(
'person-9',
subject: 'simplelogin-anna',
handle: 'anna',
keys: material.setup,
);
await start(tester);
await tapButton(tester, 'Continue with SimpleLogin');
await tester.tap(find.widgetWithText(TextButton, 'Forgot your password?'));
await tester.pumpAndSettle();
expect(find.text('A new password'), findsOneWidget);
await tester.tap(find.widgetWithText(TextButton, 'Back'));
await tester.pumpAndSettle();
expect(find.text('Your password'), findsOneWidget);
await tester.tap(find.widgetWithText(TextButton, 'Forgot your password?'));
await tester.pumpAndSettle();
const newPassword = 'a new horse battery';
await tester.enterText(field('Recovery phrase'), otherFakePhrase);
await tester.enterText(field('New password'), newPassword);
await tester.enterText(field('New password again'), newPassword);
await tapButton(tester, 'Sign in');
expect(find.text("The recovery phrase isn't right."), findsOneWidget);
await tester.enterText(field('Recovery phrase'), fakePhrase);
await tapButton(tester, 'Sign in');
expect(find.text('App of person-9'), findsOneWidget);
expect(dependencies.keys.kept, {'person-9': material.keys});
});
testWidgets('someone whose SimpleLogin account was lost links theirs '
'again with its handle and recovery phrase', (tester) async {
final material = await dependencies.crypto.newKeys(password);

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