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
6 changed files with 447 additions and 169 deletions

View file

@ -195,7 +195,7 @@ enum NewPhraseStep() {
/// Shown, to write down.
shown,
/// Three words typed back, and a code.
/// Three words typed back, the password and a code.
check,
/// Kept: the old one opens nothing any more.
@ -228,7 +228,7 @@ final class const NewPhraseState({
/// A new recovery phrase for the signed-in person's keys (on this device):
/// made, shown, three of its words typed back, then kept by the instance
/// for a code from the authenticator.
/// for the password and a code from the authenticator.
class NewPhraseCubit(final PasswordRepository _passwords)
extends Cubit<NewPhraseState> {
this : super(const NewPhraseState());
@ -263,15 +263,20 @@ class NewPhraseCubit(final PasswordRepository _passwords)
void shownAgain() =>
emit(NewPhraseState(step: NewPhraseStep.shown, phrase: state.phrase));
/// The words typed back, and a [code]: the instance keeps it.
Future<void> checked(List<String> words, String code) async {
/// The words typed back, the [password] and a [code]: the instance keeps
/// it.
Future<void> checked(
List<String> words, {
required String password,
required String code,
}) async {
if (!checksPhrase(state.phrase, state.checked, words)) {
emit(_again(phraseWrong: true));
return;
}
emit(_again(busy: true));
try {
await _passwords.keepPhrase(_made!, code);
await _passwords.keepPhrase(_made!, password: password, code: code);
if (!isClosed) {
emit(NewPhraseState(step: NewPhraseStep.done, phrase: state.phrase));
}

View file

@ -104,8 +104,16 @@ abstract interface class PasswordGateway() {
required protocol.NewPassword password,
});
/// A new recovery phrase's wrapping of the keys, for a [code].
Future<void> setRecoveryKeys(String code, protocol.WrappedKeys keys);
/// A new recovery phrase's wrapping of the keys: [answer] to [challenge]
/// ([beginChange]) proves the password, with a [totpCode] or
/// [recoveryCode].
Future<void> setRecoveryKeys(
protocol.PasswordChallenge challenge,
SrpAnswer answer, {
String? totpCode,
String? recoveryCode,
required protocol.WrappedKeys keys,
});
/// Asks for the reset of a lost authenticator: [answer] to [challenge]
/// proves the password, and a code mailed for [request] or the recovery

View file

@ -338,12 +338,27 @@ class PasswordRepository(
return NewPhrase(made.phrase, made.recoveryKeys);
}
/// [phrase] in place of the old recovery phrase, for a [code]: the old
/// one opens nothing any more.
Future<void> keepPhrase(NewPhrase phrase, String code) async {
/// [phrase] in place of the old recovery phrase, with the [password] and
/// a [code] from an authenticator, as changing the password takes: the old
/// phrase opens nothing any more. (A session and a code alone would let
/// whoever holds a phone signed in set a phrase of their own, and with it
/// a new password.)
Future<void> keepPhrase(
NewPhrase phrase, {
required String password,
required String code,
}) async {
final challenge = await _gateway.beginChange();
final unlock = await _crypto.unlock(
password,
salt: Uint8List.sublistView(challenge.salt),
costs: challenge.costs,
);
await _gateway.setRecoveryKeys(
code.replaceAll(' ', ''),
phrase._recoveryKeys,
challenge,
await _crypto.answer(unlock, challenge),
totpCode: code.replaceAll(' ', ''),
keys: phrase._recoveryKeys,
);
_changed();
}

View file

@ -190,10 +190,22 @@ class ServerpodPasswordGateway(
);
@override
Future<void> setRecoveryKeys(String code, protocol.WrappedKeys keys) =>
_failing(
() => _base.password.setRecoveryKeys(code: code, recoveryKeys: keys),
);
Future<void> setRecoveryKeys(
protocol.PasswordChallenge challenge,
SrpAnswer answer, {
String? totpCode,
String? recoveryCode,
required protocol.WrappedKeys keys,
}) => _failing(
() => _base.password.setRecoveryKeys(
challenge: challenge.challenge,
clientValue: answer.clientValue,
clientProof: answer.clientProof,
totpCode: totpCode,
recoveryCode: recoveryCode,
recoveryKeys: keys,
),
);
@override
Future<DateTime> resetAuthenticator(

View file

@ -570,6 +570,7 @@ class const _NewPhrase() extends StatefulWidget {
class _NewPhraseState() extends State<_NewPhrase> {
final _words = [for (var i = 0; i < 3; i++) TextEditingController()];
final _password = TextEditingController();
final _code = TextEditingController();
late final NewPhraseCubit _cubit;
@ -584,6 +585,7 @@ class _NewPhraseState() extends State<_NewPhrase> {
for (final word in _words) {
word.dispose();
}
_password.dispose();
_code.dispose();
_cubit.close();
super.dispose();
@ -594,7 +596,11 @@ class _NewPhraseState() extends State<_NewPhrase> {
case NewPhraseStep.shown:
_cubit.writtenDown();
case NewPhraseStep.check:
_cubit.checked([for (final word in _words) word.text], _code.text);
_cubit.checked(
[for (final word in _words) word.text],
password: _password.text,
code: _code.text,
);
case NewPhraseStep.making || NewPhraseStep.done:
Navigator.of(context).pop();
}
@ -611,6 +617,7 @@ class _NewPhraseState() extends State<_NewPhrase> {
for (final word in _words) {
word.clear();
}
_password.clear();
_code.clear();
}
if (state.step == NewPhraseStep.done) {
@ -653,6 +660,14 @@ class _NewPhraseState() extends State<_NewPhrase> {
? l10n.passwordPhraseWrong
: null,
),
// The password too, as changing it takes: a phone signed in
// with its authenticator must not set a phrase of its own.
PasswordField(
controller: _password,
label: l10n.passwordCurrent,
enabled: !state.busy,
),
const SizedBox(height: 8),
CodeField(
controller: _code,
kind: CodeKind.totp,

View file

@ -496,6 +496,20 @@ class FakePasswordGateway(final AuthGateway _auth) implements PasswordGateway {
String? recoveryCode,
required protocol.NewPassword password,
}) => _call('change', () {
_ownerProved(challenge, answer, totpCode, recoveryCode)
..password = utf8.decode(Uint8List.sublistView(password.verifier))
..passwordKeys = password.passwordKeys;
});
/// The signed-in person's account, once [answer] to [challenge] proved
/// its password and a code came: refused as the register refuses a
/// change.
FakePasswordAccount _ownerProved(
protocol.PasswordChallenge challenge,
SrpAnswer answer,
String? totpCode,
String? recoveryCode,
) {
final account = _proved(challenge, answer);
if (account.person != _signedIn() ||
((totpCode ?? '').isEmpty && (recoveryCode ?? '').isEmpty)) {
@ -508,20 +522,19 @@ class FakePasswordGateway(final AuthGateway _auth) implements PasswordGateway {
)) {
throw const PasswordFailed(PasswordProblem.setupIncomplete);
}
account
..password = utf8.decode(Uint8List.sublistView(password.verifier))
..passwordKeys = password.passwordKeys;
});
return account;
}
@override
Future<void> setRecoveryKeys(String code, protocol.WrappedKeys keys) =>
_call('setRecoveryKeys', () {
final account = _accountAs(null);
if (code != fakeTotpCode) {
throw const PasswordFailed(PasswordProblem.wrongCode);
}
account.recoveryKeys = keys;
});
Future<void> setRecoveryKeys(
protocol.PasswordChallenge challenge,
SrpAnswer answer, {
String? totpCode,
String? recoveryCode,
required protocol.WrappedKeys keys,
}) => _call('setRecoveryKeys', () {
_ownerProved(challenge, answer, totpCode, recoveryCode).recoveryKeys = keys;
});
@override
Future<DateTime> resetAuthenticator(