diff --git a/native/backup/use-client-backup.js b/native/backup/use-client-backup.js index c66bbd52f..41fddea0e 100644 --- a/native/backup/use-client-backup.js +++ b/native/backup/use-client-backup.js @@ -1,88 +1,78 @@ // @flow import * as React from 'react'; import { isLoggedIn } from 'lib/selectors/user-selectors.js'; import { accountHasPassword } from 'lib/shared/account-utils.js'; import { latestBackupInfoResponseValidator, type LatestBackupInfo, } from 'lib/types/backup-types.js'; -import type { SIWEBackupSecrets } from 'lib/types/siwe-types.js'; import { assertWithValidator } from 'lib/utils/validation-utils.js'; -import { fetchNativeKeychainCredentials } from '../account/native-credentials.js'; +import { useGetBackupSecretForLoggedInUser } from './use-get-backup-secret.js'; import { commCoreModule } from '../native-modules.js'; import { useSelector } from '../redux/redux-utils.js'; type ClientBackup = { +uploadBackupProtocol: () => Promise, +retrieveLatestBackupInfo: () => Promise, }; -async function getBackupSecret(): Promise { - const nativeCredentials = await fetchNativeKeychainCredentials(); - if (!nativeCredentials) { - throw new Error('Native credentials are missing'); - } - return nativeCredentials.password; -} - -async function getSIWEBackupSecrets(): Promise { - const siweBackupSecrets = await commCoreModule.getSIWEBackupSecrets(); - if (!siweBackupSecrets) { - throw new Error('SIWE backup message and its signature are missing'); - } - return siweBackupSecrets; -} - function useClientBackup(): ClientBackup { const currentUserID = useSelector( state => state.currentUserInfo && state.currentUserInfo.id, ); const currentUserInfo = useSelector(state => state.currentUserInfo); const loggedIn = useSelector(isLoggedIn); + const getBackupSecret = useGetBackupSecretForLoggedInUser(); const uploadBackupProtocol = React.useCallback(async () => { if (!loggedIn || !currentUserID) { throw new Error('Attempt to upload backup for not logged in user.'); } console.info('Start uploading backup...'); if (accountHasPassword(currentUserInfo)) { const backupSecret = await getBackupSecret(); await commCoreModule.createNewBackup(backupSecret); } else { - const { message, signature } = await getSIWEBackupSecrets(); - await commCoreModule.createNewSIWEBackup(signature, message); + const siweBackupSecrets = await commCoreModule.getSIWEBackupSecrets(); + if (!siweBackupSecrets) { + throw new Error('SIWE backup message and its signature are missing'); + } + await commCoreModule.createNewSIWEBackup( + siweBackupSecrets.signature, + siweBackupSecrets.message, + ); } console.info('Backup uploaded.'); - }, [currentUserID, loggedIn, currentUserInfo]); + }, [loggedIn, currentUserID, currentUserInfo, getBackupSecret]); const retrieveLatestBackupInfo = React.useCallback(async () => { if (!loggedIn || !currentUserID || !currentUserInfo?.username) { throw new Error('Attempt to restore backup for not logged in user.'); } const userIdentitifer = currentUserInfo?.username; const response = await commCoreModule.retrieveLatestBackupInfo(userIdentitifer); return assertWithValidator( JSON.parse(response), latestBackupInfoResponseValidator, ); }, [currentUserID, currentUserInfo, loggedIn]); return React.useMemo( () => ({ uploadBackupProtocol, retrieveLatestBackupInfo, }), [retrieveLatestBackupInfo, uploadBackupProtocol], ); } -export { getBackupSecret, useClientBackup }; +export { useClientBackup }; diff --git a/native/backup/use-get-backup-secret.js b/native/backup/use-get-backup-secret.js new file mode 100644 index 000000000..d0628925e --- /dev/null +++ b/native/backup/use-get-backup-secret.js @@ -0,0 +1,37 @@ +// @flow + +import * as React from 'react'; + +import { isLoggedIn } from 'lib/selectors/user-selectors.js'; +import { accountHasPassword } from 'lib/shared/account-utils.js'; + +import { fetchNativeKeychainCredentials } from '../account/native-credentials.js'; +import { commCoreModule } from '../native-modules.js'; +import { useSelector } from '../redux/redux-utils.js'; + +function useGetBackupSecretForLoggedInUser(): () => Promise { + const currentUserInfo = useSelector(state => state.currentUserInfo); + const loggedIn = useSelector(isLoggedIn); + + return React.useCallback(async () => { + if (!loggedIn || !currentUserInfo) { + throw new Error('Attempt to get backup secret for not logged in user'); + } + + if (accountHasPassword(currentUserInfo)) { + const nativeCredentials = await fetchNativeKeychainCredentials(); + if (!nativeCredentials) { + throw new Error('Native credentials are missing'); + } + return nativeCredentials.password; + } + + const siweBackupSecrets = await commCoreModule.getSIWEBackupSecrets(); + if (!siweBackupSecrets) { + throw new Error('SIWE backup message and its signature are missing'); + } + return siweBackupSecrets.signature; + }, [loggedIn, currentUserInfo]); +} + +export { useGetBackupSecretForLoggedInUser }; diff --git a/native/profile/backup-menu.react.js b/native/profile/backup-menu.react.js index df0025c33..6002a2ad1 100644 --- a/native/profile/backup-menu.react.js +++ b/native/profile/backup-menu.react.js @@ -1,234 +1,233 @@ // @flow import { useNavigation } from '@react-navigation/native'; import * as React from 'react'; import { Switch, Text, View } from 'react-native'; import { ScrollView } from 'react-native-gesture-handler'; import { accountHasPassword } from 'lib/shared/account-utils.js'; import { getMessageForException } from 'lib/utils/errors.js'; import { useDispatch } from 'lib/utils/redux-utils.js'; import type { ProfileNavigationProp } from './profile.react.js'; -import { - getBackupSecret, - useClientBackup, -} from '../backup/use-client-backup.js'; +import { useClientBackup } from '../backup/use-client-backup.js'; +import { useGetBackupSecretForLoggedInUser } from '../backup/use-get-backup-secret.js'; import Button from '../components/button.react.js'; import { commCoreModule } from '../native-modules.js'; import type { NavigationRoute } from '../navigation/route-names.js'; import { RestoreSIWEBackupRouteName } from '../navigation/route-names.js'; import { setLocalSettingsActionType } from '../redux/action-types.js'; import { persistConfig } from '../redux/persist.js'; import { useSelector } from '../redux/redux-utils.js'; import { useColors, useStyles } from '../themes/colors.js'; import Alert from '../utils/alert.js'; type Props = { +navigation: ProfileNavigationProp<'BackupMenu'>, +route: NavigationRoute<'BackupMenu'>, }; // eslint-disable-next-line no-unused-vars function BackupMenu(props: Props): React.Node { const styles = useStyles(unboundStyles); const dispatch = useDispatch(); const colors = useColors(); const currentUserInfo = useSelector(state => state.currentUserInfo); const navigation = useNavigation(); + const getBackupSecret = useGetBackupSecretForLoggedInUser(); const isBackupEnabled = useSelector( state => state.localSettings.isBackupEnabled, ); const { uploadBackupProtocol, retrieveLatestBackupInfo } = useClientBackup(); const uploadBackup = React.useCallback(async () => { let message = 'Success'; try { await uploadBackupProtocol(); } catch (e) { message = `Backup upload error: ${String(getMessageForException(e))}`; console.error(message); } Alert.alert('Upload protocol result', message); }, [uploadBackupProtocol]); const testRestoreForPasswordUser = React.useCallback(async () => { let message = 'success'; try { const [latestBackupInfo, backupSecret] = await Promise.all([ retrieveLatestBackupInfo(), getBackupSecret(), ]); await commCoreModule.restoreBackup( backupSecret, persistConfig.version.toString(), latestBackupInfo.backupID, ); console.info('Backup restored.'); } catch (e) { message = `Backup restore error: ${String(getMessageForException(e))}`; console.error(message); } Alert.alert('Restore protocol result', message); - }, [retrieveLatestBackupInfo]); + }, [getBackupSecret, retrieveLatestBackupInfo]); const testLatestBackupInfo = React.useCallback(async () => { let message; try { const backupInfo = await retrieveLatestBackupInfo(); const { backupID, userID } = backupInfo; message = `Success!\n` + `Backup ID: ${backupID},\n` + `userID: ${userID},\n` + `userID check: ${currentUserInfo?.id === userID ? 'true' : 'false'}`; } catch (e) { message = `Latest backup info error: ${String( getMessageForException(e), )}`; console.error(message); } Alert.alert('Latest backup info result', message); }, [currentUserInfo?.id, retrieveLatestBackupInfo]); const testRestoreForSIWEUser = React.useCallback(async () => { let message = 'success'; try { const backupInfo = await retrieveLatestBackupInfo(); const { siweBackupData, backupID } = backupInfo; if (!siweBackupData) { throw new Error('Missing SIWE message for Wallet user backup'); } const { siweBackupMsgNonce, siweBackupMsgIssuedAt, siweBackupMsgStatement, } = siweBackupData; navigation.navigate<'RestoreSIWEBackup'>({ name: RestoreSIWEBackupRouteName, params: { backupID, siweNonce: siweBackupMsgNonce, siweStatement: siweBackupMsgStatement, siweIssuedAt: siweBackupMsgIssuedAt, }, }); } catch (e) { message = `Backup restore error: ${String(getMessageForException(e))}`; console.error(message); } }, [navigation, retrieveLatestBackupInfo]); const onBackupToggled = React.useCallback( (value: boolean) => { dispatch({ type: setLocalSettingsActionType, payload: { isBackupEnabled: value }, }); }, [dispatch], ); const onPressRestoreButton = accountHasPassword(currentUserInfo) ? testRestoreForPasswordUser : testRestoreForSIWEUser; return ( SETTINGS Toggle automatic backup ACTIONS ); } const unboundStyles = { scrollViewContentContainer: { paddingTop: 24, }, scrollView: { backgroundColor: 'panelBackground', }, section: { backgroundColor: 'panelForeground', borderBottomWidth: 1, borderColor: 'panelForegroundBorder', borderTopWidth: 1, marginBottom: 24, marginVertical: 2, }, header: { color: 'panelBackgroundLabel', fontSize: 12, fontWeight: '400', paddingBottom: 3, paddingHorizontal: 24, }, submenuButton: { flexDirection: 'row', paddingHorizontal: 24, paddingVertical: 10, alignItems: 'center', }, submenuText: { color: 'panelForegroundLabel', flex: 1, fontSize: 16, }, row: { flexDirection: 'row', justifyContent: 'space-between', paddingHorizontal: 24, paddingVertical: 14, }, }; export default BackupMenu; diff --git a/native/profile/secondary-device-qr-code-scanner.react.js b/native/profile/secondary-device-qr-code-scanner.react.js index 95c7bab0d..a7306e5a5 100644 --- a/native/profile/secondary-device-qr-code-scanner.react.js +++ b/native/profile/secondary-device-qr-code-scanner.react.js @@ -1,499 +1,501 @@ // @flow import { useNavigation } from '@react-navigation/native'; import { BarCodeScanner, type BarCodeEvent } from 'expo-barcode-scanner'; import invariant from 'invariant'; import * as React from 'react'; import { View, Text } from 'react-native'; import { parseDataFromDeepLink } from 'lib/facts/links.js'; import { useBroadcastDeviceListUpdates, useGetAndUpdateDeviceListsForUsers, } from 'lib/hooks/peer-list-hooks.js'; import { getForeignPeerDeviceIDs, getOwnPeerDevices, getKeyserverDeviceID, } from 'lib/selectors/user-selectors.js'; import { addDeviceToDeviceList, replaceDeviceInDeviceList, } from 'lib/shared/device-list-utils.js'; import { IdentityClientContext } from 'lib/shared/identity-client-context.js'; import { useTunnelbroker } from 'lib/tunnelbroker/tunnelbroker-context.js'; import { backupKeysValidator, type BackupKeys, } from 'lib/types/backup-types.js'; import { identityDeviceTypes, type IdentityDeviceType, } from 'lib/types/identity-service-types.js'; import { tunnelbrokerToDeviceMessageTypes, type TunnelbrokerToDeviceMessage, } from 'lib/types/tunnelbroker/messages.js'; import { peerToPeerMessageTypes, peerToPeerMessageValidator, type PeerToPeerMessage, } from 'lib/types/tunnelbroker/peer-to-peer-message-types.js'; import { qrCodeAuthMessageTypes } from 'lib/types/tunnelbroker/qr-code-auth-message-types.js'; import { rawDeviceListFromSignedList } from 'lib/utils/device-list-utils.js'; import { assertWithValidator } from 'lib/utils/validation-utils.js'; import type { ProfileNavigationProp } from './profile.react.js'; -import { getBackupSecret } from '../backup/use-client-backup.js'; +import { useGetBackupSecretForLoggedInUser } from '../backup/use-get-backup-secret.js'; import TextInput from '../components/text-input.react.js'; import { commCoreModule } from '../native-modules.js'; import HeaderRightTextButton from '../navigation/header-right-text-button.react.js'; import type { NavigationRoute } from '../navigation/route-names.js'; import { composeTunnelbrokerQRAuthMessage, parseTunnelbrokerQRAuthMessage, } from '../qr-code/qr-code-utils.js'; import { useSelector } from '../redux/redux-utils.js'; import { useStyles, useColors } from '../themes/colors.js'; import Alert from '../utils/alert.js'; import { deviceIsEmulator } from '../utils/url-utils.js'; const barCodeTypes = [BarCodeScanner.Constants.BarCodeType.qr]; type Props = { +navigation: ProfileNavigationProp<'SecondaryDeviceQRCodeScanner'>, +route: NavigationRoute<'SecondaryDeviceQRCodeScanner'>, }; // eslint-disable-next-line no-unused-vars function SecondaryDeviceQRCodeScanner(props: Props): React.Node { const [hasPermission, setHasPermission] = React.useState(null); const [scanned, setScanned] = React.useState(false); const [urlInput, setURLInput] = React.useState(''); const styles = useStyles(unboundStyles); const { goBack, setOptions } = useNavigation(); const tunnelbrokerContext = useTunnelbroker(); const identityContext = React.useContext(IdentityClientContext); invariant(identityContext, 'identity context not set'); const aes256Key = React.useRef(null); const secondaryDeviceID = React.useRef(null); const secondaryDeviceType = React.useRef(null); const broadcastDeviceListUpdates = useBroadcastDeviceListUpdates(); const getAndUpdateDeviceListsForUsers = useGetAndUpdateDeviceListsForUsers(); const foreignPeerDevices = useSelector(getForeignPeerDeviceIDs); const ownPeerDevices = useSelector(getOwnPeerDevices); const keyserverDeviceID = getKeyserverDeviceID(ownPeerDevices); + const getBackupSecret = useGetBackupSecretForLoggedInUser(); const { panelForegroundTertiaryLabel } = useColors(); const tunnelbrokerMessageListener = React.useCallback( async (message: TunnelbrokerToDeviceMessage) => { const encryptionKey = aes256Key.current; const targetDeviceID = secondaryDeviceID.current; if (!encryptionKey || !targetDeviceID) { return; } if (message.type !== tunnelbrokerToDeviceMessageTypes.MESSAGE_TO_DEVICE) { return; } let innerMessage: PeerToPeerMessage; try { innerMessage = JSON.parse(message.payload); } catch { return; } if ( !peerToPeerMessageValidator.is(innerMessage) || innerMessage.type !== peerToPeerMessageTypes.QR_CODE_AUTH_MESSAGE ) { return; } const payload = await parseTunnelbrokerQRAuthMessage( encryptionKey, innerMessage, ); if ( !payload || payload.type !== qrCodeAuthMessageTypes.SECONDARY_DEVICE_REGISTRATION_SUCCESS ) { return; } invariant(identityContext, 'identity context not set'); const { getAuthMetadata, identityClient } = identityContext; const { userID, deviceID } = await getAuthMetadata(); if (!userID || !deviceID) { throw new Error('missing auth metadata'); } const deviceLists = await identityClient.getDeviceListHistoryForUser(userID); invariant(deviceLists.length > 0, 'received empty device list history'); const lastSignedDeviceList = deviceLists[deviceLists.length - 1]; const deviceList = rawDeviceListFromSignedList(lastSignedDeviceList); const ownOtherDevices = deviceList.devices.filter(it => it !== deviceID); await Promise.all([ broadcastDeviceListUpdates( [...ownOtherDevices, ...foreignPeerDevices], lastSignedDeviceList, ), getAndUpdateDeviceListsForUsers([userID]), ]); if (!payload.requestBackupKeys) { Alert.alert('Device added', 'Device registered successfully', [ { text: 'OK', onPress: goBack }, ]); return; } const backupSecret = await getBackupSecret(); const backupKeysResponse = await commCoreModule.retrieveBackupKeys(backupSecret); const backupKeys = assertWithValidator( JSON.parse(backupKeysResponse), backupKeysValidator, ); const backupKeyMessage = await composeTunnelbrokerQRAuthMessage( encryptionKey, { type: qrCodeAuthMessageTypes.BACKUP_DATA_KEY_MESSAGE, ...backupKeys, }, ); await tunnelbrokerContext.sendMessageToDevice({ deviceID: targetDeviceID, payload: JSON.stringify(backupKeyMessage), }); Alert.alert('Device added', 'Device registered successfully', [ { text: 'OK', onPress: goBack }, ]); }, [ identityContext, broadcastDeviceListUpdates, foreignPeerDevices, getAndUpdateDeviceListsForUsers, + getBackupSecret, tunnelbrokerContext, goBack, ], ); React.useEffect(() => { tunnelbrokerContext.addListener(tunnelbrokerMessageListener); return () => { tunnelbrokerContext.removeListener(tunnelbrokerMessageListener); }; }, [tunnelbrokerMessageListener, tunnelbrokerContext]); React.useEffect(() => { void (async () => { const { status } = await BarCodeScanner.requestPermissionsAsync(); setHasPermission(status === 'granted'); if (status !== 'granted') { Alert.alert( 'No access to camera', 'Please allow Comm to access your camera in order to scan the QR code.', [{ text: 'OK' }], ); goBack(); } })(); }, [goBack]); const processDeviceListUpdate = React.useCallback(async () => { try { const { deviceID: primaryDeviceID, userID } = await identityContext.getAuthMetadata(); if (!primaryDeviceID || !userID) { throw new Error('missing auth metadata'); } const encryptionKey = aes256Key.current; const targetDeviceID = secondaryDeviceID.current; if (!encryptionKey || !targetDeviceID) { throw new Error('missing tunnelbroker message data'); } const deviceType = secondaryDeviceType.current; const sendDeviceListUpdateSuccessMessage = async () => { const message = await composeTunnelbrokerQRAuthMessage(encryptionKey, { type: qrCodeAuthMessageTypes.DEVICE_LIST_UPDATE_SUCCESS, userID, primaryDeviceID, }); await tunnelbrokerContext.sendMessageToDevice({ deviceID: targetDeviceID, payload: JSON.stringify(message), }); }; const handleReplaceDevice = async () => { try { if (!keyserverDeviceID) { throw new Error('missing keyserver device ID'); } await replaceDeviceInDeviceList( identityContext.identityClient, userID, keyserverDeviceID, targetDeviceID, ); await sendDeviceListUpdateSuccessMessage(); } catch (err) { console.log('Device replacement error:', err); Alert.alert( 'Adding device failed', 'Failed to update the device list', [{ text: 'OK' }], ); goBack(); } }; if ( deviceType !== identityDeviceTypes.KEYSERVER || !keyserverDeviceID || keyserverDeviceID === targetDeviceID ) { await addDeviceToDeviceList( identityContext.identityClient, userID, targetDeviceID, ); await sendDeviceListUpdateSuccessMessage(); return; } Alert.alert( 'Existing keyserver detected', 'Do you want to replace your existing keyserver with this new one?', [ { text: 'No', onPress: goBack, style: 'cancel', }, { text: 'Replace', onPress: handleReplaceDevice, style: 'destructive', }, ], ); } catch (err) { console.log('Primary device error:', err); Alert.alert('Adding device failed', 'Failed to update the device list', [ { text: 'OK' }, ]); goBack(); } }, [goBack, identityContext, keyserverDeviceID, tunnelbrokerContext]); const onPressSave = React.useCallback(async () => { if (!urlInput) { return; } const parsedData = parseDataFromDeepLink(urlInput); const keysMatch = parsedData?.data?.keys; if (!parsedData || !keysMatch) { Alert.alert( 'Scan failed', 'QR code does not contain a valid pair of keys.', [{ text: 'OK' }], ); return; } try { const keys = JSON.parse(decodeURIComponent(keysMatch)); const { aes256, ed25519 } = keys; aes256Key.current = aes256; secondaryDeviceID.current = ed25519; secondaryDeviceType.current = parsedData.data.deviceType; } catch (err) { console.log('Failed to decode URI component:', err); return; } await processDeviceListUpdate(); }, [processDeviceListUpdate, urlInput]); const buttonDisabled = !urlInput; React.useEffect(() => { if (!deviceIsEmulator) { return; } setOptions({ headerRight: () => ( ), }); }, [buttonDisabled, onPressSave, setOptions]); const onChangeText = React.useCallback( (text: string) => setURLInput(text), [], ); const onConnect = React.useCallback( async (barCodeEvent: BarCodeEvent) => { const { data } = barCodeEvent; const parsedData = parseDataFromDeepLink(data); const keysMatch = parsedData?.data?.keys; if (!parsedData || !keysMatch) { Alert.alert( 'Scan failed', 'QR code does not contain a valid pair of keys.', [{ text: 'OK' }], ); return; } try { const keys = JSON.parse(decodeURIComponent(keysMatch)); const { aes256, ed25519 } = keys; aes256Key.current = aes256; secondaryDeviceID.current = ed25519; secondaryDeviceType.current = parsedData.data.deviceType; } catch (err) { console.log('Failed to decode URI component:', err); return; } await processDeviceListUpdate(); }, [processDeviceListUpdate], ); const handleBarCodeScanned = React.useCallback( (barCodeEvent: BarCodeEvent) => { setScanned(true); Alert.alert( 'Connect with this device?', 'Are you sure you want to allow this device to log in to your account?', [ { text: 'Cancel', style: 'cancel', onPress: goBack, }, { text: 'Connect', onPress: () => onConnect(barCodeEvent), }, ], { cancelable: false }, ); }, [goBack, onConnect], ); if (hasPermission === null) { return ; } if (deviceIsEmulator) { return ( QR Code URL ); } // Note: According to the BarCodeScanner Expo docs, we should adhere to two // guidances when using the BarCodeScanner: // 1. We should specify the potential barCodeTypes we want to scan for to // minimize battery usage. // 2. We should set the onBarCodeScanned callback to undefined if it scanned // in order to 'pause' the scanner from continuing to scan while we // process the data from the scan. // See: https://docs.expo.io/versions/latest/sdk/bar-code-scanner return ( ); } const unboundStyles = { scannerContainer: { flex: 1, flexDirection: 'column', justifyContent: 'center', }, scanner: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, }, textInputContainer: { paddingTop: 8, }, header: { color: 'panelBackgroundLabel', fontSize: 12, fontWeight: '400', paddingBottom: 3, paddingHorizontal: 24, }, inputContainer: { backgroundColor: 'panelForeground', flexDirection: 'row', justifyContent: 'space-between', paddingHorizontal: 24, paddingVertical: 12, borderBottomWidth: 1, borderColor: 'panelForegroundBorder', borderTopWidth: 1, }, input: { color: 'panelForegroundLabel', flex: 1, fontFamily: 'Arial', fontSize: 16, paddingVertical: 0, borderBottomColor: 'transparent', }, }; export default SecondaryDeviceQRCodeScanner;