diff --git a/assets/images/hardware_wallet/keystone_scan_title.png b/assets/images/hardware_wallet/keystone_scan_title.png new file mode 100644 index 0000000000..b95ef13c1c Binary files /dev/null and b/assets/images/hardware_wallet/keystone_scan_title.png differ diff --git a/cw_core/lib/wallet_base.dart b/cw_core/lib/wallet_base.dart index 7d0fc4f042..b34f27e332 100644 --- a/cw_core/lib/wallet_base.dart +++ b/cw_core/lib/wallet_base.dart @@ -62,6 +62,8 @@ abstract class WalletBase walletInfo.isHardwareWallet; + bool get isLedger => walletInfo.isLedger; + bool get hasRescan => false; Future connectToNode({required Node node}); diff --git a/cw_core/lib/wallet_info.dart b/cw_core/lib/wallet_info.dart index d62e61941b..28e4b479b4 100644 --- a/cw_core/lib/wallet_info.dart +++ b/cw_core/lib/wallet_info.dart @@ -25,6 +25,8 @@ enum DerivationType { enum HardwareWalletType { @HiveField(0) ledger, + @HiveField(1) + keystone, } @HiveType(typeId: DerivationInfo.typeId) @@ -226,6 +228,12 @@ class WalletInfo extends HiveObject { bool get isHardwareWallet => hardwareWalletType != null; + bool get isConnectableHardwareWallet => isLedger; + + bool get isLedger => hardwareWalletType == HardwareWalletType.ledger; + + bool get isKeystone => hardwareWalletType == HardwareWalletType.keystone; + DateTime get date => DateTime.fromMillisecondsSinceEpoch(timestamp); Stream get yatLastUsedAddressStream => _yatLastUsedAddressController.stream; diff --git a/cw_monero/lib/monero_wallet_service.dart b/cw_monero/lib/monero_wallet_service.dart index 6fc827068f..ac4fbf72d6 100644 --- a/cw_monero/lib/monero_wallet_service.dart +++ b/cw_monero/lib/monero_wallet_service.dart @@ -79,8 +79,9 @@ class MoneroRestoreWalletFromKeysCredentials extends WalletCredentials { required this.address, required this.viewKey, required this.spendKey, - int height = 0}) - : super(name: name, password: password, height: height); + int height = 0, + HardwareWalletType? hardwareWalletType}) + : super(name: name, password: password, height: height, hardwareWalletType: hardwareWalletType); final String language; final String address; @@ -189,7 +190,7 @@ class MoneroWalletService extends WalletService< unspentCoinsInfo: unspentCoinsInfoSource, password: password); - if (wallet.isHardwareWallet) { + if (wallet.isLedger) { wallet.setLedgerConnection(gLedger!); gLedger = null; } @@ -548,7 +549,7 @@ class MoneroWalletService extends WalletService< return walletInfoSource.values .firstWhereOrNull( (info) => info.id == WalletBase.idFor(name, getType())) - ?.isHardwareWallet ?? + ?.isConnectableHardwareWallet ?? false; } } diff --git a/lib/buy/dfx/dfx_buy_provider.dart b/lib/buy/dfx/dfx_buy_provider.dart index 3ab5d4273a..dbb640a560 100644 --- a/lib/buy/dfx/dfx_buy_provider.dart +++ b/lib/buy/dfx/dfx_buy_provider.dart @@ -335,7 +335,7 @@ class DFXBuyProvider extends BuyProvider { required bool isBuyAction, required String cryptoCurrencyAddress, String? countryCode}) async { - if (wallet.isHardwareWallet) { + if (wallet.isLedger) { if (!ledgerVM!.isConnected) { await Navigator.of(context).pushNamed(Routes.connectDevices, arguments: ConnectDevicePageParams( diff --git a/lib/buy/robinhood/robinhood_buy_provider.dart b/lib/buy/robinhood/robinhood_buy_provider.dart index 4b9c1aa708..984fb78da3 100644 --- a/lib/buy/robinhood/robinhood_buy_provider.dart +++ b/lib/buy/robinhood/robinhood_buy_provider.dart @@ -157,7 +157,7 @@ class RobinhoodBuyProvider extends BuyProvider { required bool isBuyAction, required String cryptoCurrencyAddress, String? countryCode}) async { - if (wallet.isHardwareWallet) { + if (wallet.isLedger) { if (!ledgerVM!.isConnected) { await Navigator.of(context).pushNamed(Routes.connectDevices, arguments: ConnectDevicePageParams( diff --git a/lib/di.dart b/lib/di.dart index e00bc5c652..ef8f7683b0 100644 --- a/lib/di.dart +++ b/lib/di.dart @@ -66,6 +66,7 @@ import 'package:cake_wallet/src/screens/buy/payment_method_options_page.dart'; import 'package:cake_wallet/src/screens/exchange_trade/exchange_trade_external_send_page.dart'; import 'package:cake_wallet/src/screens/payjoin_details/payjoin_details_page.dart'; import 'package:cake_wallet/src/screens/receive/address_list_page.dart'; +import 'package:cake_wallet/src/screens/restore/restore_from_keystone_private_mode_page.dart'; import 'package:cake_wallet/src/screens/seed/seed_verification/seed_verification_page.dart'; import 'package:cake_wallet/src/screens/send/transaction_success_info_page.dart'; import 'package:cake_wallet/src/screens/wallet_list/wallet_list_page.dart'; @@ -816,7 +817,7 @@ Future setup({ getIt.get(), getIt.get(), _transactionDescriptionBox, - getIt.get().wallet!.isHardwareWallet ? getIt.get() : null, + getIt.get().wallet!.isLedger ? getIt.get() : null, coinTypeToSpendFrom: coinTypeToSpendFrom ?? UnspentCoinType.nonMweb, getIt.get(param1: coinTypeToSpendFrom), getIt.get(), @@ -1079,12 +1080,12 @@ Future setup({ getIt.registerFactory(() => RobinhoodBuyProvider( wallet: getIt.get().wallet!, ledgerVM: - getIt.get().wallet!.isHardwareWallet ? getIt.get() : null)); + getIt.get().wallet!.isLedger ? getIt.get() : null)); getIt.registerFactory(() => DFXBuyProvider( wallet: getIt.get().wallet!, ledgerVM: - getIt.get().wallet!.isHardwareWallet ? getIt.get() : null)); + getIt.get().wallet!.isLedger ? getIt.get() : null)); getIt.registerFactory(() => MoonPayProvider( appStore: getIt.get(), @@ -1334,6 +1335,9 @@ Future setup({ getIt.registerFactory(() => RestoreFromBackupPage(getIt.get())); + getIt.registerFactoryParam( + (String code, _) => RestoreFromKeystonePrivateModePage(code)); + getIt.registerFactoryParam( (Trade trade, _) => TradeDetailsPage(getIt.get(param1: trade))); diff --git a/lib/monero/cw_monero.dart b/lib/monero/cw_monero.dart index 8de8f90781..1dc1a2fafc 100644 --- a/lib/monero/cw_monero.dart +++ b/lib/monero/cw_monero.dart @@ -215,7 +215,8 @@ class CWMonero extends Monero { required String address, required String password, required String language, - required int height}) => + required int height, + HardwareWalletType? hardwareWalletType}) => MoneroRestoreWalletFromKeysCredentials( name: name, spendKey: spendKey, @@ -223,7 +224,8 @@ class CWMonero extends Monero { address: address, password: password, language: language, - height: height); + height: height, + hardwareWalletType: hardwareWalletType); @override WalletCredentials createMoneroRestoreWalletFromHardwareCredentials({ diff --git a/lib/router.dart b/lib/router.dart index ad9c1fa9b9..7d28310995 100644 --- a/lib/router.dart +++ b/lib/router.dart @@ -73,6 +73,7 @@ import 'package:cake_wallet/src/screens/receive/fullscreen_qr_page.dart'; import 'package:cake_wallet/src/screens/receive/receive_page.dart'; import 'package:cake_wallet/src/screens/rescan/rescan_page.dart'; import 'package:cake_wallet/src/screens/restore/restore_from_backup_page.dart'; +import 'package:cake_wallet/src/screens/restore/restore_from_keystone_private_mode_page.dart'; import 'package:cake_wallet/src/screens/restore/restore_options_page.dart'; import 'package:cake_wallet/src/screens/restore/sweeping_wallet_page.dart'; import 'package:cake_wallet/src/screens/restore/wallet_restore_choose_derivation.dart'; @@ -673,6 +674,11 @@ Route createRoute(RouteSettings settings) { return CupertinoPageRoute( fullscreenDialog: true, builder: (_) => getIt.get()); + case Routes.restoreFromKeystonePrivateMode: + final args = settings.arguments as String; + return CupertinoPageRoute( + fullscreenDialog: true, builder: (_) => getIt.get(param1: args)); + case Routes.support: return handleRouteWithPlatformAwareness( (context) => getIt.get(), diff --git a/lib/routes.dart b/lib/routes.dart index 4894956640..a9c3cc6149 100644 --- a/lib/routes.dart +++ b/lib/routes.dart @@ -59,6 +59,7 @@ class Routes { static const backup = '/backup'; static const editBackupPassword = '/edit_backup_passowrd'; static const restoreFromBackup = '/restore_from_backup'; + static const restoreFromKeystonePrivateMode = '/restore_from_keystone_private_mode'; static const support = '/support'; static const supportLiveChat = '/support/live_chat'; static const supportOtherLinks = '/support/other'; diff --git a/lib/src/screens/pin_code/pin_code_widget.dart b/lib/src/screens/pin_code/pin_code_widget.dart index d89fe3d99d..4739ba6cf8 100644 --- a/lib/src/screens/pin_code/pin_code_widget.dart +++ b/lib/src/screens/pin_code/pin_code_widget.dart @@ -13,11 +13,13 @@ class PinCodeWidget extends StatefulWidget { required this.onChangedPin, required this.hasLengthSwitcher, this.onChangedPinLength, + this.title, }) : super(key: key); final void Function(String pin, PinCodeState state) onFullPin; final void Function(String pin) onChangedPin; final void Function(int length)? onChangedPinLength; + final String? title; final bool hasLengthSwitcher; final int initialPinLength; @@ -51,7 +53,7 @@ class PinCodeState extends State { super.initState(); pinLength = widget.initialPinLength; pin = ''; - title = S.current.enter_your_pin; + title = widget.title ?? S.current.enter_your_pin; _aspectRatio = 0; _focusNode = FocusNode(); WidgetsBinding.instance.addPostFrameCallback((_) { diff --git a/lib/src/screens/restore/restore_from_keystone_private_mode_page.dart b/lib/src/screens/restore/restore_from_keystone_private_mode_page.dart new file mode 100644 index 0000000000..e154616495 --- /dev/null +++ b/lib/src/screens/restore/restore_from_keystone_private_mode_page.dart @@ -0,0 +1,83 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:cake_wallet/generated/i18n.dart'; +import 'package:cake_wallet/src/screens/base_page.dart'; +import 'package:cake_wallet/src/screens/pin_code/pin_code_widget.dart'; +import 'package:cake_wallet/utils/show_bar.dart'; +import 'package:eth_sig_util/util/keccak.dart'; +import 'package:flutter/material.dart'; +import 'package:hex/hex.dart' as Hex; +import 'package:cryptography/cryptography.dart'; + +class RestoreFromKeystonePrivateModePage extends BasePage { + @override + String get title => ''; + + RestoreFromKeystonePrivateModePage(this.code) + : pinCodeStateKey = GlobalKey(); + + final GlobalKey pinCodeStateKey; + final String code; + static const sixPinLength = 6; + + Future _deriveKeyFromPin(Uint8List pin) async { + final hash = keccak256(pin); + return SecretKey(hash.buffer.asUint8List()); + } + + Future _decryptData(Uint8List cipherText, SecretKey secretKey) async { + final algorithm = Chacha20(macAlgorithm: MacAlgorithm.empty); + final secretBox = SecretBox( + cipherText, + nonce: Uint8List(12), + mac: Mac.empty, + ); + final text = await algorithm.decrypt( + secretBox, + secretKey: secretKey, + ); + + return utf8.decode(text); + } + + @override + Widget body(BuildContext context) { + return PinCodeWidget( + key: pinCodeStateKey, + title: S.of(context).restore_from_private_mode_title, + onFullPin: (pin, state) async { + try { + Uint8List pinDigits = Uint8List.fromList( + pin.split('').map((char) => int.parse(char)).toList()); + final secretKey = await _deriveKeyFromPin(pinDigits); + + final restoreJson = json.decode(code); + + restoreJson['primaryAddress'] = await _decryptData( + Uint8List.fromList( + Hex.HEX.decode(restoreJson['primaryAddress'] as String)), + secretKey); + + restoreJson['privateViewKey'] = await _decryptData( + Uint8List.fromList( + Hex.HEX.decode(restoreJson['privateViewKey'] as String)), + secretKey); + + final res = json.encode(restoreJson); + Navigator.of(context).pop(res); + } catch (_) { + pinCodeStateKey.currentState?.reset(); + showBar( + context, + S + .of(context) + .error_text_failed_to_resotre_from_keystone_private_mode); + } + }, + initialPinLength: sixPinLength, + onChangedPin: (String pin) {}, + hasLengthSwitcher: false, + ); + } +} \ No newline at end of file diff --git a/lib/src/screens/restore/restore_options_page.dart b/lib/src/screens/restore/restore_options_page.dart index ea1bff6bb9..b0d708b256 100644 --- a/lib/src/screens/restore/restore_options_page.dart +++ b/lib/src/screens/restore/restore_options_page.dart @@ -140,6 +140,15 @@ class _RestoreOptionsBodyState extends State<_RestoreOptionsBody> { ), ), if (DeviceInfo.instance.isMobile) + Padding( + padding: EdgeInsets.only(top: 24), + child: OptionTile( + key: ValueKey('restore_options_from_keystone_button_key'), + onPressed: () => _onScanQRCode(context), + image: imageRestoreQR, + title: S.of(context).restore_title_from_keystone, + description: S.of(context).restore_description_from_keystone), + ), Padding( padding: EdgeInsets.only(top: 24), child: OptionTile( diff --git a/lib/src/screens/send/send_page.dart b/lib/src/screens/send/send_page.dart index 9bc18dc2ec..93be2a4287 100644 --- a/lib/src/screens/send/send_page.dart +++ b/lib/src/screens/send/send_page.dart @@ -435,7 +435,7 @@ class SendPage extends BasePage { return; } - if (sendViewModel.wallet.isHardwareWallet) { + if (sendViewModel.wallet.isLedger) { if (!sendViewModel.ledgerViewModel!.isConnected) { await Navigator.of(context).pushNamed(Routes.connectDevices, arguments: ConnectDevicePageParams( diff --git a/lib/src/screens/ur/animated_ur_page.dart b/lib/src/screens/ur/animated_ur_page.dart index 0ee5e021d0..8c869cc7bf 100644 --- a/lib/src/screens/ur/animated_ur_page.dart +++ b/lib/src/screens/ur/animated_ur_page.dart @@ -12,6 +12,7 @@ import 'package:cake_wallet/src/widgets/primary_button.dart'; import 'package:cake_wallet/utils/show_pop_up.dart'; import 'package:cake_wallet/view_model/animated_ur_model.dart'; import 'package:cw_core/wallet_type.dart'; +import 'package:cw_core/wallet_info.dart'; import 'package:flutter/material.dart'; class AnimatedURPage extends BasePage { @@ -51,11 +52,21 @@ class AnimatedURPage extends BasePage { return first.split('/')[0]; } + bool get isKeystone => + animatedURmodel.wallet.walletInfo.hardwareWalletType == + HardwareWalletType.keystone; + @override Widget body(BuildContext context) { return Column( mainAxisSize: MainAxisSize.min, children: [ + if (isKeystone) + Padding( + padding: const EdgeInsets.only(top: 32.0), + child: Image.asset('assets/images/hardware_wallet/keystone_scan_title.png', + width: 160, height: 36), + ), Padding( padding: const EdgeInsets.all(32.0), child: URQR( @@ -66,15 +77,19 @@ class AnimatedURPage extends BasePage { if (["ur:xmr-txunsigned", "ur:xmr-output", "ur:psbt", BBQR.header] .contains(urQrType)) ...{ Spacer(), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: SizedBox( - width: double.maxFinite, - child: PrimaryButton( - onPressed: () => _continue(context), - text: "Scan QR Code", - color: Theme.of(context).colorScheme.primary, - textColor: Theme.of(context).colorScheme.onPrimary, + SafeArea( + top: false, + minimum: const EdgeInsets.symmetric(horizontal: 32.0), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: SizedBox( + width: double.maxFinite, + child: PrimaryButton( + onPressed: () => _continue(context), + text: "Scan QR Code", + color: Theme.of(context).colorScheme.primary, + textColor: Theme.of(context).colorScheme.onPrimary, + ) ), ), ), diff --git a/lib/view_model/restore/restore_wallet.dart b/lib/view_model/restore/restore_wallet.dart index 153a9960ad..ee71f33d98 100644 --- a/lib/view_model/restore/restore_wallet.dart +++ b/lib/view_model/restore/restore_wallet.dart @@ -17,7 +17,8 @@ class RestoredWallet { this.txDescription, this.recipientName, this.height, - this.privateKey}); + this.privateKey, + this.source}); final WalletRestoreMode restoreMode; final WalletType type; @@ -32,6 +33,7 @@ class RestoredWallet { final String? recipientName; final int? height; final String? privateKey; + final String? source; factory RestoredWallet.fromKey(Map json) { try { @@ -40,6 +42,7 @@ class RestoredWallet { json['address'] = codeParsed["primaryAddress"]; json['view_key'] = codeParsed["privateViewKey"]; json['height'] = codeParsed["restoreHeight"].toString(); + json['source'] = codeParsed["source"] ?? ''; } } catch (e) { // fine, we don't care, it is only for monero anyway @@ -54,6 +57,7 @@ class RestoredWallet { viewKey: json['view_key'] as String?, height: height != null ? int.tryParse(height) ?? 0 : 0, privateKey: json['private_key'] as String?, + source: json['source'] as String?, ); } diff --git a/lib/view_model/restore/wallet_restore_from_qr_code.dart b/lib/view_model/restore/wallet_restore_from_qr_code.dart index 94b1072ab9..4e0434ec47 100644 --- a/lib/view_model/restore/wallet_restore_from_qr_code.dart +++ b/lib/view_model/restore/wallet_restore_from_qr_code.dart @@ -104,6 +104,14 @@ class WalletRestoreFromQRCode { return null; } + static bool _shouldDecrypt(String code) { + final codeParsed = json.decode(code); + if (codeParsed["source"] == "Keystone" && codeParsed["encrypted"] == true) { + return true; + } + return false; + } + static Future scanQRCodeForRestoring(BuildContext context) async { String? code = await presentQRScanner(context); if (code == null) throw Exception("Unexpected scan QR code value: aborted"); @@ -126,6 +134,15 @@ class WalletRestoreFromQRCode { ? '$walletType:?xpub=$code' : throw Exception('Failed to determine valid seed phrase'); } else { + if (walletType == WalletType.monero && _shouldDecrypt(code)) { + final possibleDecryptedCode = await Navigator.pushNamed(context, Routes.restoreFromKeystonePrivateMode, + arguments: code); + if (possibleDecryptedCode == null) { + throw Exception('Failed to decrypt the keystone'); + } + code = possibleDecryptedCode as String; + } + final index = code.indexOf(':'); final query = code.substring(index + 1).replaceAll('?', '&'); formattedUri = code.startsWith('xpub') diff --git a/lib/view_model/send/send_view_model.dart b/lib/view_model/send/send_view_model.dart index 2a956a5e23..b65fcfc307 100644 --- a/lib/view_model/send/send_view_model.dart +++ b/lib/view_model/send/send_view_model.dart @@ -491,7 +491,7 @@ abstract class SendViewModelBase extends WalletChangeListenerViewModel with Stor try { if (!(state is IsExecutingState)) state = IsExecutingState(); - if (wallet.isHardwareWallet) { + if (wallet.isLedger) { state = IsAwaitingDeviceResponseState(); if (walletType == WalletType.monero) _ledgerTxStateTimer = Timer.periodic(Duration(seconds: 1), (timer) { diff --git a/lib/view_model/wallet_restore_view_model.dart b/lib/view_model/wallet_restore_view_model.dart index 7e73cb83bd..bf900c211c 100644 --- a/lib/view_model/wallet_restore_view_model.dart +++ b/lib/view_model/wallet_restore_view_model.dart @@ -237,6 +237,7 @@ abstract class WalletRestoreViewModelBase extends WalletCreationVM with Store { address: address!, password: password, language: 'English', + hardwareWalletType: restoredWallet?.source == 'Keystone' ? HardwareWalletType.keystone : null, ); case WalletType.ethereum: diff --git a/res/values/strings_en.arb b/res/values/strings_en.arb index e7d1775917..2f24c268f8 100644 --- a/res/values/strings_en.arb +++ b/res/values/strings_en.arb @@ -351,6 +351,7 @@ "error_text_template": "Template name and address can't contain ` , ' \" symbols\nand must be between 1 and 106 characters long", "error_text_wallet_name": "Wallet name can only contain letters, numbers, _ - symbols \nand must be between 1 and 33 characters long", "error_text_xmr": "XMR value can't exceed available balance.\nThe number of fraction digits must be less or equal to 12", + "error_text_failed_to_resotre_from_keystone_private_mode": "Failed to restore from Keystone private mode", "error_while_processing": "An error occurred while proceessing", "errorGettingCredentials": "Failed: Error while getting credentials", "errorSigningTransaction": "An error has occured while signing transaction", @@ -702,6 +703,8 @@ "restore_description_from_backup": "You can restore the whole Cake Wallet app from your back-up file", "restore_description_from_cupcake": "Link with our companion air-gapped app, installed on a second phone.", "restore_description_from_hardware_wallet": "Restore from a Ledger hardware wallet", + "restore_from_private_mode_title": "Enter PIN to Unlock QR", + "restore_description_from_keystone": "Restore from a Keystone hardware wallet", "restore_description_from_keys": "Restore your wallet from generated keystrokes saved from your private keys", "restore_description_from_seed": "Restore your wallet from either the 25 word or 13 word combination code", "restore_description_from_seed_keys": "Get back your wallet from seed/keys that you've saved to secure place", @@ -717,6 +720,7 @@ "restore_title_from_backup": "Restore from backup", "restore_title_from_cupcake": "Cupcake App", "restore_title_from_hardware_wallet": "Restore from hardware wallet", + "restore_title_from_keystone": "Restore from Keystone", "restore_title_from_keys": "Restore from keys", "restore_title_from_seed": "Restore from seed", "restore_title_from_seed_keys": "Restore from seed/keys", diff --git a/tool/configure.dart b/tool/configure.dart index c41ccc4962..c106d9b6e2 100644 --- a/tool/configure.dart +++ b/tool/configure.dart @@ -423,7 +423,8 @@ abstract class Monero { required String address, required String password, required String language, - required int height}); + required int height, + HardwareWalletType? hardwareWalletType}); WalletCredentials createMoneroRestoreWalletFromSeedCredentials({required String name, required String password, required String passphrase, required int height, required String mnemonic}); WalletCredentials createMoneroRestoreWalletFromHardwareCredentials({required String name, required String password, required int height, required ledger.LedgerConnection ledgerConnection}); WalletCredentials createMoneroNewWalletCredentials({required String name, required String language, required int seedType, required String? passphrase, String? password, String? mnemonic});