diff --git a/ios/backupfile/BackupFileHelper.swift b/ios/backupfile/BackupFileHelper.swift new file mode 100644 index 0000000000..15cb8d65f8 --- /dev/null +++ b/ios/backupfile/BackupFileHelper.swift @@ -0,0 +1,23 @@ +import Foundation + +@objc class BackupFileHelper: NSObject { + + override init() { + super.init() + } + + @objc func setFileProtection(filePath: String, callback: @escaping ((Bool, String?) -> Void)) { + let fileManager = FileManager.default + + do { + // Set file protection attribute + try fileManager.setAttributes( + [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication], + ofItemAtPath: filePath + ) + callback(true, nil) + } catch { + callback(false, error.localizedDescription) + } + } +} diff --git a/ios/backupfile/backupfile.h b/ios/backupfile/backupfile.h new file mode 100644 index 0000000000..3ad5f0019f --- /dev/null +++ b/ios/backupfile/backupfile.h @@ -0,0 +1,6 @@ +#import +#import + +@class BackupFile; +@interface BackupFile:NSObject +@end diff --git a/ios/backupfile/backupfile.m b/ios/backupfile/backupfile.m new file mode 100644 index 0000000000..d2371828d9 --- /dev/null +++ b/ios/backupfile/backupfile.m @@ -0,0 +1,24 @@ +#import "backupfile.h" + +#import +#import "hexa_keeper-Swift.h" +#import + +@implementation BackupFile + +RCT_EXPORT_MODULE(); + +RCT_EXPORT_METHOD(setFileProtection:(NSString *)filePath + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject){ + BackupFileHelper *helper = [[BackupFileHelper alloc]init]; + [helper setFileProtectionWithFilePath:filePath callback:^(BOOL success, NSString * _Nullable error) { + if (success) { + resolve(@YES); + } else { + reject(@"FILE_PROTECTION_ERROR", error ?: @"Failed to set file protection", nil); + } + }]; +} + +@end diff --git a/src/components/Backup/BackupHealthCheckList.tsx b/src/components/Backup/BackupHealthCheckList.tsx index ed3fbf95c4..f43c2bf04b 100644 --- a/src/components/Backup/BackupHealthCheckList.tsx +++ b/src/components/Backup/BackupHealthCheckList.tsx @@ -28,6 +28,7 @@ import Text from '../KeeperText'; import { hp } from 'src/constants/responsive'; import ThemedSvg from '../ThemedSvg.tsx/ThemedSvg'; import ConfirmSeedWord from '../SeedWordBackup/ConfirmSeedWord'; +import { setRecoveryKeyBackedUp } from 'src/store/reducers/account'; const ContentType = { verifying: 'verifying', @@ -67,7 +68,7 @@ function BackupHealthCheckList({ isUaiFlow }) { const { translations } = useContext(LocalizationContext); const { BackupWallet, vault: vaultText, common } = translations; const dispatch = useAppDispatch(); - const { primaryMnemonic, backup } = useQuery(RealmSchema.KeeperApp).map( + const { primaryMnemonic, backup, id } = useQuery(RealmSchema.KeeperApp).map( getJSONFromRealmObject )[0]; const { @@ -95,6 +96,7 @@ function BackupHealthCheckList({ isUaiFlow }) { useEffect(() => { if (seedConfirmed) { setShowConfirmSeedModal(false); + dispatch(setRecoveryKeyBackedUp({ appId: id as string, status: true })); setTimeout(() => { setHealthCheckModal(true); }, 100); diff --git a/src/context/Localization/language/en.json b/src/context/Localization/language/en.json index 826353adb7..9fb3fb569e 100644 --- a/src/context/Localization/language/en.json +++ b/src/context/Localization/language/en.json @@ -198,6 +198,7 @@ "Createpasscode": "Create a passcode", "Confirmyourpasscode": "Confirm your passcode", "CreateApp": "Create a fresh app or recover an exisiting one", + "CreateAppOnly": "Create a fresh app", "passcode": "passcode", "Incorrect": "Incorrect Passcode, Try Again!", "newKeeperAppDesc": "Create a new Keeper app", @@ -222,7 +223,11 @@ "checkConnection": "Please check your internet connection and try again. If you continue offline, some features may not be available.", "incorrectPassword": "Incorrect Password", "youEnteredIncorrectPasscode": "You have entered an incorrect passcode. Please, try again. If you don’t remember your passcode, you will have to recover your wallet through the recovery flow", - "checkinternetConnection": "Please check your internet connection and try again." + "checkinternetConnection": "Please check your internet connection and try again.", + "forgotPasscode": "Forgot passcode?", + "resetPasscodeTitle": "Reset Passcode", + "resetPasscodeDec1": "For your security, passcode resets are verified with your recovery key.", + "resetPasscodeDec2": "Please have your recovery key ready before continuing." }, "home": { "wallet": "Wallet", @@ -243,7 +248,14 @@ "incommingAndOutgoing": "All incoming and outgoing transactions", "viewAll": " View All", "securityTip": "Security Tip", - "securityTipDesc": "Recreate the multisig on more coordinators. Receive a small amount and send a part of it. Check the balances are appropriately reflected across all the coordinators after each step." + "securityTipDesc": "Recreate the multisig on more coordinators. Receive a small amount and send a part of it. Check the balances are appropriately reflected across all the coordinators after each step.", + "backupModalTitle": "Set Up Wallet Recovery", + "backupModalSubTitle": "To ensure you can always recover your wallet, please secure your Recovery Key and enable Assisted Server Backup.", + "backupModalDesc": "This ensure that your encrypted data is backed up and can be decrypted by your Recovery key", + "backupFileFailTitle": "Setup Adjustment Required", + "backupFileFailSubTitle": "We’ll guide you through it", + "backupFileFailDesc1": "It looks like some app information couldn't be loaded, which can happen after an update or when certain files aren’t available.", + "backupFileFailDesc2": "Please set a new passcode and restore using your recovery key to continue." }, "transactions": { "Fees": "Fees", diff --git a/src/context/Localization/language/es.json b/src/context/Localization/language/es.json index 826353adb7..9fb3fb569e 100644 --- a/src/context/Localization/language/es.json +++ b/src/context/Localization/language/es.json @@ -198,6 +198,7 @@ "Createpasscode": "Create a passcode", "Confirmyourpasscode": "Confirm your passcode", "CreateApp": "Create a fresh app or recover an exisiting one", + "CreateAppOnly": "Create a fresh app", "passcode": "passcode", "Incorrect": "Incorrect Passcode, Try Again!", "newKeeperAppDesc": "Create a new Keeper app", @@ -222,7 +223,11 @@ "checkConnection": "Please check your internet connection and try again. If you continue offline, some features may not be available.", "incorrectPassword": "Incorrect Password", "youEnteredIncorrectPasscode": "You have entered an incorrect passcode. Please, try again. If you don’t remember your passcode, you will have to recover your wallet through the recovery flow", - "checkinternetConnection": "Please check your internet connection and try again." + "checkinternetConnection": "Please check your internet connection and try again.", + "forgotPasscode": "Forgot passcode?", + "resetPasscodeTitle": "Reset Passcode", + "resetPasscodeDec1": "For your security, passcode resets are verified with your recovery key.", + "resetPasscodeDec2": "Please have your recovery key ready before continuing." }, "home": { "wallet": "Wallet", @@ -243,7 +248,14 @@ "incommingAndOutgoing": "All incoming and outgoing transactions", "viewAll": " View All", "securityTip": "Security Tip", - "securityTipDesc": "Recreate the multisig on more coordinators. Receive a small amount and send a part of it. Check the balances are appropriately reflected across all the coordinators after each step." + "securityTipDesc": "Recreate the multisig on more coordinators. Receive a small amount and send a part of it. Check the balances are appropriately reflected across all the coordinators after each step.", + "backupModalTitle": "Set Up Wallet Recovery", + "backupModalSubTitle": "To ensure you can always recover your wallet, please secure your Recovery Key and enable Assisted Server Backup.", + "backupModalDesc": "This ensure that your encrypted data is backed up and can be decrypted by your Recovery key", + "backupFileFailTitle": "Setup Adjustment Required", + "backupFileFailSubTitle": "We’ll guide you through it", + "backupFileFailDesc1": "It looks like some app information couldn't be loaded, which can happen after an update or when certain files aren’t available.", + "backupFileFailDesc2": "Please set a new passcode and restore using your recovery key to continue." }, "transactions": { "Fees": "Fees", diff --git a/src/nativemodules/BackupFile.ts b/src/nativemodules/BackupFile.ts new file mode 100644 index 0000000000..3a85b3cac1 --- /dev/null +++ b/src/nativemodules/BackupFile.ts @@ -0,0 +1,13 @@ +import { NativeModules, Platform } from 'react-native'; + +const { BackupFile } = NativeModules; + +export default class BackupFileModule { + static setFileProtection = async (filePath: string): Promise => { + if (Platform.OS === 'ios' && BackupFile) { + return await BackupFile.setFileProtection(filePath); + } + // Android doesn't need this, return true + return true; + }; +} diff --git a/src/screens/Home/HomeScreen.tsx b/src/screens/Home/HomeScreen.tsx index bd93b1f04a..6039451ea4 100644 --- a/src/screens/Home/HomeScreen.tsx +++ b/src/screens/Home/HomeScreen.tsx @@ -17,12 +17,17 @@ import MenuFooter from 'src/components/MenuFooter'; import HomeWallet from './components/Wallet/HomeWallet'; import ManageKeys from './components/Keys/ManageKeys'; import KeeperSettings from './components/Settings/keeperSettings'; -import { useNavigation } from '@react-navigation/native'; +import { CommonActions, useFocusEffect, useNavigation } from '@react-navigation/native'; import TickIcon from 'src/assets/images/icon_tick.svg'; import ThemedSvg from 'src/components/ThemedSvg.tsx/ThemedSvg'; import ThemedColor from 'src/components/ThemedColor/ThemedColor'; import BuyBtc from './components/buyBtc/BuyBtc'; import ConciergeComponent from './components/ConciergeComponent'; +import KeeperModal from 'src/components/KeeperModal'; +import Text from 'src/components/KeeperText'; +import { useQuery } from '@realm/react'; +import { RealmSchema } from 'src/storage/realm/enum'; +import dbManager from 'src/storage/realm/dbManager'; function NewHomeScreen({ route }) { const { colorMode } = useColorMode(); @@ -31,6 +36,7 @@ function NewHomeScreen({ route }) { const { addedSigner, selectedOption: selectedOptionFromRoute } = route.params || {}; const { wallets } = useWallets({ getAll: true }); const [electrumErrorVisible, setElectrumErrorVisible] = useState(false); + const [backupModalVisible, setBackupModalVisible] = useState(true); const home_header_circle_background = ThemedColor({ name: 'home_header_circle_background' }); const { relayWalletUpdate, relayWalletError, realyWalletErrorMessage, homeToastMessage } = @@ -41,6 +47,10 @@ function NewHomeScreen({ route }) { const [selectedOption, setSelectedOption] = useState( selectedOptionFromRoute || walletText.homeWallets ); + const backupHistory = useQuery(RealmSchema.BackupHistory); + const { recoveryKeyBackedUpByAppId } = useAppSelector((state) => state.account); + const { id } = dbManager.getObjectByIndex(RealmSchema.KeeperApp) as any; + const shouldShowBackupModal = !(recoveryKeyBackedUpByAppId?.[id] ?? false); useEffect(() => { if (selectedOptionFromRoute && selectedOptionFromRoute !== selectedOption) { @@ -48,6 +58,23 @@ function NewHomeScreen({ route }) { } }, [selectedOptionFromRoute]); + useEffect(() => { + if (shouldShowBackupModal) { + setBackupModalVisible(true); + } + }, [shouldShowBackupModal]); + + useFocusEffect( + React.useCallback(() => { + if (shouldShowBackupModal && selectedOption !== walletText.more) { + const timer = setTimeout(() => { + setBackupModalVisible(true); + }, 300); + return () => clearTimeout(timer); + } + }, [shouldShowBackupModal, selectedOption, walletText.more]) + ); + const getContent = () => { switch (selectedOption) { case walletText.homeWallets: @@ -159,6 +186,16 @@ function NewHomeScreen({ route }) { } }, [homeToastMessage]); + const BackupModalContent = () => { + return ( + + + {homeTranslation.backupModalDesc} + + + ); + }; + return ( + {}} + title={homeTranslation.backupModalTitle} + subTitle={homeTranslation.backupModalSubTitle} + modalBackground={`${colorMode}.modalWhiteBackground`} + textColor={`${colorMode}.textGreen`} + subTitleColor={`${colorMode}.modalSubtitleBlack`} + buttonBackground={`${colorMode}.pantoneGreen`} + showCloseIcon={false} + buttonText={'Backup Recovery Key'} + buttonCallback={() => { + setBackupModalVisible(false); + setTimeout(() => { + if (backupHistory.length === 0) { + navigation.dispatch( + CommonActions.navigate('Home', { + selectedOption: 'More', + isUaiFlow: true, + }) + ); + } else { + navigation.dispatch( + CommonActions.navigate('WalletBackHistory', { + isUaiFlow: true, + }) + ); + } + }, 300); + }} + buttonTextColor={`${colorMode}.buttonText`} + Content={BackupModalContent} + /> ); } diff --git a/src/screens/Home/components/Settings/AppSettings.tsx b/src/screens/Home/components/Settings/AppSettings.tsx index cfe93dcc7f..3a82201888 100644 --- a/src/screens/Home/components/Settings/AppSettings.tsx +++ b/src/screens/Home/components/Settings/AppSettings.tsx @@ -1,6 +1,6 @@ import { Box, useColorMode } from 'native-base'; import React, { useContext, useState } from 'react'; -import { Alert, Pressable, StyleSheet } from 'react-native'; +import { Alert, Platform, Pressable, StyleSheet } from 'react-native'; import ScreenWrapper from 'src/components/ScreenWrapper'; import WalletHeader from 'src/components/WalletHeader'; import { LocalizationContext } from 'src/context/Localization/LocContext'; @@ -21,6 +21,10 @@ import useToastMessage from 'src/hooks/useToastMessage'; import TickIcon from 'src/assets/images/tick_icon.svg'; import ToastErrorIcon from 'src/assets/images/toast_error.svg'; import ThemedSvg from 'src/components/ThemedSvg.tsx/ThemedSvg'; +import * as Keychain from 'react-native-keychain'; +import * as SecureStore from 'src/storage/secure-store'; +import { deleteBackupFile, restoreBackupKey } from 'src/services/backupfile'; +import RNFS from 'react-native-fs'; const SettingsApp = () => { const { colorMode } = useColorMode(); @@ -31,6 +35,7 @@ const SettingsApp = () => { const [networkModeModal, setNetworkModeModal] = useState(false); const [selectedNetwork, setSelectedNetwork] = useState(bitcoinNetworkType); const [loading, setLoading] = useState(false); + const { allAccounts } = useAppSelector((state) => state.account); let appSetting = [ ...useSettingKeeper().appSetting, @@ -135,6 +140,54 @@ const SettingsApp = () => { )} /> + {/* // ! Remove this later | Only for reproducing condition for tester */} + {Platform.OS == 'ios' && ( + <> + { + let count = 0; + for (const acc of allAccounts) { + const service = acc.accountIdentifier == '' ? undefined : acc.accountIdentifier; + const res = await Keychain.resetGenericPassword({ service: service }); + count++; + console.log('🚀 ~ Delete keychain for :', service, res); + } + showToast(`Clear passcode for ${count} accounts`); + }} + secondaryText="Has Passcode" + secondaryCallback={async () => { + const hasCreds = await SecureStore.hasPin(); + console.log('🚀 ~ attemptLogin ~ hasCreds:', hasCreds); + showToast(`Passcode is set: ${hasCreds}`); + }} + /> + { + const res = await deleteBackupFile('backup.txt'); + showToast(`Backup file deleted: ${res}`); + }} + secondaryText="Try Restore" + secondaryCallback={async () => { + const res = await restoreBackupKey(false); + showToast(`Restore status: ${res}`); + }} + /> + {/* */} + { + const FILE_NAME = 'backup.txt'; + const filePath = `${RNFS.DocumentDirectoryPath}/${FILE_NAME}`; + const gibberishData = + "Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lore"; + await RNFS.writeFile(filePath, gibberishData, 'utf8'); + }} + /> + + )} + {/* // ! Remove this later | Only for reproducing condition for tester */} diff --git a/src/screens/Home/components/Settings/Component/SettingModal.tsx b/src/screens/Home/components/Settings/Component/SettingModal.tsx index c9c52a5caa..e3a80e5ea5 100644 --- a/src/screens/Home/components/Settings/Component/SettingModal.tsx +++ b/src/screens/Home/components/Settings/Component/SettingModal.tsx @@ -2,6 +2,7 @@ import { CommonActions, useNavigation } from '@react-navigation/native'; import { useQuery } from '@realm/react'; import { Box, useColorMode } from 'native-base'; import React, { useContext, useEffect, useState } from 'react'; +import { Platform } from 'react-native'; import KeeperModal from 'src/components/KeeperModal'; import PasscodeVerifyModal from 'src/components/Modal/PasscodeVerify'; import { wp } from 'src/constants/responsive'; @@ -26,7 +27,10 @@ const SettingModal = ({ isUaiFlow, confirmPass, setConfirmPass }) => { useEffect(() => { if (confirmPass || isUaiFlow) { navigation.setParams({ isUaiFlow: false }); - setConfirmPassVisible(true); + const delay = Platform.OS === 'ios' ? 400 : 0; + setTimeout(() => { + setConfirmPassVisible(true); + }, delay); } }, [confirmPass, isUaiFlow]); diff --git a/src/screens/LoginScreen/CreatePin.tsx b/src/screens/LoginScreen/CreatePin.tsx index 04d21dec28..bef8c13146 100644 --- a/src/screens/LoginScreen/CreatePin.tsx +++ b/src/screens/LoginScreen/CreatePin.tsx @@ -38,7 +38,7 @@ export default function CreatePin(props) { stage: PasscodeStages.CREATE, }); const [createPassword, setCreatePassword] = useState(false); - const { oldPasscode } = props.route.params || {}; + const { oldPasscode, isForgot = false } = props.route.params || {}; const dispatch = useAppDispatch(); const { credsChanged, hasCreds } = useAppSelector((state) => state.login); const { translations } = useContext(LocalizationContext); @@ -62,7 +62,9 @@ export default function CreatePin(props) { useEffect(() => { if (hasCreds) { - if (allAccounts.length) props.navigation.replace('OnBoardingSlides'); + if (isForgot) + props.navigation.replace('LoginStack', { screen: 'EnterSeedScreen', params: { isForgot } }); + else if (allAccounts.length) props.navigation.replace('OnBoardingSlides'); else setEnableBiometric(true); } }, [hasCreds]); diff --git a/src/screens/LoginScreen/Login.tsx b/src/screens/LoginScreen/Login.tsx index 3464d4f0b3..934185840a 100644 --- a/src/screens/LoginScreen/Login.tsx +++ b/src/screens/LoginScreen/Login.tsx @@ -2,7 +2,7 @@ import Text from 'src/components/KeeperText'; import { Box, StatusBar, theme, useColorMode } from 'native-base'; import React, { useContext, useEffect, useState, useMemo } from 'react'; -import { StyleSheet } from 'react-native'; +import { Pressable, StyleSheet } from 'react-native'; import { widthPercentageToDP } from 'react-native-responsive-screen'; import { hp, windowWidth, wp } from 'src/constants/responsive'; import { useAppDispatch, useAppSelector } from 'src/store/hooks'; @@ -26,6 +26,7 @@ import { RealmSchema } from 'src/storage/realm/enum'; import { KeeperApp } from 'src/models/interfaces/KeeperApp'; import { credsAuth } from 'src/store/sagaActions/login'; import { + clearHasCreds, credsAuthenticated, setOfflineStatus, setRecepitVerificationError, @@ -33,6 +34,7 @@ import { import KeyPadView from 'src/components/AppNumPad/KeyPadView'; import { increasePinFailAttempts, + setAppCreated, setAutoUpdateEnabledBeforeDowngrade, setPlebDueToOffline, } from 'src/store/reducers/storage'; @@ -41,12 +43,14 @@ import BounceLoader from 'src/components/BounceLoader'; import { formatCoolDownTime, PasswordTimeout } from 'src/utils/PasswordTimeout'; import Buttons from 'src/components/Buttons'; import PinDotView from 'src/components/AppPinInput/PinDotView'; -import { setAutomaticCloudBackup } from 'src/store/reducers/bhr'; +import { setAutomaticCloudBackup, setBackupType } from 'src/store/reducers/bhr'; import Relay from 'src/services/backend/Relay'; import { setAccountManagerDetails } from 'src/store/reducers/concierge'; import Fonts from 'src/constants/Fonts'; import ThemedColor from 'src/components/ThemedColor/ThemedColor'; import ThemedSvg from 'src/components/ThemedSvg.tsx/ThemedSvg'; +import { setInitialNodesSaved } from 'src/store/reducers/network'; +import { saveBackupMethodByAppId } from 'src/store/sagaActions/account'; const RNBiometrics = new ReactNativeBiometrics(); @@ -93,6 +97,7 @@ function LoginScreen({ navigation, route }) { const { login } = translations; const { common } = translations; const { allAccounts, biometricEnabledAppId } = useAppSelector((state) => state.account); + const [forgotModal, setForgotModal] = useState(false); const onChangeTorStatus = (status: TorStatus) => { settorStatus(status); @@ -440,6 +445,15 @@ function LoginScreen({ navigation, route }) { )} + {!canLogin && ( + + setForgotModal(true)}> + + {login.forgotPasscode} + + + + )} setIncorrectPassword(false)} subTitleWidth={wp(250)} /> + setForgotModal(false)} + title={login.resetPasscodeTitle} + subTitle={login.resetPasscodeDec1} + modalBackground={`${colorMode}.modalWhiteBackground`} + subTitleColor={`${colorMode}.secondaryText`} + textColor={`${colorMode}.modalGreenTitle`} + buttonText={common.continue} + buttonCallback={() => { + dispatch(clearHasCreds()); + dispatch(setAppCreated(false)); + dispatch(setInitialNodesSaved(false)); + dispatch(saveBackupMethodByAppId()); + dispatch(setBackupType(null)); + navigation.replace('LoginStack', { + screen: 'CreatePin', + params: { + isForgot: true, + }, + }); + }} + subTitleWidth={wp(250)} + Content={() => { + return ( + + + {login.resetPasscodeDec2} + + + ); + }} + /> ); } @@ -630,6 +677,15 @@ const styles = StyleSheet.create({ marginTop: hp(45), gap: hp(15), }, + forgotCtr: { + flex: 1, + alignItems: 'flex-end', + justifyContent: 'flex-end', + paddingBottom: hp(10), + }, + forgotTxt: { + padding: wp(15), + }, }); export default LoginScreen; diff --git a/src/screens/NewKeeperAppScreen/NewKeeperAppScreen.tsx b/src/screens/NewKeeperAppScreen/NewKeeperAppScreen.tsx index 921801d9f4..5eb136a5d4 100644 --- a/src/screens/NewKeeperAppScreen/NewKeeperAppScreen.tsx +++ b/src/screens/NewKeeperAppScreen/NewKeeperAppScreen.tsx @@ -2,7 +2,7 @@ /* eslint-disable react/no-unstable-nested-components */ import { ActivityIndicator, StyleSheet, BackHandler } from 'react-native'; import Text from 'src/components/KeeperText'; -import React, { useContext, useEffect, useState } from 'react'; +import React, { useContext, useEffect, useRef, useState } from 'react'; import { hp, windowWidth, wp } from 'src/constants/responsive'; import { useAppDispatch, useAppSelector } from 'src/store/hooks'; import AppIcon from 'src/assets/images/new-app-icon.svg'; @@ -76,6 +76,7 @@ function StartNewModalContent() { const { colorMode } = useColorMode(); const { translations } = useContext(LocalizationContext); const { login } = translations; + const isMultiAccountFlow = !!useAppSelector((state) => state.account).allAccounts.length; return ( @@ -84,14 +85,16 @@ function StartNewModalContent() { {login.NewSatrtWalet} - - - {login.RecoverApp}{' '} - - - {login.RecoverExistingAppDesc} - - + {!isMultiAccountFlow && ( + + + {login.RecoverApp}{' '} + + + {login.RecoverExistingAppDesc} + + + )} ); @@ -110,6 +113,9 @@ function NewKeeperApp({ navigation }: { navigation }) { const { translations } = useContext(LocalizationContext); const { login, common, error: errorText, home } = translations; const isFocused = useIsFocused(); + const accountsLength = useAppSelector((state) => state.account.allAccounts.length); + const isMultiAccountFlowRef = useRef(accountsLength > 0); + const isMultiAccountFlow = isMultiAccountFlowRef.current; useEffect(() => { if (appCreated) { @@ -228,7 +234,7 @@ function NewKeeperApp({ navigation }: { navigation }) { {login.welcomeToBitcoinKeeper} - {login.CreateApp} + {isMultiAccountFlow ? login.CreateAppOnly : login.CreateApp} - { - navigation.navigate('LoginStack', { screen: 'EnterSeedScreen' }); - }} - > - - - - {login.RecoverApp} - - - {login.Enter12WordsRecovery} - - - - - - * - - - { - 'If Assisted Server Backup was not enabled, you will start with a blank app and will have to add the wallets back.' - } - - + {!isMultiAccountFlow && ( + <> + { + navigation.navigate('LoginStack', { screen: 'EnterSeedScreen' }); + }} + > + + + + {login.RecoverApp} + + + {login.Enter12WordsRecovery} + + + + + + * + + + { + 'If Assisted Server Backup was not enabled, you will start with a blank app and will have to add the wallets back.' + } + + + + )} diff --git a/src/screens/Recovery/EnterSeedScreen.tsx b/src/screens/Recovery/EnterSeedScreen.tsx index db2df5a674..705bee3915 100644 --- a/src/screens/Recovery/EnterSeedScreen.tsx +++ b/src/screens/Recovery/EnterSeedScreen.tsx @@ -39,6 +39,7 @@ import { updateSignerDetails, updateVaultSignersXpriv } from 'src/store/sagaActi import ConciergeNeedHelp from 'src/assets/images/conciergeNeedHelp.svg'; import { setShowTipModal } from 'src/store/reducers/settings'; import config from 'src/utils/service-utilities/config'; +import { resetPasscodeTimeout } from 'src/store/reducers/storage'; function EnterSeedScreen({ route, navigation }) { const { translations } = useContext(LocalizationContext); @@ -65,6 +66,7 @@ function EnterSeedScreen({ route, navigation }) { onSuccess, step = 1, selectedNumberOfWordsFromParams, + isForgot = false, } = route.params || {}; const { appImageError } = useAppSelector((state) => state.bhr); @@ -120,6 +122,7 @@ function EnterSeedScreen({ route, navigation }) { setRecoveryLoading(false); setRecoverySuccessModal(true); dispatch(resetSeedWords()); + dispatch(resetPasscodeTimeout()); } }, [appCreated]); @@ -317,7 +320,7 @@ function EnterSeedScreen({ route, navigation }) { setRecoveryLoading(true); try { const seedWord = seedWords.map((word) => word.name).join(' '); - dispatch(getAppImage(seedWord)); + dispatch(getAppImage(seedWord, isForgot)); } catch (err) { console.error('getAppImage error:', err); showToast(seed.SeedErrorToast, ); @@ -649,6 +652,7 @@ function EnterSeedScreen({ route, navigation }) { }} learnMore={isUSDTWallet} learnMorePressed={() => setShowInfo(true)} + enableBack={navigation.canGoBack()} /> diff --git a/src/screens/Splash/SplashScreen.tsx b/src/screens/Splash/SplashScreen.tsx index 7f9c2e465a..54b6fadbdb 100644 --- a/src/screens/Splash/SplashScreen.tsx +++ b/src/screens/Splash/SplashScreen.tsx @@ -1,6 +1,6 @@ -import React, { useEffect } from 'react'; +import React, { useContext, useEffect, useState } from 'react'; import { StyleSheet } from 'react-native'; -import { useColorMode } from 'native-base'; +import { Box, useColorMode } from 'native-base'; import RestClient from 'src/services/rest/RestClient'; import { useAppSelector } from 'src/store/hooks'; import * as SecureStore from 'src/storage/secure-store'; @@ -12,18 +12,29 @@ import Animated, { withSpring, withTiming, } from 'react-native-reanimated'; -import { windowHeight, windowWidth } from 'src/constants/responsive'; +import { hp, windowHeight, windowWidth } from 'src/constants/responsive'; import { useDispatch } from 'react-redux'; import config from 'src/utils/service-utilities/config'; import { NetworkType } from 'src/services/wallets/enums'; import { changeBitcoinNetwork } from 'src/store/sagaActions/settings'; -import { setDefaultWalletCreated } from 'src/store/reducers/storage'; +import { setAppCreated, setDefaultWalletCreated } from 'src/store/reducers/storage'; import ThemedSvg from 'src/components/ThemedSvg.tsx/ThemedSvg'; +import { restoreBackupKey } from 'src/services/backupfile'; +import KeeperModal from 'src/components/KeeperModal'; +import Text from 'src/components/KeeperText'; +import { LocalizationContext } from 'src/context/Localization/LocContext'; +import { clearHasCreds } from 'src/store/reducers/login'; +import { setInitialNodesSaved } from 'src/store/reducers/network'; +import { saveBackupMethodByAppId } from 'src/store/sagaActions/account'; +import { setBackupType } from 'src/store/reducers/bhr'; function SplashScreen({ navigation }) { const { torEnbled, themeMode, bitcoinNetworkType } = useAppSelector((state) => state.settings); + const { appId } = useAppSelector((state) => state.storage); const dispatch = useDispatch(); const { toggleColorMode, colorMode } = useColorMode(); + const { common, home } = useContext(LocalizationContext).translations; + const [backupFailureModal, setBackupFailureModal] = useState(false); const animate = () => { progress.value = withTiming(4, { duration: 3000 }, (finished) => { @@ -60,6 +71,10 @@ function SplashScreen({ navigation }) { const hasCreds = await SecureStore.hasPin(); if (hasCreds) { navigation.replace('Login', { relogin: false }); + } else if (!hasCreds && appId) { + const res = await restoreBackupKey(); + if (res) navigation.replace('Login', { relogin: false }); + else setBackupFailureModal(true); } else { navigation.replace('CreatePin'); } @@ -110,12 +125,50 @@ function SplashScreen({ navigation }) { }); return ( - - - - + <> + + + + + - + setBackupFailureModal(false)} + title={home.backupFileFailTitle} + subTitle={home.backupFileFailSubTitle} + showCloseIcon={false} + modalBackground={`${colorMode}.modalWhiteBackground`} + subTitleColor={`${colorMode}.secondaryText`} + textColor={`${colorMode}.modalGreenTitle`} + buttonText={common.continue} + buttonCallback={() => { + dispatch(clearHasCreds()); + dispatch(setAppCreated(false)); + dispatch(setInitialNodesSaved(false)); + dispatch(saveBackupMethodByAppId()); + dispatch(setBackupType(null)); + navigation.replace('LoginStack', { + screen: 'CreatePin', + params: { + isForgot: true, + }, + }); + }} + Content={() => { + return ( + + + {home.backupFileFailDesc1} + + + {home.backupFileFailDesc2} + + + ); + }} + /> + ); } @@ -125,6 +178,10 @@ const styles = StyleSheet.create({ alignItems: 'center', justifyContent: 'center', }, + modalMessageText: { + fontSize: 14, + letterSpacing: 0.13, + }, }); export default SplashScreen; diff --git a/src/services/backupfile/index.ts b/src/services/backupfile/index.ts new file mode 100644 index 0000000000..92f1f90af5 --- /dev/null +++ b/src/services/backupfile/index.ts @@ -0,0 +1,112 @@ +import RNFS from 'react-native-fs'; +import { Platform } from 'react-native'; +import BackupFileModule from 'src/nativemodules/BackupFile'; +import { decrypt, encrypt } from 'src/utils/service-utilities/encryption'; +import * as SecureStore from 'src/storage/secure-store'; +import config from 'src/utils/service-utilities/config'; +import { store as reduxStore } from 'src/store/store'; +import { setLoginMethod } from 'src/store/reducers/settings'; +import LoginMethod from 'src/models/enums/LoginMethod'; +import { setBiometricEnabledAppId } from 'src/store/reducers/account'; + +const FILE_NAME = 'backup.txt'; + +const getBackupFilePath = (fileName: string): string => { + return `${RNFS.DocumentDirectoryPath}/${fileName}`; +}; + +export const createBackup = async (appId: string, hash: string, encryptedKey): Promise => { + if (Platform.OS != 'ios') return null; + try { + if ( + !hash || + typeof hash !== 'string' || + !encryptedKey || + typeof hash != 'string' || + !appId || + typeof appId != 'string' + ) { + return null; + } + + const filePath = `${RNFS.DocumentDirectoryPath}/${FILE_NAME}`; + + let data = {}; + + const exists = await RNFS.exists(filePath); + if (exists) { + console.log('Backup file does exists'); + const file = await RNFS.readFile(filePath, 'utf8'); + const fileData = decrypt(config.HEXA_ID, file); + data = JSON.parse(fileData); + } + data[appId] = { hash, encryptedKey }; + const encryptedData = encrypt(config.HEXA_ID, JSON.stringify(data)); + await RNFS.writeFile(filePath, encryptedData, 'utf8'); + try { + await BackupFileModule.setFileProtection(filePath); + } catch (error) { + console.warn('Failed to set file protection:', error); + } + + return true; + } catch (error) { + return false; + } +}; + +export const readBackupFile = async (): Promise => { + try { + const filePath = getBackupFilePath(FILE_NAME); + const exists = await RNFS.exists(filePath); + if (!exists) { + return null; + } + const encryptedData = await RNFS.readFile(filePath, 'utf8'); + return encryptedData; + } catch (error) { + throw error; + } +}; + +export const deleteBackupFile = async (fileName: string): Promise => { + try { + if (!fileName) { + throw new Error('Backup file name is required'); + } + const filePath = getBackupFilePath(fileName); + const exists = await RNFS.exists(filePath); + if (!exists)return false; + await RNFS.unlink(filePath); + return true; + } catch (error) { + throw error; + } +}; + +export const restoreBackupKey = async (del=true): Promise => { + try { + let data = await readBackupFile(); + if (!data) return false; + data = decrypt(config.HEXA_ID, data); + data = JSON.parse(data); + const allAccounts = reduxStore.getState().account.allAccounts; + for (const [appId, _] of Object.entries(data)) { + const accountIdentifier = allAccounts.find(acc=> acc.appId == appId).accountIdentifier; + const service = accountIdentifier == '' ? undefined : accountIdentifier; + if(del){ + await SecureStore.store(data[appId].hash, data[appId].encryptedKey, service); + } + } + + if(del){ + await deleteBackupFile(FILE_NAME); + } + reduxStore.dispatch(setLoginMethod(LoginMethod.PIN)); + reduxStore.dispatch(setBiometricEnabledAppId(null)); + return true; + } catch (error) { + console.log({ error }); + return false; + } +}; diff --git a/src/store/reducers/account.ts b/src/store/reducers/account.ts index 1356d17955..5d5383b842 100644 --- a/src/store/reducers/account.ts +++ b/src/store/reducers/account.ts @@ -25,6 +25,9 @@ export type BackupMethodByAppId = { export type OneTimeBackupStatusByAppId = { [appId: string]: Boolean; }; +export type recoveryKeyBackedUpByAppId = { + [appId: string]: Boolean; +}; export type DefaultWalletCreatedByAppId = { [appId: string]: { @@ -36,6 +39,9 @@ export type DefaultWalletCreatedByAppId = { export type personalBackupPasswordByAppId = { [appId: string]: string; }; +export type backupFileByAppId = { + [appId: string]: boolean; +}; const initialState: { allAccounts: Account[]; @@ -46,6 +52,8 @@ const initialState: { oneTimeBackupStatusByAppId: OneTimeBackupStatusByAppId; defaultWalletCreatedByAppId: DefaultWalletCreatedByAppId; personalBackupPasswordByAppId: personalBackupPasswordByAppId; + recoveryKeyBackedUpByAppId: recoveryKeyBackedUpByAppId; + backupFileByAppId: backupFileByAppId; } = { allAccounts: [], tempDetails: null, @@ -55,6 +63,8 @@ const initialState: { oneTimeBackupStatusByAppId: {}, // for signing server backup defaultWalletCreatedByAppId: {}, personalBackupPasswordByAppId: {}, + recoveryKeyBackedUpByAppId: {}, + backupFileByAppId: {}, }; const accountSlice = createSlice({ @@ -62,14 +72,27 @@ const accountSlice = createSlice({ initialState, reducers: { addAccount: (state, action: PayloadAction) => { - const account = { - appId: action.payload, - hash: state.tempDetails.hash, - realmId: state.tempDetails.realmId, - accountIdentifier: state.tempDetails.accountIdentifier, - isDefault: state.allAccounts.length === 0, - }; - state.allAccounts.push(account); + const existing = state.allAccounts.find((acc) => acc.appId == action.payload); + const index = state.allAccounts.findIndex((acc) => acc.appId == action.payload); + if (existing && index != -1) { + // appId already exists, user is recovering existing account, overwrite existing with new credentials + state.allAccounts[index] = { + ...state.allAccounts[index], + hash: state.tempDetails.hash, + realmId: state.tempDetails.realmId, + accountIdentifier: state.tempDetails.accountIdentifier, + isDefault: state.allAccounts.length === 0, + }; + } else { + const account = { + appId: action.payload, + hash: state.tempDetails.hash, + realmId: state.tempDetails.realmId, + accountIdentifier: state.tempDetails.accountIdentifier, + isDefault: state.allAccounts.length === 0, + }; + state.allAccounts.push(account); + } state.tempDetails = null; }, setTempDetails: (state, action: PayloadAction) => { @@ -144,6 +167,14 @@ const accountSlice = createSlice({ ) => { (state.personalBackupPasswordByAppId ??= {})[action.payload.appId] = action.payload.password; }, + + setRecoveryKeyBackedUp: (state, action: PayloadAction<{ appId: string; status: boolean }>) => { + (state.recoveryKeyBackedUpByAppId ??= {})[action.payload.appId] = action.payload.status; + }, + + setBackupFileByAppId: (state, action: PayloadAction<{ appId: string; status: boolean }>) => { + (state.backupFileByAppId ??= {})[action.payload.appId] = action.payload.status; + }, }, }); @@ -159,5 +190,7 @@ export const { updateDefaultWalletCreatedByAppId, saveDefaultWalletState, setPersonalBackupPassword, + setRecoveryKeyBackedUp, + setBackupFileByAppId, } = accountSlice.actions; export default accountSlice.reducer; diff --git a/src/store/reducers/storage.ts b/src/store/reducers/storage.ts index e9a93ab225..ab7363fd2c 100644 --- a/src/store/reducers/storage.ts +++ b/src/store/reducers/storage.ts @@ -183,6 +183,9 @@ const storageSlice = createSlice({ setAppCreated: (state, action: PayloadAction) => { state.appCreated = action.payload; }, + resetPasscodeTimeout: (state) => { + state.lastLoginFailedAt = null; + }, }, }); @@ -203,6 +206,7 @@ export const { setCampaignFlags, setAllCampaigns, setAppCreated, + resetPasscodeTimeout, } = storageSlice.actions; export default storageSlice.reducer; diff --git a/src/store/sagaActions/bhr.ts b/src/store/sagaActions/bhr.ts index 7afd5099fa..39e8eadfd3 100644 --- a/src/store/sagaActions/bhr.ts +++ b/src/store/sagaActions/bhr.ts @@ -47,10 +47,11 @@ export const updateVaultImage = (payload: { payload, }); -export const getAppImage = (primaryMnemonic: string) => ({ +export const getAppImage = (primaryMnemonic: string, isForgot: boolean) => ({ type: GET_APP_IMAGE, payload: { primaryMnemonic, + isForgot, }, }); diff --git a/src/store/sagas/bhr.ts b/src/store/sagas/bhr.ts index 9f6f195f5c..e9c179b2dc 100644 --- a/src/store/sagas/bhr.ts +++ b/src/store/sagas/bhr.ts @@ -90,9 +90,11 @@ import { } from 'src/utils/utilities'; import NetInfo from '@react-native-community/netinfo'; import { addToUaiStackWorker, uaiActionedWorker } from './uai'; -import { addAccount, saveDefaultWalletState } from '../reducers/account'; +import { addAccount, saveDefaultWalletState, setBackupFileByAppId } from '../reducers/account'; import { loadConciergeTickets, loadConciergeUser } from '../reducers/concierge'; import { USDTWallet } from 'src/services/wallets/factories/USDTWalletFactory'; +import { createBackup } from 'src/services/backupfile'; +import * as SecureStore from 'src/storage/secure-store'; export function* updateAppImageWorker({ payload, @@ -336,7 +338,7 @@ function* seedBackedUpWorker() { } function* getAppImageWorker({ payload }) { - const { primaryMnemonic } = payload; + const { primaryMnemonic, isForgot } = payload; try { yield put(setAppImageError('')); if (!bip39.validateMnemonic(primaryMnemonic)) { @@ -345,6 +347,13 @@ function* getAppImageWorker({ payload }) { const { bitcoinNetworkType } = yield select((state: RootState) => state.settings); const primarySeed = bip39.mnemonicToSeedSync(primaryMnemonic); const appID = crypto.createHash('sha256').update(primarySeed).digest('hex'); + if (isForgot) { + // Allow only existing appId to be recovered using forgot passcode flow. + const { allAccounts } = yield select((state: RootState) => state.account); + const idx = allAccounts.findIndex((acc) => acc.appId == appID); + if (idx == -1) throw Error('Not an existing app. Please recover an existing app.'); + } + const encryptionKey = generateEncryptionKey(primarySeed.toString('hex')); let appImage = { appId: appID, version: null, wallets: {}, signers: {}, nodes: [] }; let subscription = null; @@ -441,6 +450,10 @@ function* getAppImageWorker({ payload }) { yield put(uaiChecks([uaiType.SECURE_VAULT])); yield put(loadConciergeUser(null)); yield put(loadConciergeTickets([])); + const { pinHash } = yield select((state: RootState) => state.storage); + const encryptedKey = yield call(SecureStore.fetch, pinHash); + const res = yield call(createBackup, appID, pinHash, encryptedKey); + yield put(setBackupFileByAppId({ appId: appID, status: res })); } catch (err) { yield put(setAppImageError(err.message)); } finally { diff --git a/src/store/sagas/login.ts b/src/store/sagas/login.ts index 44ba46e970..6194873ade 100644 --- a/src/store/sagas/login.ts +++ b/src/store/sagas/login.ts @@ -61,6 +61,7 @@ import { autoWalletsSyncWorker } from './wallets'; import { addAccount, saveDefaultWalletState, + setBackupFileByAppId, setBiometricEnabledAppId, setTempDetails, updateOneTimeBackupStatus, @@ -68,6 +69,7 @@ import { } from '../reducers/account'; import { REALM_FILE } from 'src/storage/realm/realm'; import { loadConciergeUserOnLogin, saveBackupMethodByAppId } from '../sagaActions/account'; +import { createBackup } from 'src/services/backupfile'; export const stringToArrayBuffer = (byteString: string): Uint8Array => { if (byteString) { @@ -182,7 +184,11 @@ function* credentialsAuthWorker({ payload }) { if (!success) { throw Error(`Failed to load the database: ${error}`); } - + const { backupFileByAppId } = yield select((state) => state.account); + if (!backupFileByAppId || !backupFileByAppId[appId]) { + const res = yield call(createBackup, appId, hash, encryptedKey); + yield put(setBackupFileByAppId({ appId, status: res })); + } const previousVersion = yield select((state) => state.storage.appVersion); const { plebDueToOffline, wasAutoUpdateEnabledBeforeDowngrade, defaultWalletCreated } = yield select((state) => state.storage); @@ -360,6 +366,7 @@ function* changeAuthCredWorker({ payload }) { yield call(SecureStore.store, newHash, newEncryptedKey, currentAccount.accountIdentifier); yield put(updatePasscodeHash({ newHash, appId })); yield put(credsChanged('changed')); + yield call(createBackup, appId, newHash, newEncryptedKey); } catch (err) { console.log({ err, diff --git a/src/store/sagas/storage.ts b/src/store/sagas/storage.ts index 99eaa08f39..fdc76612cf 100644 --- a/src/store/sagas/storage.ts +++ b/src/store/sagas/storage.ts @@ -36,11 +36,14 @@ import { addToUaiStack } from '../sagaActions/uai'; import { RootState } from '../store'; import { addAccount, + setBackupFileByAppId, setBiometricEnabledAppId, updateDefaultWalletCreatedByAppId, } from '../reducers/account'; import { loadConciergeTickets, loadConciergeUser } from '../reducers/concierge'; import LoginMethod from 'src/models/enums/LoginMethod'; +import { createBackup } from 'src/services/backupfile'; +import * as SecureStore from 'src/storage/secure-store'; export function* setupKeeperAppWorker({ payload }) { try { @@ -126,6 +129,10 @@ export function* setupKeeperAppWorker({ payload }) { const { allAccounts } = yield select((state: RootState) => state.account); if (allAccounts.length == 1) yield put(setBiometricEnabledAppId(appID)); } + const { pinHash } = yield select((state: RootState) => state.storage); + const encryptedKey = yield call(SecureStore.fetch, pinHash); + const res = yield call(createBackup, appID, pinHash, encryptedKey); + yield put(setBackupFileByAppId({ appId: appID, status: res })); } else { yield put(setAppCreationError(true)); }