diff --git a/catalyst_voices/apps/voices/lib/pages/registration/recover/seed_phrase/account_details_panel.dart b/catalyst_voices/apps/voices/lib/pages/registration/recover/seed_phrase/account_details_panel.dart index f8b2ed8495..f9f3ac815d 100644 --- a/catalyst_voices/apps/voices/lib/pages/registration/recover/seed_phrase/account_details_panel.dart +++ b/catalyst_voices/apps/voices/lib/pages/registration/recover/seed_phrase/account_details_panel.dart @@ -1,5 +1,3 @@ -import 'dart:async'; - import 'package:catalyst_voices/pages/registration/recover/bloc_recover_builder.dart'; import 'package:catalyst_voices/pages/registration/widgets/wallet_connection_status.dart'; import 'package:catalyst_voices/pages/registration/widgets/wallet_summary.dart'; @@ -33,11 +31,9 @@ class AccountDetailsPanel extends StatelessWidget { style: theme.textTheme.titleMedium?.copyWith(color: textColor), ), const SizedBox(height: 24), - Expanded( + const Expanded( child: SingleChildScrollView( - child: _BlocAccountSummery( - onRetry: () => unawaited(_retryAccountRestore(context)), - ), + child: _BlocAccountSummery(), ), ), const SizedBox(height: 24), @@ -45,19 +41,10 @@ class AccountDetailsPanel extends StatelessWidget { ], ); } - - Future _retryAccountRestore(BuildContext context) async { - final recover = RegistrationCubit.of(context).recover; - await recover.recoverAccount(); - } } class _BlocAccountSummery extends StatelessWidget { - final VoidCallback? onRetry; - - const _BlocAccountSummery({ - this.onRetry, - }); + const _BlocAccountSummery(); @override Widget build(BuildContext context) { @@ -71,10 +58,7 @@ class _BlocAccountSummery extends StatelessWidget { walletSummary: value.walletSummary, ), Failure(:final value) => - _RecoverAccountFailure( - exception: value, - onRetry: onRetry, - ), + _RecoverAccountFailure(exception: value), _ => const Center(child: VoicesCircularProgressIndicator()), }; }, @@ -117,18 +101,19 @@ class _RecoveredAccountSummary extends StatelessWidget { class _RecoverAccountFailure extends StatelessWidget { final LocalizedException exception; - final VoidCallback? onRetry; const _RecoverAccountFailure({ required this.exception, - this.onRetry, }); @override Widget build(BuildContext context) { return VoicesErrorIndicator( message: exception.message(context), - onRetry: onRetry, + onRetry: () async { + final recover = RegistrationCubit.of(context).recover; + await recover.recoverAccount(); + }, ); } } @@ -185,8 +170,12 @@ class _Navigation extends StatelessWidget { ), const SizedBox(height: 10), VoicesTextButton( - onTap: () => RegistrationCubit.of(context).previousStep(), - child: Text(context.l10n.back), + onTap: () async { + final cubit = RegistrationCubit.of(context); + await cubit.recover.reset(); + cubit.previousStep(); + }, + child: Text(context.l10n.recoverDifferentKeychain), ), ], ); diff --git a/catalyst_voices/apps/voices/lib/pages/registration/recover/seed_phrase/unlock_password_panel.dart b/catalyst_voices/apps/voices/lib/pages/registration/recover/seed_phrase/unlock_password_panel.dart index 4a63389376..6e81bc7c36 100644 --- a/catalyst_voices/apps/voices/lib/pages/registration/recover/seed_phrase/unlock_password_panel.dart +++ b/catalyst_voices/apps/voices/lib/pages/registration/recover/seed_phrase/unlock_password_panel.dart @@ -56,6 +56,7 @@ class _UnlockPasswordPanelState extends State { ), const SizedBox(height: 22), _BlocNavigation( + onNextTap: _createKeychain, onBackTap: _clearPasswordAndGoBack, ), ], @@ -74,6 +75,16 @@ class _UnlockPasswordPanelState extends State { RegistrationCubit.of(context).recover.setConfirmPassword(confirmPassword); } + Future _createKeychain() async { + final cubit = RegistrationCubit.of(context); + + final success = await cubit.recover.createKeychain(); + + if (success) { + cubit.nextStep(); + } + } + void _clearPasswordAndGoBack() { final registration = RegistrationCubit.of(context); @@ -124,9 +135,11 @@ class _BlocUnlockPasswordForm extends StatelessWidget { } class _BlocNavigation extends StatelessWidget { + final VoidCallback onNextTap; final VoidCallback onBackTap; const _BlocNavigation({ + required this.onNextTap, required this.onBackTap, }); @@ -138,6 +151,7 @@ class _BlocNavigation extends StatelessWidget { builder: (context, state) { return RegistrationBackNextNavigation( isNextEnabled: state, + onNextTap: onNextTap, onBackTap: onBackTap, ); }, diff --git a/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/registration/cubits/recover_cubit.dart b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/registration/cubits/recover_cubit.dart index 5105628edd..df6bbbc3c6 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/registration/cubits/recover_cubit.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/registration/cubits/recover_cubit.dart @@ -17,7 +17,11 @@ abstract interface class RecoverManager implements UnlockPasswordManager { void setSeedPhraseWords(List words); - Future recoverAccount(); + Future recoverAccount(); + + Future createKeychain(); + + Future reset(); } final class RecoverCubit extends Cubit @@ -27,6 +31,7 @@ final class RecoverCubit extends Cubit final RegistrationService _registrationService; SeedPhrase? _seedPhrase; + Account? _recoveredAccount; RecoverCubit({ required UserService userService, @@ -66,24 +71,23 @@ final class RecoverCubit extends Cubit } @override - Future recoverAccount() async { + Future recoverAccount() async { try { emit(state.copyWith(accountDetails: const Optional.empty())); final seedPhrase = _seedPhrase; - final lockFactor = PasswordLockFactor(password.value); - if (seedPhrase == null) { const exception = LocalizedRegistrationSeedPhraseNotFoundException(); emit(state.copyWith(accountDetails: Optional(Failure(exception)))); - return; + return false; } final account = await _registrationService.recoverAccount( seedPhrase: seedPhrase, - lockFactor: lockFactor, ); + _recoveredAccount = account; + await _userService.useAccount(account); final walletInfo = account.walletInfo; @@ -102,23 +106,69 @@ final class RecoverCubit extends Cubit ); emit(state.copyWith(accountDetails: Optional(Success(accountDetails)))); + + return true; } on RegistrationException catch (error, stack) { _logger.severe('recover account', error, stack); + _recoveredAccount = null; + final exception = LocalizedRegistrationException.from(error); emit(state.copyWith(accountDetails: Optional(Failure(exception)))); + + return false; } catch (error, stack) { _logger.severe('recover account', error, stack); + _recoveredAccount = null; + const exception = LocalizedUnknownException(); emit(state.copyWith(accountDetails: Optional(Failure(exception)))); + + return false; + } + } + + @override + Future createKeychain() async { + final account = _recoveredAccount; + final seedPhrase = _seedPhrase; + final password = this.password; + + if (account == null || seedPhrase == null || password.isNotValid) { + emitError(const LocalizedRegistrationUnknownException()); + return false; } + + final lockFactor = PasswordLockFactor(password.value); + + await _registrationService.createKeychainFor( + account: account, + seedPhrase: seedPhrase, + lockFactor: lockFactor, + ); + + await _userService.useAccount(account); + + return true; } @override void onUnlockPasswordStateChanged(UnlockPasswordState data) { emit(state.copyWith(unlockPasswordState: data)); } + + @override + Future reset() async { + final recoveredAccount = _recoveredAccount; + if (recoveredAccount != null) { + await _userService.removeKeychain(recoveredAccount.keychainId); + } + + _recoveredAccount = null; + + setSeedPhraseWords([]); + } } const _testWords = [ diff --git a/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/session/session_cubit.dart b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/session/session_cubit.dart index e47537d544..86adb2dbd5 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/session/session_cubit.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/session/session_cubit.dart @@ -61,7 +61,7 @@ final class SessionCubit extends Cubit } Future removeKeychain() { - return _userService.removeCurrentAccount(); + return _userService.removeCurrentKeychain(); } Future switchToDummyAccount() async { diff --git a/catalyst_voices/packages/internal/catalyst_voices_blocs/test/session/session_cubit_test.dart b/catalyst_voices/packages/internal/catalyst_voices_blocs/test/session/session_cubit_test.dart index 0a058f7c00..ae3569baa4 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_blocs/test/session/session_cubit_test.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_blocs/test/session/session_cubit_test.dart @@ -43,7 +43,7 @@ void main() { // Given // When - await userService.removeCurrentAccount(); + await userService.removeCurrentKeychain(); // Then expect(userService.keychain, isNull); @@ -54,7 +54,7 @@ void main() { // Given // When - await userService.removeCurrentAccount(); + await userService.removeCurrentKeychain(); // Gives time for stream to emit. await Future.delayed(const Duration(milliseconds: 100)); @@ -80,7 +80,7 @@ void main() { // When notifier.value = RegistrationProgress(keychainProgress: keychainProgress); - await userService.removeCurrentAccount(); + await userService.removeCurrentKeychain(); // Gives time for stream to emit. await Future.delayed(const Duration(milliseconds: 100)); diff --git a/catalyst_voices/packages/internal/catalyst_voices_localization/lib/generated/catalyst_voices_localizations.dart b/catalyst_voices/packages/internal/catalyst_voices_localization/lib/generated/catalyst_voices_localizations.dart index 356a846fc5..e25a788ede 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_localization/lib/generated/catalyst_voices_localizations.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_localization/lib/generated/catalyst_voices_localizations.dart @@ -1738,6 +1738,12 @@ abstract class VoicesLocalizations { /// **'With over 300 trillion possible combinations, your 12 word seed phrase is great for keeping your account safe. 

But it can be a bit tedious to enter every single time you want to use the app. 

In this next step, you\'ll set your Unlock Password for your current device. It\'s like a shortcut for proving ownership of your Keychain. 

Whenever you recover your account for the first time on a new device, you\'ll need to use your Catalyst Keychain to get started. Every time after that, you can use your Unlock Password to quickly regain access.'** String get recoveryUnlockPasswordInstructionsSubtitle; + /// No description provided for @recoverDifferentKeychain. + /// + /// In en, this message translates to: + /// **'Restore a different keychain'** + String get recoverDifferentKeychain; + /// The header label in unlock dialog. /// /// In en, this message translates to: diff --git a/catalyst_voices/packages/internal/catalyst_voices_localization/lib/generated/catalyst_voices_localizations_en.dart b/catalyst_voices/packages/internal/catalyst_voices_localization/lib/generated/catalyst_voices_localizations_en.dart index 4d0879c593..5cd6739f26 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_localization/lib/generated/catalyst_voices_localizations_en.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_localization/lib/generated/catalyst_voices_localizations_en.dart @@ -914,6 +914,9 @@ class VoicesLocalizationsEn extends VoicesLocalizations { @override String get recoveryUnlockPasswordInstructionsSubtitle => 'With over 300 trillion possible combinations, your 12 word seed phrase is great for keeping your account safe. 

But it can be a bit tedious to enter every single time you want to use the app. 

In this next step, you\'ll set your Unlock Password for your current device. It\'s like a shortcut for proving ownership of your Keychain. 

Whenever you recover your account for the first time on a new device, you\'ll need to use your Catalyst Keychain to get started. Every time after that, you can use your Unlock Password to quickly regain access.'; + @override + String get recoverDifferentKeychain => 'Restore a different keychain'; + @override String get unlockDialogHeader => 'Unlock Catalyst'; diff --git a/catalyst_voices/packages/internal/catalyst_voices_localization/lib/generated/catalyst_voices_localizations_es.dart b/catalyst_voices/packages/internal/catalyst_voices_localization/lib/generated/catalyst_voices_localizations_es.dart index c63315deed..4384ef7847 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_localization/lib/generated/catalyst_voices_localizations_es.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_localization/lib/generated/catalyst_voices_localizations_es.dart @@ -914,6 +914,9 @@ class VoicesLocalizationsEs extends VoicesLocalizations { @override String get recoveryUnlockPasswordInstructionsSubtitle => 'With over 300 trillion possible combinations, your 12 word seed phrase is great for keeping your account safe. 

But it can be a bit tedious to enter every single time you want to use the app. 

In this next step, you\'ll set your Unlock Password for your current device. It\'s like a shortcut for proving ownership of your Keychain. 

Whenever you recover your account for the first time on a new device, you\'ll need to use your Catalyst Keychain to get started. Every time after that, you can use your Unlock Password to quickly regain access.'; + @override + String get recoverDifferentKeychain => 'Restore a different keychain'; + @override String get unlockDialogHeader => 'Unlock Catalyst'; diff --git a/catalyst_voices/packages/internal/catalyst_voices_localization/lib/l10n/intl_en.arb b/catalyst_voices/packages/internal/catalyst_voices_localization/lib/l10n/intl_en.arb index 9358da69a7..cee75b5d56 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_localization/lib/l10n/intl_en.arb +++ b/catalyst_voices/packages/internal/catalyst_voices_localization/lib/l10n/intl_en.arb @@ -900,6 +900,7 @@ "recoveryAccountDetailsAction": "Set unlock password for this device", "recoveryUnlockPasswordInstructionsTitle": "Set your Catalyst unlock password f\u2028or this device", "recoveryUnlockPasswordInstructionsSubtitle": "With over 300 trillion possible combinations, your 12 word seed phrase is great for keeping your account safe. \u2028\u2028But it can be a bit tedious to enter every single time you want to use the app. \u2028\u2028In this next step, you'll set your Unlock Password for your current device. It's like a shortcut for proving ownership of your Keychain. \u2028\u2028Whenever you recover your account for the first time on a new device, you'll need to use your Catalyst Keychain to get started. Every time after that, you can use your Unlock Password to quickly regain access.", + "recoverDifferentKeychain": "Restore a different keychain", "unlockDialogHeader": "Unlock Catalyst", "@unlockDialogHeader": { "description": "The header label in unlock dialog." diff --git a/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/registration/registration_service.dart b/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/registration/registration_service.dart index 731d25099b..e7f162a345 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/registration/registration_service.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/registration/registration_service.dart @@ -48,8 +48,15 @@ abstract interface class RegistrationService { required SeedPhrase seedPhrase, }); + /// Loads account related to this [seedPhrase]. Throws exception if non found. Future recoverAccount({ required SeedPhrase seedPhrase, + }); + + /// Creates [Keychain] for given [account] with [lockFactor]. + Future createKeychainFor({ + required Account account, + required SeedPhrase seedPhrase, required LockFactor lockFactor, }); @@ -133,7 +140,6 @@ final class RegistrationServiceImpl implements RegistrationService { @override Future recoverAccount({ required SeedPhrase seedPhrase, - required LockFactor lockFactor, }) async { await Future.delayed(const Duration(milliseconds: 200)); @@ -142,16 +148,9 @@ final class RegistrationServiceImpl implements RegistrationService { throw const RegistrationUnknownException(); } - final masterKey = await deriveMasterKey(seedPhrase: seedPhrase); - - // TODO(dtscalac): Update key value when derivation is final. + // TODO(dtscalac): support more roles when backend is ready final roles = {AccountRole.root}; - final keychainId = const Uuid().v4(); - final keychain = await _keychainProvider.create(keychainId); - await keychain.setLock(lockFactor); - await keychain.unlock(lockFactor); - await keychain.setMasterKey(masterKey); // Note. with rootKey query backend for account details. return Account( @@ -165,6 +164,23 @@ final class RegistrationServiceImpl implements RegistrationService { ); } + @override + Future createKeychainFor({ + required Account account, + required SeedPhrase seedPhrase, + required LockFactor lockFactor, + }) async { + final keychainId = account.keychainId; + final masterKey = await deriveMasterKey(seedPhrase: seedPhrase); + + final keychain = await _keychainProvider.create(keychainId); + await keychain.setLock(lockFactor); + await keychain.unlock(lockFactor); + await keychain.setMasterKey(masterKey); + + return keychain; + } + @override Future prepareRegistration({ required CardanoWallet wallet, @@ -186,7 +202,7 @@ final class RegistrationServiceImpl implements RegistrationService { final keyPair = await _keyDerivation.deriveAccountRoleKeyPair( masterKey: masterKey, - // TODO(dtscalac): Only one roles is supported atm. + // TODO(dtscalac): support more roles when backend is ready role: AccountRole.root, ); diff --git a/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/user/user_service.dart b/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/user/user_service.dart index 53030b69fc..5ad6755c89 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/user/user_service.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/user/user_service.dart @@ -32,7 +32,9 @@ abstract interface class UserService { Future useKeychain(String id); - Future removeCurrentAccount(); + Future removeCurrentKeychain(); + + Future removeKeychain(String id); Future dispose(); } @@ -110,16 +112,34 @@ final class UserServiceImpl implements UserService { } @override - Future removeCurrentAccount() async { + Future removeCurrentKeychain() async { final keychain = _keychain; if (keychain == null) { - _logger.warning('Called remove account but no active keychain found'); + _logger.warning('Called remove keychain but no active found'); + return; + } + + await removeKeychain(keychain.id); + } + + @override + Future removeKeychain(String id) async { + if (!await _keychainProvider.exists(id)) { + _logger.warning( + 'Called remove keychain[$id] but no such keychain was found', + ); return; } + final isCurrentKeychain = id == _keychain?.id; + + final keychain = await _keychainProvider.get(id); await keychain.clear(); - await _clearUser(); - await _useKeychain(null); + + if (isCurrentKeychain) { + await _clearUser(); + await _useKeychain(null); + } } @override diff --git a/catalyst_voices/packages/internal/catalyst_voices_services/test/src/user/user_service_test.dart b/catalyst_voices/packages/internal/catalyst_voices_services/test/src/user/user_service_test.dart index eac0f3d7d3..045abedef1 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_services/test/src/user/user_service_test.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_services/test/src/user/user_service_test.dart @@ -56,7 +56,7 @@ void main() { await service.useKeychain(keychainOne.id); await service.useKeychain(keychainTwo.id); - await service.removeCurrentAccount(); + await service.removeCurrentKeychain(); await service.dispose(); }); @@ -120,7 +120,7 @@ void main() { // Then expect(service.keychain, isNotNull); - await service.removeCurrentAccount(); + await service.removeCurrentKeychain(); expect(service.keychain, isNull); expect(await currentKeychain.isEmpty, isTrue);