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:
parent
4f7b123ad4
commit
5fb28bd817
6 changed files with 447 additions and 169 deletions
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in a new issue