fix(auth): a new recovery phrase takes the password too

password.setRecoveryKeys took a session and a TOTP code alone, so a phone
signed in with its authenticator beside it could set a phrase of its
holder's own and, through the phrase's reset, a new password, never
knowing the old one. It now takes the old password's SRP proof (from
beginChange, the caller's own) and a TOTP or recovery code, as changing
the password does: one owner proof for both (_proveOwner), forwarded by
members, asked for in the account section's New recovery phrase.

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 20:01:07 +02:00
commit 5fb28bd817
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
2 changed files with 447 additions and 169 deletions

View file

@ -302,15 +302,33 @@ void main() {
expect(anna.password, 'correct horse');
});
test('makes a new recovery phrase, which the instance takes for a '
'code', () async {
test('makes a new recovery phrase, which the instance takes for the '
'password and a code alone', () async {
final phrase = await passwords.newPhrase();
final before = anna.recoveryKeys;
await expectLater(
passwords.keepPhrase(phrase, '000000'),
failsWith(PasswordProblem.wrongCode),
passwords.keepPhrase(
phrase,
password: 'correct horse',
code: '000000',
),
failsWith(PasswordProblem.wrongCredentials),
);
await expectLater(
passwords.keepPhrase(
phrase,
password: 'wrong',
code: fakeTotpCode,
),
failsWith(PasswordProblem.wrongCredentials),
);
expect(anna.recoveryKeys, same(before));
await passwords.keepPhrase(phrase, fakeTotpCode);
await passwords.keepPhrase(
phrase,
password: 'correct horse',
code: fakeTotpCode,
);
await auth.signOut();
final pending = await passwords.resetPassword(

View file

@ -352,5 +352,53 @@ void main() {
findsOneWidget,
);
});
testWidgets('makes a new recovery phrase only with the password and a '
'code', (tester) async {
final anna = dependencies.passwordGateway.addAccount(
'anna@example.org',
password: 'correct horse',
crypto: dependencies.crypto,
);
final passwords = dependencies.passwords;
await passwords.enter(
await passwords.signIn(
await passwords.unlock('anna@example.org', 'correct horse'),
totpCode: fakeTotpCode,
),
);
final before = anna.recoveryKeys;
await show(tester);
await tester.tap(find.text('New recovery phrase'));
await tester.pumpAndSettle();
expect(
find.textContaining(wordsOf(otherFakePhrase).first),
findsWidgets,
);
await tapButton(tester, "I've written it down");
await typeCheckedWords(tester, otherFakePhrase);
// A code alone, from a phone signed in with its authenticator.
await tester.enterText(field('Current password'), 'wrong');
await tester.enterText(
field('Code from your authenticator app'),
fakeTotpCode,
);
await tapButton(tester, 'Continue');
expect(
find.text("The address, the password or the code isn't right."),
findsOneWidget,
);
expect(anna.recoveryKeys, same(before));
await tester.enterText(field('Current password'), 'correct horse');
await tapButton(tester, 'Continue');
expect(anna.recoveryKeys, isNot(same(before)));
expect(
find.text('New recovery phrase set. The old one no longer works.'),
findsOneWidget,
);
});
});
}