diff --git a/lib/cubit/account_cubit.dart b/lib/cubit/account_cubit.dart index 1f25691..37eead3 100644 --- a/lib/cubit/account_cubit.dart +++ b/lib/cubit/account_cubit.dart @@ -330,7 +330,7 @@ class AccountCubit extends Cubit { emit(AccountEmailChangeRequested(currentUser, null)); } - Future changePassword(ProtectedValue password, Future Function() onChangeStarted) async { + Future changePassword(ProtectedValue password, Future Function(User user) onChangeStarted) async { if (currentUser.emailHashed == null) throw KeeInvalidStateException(); if (currentUser.email?.isEmpty ?? true) throw KeeInvalidStateException(); if (currentUser.salt?.isEmpty ?? true) throw KeeInvalidStateException(); @@ -341,7 +341,7 @@ class AccountCubit extends Cubit { try { await _userRepo.changePasswordStart(currentUser, newPassKey); - final success = await onChangeStarted(); + final success = await onChangeStarted(currentUser); if (!success) { throw KeeException('Password change aborted.'); diff --git a/lib/cubit/vault_cubit.dart b/lib/cubit/vault_cubit.dart index 8dea69d..892d045 100644 --- a/lib/cubit/vault_cubit.dart +++ b/lib/cubit/vault_cubit.dart @@ -559,10 +559,15 @@ class VaultCubit extends Cubit { l.i('refresh called during an ongoing upload. Will not refresh now.'); return; } + if (s is VaultChangingPassword) { + l.i('refresh called during a password change. Will not refresh now.'); + return; + } Credentials creds = s.vault.files.current.credentials; if (overridePassword != null) { + //TODO: Why does this not update the local kdbx file and QU? l.d('we have a password explicitly supplied'); final protectedValue = ProtectedValue.fromString(overridePassword); final key = protectedValue.hash; @@ -1059,11 +1064,12 @@ class VaultCubit extends Cubit { final protectedValue = ProtectedValue.fromString(overridePassword); final key = protectedValue.hash; await user.attachKey(key); - creds = Credentials(protectedValue); + final credentialsWithStrength = StrengthAssessedCredentials(protectedValue, user.emailParts); + creds = credentialsWithStrength.credentials; // If user has supplied the correct password for the upload to succeed, we must make sure the locked data uploaded is encrypted using that new password. remoteMergeTarget and current are identical at this time because user has just supplied a new password through the UI so can't have any outstanding modifications in the current vault file. There must also be no pending files. - updatedLocalFile = LocalVaultFile(await vault.files.copyWithNewCredentials(creds), vault.lastOpenedAt, - vault.persistedAt, vault.uuid, vault.etag, vault.versionId); + updatedLocalFile = LocalVaultFile(await vault.files.copyWithNewCredentials(credentialsWithStrength), + vault.lastOpenedAt, vault.persistedAt, vault.uuid, vault.etag, vault.versionId); } String? lastRemoteEtag; @@ -1311,6 +1317,9 @@ class VaultCubit extends Cubit { castState.remotely, castState.causedByInteraction, )); + } else if (state is VaultChangingPassword) { + final castState = state as VaultChangingPassword; + emit(VaultChangingPassword(castState.vault)); } else if (state is VaultReconcilingUpload) { final castState = state as VaultReconcilingUpload; emit(VaultReconcilingUpload(castState.vault, castState.locally, castState.remotely)); @@ -1475,11 +1484,21 @@ class VaultCubit extends Cubit { l.e(message); throw Exception(message); } - //TODO: Any other situations we want to prevent for remote changing? - if (s is VaultLoaded) { + //TODO: Any other situations we want to prevent for remote changing? e.g. remoteauthrequested + // also need to put same checks into beginChangePasswordIfPossible so we don't give the user a chance to reach this point when we know current state is invalid + if (s is VaultChangingPassword) { final protectedValue = ProtectedValue.fromString(password); - await _accountCubit.changePassword(protectedValue, () async { + await _accountCubit.changePassword(protectedValue, (User user) async { //TODO: change kdbx password + l.d('changing KDBX password'); + final credentialsWithStrength = StrengthAssessedCredentials(protectedValue, user.emailParts); + s.vault.files.current + ..changeCredentials(credentialsWithStrength.credentials) + ..header.writeKdfParameters(credentialsWithStrength.createNewKdfParameters()); + await save(user); + //TODO: Do we need to refresh manually before we start this process so that we know we have the latest remote changes too? + // Need to protect against situation where remote file gets updated in the mean time and thus we start a sync operation but no longer have matching KDBX passwords so sync fails and thus password change fails. Edge case though so maybe instruct user to not do that and then just throw failure error if it happens. Double-check that this does not lead to permenant problem. user should be able to re-sign in and do it again successfully after sync happens. + l.d('KDBX password changed and uploaded'); // const emailAddrParts = EmailUtil.split(this.account.get('email')); // const file = this.files.first(); // const oldPasswordHash = file.db.credentials.passwordHash.clone(); // Not certain clone is needed @@ -1489,7 +1508,6 @@ class VaultCubit extends Cubit { }); // l.d('changing KDBX password'); - // final protectedValue = ProtectedValue.fromString(password); // final creds = Credentials(protectedValue); // s.vault.files.current.changeCredentials(creds); // await save(null); @@ -1497,6 +1515,28 @@ class VaultCubit extends Cubit { } } + void startEmailChange() { + if (currentVaultFile == null || (!currentVaultFile!.files.current.isDirty && _entryCubit.state is! EntryLoaded)) { + //TODO: test if just signing out triggers the request to sign in again automatically or if it is to do with the change to the account cubit + // _accountCubit.startEmailChange(); + signout(); + } else { + emitError('You must save your changes first!', toast: true); + } + } + + bool beginChangePasswordIfPossible() { + if (currentVaultFile == null) { + return false; + } + if (!currentVaultFile!.files.current.isDirty && _entryCubit.state is! EntryLoaded) { + emit(VaultChangingPassword(currentVaultFile!)); + return true; + } + emitError('You must save your changes first!', toast: true); + return false; + } + Future autofillMerge(User? user, {bool onlyIfAttemptAlreadyDue = false}) async { if (onlyIfAttemptAlreadyDue && !autoFillMergeAttemptDue) { return; diff --git a/lib/cubit/vault_state.dart b/lib/cubit/vault_state.dart index 8d3036b..824122c 100644 --- a/lib/cubit/vault_state.dart +++ b/lib/cubit/vault_state.dart @@ -107,3 +107,7 @@ class VaultUploadCredentialsRequired extends VaultReconcilingUpload { final bool causedByInteraction; const VaultUploadCredentialsRequired(super.vault, super.locally, super.remotely, this.causedByInteraction); } + +class VaultChangingPassword extends VaultLoaded { + const VaultChangingPassword(super.vault); +} diff --git a/lib/generated/intl/messages_en.dart b/lib/generated/intl/messages_en.dart index eebe8e5..a535e65 100644 --- a/lib/generated/intl/messages_en.dart +++ b/lib/generated/intl/messages_en.dart @@ -141,6 +141,8 @@ class MessageLookup extends MessageLookupByLibrary { "Change or Cancel Subscription"), "changeEmail": MessageLookupByLibrary.simpleMessage("Change email address"), + "changeEmailConfirmCheckbox": MessageLookupByLibrary.simpleMessage( + "I have read the above warnings, mitigated the risks and wish to continue"), "changeEmailInfo1": MessageLookupByLibrary.simpleMessage( "Your email address has a crucial role in the advanced security protections Kee Vault offers. Changing it securely is a far more complex task than for most of the places you might wish to change it. We are happy to finally offer this feature to you but please read the information carefully and don\'t proceed when you are in a rush."), "changeEmailInfo2": MessageLookupByLibrary.simpleMessage( @@ -158,11 +160,9 @@ class MessageLookup extends MessageLookupByLibrary { "changeEmailInfo3c": MessageLookupByLibrary.simpleMessage( "3) Copy/paste what you have entered in the email address box and store somewhere like a note on your phone."), "changeEmailInfo4": MessageLookupByLibrary.simpleMessage( - "If you make a mistake, you should be able to regain access to your Vault but in some cases you may need to create a new Kee Vault subscription and import from your previously exported KDBX file - this can result in additional hassle and costs since your current subscription would not automatically end."), + "If you make a mistake, you should be able to regain access to your Vault but in some cases you may need to create a new Kee Vault subscription and import from your previously exported KDBX file - this can result in additional hassle and costs since your old subscription would not be refundable."), "changeEmailInfo5": MessageLookupByLibrary.simpleMessage( "Your password will remain the same throughout the process. If you want to change that too, we first recommend signing in on multiple devices using your new email address and waiting at least an hour."), - "changeEmailInfo6": MessageLookupByLibrary.simpleMessage( - "I have read the above warnings, mitigated the risks and wish to continue"), "changeEmailPrefs": MessageLookupByLibrary.simpleMessage("Change email preferences"), "changePassword": @@ -245,6 +245,8 @@ class MessageLookup extends MessageLookupByLibrary { "done": MessageLookupByLibrary.simpleMessage("Done"), "downloading": MessageLookupByLibrary.simpleMessage("Downloading"), "email": MessageLookupByLibrary.simpleMessage("Email"), + "emailChanged": + MessageLookupByLibrary.simpleMessage("Email address changed"), "emailValidationFail": MessageLookupByLibrary.simpleMessage( "Not a valid email address. Please try again."), "emailVerification": diff --git a/lib/generated/l10n.dart b/lib/generated/l10n.dart index f841486..3785b01 100644 --- a/lib/generated/l10n.dart +++ b/lib/generated/l10n.dart @@ -3950,10 +3950,10 @@ class S { ); } - /// `If you make a mistake, you should be able to regain access to your Vault but in some cases you may need to create a new Kee Vault subscription and import from your previously exported KDBX file - this can result in additional hassle and costs since your current subscription would not automatically end.` + /// `If you make a mistake, you should be able to regain access to your Vault but in some cases you may need to create a new Kee Vault subscription and import from your previously exported KDBX file - this can result in additional hassle and costs since your old subscription would not be refundable.` String get changeEmailInfo4 { return Intl.message( - 'If you make a mistake, you should be able to regain access to your Vault but in some cases you may need to create a new Kee Vault subscription and import from your previously exported KDBX file - this can result in additional hassle and costs since your current subscription would not automatically end.', + 'If you make a mistake, you should be able to regain access to your Vault but in some cases you may need to create a new Kee Vault subscription and import from your previously exported KDBX file - this can result in additional hassle and costs since your old subscription would not be refundable.', name: 'changeEmailInfo4', desc: '', args: [], @@ -3971,10 +3971,10 @@ class S { } /// `I have read the above warnings, mitigated the risks and wish to continue` - String get changeEmailInfo6 { + String get changeEmailConfirmCheckbox { return Intl.message( 'I have read the above warnings, mitigated the risks and wish to continue', - name: 'changeEmailInfo6', + name: 'changeEmailConfirmCheckbox', desc: '', args: [], ); @@ -3999,6 +3999,16 @@ class S { args: [], ); } + + /// `Email address changed` + String get emailChanged { + return Intl.message( + 'Email address changed', + name: 'emailChanged', + desc: '', + args: [], + ); + } } class AppLocalizationDelegate extends LocalizationsDelegate { diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index a37eb7e..a7584a2 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -389,10 +389,11 @@ "changeEmailInfo3a": "1) Click the Cancel button below, sign in to Kee Vault again, Export your Vault to a KDBX file and store it somewhere safe as a backup.", "changeEmailInfo3b": "2) Double check you enter the correct email address - you will need to type it exactly to sign in to your account in a moment.", "changeEmailInfo3c": "3) Copy/paste what you have entered in the email address box and store somewhere like a note on your phone.", - "changeEmailInfo4": "If you make a mistake, you should be able to regain access to your Vault but in some cases you may need to create a new Kee Vault subscription and import from your previously exported KDBX file - this can result in additional hassle and costs since your current subscription would not automatically end.", + "changeEmailInfo4": "If you make a mistake, you should be able to regain access to your Vault but in some cases you may need to create a new Kee Vault subscription and import from your previously exported KDBX file - this can result in additional hassle and costs since your old subscription would not be refundable.", "changeEmailInfo5": "Your password will remain the same throughout the process. If you want to change that too, we first recommend signing in on multiple devices using your new email address and waiting at least an hour.", - "changeEmailInfo6": "I have read the above warnings, mitigated the risks and wish to continue", + "changeEmailConfirmCheckbox": "I have read the above warnings, mitigated the risks and wish to continue", "currentEmailAddress": "Current email address", - "newEmailAddress": "New email address" + "newEmailAddress": "New email address", + "emailChanged": "Email address changed" } \ No newline at end of file diff --git a/lib/password_strength.dart b/lib/password_strength.dart index 2083864..1be63a1 100644 --- a/lib/password_strength.dart +++ b/lib/password_strength.dart @@ -88,9 +88,9 @@ class StrengthAssessedCredentials { : strength = fuzzyStrength(password.getText(), emailAddrParts), credentials = Credentials(password); - KdbxHeader createNewKdbxHeader() { + VarDictionary createNewKdfParameters() { final argon2Params = Argon2Params.forStrength(strength); - final kdfParameters = VarDictionary([ + return VarDictionary([ KdfField.uuid.item(KeyEncrypterKdf.kdfUuidForType(KdfType.Argon2d).toBytes()), KdfField.salt.item(ByteUtils.randomBytes(argon2Params.saltLength)), KdfField.parallelism.item(argon2Params.parallelism), @@ -98,6 +98,9 @@ class StrengthAssessedCredentials { KdfField.memory.item(argon2Params.memory), KdfField.version.item(argon2Params.version), ]); - return KdbxHeader.createV4_1()..writeKdfParameters(kdfParameters); + } + + KdbxHeader createNewKdbxHeader() { + return KdbxHeader.createV4_1()..writeKdfParameters(createNewKdfParameters()); } } diff --git a/lib/vault_backend/user_service.dart b/lib/vault_backend/user_service.dart index 637e1df..673a92f 100644 --- a/lib/vault_backend/user_service.dart +++ b/lib/vault_backend/user_service.dart @@ -41,8 +41,7 @@ class UserService { return user; } - // Actually can't ever be false - just throws instead - Future loginFinish(User user, {List? hashedMasterKey, required bool notifyListeners}) async { + Future loginFinish(User user, {List? hashedMasterKey, required bool notifyListeners}) async { if (user.loginParameters == null) throw KeeMaybeOfflineException(); if (user.emailHashed?.isEmpty ?? true) throw KeeInvalidStateException(); if (user.salt?.isEmpty ?? true) throw KeeInvalidStateException(); @@ -80,7 +79,7 @@ class UserService { user.verificationStatus = srp2.verificationStatus; await _finishIosIapTransaction(user); - return true; + return; } Future _finishIosIapTransaction(User user) async { @@ -156,9 +155,9 @@ class UserService { user.emailHashed!.isNotEmpty && user.passKey!.isNotEmpty) { await loginStart(user); - final loginResult = await loginFinish(user, notifyListeners: notifyListeners); + await loginFinish(user, notifyListeners: notifyListeners); // Unlike with the refresh operation above, user.verificationStatus is updated as part of the loginFinish function - if (loginResult && user.tokens != null) { + if (user.tokens != null) { return user.tokens!; } else { throw KeeLoginRequiredException(); @@ -222,7 +221,7 @@ class UserService { final newPrivateKey = derivePrivateKey(newHexSalt, newEmailHashed, newPassKey); final newVerifier = deriveVerifier(newPrivateKey); - final oldPrivateKey = derivePrivateKey(user.salt!, user.emailHashed!, oldPassKey); + final oldPrivateKey = derivePrivateKey(base642hex(user.salt!), user.emailHashed!, oldPassKey); final oldVerifier = deriveVerifier(oldPrivateKey); final oldVerifierHashed = await hashBytes(Uint8List.fromList(hex.decode(oldVerifier))); @@ -240,7 +239,7 @@ class UserService { } Future changePasswordStart(User user, String newPassKey) async { - final newPrivateKey = derivePrivateKey(user.salt!, user.emailHashed!, newPassKey); + final newPrivateKey = derivePrivateKey(base642hex(user.salt!), user.emailHashed!, newPassKey); final newVerifier = deriveVerifier(newPrivateKey); await _service.postRequest( diff --git a/lib/vault_file.dart b/lib/vault_file.dart index 5f7d957..fdc0c63 100644 --- a/lib/vault_file.dart +++ b/lib/vault_file.dart @@ -6,6 +6,7 @@ import 'package:keevault/locked_vault_file.dart'; import 'kdbx_argon2_ffi.dart'; import 'kdf_cache.dart'; +import 'password_strength.dart'; class VaultFileVersions { // Current unlocked kdbx must always be available @@ -71,21 +72,19 @@ class VaultFileVersions { // remoteMergeTarget and current are identical at this time because user has just // supplied a new password through the UI so can't have any outstanding modifications // in the current vault file. There must also be no pending files. - Future copyWithNewCredentials(Credentials credentials) async { - // final unlockedFiles = await unlockTwice(this.remoteMergeTargetLocked!); - // final unlockedCurrent = unlockedFiles[0]; - //unlockedCurrent.overwriteCredentials(credentials, DateTime.now()); + Future copyWithNewCredentials(StrengthAssessedCredentials credentialsWithStrength) async { final unlockedFile = await unlock(remoteMergeTargetLocked); - unlockedFile.changeCredentials(credentials); - // final unlockedMergeTarget = unlockedFiles[1]; - // unlockedMergeTarget.changeCredentials(credentials); + unlockedFile + ..changeCredentials(credentialsWithStrength.credentials) + ..header.writeKdfParameters(credentialsWithStrength.createNewKdfParameters()); final kdbxData = await unlockedFile.save(); return VaultFileVersions( current: _current, pending: null, pendingLocked: null, remoteMergeTarget: null, - remoteMergeTargetLocked: LockedVaultFile(kdbxData, DateTime.now(), credentials, null, null)); + remoteMergeTargetLocked: + LockedVaultFile(kdbxData, DateTime.now(), credentialsWithStrength.credentials, null, null)); } @override diff --git a/lib/widgets/account_email_change.dart b/lib/widgets/account_email_change.dart index f52dd2b..38c3f2b 100644 --- a/lib/widgets/account_email_change.dart +++ b/lib/widgets/account_email_change.dart @@ -3,13 +3,8 @@ import 'dart:async'; import 'package:email_validator/email_validator.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:kdbx/kdbx.dart'; import 'package:keevault/cubit/account_cubit.dart'; -import '../cubit/vault_cubit.dart'; import '../generated/l10n.dart'; -import '../vault_backend/exceptions.dart'; -import '../vault_backend/user.dart'; -import '../vault_backend/utils.dart'; typedef SubmitCallback = Future Function(String string); @@ -63,12 +58,13 @@ class _AccountEmailChangeWidgetState extends State { if (state is AccountEmailChangeRequested) { return Padding( padding: const EdgeInsets.all(16.0), - child: Column(crossAxisAlignment: CrossAxisAlignment.center, children: [ + child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Padding( padding: const EdgeInsets.only(bottom: 16.0), child: Text( str.changeEmail, style: theme.textTheme.titleLarge, + textAlign: TextAlign.center, ), ), Padding( @@ -143,9 +139,22 @@ class _AccountEmailChangeWidgetState extends State { textAlign: TextAlign.left, ), ), + CheckboxListTile( + value: !disableChange, + title: Text(str.changeEmailConfirmCheckbox, style: theme.textTheme.labelLarge), + controlAffinity: ListTileControlAffinity.leading, + contentPadding: EdgeInsets.zero, + visualDensity: VisualDensity.compact, + onChanged: (bool? value) { + setState(() { + disableChange = !value!; + }); + }, + ), Padding( padding: const EdgeInsets.fromLTRB(0, 12, 0, 32), child: TextFormField( + enabled: !disableChange, controller: _newEmailAddress, enableSuggestions: false, autocorrect: false, @@ -169,6 +178,7 @@ class _AccountEmailChangeWidgetState extends State { Padding( padding: const EdgeInsets.fromLTRB(0, 12, 0, 32), child: TextFormField( + enabled: !disableChange, controller: _currentPassword, obscureText: passwordObscured, enableSuggestions: false, @@ -199,8 +209,7 @@ class _AccountEmailChangeWidgetState extends State { keyboardType: TextInputType.visiblePassword, ), ), - //TODO: form fields - OutlinedButton( + FilledButton( onPressed: changing || disableChange ? null : () async { @@ -214,7 +223,7 @@ class _AccountEmailChangeWidgetState extends State { try { await changeEmailAddress(submittedValuePassword!, submittedValueNewEmailAddress!); sm.showSnackBar(SnackBar( - content: Text(str.passwordChanged), + content: Text(str.emailChanged), duration: Duration(seconds: 4), )); //navigator.pop(); @@ -238,7 +247,24 @@ class _AccountEmailChangeWidgetState extends State { ) : Text(str.changeEmail), ), - //TODO: Cancel button + OutlinedButton( + onPressed: changing + ? null + : () async { + await cancel(); + }, + child: changing + ? Container( + width: 24, + height: 24, + padding: const EdgeInsets.all(2.0), + child: const CircularProgressIndicator( + strokeWidth: 3, + ), + ) + : Text(str.alertCancel), + ), + //TODO: Cancel button location //TODO: Handle back press - cancel automatically. ]), ); diff --git a/lib/widgets/account_email_not_verified.dart b/lib/widgets/account_email_not_verified.dart index 30adf93..d05a580 100644 --- a/lib/widgets/account_email_not_verified.dart +++ b/lib/widgets/account_email_not_verified.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:keevault/cubit/account_cubit.dart'; +import '../cubit/vault_cubit.dart'; import '../generated/l10n.dart'; import '../vault_backend/exceptions.dart'; @@ -92,6 +93,7 @@ class _AccountEmailNotVerifiedWidgetState extends State(context); accountCubit.startEmailChange(); + BlocProvider.of(context).signout(); } bool disableResending = true; diff --git a/lib/widgets/settings.dart b/lib/widgets/settings.dart index 6d2292c..f1adba8 100644 --- a/lib/widgets/settings.dart +++ b/lib/widgets/settings.dart @@ -57,6 +57,16 @@ class _SettingsWidgetState extends State with TraceableClientMix } } + void changePassword() async { + if (BlocProvider.of(context).beginChangePasswordIfPossible()) { + await AppConfig.router.navigateTo( + context, + Routes.changePassword, + transition: TransitionType.inFromRight, + ); + } + } + @override Widget build(BuildContext context) { final str = S.of(context); @@ -106,11 +116,7 @@ class _SettingsWidgetState extends State with TraceableClientMix SimpleSettingsTile( title: str.changePassword, subtitle: str.changePasswordDetail, - onTap: () async => await AppConfig.router.navigateTo( - context, - Routes.changePassword, - transition: TransitionType.inFromRight, - ), + onTap: changePassword, //TODO: need to pass anything to widget so we know it's for a remote account? ), ); @@ -152,8 +158,7 @@ class _SettingsWidgetState extends State with TraceableClientMix SimpleSettingsTile( title: str.changeEmail, onTap: () { - //TODO: lock vault here? Also need to kick user out from this vaultloader widget entirely. probably do something similar when verification grace expires or subscription expires? - BlocProvider.of(context).startEmailChange(); + BlocProvider.of(context).startEmailChange(); }, ), ); @@ -196,11 +201,7 @@ class _SettingsWidgetState extends State with TraceableClientMix SimpleSettingsTile( title: str.changePassword, subtitle: str.changePasswordDetail, - onTap: () async => await AppConfig.router.navigateTo( - context, - Routes.changePassword, - transition: TransitionType.inFromRight, - ), + onTap: changePassword, ), ); }