diff --git a/lib/shared/device-list-utils.js b/lib/shared/device-list-utils.js index eb530dabf..f9a5823a2 100644 --- a/lib/shared/device-list-utils.js +++ b/lib/shared/device-list-utils.js @@ -1,254 +1,379 @@ // @flow import invariant from 'invariant'; +import * as React from 'react'; +import { IdentityClientContext } from './identity-client-context.js'; +import { + useBroadcastDeviceListUpdates, + useGetAndUpdateDeviceListsForUsers, +} from '../hooks/peer-list-hooks.js'; +import { + getAllPeerDevices, + getForeignPeerDeviceIDs, +} from '../selectors/user-selectors.js'; import type { IdentityServiceClient, RawDeviceList, SignedDeviceList, } from '../types/identity-service-types.js'; import { getConfig } from '../utils/config.js'; import { getContentSigningKey } from '../utils/crypto-utils.js'; import { composeRawDeviceList, rawDeviceListFromSignedList, } from '../utils/device-list-utils.js'; +import { useSelector } from '../utils/redux-utils.js'; export type DeviceListVerificationResult = | { +valid: true, +deviceList: RawDeviceList } | DeviceListVerificationFailure; type DeviceListVerificationFailure = | { +valid: false, +reason: 'empty_device_list_history' } | { +valid: false, +reason: 'empty_device_list_update', +timestamp: number } | { +valid: false, +reason: 'invalid_timestamp_order', +timestamp: number } | { +valid: false, +reason: 'invalid_cur_primary_signature', +timestamp: number, } | { +valid: false, +reason: 'invalid_last_primary_signature', +timestamp: number, }; // Verifies all device list updates for given `userID` since // last known (and valid) device list. The updates are fetched // from Identity Service. If `lastKnownDeviceList` is not provided, // the whole device list history will be verified. // Returns latest device list from Identity Service. async function verifyAndGetDeviceList( identityClient: IdentityServiceClient, userID: string, lastKnownDeviceList: ?SignedDeviceList, ): Promise { let since; if (lastKnownDeviceList) { const rawList = rawDeviceListFromSignedList(lastKnownDeviceList); since = rawList.timestamp; } const history = await identityClient.getDeviceListHistoryForUser( userID, since, ); if (history.length < 1) { return { valid: false, reason: 'empty_device_list_history' }; } const [firstUpdate, ...updates] = history; const deviceListUpdates = lastKnownDeviceList ? history : updates; let previousDeviceList = lastKnownDeviceList ?? firstUpdate; const { olmAPI } = getConfig(); for (const deviceList of deviceListUpdates) { const currentPayload = rawDeviceListFromSignedList(deviceList); const previousPayload = rawDeviceListFromSignedList(previousDeviceList); // verify timestamp order const { timestamp } = currentPayload; if (previousPayload.timestamp >= timestamp) { return { valid: false, reason: 'invalid_timestamp_order', timestamp, }; } const currentPrimaryDeviceID = currentPayload.devices[0]; const previousPrimaryDeviceID = previousPayload.devices[0]; if (!currentPrimaryDeviceID || !previousPrimaryDeviceID) { return { valid: false, reason: 'empty_device_list_update', timestamp }; } // verify signatures if (deviceList.curPrimarySignature) { // verify signature using previous primary device signature const signatureValid = await olmAPI.verifyMessage( deviceList.rawDeviceList, deviceList.curPrimarySignature, currentPrimaryDeviceID, ); if (!signatureValid) { return { valid: false, reason: 'invalid_cur_primary_signature', timestamp, }; } } if ( currentPrimaryDeviceID !== previousPrimaryDeviceID && deviceList.lastPrimarySignature ) { // verify signature using previous primary device signature const signatureValid = await olmAPI.verifyMessage( deviceList.rawDeviceList, deviceList.lastPrimarySignature, previousPrimaryDeviceID, ); if (!signatureValid) { return { valid: false, reason: 'invalid_last_primary_signature', timestamp, }; } } previousDeviceList = deviceList; } const deviceList = rawDeviceListFromSignedList(previousDeviceList); return { valid: true, deviceList }; } async function createAndSignSingletonDeviceList( primaryDeviceID: string, ): Promise { const initialDeviceList = composeRawDeviceList([primaryDeviceID]); const rawDeviceList = JSON.stringify(initialDeviceList); const { olmAPI } = getConfig(); const curPrimarySignature = await olmAPI.signMessage(rawDeviceList); return { rawDeviceList, curPrimarySignature, }; } async function signDeviceListUpdate( deviceListPayload: RawDeviceList, ): Promise { const deviceID = await getContentSigningKey(); if (deviceListPayload.devices[0] !== deviceID) { throw new Error('non-primary device tried to sign device list'); } const { olmAPI } = getConfig(); const rawDeviceList = JSON.stringify(deviceListPayload); const curPrimarySignature = await olmAPI.signMessage(rawDeviceList); return { rawDeviceList, curPrimarySignature, }; } async function fetchLatestDeviceList( identityClient: IdentityServiceClient, userID: string, ): Promise { const deviceLists = await identityClient.getDeviceListHistoryForUser(userID); if (deviceLists.length < 1) { throw new Error('received empty device list history'); } const lastSignedDeviceList = deviceLists[deviceLists.length - 1]; return rawDeviceListFromSignedList(lastSignedDeviceList); } async function addDeviceToDeviceList( identityClient: IdentityServiceClient, userID: string, newDeviceID: string, -) { +): Promise { const { updateDeviceList } = identityClient; invariant( updateDeviceList, 'updateDeviceList() should be defined on native. ' + 'Are you calling it on a non-primary device?', ); const { devices } = await fetchLatestDeviceList(identityClient, userID); if (devices.includes(newDeviceID)) { // the device was already on the device list - return; + return null; } const newDeviceList = composeRawDeviceList([...devices, newDeviceID]); const signedDeviceList = await signDeviceListUpdate(newDeviceList); await updateDeviceList(signedDeviceList); + return signedDeviceList; } async function removeDeviceFromDeviceList( identityClient: IdentityServiceClient, userID: string, deviceIDToRemove: string, ): Promise { const { updateDeviceList } = identityClient; invariant( updateDeviceList, 'updateDeviceList() should be defined on native. ' + 'Are you calling it on a non-primary device?', ); const { devices } = await fetchLatestDeviceList(identityClient, userID); const newDevices = devices.filter(it => it !== deviceIDToRemove); if (devices.length === newDevices.length) { // the device wasn't on the device list return; } const newDeviceList = composeRawDeviceList(newDevices); const signedDeviceList = await signDeviceListUpdate(newDeviceList); await updateDeviceList(signedDeviceList); } async function replaceDeviceInDeviceList( identityClient: IdentityServiceClient, userID: string, deviceIDToRemove: string, newDeviceID: string, -): Promise { +): Promise { const { updateDeviceList } = identityClient; invariant( updateDeviceList, 'updateDeviceList() should be defined on native. ' + 'Are you calling it on a non-primary device?', ); const { devices } = await fetchLatestDeviceList(identityClient, userID); // If the device to remove is not on the list and the new device is already on // the list, return if (!devices.includes(deviceIDToRemove) && devices.includes(newDeviceID)) { - return; + return null; } const newDevices = devices.filter(it => it !== deviceIDToRemove); newDevices.push(newDeviceID); const newDeviceList = composeRawDeviceList(newDevices); const signedDeviceList = await signDeviceListUpdate(newDeviceList); await updateDeviceList(signedDeviceList); + return signedDeviceList; +} + +type DeviceListUpdate = + | { + +type: 'add', + +deviceID: string, + } + | { + +type: 'replace', + +deviceIDToRemove: string, + +newDeviceID: string, + } + | { + +type: 'remove', + +deviceID: string, + }; + +function useDeviceListUpdate(): (update: DeviceListUpdate) => Promise { + const identityContext = React.useContext(IdentityClientContext); + invariant(identityContext, 'identity context not set'); + const { identityClient, getAuthMetadata } = identityContext; + + const allPeerDevices = useSelector(getAllPeerDevices); + const foreignPeerDevices = useSelector(getForeignPeerDeviceIDs); + const broadcastDeviceListUpdates = useBroadcastDeviceListUpdates(); + const getAndUpdateDeviceListsForUsers = useGetAndUpdateDeviceListsForUsers(); + + const sendDeviceListUpdates = React.useCallback( + async ( + signedDeviceList: ?SignedDeviceList, + userID: string, + primaryDeviceID: string, + ) => { + if (!signedDeviceList) { + return; + } + + const deviceList = rawDeviceListFromSignedList(signedDeviceList); + const ownOtherDevices = deviceList.devices.filter( + it => it !== primaryDeviceID, + ); + + await Promise.all([ + broadcastDeviceListUpdates( + [...ownOtherDevices, ...foreignPeerDevices], + signedDeviceList, + ), + // We need to call it in order to fetch platform details for + // the added device + getAndUpdateDeviceListsForUsers([userID]), + ]); + }, + [ + broadcastDeviceListUpdates, + foreignPeerDevices, + getAndUpdateDeviceListsForUsers, + ], + ); + + return React.useCallback( + async (update: DeviceListUpdate) => { + if (update.type === 'add') { + const { deviceID } = update; + const { userID, deviceID: primaryDeviceID } = await getAuthMetadata(); + if (!userID || !primaryDeviceID) { + throw new Error('missing auth metadata'); + } + + const signedDeviceList = await addDeviceToDeviceList( + identityClient, + userID, + deviceID, + ); + await sendDeviceListUpdates(signedDeviceList, userID, primaryDeviceID); + } else if (update.type === 'replace') { + const { deviceIDToRemove, newDeviceID } = update; + const { userID, deviceID: primaryDeviceID } = await getAuthMetadata(); + + if (!userID || !primaryDeviceID) { + throw new Error('missing auth metadata'); + } + + const signedDeviceList = await replaceDeviceInDeviceList( + identityClient, + userID, + deviceIDToRemove, + newDeviceID, + ); + await sendDeviceListUpdates(signedDeviceList, userID, primaryDeviceID); + } else if (update.type === 'remove') { + const { deviceID } = update; + const { userID } = await getAuthMetadata(); + + if (!userID) { + throw new Error('missing auth metadata'); + } + + await removeDeviceFromDeviceList(identityClient, userID, deviceID); + await broadcastDeviceListUpdates( + allPeerDevices.filter(it => it !== deviceID), + ); + } + }, + [ + allPeerDevices, + broadcastDeviceListUpdates, + getAuthMetadata, + identityClient, + sendDeviceListUpdates, + ], + ); } export { verifyAndGetDeviceList, createAndSignSingletonDeviceList, fetchLatestDeviceList, addDeviceToDeviceList, removeDeviceFromDeviceList, replaceDeviceInDeviceList, signDeviceListUpdate, + useDeviceListUpdate, }; diff --git a/lib/tunnelbroker/use-peer-to-peer-message-handler.js b/lib/tunnelbroker/use-peer-to-peer-message-handler.js index 6ef0bee1d..35038b9d8 100644 --- a/lib/tunnelbroker/use-peer-to-peer-message-handler.js +++ b/lib/tunnelbroker/use-peer-to-peer-message-handler.js @@ -1,472 +1,462 @@ // @flow import invariant from 'invariant'; import _isEqual from 'lodash/fp/isEqual.js'; import * as React from 'react'; import uuid from 'uuid'; import { useResendPeerToPeerMessages } from './use-resend-peer-to-peer-messages.js'; import { removePeerUsersActionType } from '../actions/aux-user-actions.js'; import { invalidateTunnelbrokerDeviceTokenActionType } from '../actions/tunnelbroker-actions.js'; import { logOutActionTypes, useLogOut } from '../actions/user-actions.js'; import { usePeerOlmSessionsCreatorContext } from '../components/peer-olm-session-creator-provider.react.js'; import { useBroadcastDeviceListUpdates, useBroadcastAccountDeletion, useGetAndUpdateDeviceListsForUsers, } from '../hooks/peer-list-hooks.js'; -import { - getAllPeerDevices, - getForeignPeerDeviceIDs, -} from '../selectors/user-selectors.js'; +import { getForeignPeerDeviceIDs } from '../selectors/user-selectors.js'; import { verifyAndGetDeviceList, - removeDeviceFromDeviceList, + useDeviceListUpdate, } from '../shared/device-list-utils.js'; import { dmOperationSpecificationTypes } from '../shared/dm-ops/dm-op-utils.js'; import { useProcessDMOperation } from '../shared/dm-ops/process-dm-ops.js'; import { IdentityClientContext } from '../shared/identity-client-context.js'; import { useStaffAlert } from '../shared/staff-utils.js'; import type { DeviceOlmInboundKeys } from '../types/identity-service-types.js'; import { peerToPeerMessageTypes, type PeerToPeerMessage, type SenderInfo, } from '../types/tunnelbroker/peer-to-peer-message-types.js'; import { userActionsP2PMessageTypes, userActionP2PMessageValidator, type UserActionP2PMessage, } from '../types/tunnelbroker/user-actions-peer-to-peer-message-types.js'; import { updateTypes } from '../types/update-types-enum.js'; import type { AccountDeletionUpdateInfo } from '../types/update-types.js'; import { getConfig } from '../utils/config.js'; import { getContentSigningKey } from '../utils/crypto-utils.js'; import { getMessageForException } from '../utils/errors.js'; import { hasHigherDeviceID, OLM_ERROR_FLAG, olmSessionErrors, } from '../utils/olm-utils.js'; import { getClientMessageIDFromTunnelbrokerMessageID } from '../utils/peer-to-peer-communication-utils.js'; import { useDispatchActionPromise } from '../utils/redux-promise-utils.js'; import { useDispatch, useSelector } from '../utils/redux-utils.js'; // When logout is requested by primary device, logging out of Identity Service // is already handled by the primary device const primaryRequestLogoutOptions = Object.freeze({ skipIdentityLogOut: true }); // When re-broadcasting, we want to do it only to foreign peers // to avoid a vicious circle of deletion messages sent by own devices. const accountDeletionBroadcastOptions = Object.freeze({ includeOwnDevices: false, }); // handles `peerToPeerMessageTypes.ENCRYPTED_MESSAGE` function useHandleOlmMessageToDevice(): ( decryptedMessageContent: string, senderInfo: SenderInfo, messageID: string, ) => Promise { const identityContext = React.useContext(IdentityClientContext); invariant(identityContext, 'Identity context should be set'); - const { identityClient, getAuthMetadata } = identityContext; - const broadcastDeviceListUpdates = useBroadcastDeviceListUpdates(); + const { getAuthMetadata } = identityContext; const reBroadcastAccountDeletion = useBroadcastAccountDeletion( accountDeletionBroadcastOptions, ); - const allPeerDevices = useSelector(getAllPeerDevices); const dispatch = useDispatch(); const dispatchActionPromise = useDispatchActionPromise(); const primaryDeviceRequestedLogOut = useLogOut(primaryRequestLogoutOptions); + const runDeviceListUpdate = useDeviceListUpdate(); const processDMOperation = useProcessDMOperation(); return React.useCallback( async ( decryptedMessageContent: string, senderInfo: SenderInfo, messageID: string, ) => { const { sqliteAPI } = getConfig(); const parsedMessageToDevice = JSON.parse(decryptedMessageContent); // Handle user-action messages if (!userActionP2PMessageValidator.is(parsedMessageToDevice)) { return; } const userActionMessage: UserActionP2PMessage = parsedMessageToDevice; if ( userActionMessage.type === userActionsP2PMessageTypes.LOG_OUT_DEVICE ) { // causes log out, there is no need to remove Inbound P2P message void dispatchActionPromise( logOutActionTypes, primaryDeviceRequestedLogOut(), ); } else if ( userActionMessage.type === userActionsP2PMessageTypes.LOG_OUT_SECONDARY_DEVICE ) { - const { userID, deviceID: deviceIDToLogOut } = senderInfo; - await removeDeviceFromDeviceList( - identityClient, - userID, - deviceIDToLogOut, - ); - await broadcastDeviceListUpdates( - allPeerDevices.filter(deviceID => deviceID !== deviceIDToLogOut), - ); + const { deviceID: deviceIDToLogOut } = senderInfo; + await runDeviceListUpdate({ + type: 'remove', + deviceID: deviceIDToLogOut, + }); await sqliteAPI.removeInboundP2PMessages([messageID]); } else if ( userActionMessage.type === userActionsP2PMessageTypes.DM_OPERATION ) { // inbound P2P message is removed in DBOpsHandler after processing await processDMOperation({ type: dmOperationSpecificationTypes.INBOUND, op: userActionMessage.op, metadata: { messageID, senderDeviceID: senderInfo.deviceID, }, }); } else if ( userActionMessage.type === userActionsP2PMessageTypes.ACCOUNT_DELETION ) { const { userID: thisUserID } = await getAuthMetadata(); if (!thisUserID) { return; } // own devices re-broadcast account deletion to foreign peer devices if (senderInfo.userID === thisUserID) { await reBroadcastAccountDeletion(); // we treat account deletion the same way as primary-device-requested // logout, no need to remove Inbound P2P message void dispatchActionPromise( logOutActionTypes, primaryDeviceRequestedLogOut(), ); } else { const deleteUserUpdate: AccountDeletionUpdateInfo = { time: Date.now(), id: uuid.v4(), deletedUserID: senderInfo.userID, type: updateTypes.DELETE_ACCOUNT, }; dispatch({ type: removePeerUsersActionType, payload: { updatesResult: { newUpdates: [deleteUserUpdate] } }, }); await sqliteAPI.removeInboundP2PMessages([messageID]); } } else { console.warn( 'Unsupported P2P user action message:', userActionMessage.type, ); } }, [ - allPeerDevices, - broadcastDeviceListUpdates, dispatch, dispatchActionPromise, getAuthMetadata, - identityClient, primaryDeviceRequestedLogOut, processDMOperation, reBroadcastAccountDeletion, + runDeviceListUpdate, ], ); } function usePeerToPeerMessageHandler(): ( message: PeerToPeerMessage, messageID: string, ) => Promise { const { olmAPI, sqliteAPI } = getConfig(); const identityContext = React.useContext(IdentityClientContext); invariant(identityContext, 'Identity context should be set'); const { identityClient, getAuthMetadata } = identityContext; const foreignPeerDevices = useSelector(getForeignPeerDeviceIDs); const broadcastDeviceListUpdates = useBroadcastDeviceListUpdates(); const getAndUpdateDeviceListsForUsers = useGetAndUpdateDeviceListsForUsers(); const dispatch = useDispatch(); const handleOlmMessageToDevice = useHandleOlmMessageToDevice(); const resendPeerToPeerMessages = useResendPeerToPeerMessages(); const { createOlmSessionsWithUser } = usePeerOlmSessionsCreatorContext(); const { showAlertToStaff } = useStaffAlert(); return React.useCallback( async (message: PeerToPeerMessage, messageID: string) => { if (message.type === peerToPeerMessageTypes.OUTBOUND_SESSION_CREATION) { const { senderInfo, encryptedData, sessionVersion } = message; const { userID: senderUserID, deviceID: senderDeviceID } = senderInfo; let deviceKeys: ?DeviceOlmInboundKeys = null; try { const { keys } = await identityClient.getInboundKeysForUser(senderUserID); deviceKeys = keys[senderDeviceID]; } catch (e) { console.log(getMessageForException(e)); } if (!deviceKeys) { console.log( 'Error creating inbound session with device ' + `${senderDeviceID}: No keys for the device, ` + `session version: ${sessionVersion}`, ); showAlertToStaff( 'Error creating inbound session with device ', `${senderDeviceID}: No keys for the device, ` + `session version: ${sessionVersion}`, ); return; } try { await olmAPI.initializeCryptoAccount(); const result = await olmAPI.contentInboundSessionCreator( deviceKeys.identityKeysBlob.primaryIdentityPublicKeys, encryptedData, sessionVersion, false, ); await resendPeerToPeerMessages(senderDeviceID); console.log( 'Created inbound session with device ' + `${senderDeviceID}: ${result}, ` + `session version: ${sessionVersion}`, ); } catch (e) { const errorMessage = getMessageForException(e) ?? ''; if (errorMessage.includes(olmSessionErrors.alreadyCreated)) { console.log( 'Received session request with lower session version from ' + `${senderDeviceID}, session version: ${sessionVersion}`, ); } else if (errorMessage.includes(olmSessionErrors.raceCondition)) { const currentDeviceID = await getContentSigningKey(); if (hasHigherDeviceID(currentDeviceID, senderDeviceID)) { console.log( 'Race condition while creating session with ' + `${senderDeviceID}, session version: ${sessionVersion}, ` + `this device has a higher deviceID and the session will be kept`, ); } else { const result = await olmAPI.contentInboundSessionCreator( deviceKeys.identityKeysBlob.primaryIdentityPublicKeys, encryptedData, sessionVersion, true, ); console.log( 'Overwrite session with device ' + `${senderDeviceID}: ${result}, ` + `session version: ${sessionVersion}`, ); await resendPeerToPeerMessages(senderDeviceID); } } else { console.log( 'Error creating inbound session with device ' + `${senderDeviceID}: ${errorMessage}, ` + `session version: ${sessionVersion}`, ); showAlertToStaff( 'Error creating inbound session with device ', `${senderDeviceID}: ${errorMessage}, ` + `session version: ${sessionVersion}`, ); } } } else if (message.type === peerToPeerMessageTypes.ENCRYPTED_MESSAGE) { try { await olmAPI.initializeCryptoAccount(); const decrypted = await olmAPI.decryptAndPersist( message.encryptedData, message.senderInfo.deviceID, message.senderInfo.userID, messageID, ); console.log( 'Decrypted message from device ' + `${message.senderInfo.deviceID}: ${decrypted}`, ); try { await handleOlmMessageToDevice( decrypted, message.senderInfo, messageID, ); } catch (e) { console.log('Failed processing Olm P2P message:', e); } } catch (e) { const errorMessage = getMessageForException(e) ?? ''; if (errorMessage.includes(olmSessionErrors.invalidSessionVersion)) { console.log( 'Received message decrypted with different session from ' + `${message.senderInfo.deviceID}.`, ); return; } if (errorMessage.includes(olmSessionErrors.alreadyDecrypted)) { const sqliteMessages = await sqliteAPI.getInboundP2PMessagesByID([ messageID, ]); if (sqliteMessages.length > 0) { console.log( 'Message skipped because it was already decrypted ' + `messageID: ${messageID} ` + `sender: ${message.senderInfo.deviceID}.`, ); return; } } console.log( 'Error decrypting message from device ' + `${message.senderInfo.deviceID}: ${errorMessage}`, ); showAlertToStaff( 'Error decrypting message from device ', `${message.senderInfo.deviceID}: ${errorMessage}`, ); if ( !errorMessage.includes(OLM_ERROR_FLAG) && !errorMessage.includes(olmSessionErrors.sessionDoesNotExist) ) { throw e; } await createOlmSessionsWithUser(message.senderInfo.userID, [ { deviceID: message.senderInfo.deviceID, sessionCreationOptions: { overwriteContentSession: true }, }, ]); await resendPeerToPeerMessages(message.senderInfo.deviceID); } } else if (message.type === peerToPeerMessageTypes.REFRESH_KEY_REQUEST) { try { await olmAPI.initializeCryptoAccount(); const oneTimeKeys = await olmAPI.getOneTimeKeys(message.numberOfKeys); await identityClient.uploadOneTimeKeys(oneTimeKeys); } catch (e) { console.log( `Error uploading one-time keys: ${getMessageForException(e) ?? ''}`, ); } } else if (message.type === peerToPeerMessageTypes.DEVICE_LIST_UPDATED) { try { const result = await verifyAndGetDeviceList( identityClient, message.userID, null, ); if (!result.valid) { console.log( `Received invalid device list update for user ${message.userID}. Reason: ${result.reason}`, ); } else { console.log( `Received valid device list update for user ${message.userID}`, ); } await getAndUpdateDeviceListsForUsers([message.userID]); if (result.valid && message?.signedDeviceList?.rawDeviceList) { const receivedRawList = JSON.parse( message.signedDeviceList.rawDeviceList, ); // additional check for broadcasted and Identity device // list equality const listsAreEqual = _isEqual(result.deviceList)(receivedRawList); console.log( `Identity and received device lists are ${ listsAreEqual ? '' : 'not' } equal.`, ); } } catch (e) { console.log( `Error verifying device list for user ${message.userID}: ${e}`, ); } } else if ( message.type === peerToPeerMessageTypes.IDENTITY_DEVICE_LIST_UPDATED ) { try { const { userID } = await getAuthMetadata(); if (!userID) { return; } await Promise.all([ broadcastDeviceListUpdates(foreignPeerDevices), getAndUpdateDeviceListsForUsers([userID]), ]); } catch (e) { console.log( `Error updating device list after Identity request: ${ getMessageForException(e) ?? 'unknown error' }`, ); } } else if (message.type === peerToPeerMessageTypes.MESSAGE_PROCESSED) { try { const { deviceID, messageID: tunnelbrokerMessageID } = message; const clientMessageID = getClientMessageIDFromTunnelbrokerMessageID( tunnelbrokerMessageID, ); await sqliteAPI.removeOutboundP2PMessage(clientMessageID, deviceID); } catch (e) { console.log( `Error removing message after processing: ${ getMessageForException(e) ?? 'unknown error' }`, ); } } else if (message.type === peerToPeerMessageTypes.BAD_DEVICE_TOKEN) { dispatch({ type: invalidateTunnelbrokerDeviceTokenActionType, payload: { deviceToken: message.invalidatedToken, }, }); } }, [ broadcastDeviceListUpdates, createOlmSessionsWithUser, dispatch, foreignPeerDevices, getAndUpdateDeviceListsForUsers, getAuthMetadata, handleOlmMessageToDevice, identityClient, olmAPI, resendPeerToPeerMessages, showAlertToStaff, sqliteAPI, ], ); } export { usePeerToPeerMessageHandler, useHandleOlmMessageToDevice }; diff --git a/native/profile/linked-devices-bottom-sheet.react.js b/native/profile/linked-devices-bottom-sheet.react.js index 7dbc1f94f..17db0551d 100644 --- a/native/profile/linked-devices-bottom-sheet.react.js +++ b/native/profile/linked-devices-bottom-sheet.react.js @@ -1,178 +1,171 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { View, Text } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { useBroadcastDeviceListUpdates } from 'lib/hooks/peer-list-hooks.js'; -import { getAllPeerDevices } from 'lib/selectors/user-selectors.js'; -import { removeDeviceFromDeviceList } from 'lib/shared/device-list-utils.js'; +import { useDeviceListUpdate } from 'lib/shared/device-list-utils.js'; import { IdentityClientContext } from 'lib/shared/identity-client-context.js'; import { usePeerToPeerCommunication } from 'lib/tunnelbroker/peer-to-peer-context.js'; import { userActionsP2PMessageTypes, type DeviceLogoutP2PMessage, } from 'lib/types/tunnelbroker/user-actions-peer-to-peer-message-types.js'; -import { useSelector } from 'lib/utils/redux-utils.js'; import { BottomSheetContext } from '../bottom-sheet/bottom-sheet-provider.react.js'; import BottomSheet from '../bottom-sheet/bottom-sheet.react.js'; import Button from '../components/button.react.js'; import type { RootNavigationProp } from '../navigation/root-navigator.react.js'; import type { NavigationRoute } from '../navigation/route-names.js'; import { useStyles } from '../themes/colors.js'; import type { BottomSheetRef } from '../types/bottom-sheet.js'; import Alert from '../utils/alert.js'; export type LinkedDevicesBottomSheetParams = { +deviceID: string, +shouldDisplayRemoveButton: boolean, }; type Props = { +navigation: RootNavigationProp<'LinkedDevicesBottomSheet'>, +route: NavigationRoute<'LinkedDevicesBottomSheet'>, }; function LinkedDevicesBottomSheet(props: Props): React.Node { const { navigation, route: { params: { deviceID, shouldDisplayRemoveButton }, }, } = props; const { goBack } = navigation; const identityContext = React.useContext(IdentityClientContext); invariant(identityContext, 'identity context not set'); - const { identityClient, getAuthMetadata } = identityContext; + const { getAuthMetadata } = identityContext; - const broadcastDeviceListUpdates = useBroadcastDeviceListUpdates(); + const runDeviceListUpdate = useDeviceListUpdate(); const { broadcastEphemeralMessage } = usePeerToPeerCommunication(); - const allPeerDevices = useSelector(getAllPeerDevices); const bottomSheetContext = React.useContext(BottomSheetContext); invariant(bottomSheetContext, 'bottomSheetContext should be set'); const { setContentHeight } = bottomSheetContext; const bottomSheetRef = React.useRef(); const removeDeviceContainerRef = React.useRef>(); const styles = useStyles(unboundStyles); const insets = useSafeAreaInsets(); const handleDeviceRemoval = React.useCallback(async () => { const authMetadata = await getAuthMetadata(); const { userID } = authMetadata; if (!userID) { throw new Error('No user ID'); } try { - await removeDeviceFromDeviceList(identityClient, userID, deviceID); + await runDeviceListUpdate({ + type: 'remove', + deviceID, + }); } catch (err) { console.log('Primary device error:', err); Alert.alert( 'Removing device failed', 'Failed to update the device list', [{ text: 'OK' }], ); bottomSheetRef.current?.close(); } const messageContents: DeviceLogoutP2PMessage = { type: userActionsP2PMessageTypes.LOG_OUT_DEVICE, }; - const sendLogoutMessagePromise = broadcastEphemeralMessage( + await broadcastEphemeralMessage( JSON.stringify(messageContents), [{ userID, deviceID }], authMetadata, ); - const broadcastUpdatePromise = broadcastDeviceListUpdates( - allPeerDevices.filter(peerDeviceID => deviceID !== peerDeviceID), - ); - await Promise.all([sendLogoutMessagePromise, broadcastUpdatePromise]); bottomSheetRef.current?.close(); }, [ - broadcastDeviceListUpdates, + getAuthMetadata, broadcastEphemeralMessage, deviceID, - allPeerDevices, - getAuthMetadata, - identityClient, + runDeviceListUpdate, ]); const confirmDeviceRemoval = () => { Alert.alert( 'Remove device', 'Are you sure you want to remove this device?', [ { text: 'Cancel', style: 'cancel' }, { text: 'Remove', style: 'destructive', onPress: handleDeviceRemoval }, ], { cancelable: true }, ); }; const onLayout = React.useCallback(() => { removeDeviceContainerRef.current?.measure( (x, y, width, height, pageX, pageY) => { if ( height === null || height === undefined || pageY === null || pageY === undefined ) { return; } setContentHeight(height + insets.bottom); }, ); }, [insets.bottom, setContentHeight]); let removeDeviceButton; if (shouldDisplayRemoveButton) { removeDeviceButton = ( ); } return ( {removeDeviceButton} ); } const unboundStyles = { container: { paddingHorizontal: 16, }, removeButtonContainer: { backgroundColor: 'vibrantRedButton', paddingVertical: 12, borderRadius: 8, alignItems: 'center', }, removeButtonText: { color: 'floatingButtonLabel', }, }; export default LinkedDevicesBottomSheet; diff --git a/native/profile/secondary-device-qr-code-scanner.react.js b/native/profile/secondary-device-qr-code-scanner.react.js index 5fc392624..898dc260a 100644 --- a/native/profile/secondary-device-qr-code-scanner.react.js +++ b/native/profile/secondary-device-qr-code-scanner.react.js @@ -1,509 +1,469 @@ // @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 { useDeviceListUpdate } 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 { useClientBackup } 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 runDeviceListUpdate = useDeviceListUpdate(); - const foreignPeerDevices = useSelector(getForeignPeerDeviceIDs); const ownPeerDevices = useSelector(getOwnPeerDevices); const keyserverDeviceID = getKeyserverDeviceID(ownPeerDevices); const getBackupSecret = useGetBackupSecretForLoggedInUser(); const { retrieveLatestBackupInfo } = useClientBackup(); 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, latestBackupInfo] = await Promise.all([ getBackupSecret(), retrieveLatestBackupInfo(), ]); const backupKeysResponse = await commCoreModule.retrieveBackupKeys( backupSecret, latestBackupInfo.backupID, ); 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, - retrieveLatestBackupInfo, - ], + [getBackupSecret, goBack, retrieveLatestBackupInfo, tunnelbrokerContext], ); 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 runDeviceListUpdate({ + type: 'replace', + deviceIDToRemove: keyserverDeviceID, + newDeviceID: 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 runDeviceListUpdate({ + type: 'add', + deviceID: 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]); + }, [ + goBack, + identityContext, + keyserverDeviceID, + runDeviceListUpdate, + 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;