diff --git a/lib/shared/device-list-utils.js b/lib/shared/device-list-utils.js --- a/lib/shared/device-list-utils.js +++ b/lib/shared/device-list-utils.js @@ -1,7 +1,17 @@ // @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, @@ -13,6 +23,7 @@ composeRawDeviceList, rawDeviceListFromSignedList, } from '../utils/device-list-utils.js'; +import { useSelector } from '../utils/redux-utils.js'; export type DeviceListVerificationResult = | { +valid: true, +deviceList: RawDeviceList } @@ -171,7 +182,7 @@ identityClient: IdentityServiceClient, userID: string, newDeviceID: string, -) { +): Promise { const { updateDeviceList } = identityClient; invariant( updateDeviceList, @@ -182,12 +193,13 @@ 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( @@ -219,7 +231,7 @@ userID: string, deviceIDToRemove: string, newDeviceID: string, -): Promise { +): Promise { const { updateDeviceList } = identityClient; invariant( updateDeviceList, @@ -232,7 +244,7 @@ // 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); @@ -241,6 +253,118 @@ 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 { @@ -251,4 +375,5 @@ 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 --- a/lib/tunnelbroker/use-peer-to-peer-message-handler.js +++ b/lib/tunnelbroker/use-peer-to-peer-message-handler.js @@ -15,13 +15,10 @@ 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'; @@ -71,16 +68,15 @@ 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(); @@ -112,15 +108,11 @@ 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 @@ -171,15 +163,13 @@ } }, [ - allPeerDevices, - broadcastDeviceListUpdates, dispatch, dispatchActionPromise, getAuthMetadata, - identityClient, primaryDeviceRequestedLogOut, processDMOperation, reBroadcastAccountDeletion, + runDeviceListUpdate, ], ); } diff --git a/native/profile/linked-devices-bottom-sheet.react.js b/native/profile/linked-devices-bottom-sheet.react.js --- a/native/profile/linked-devices-bottom-sheet.react.js +++ b/native/profile/linked-devices-bottom-sheet.react.js @@ -5,16 +5,13 @@ 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'; @@ -47,11 +44,10 @@ 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'); @@ -72,7 +68,10 @@ } try { - await removeDeviceFromDeviceList(identityClient, userID, deviceID); + await runDeviceListUpdate({ + type: 'remove', + deviceID, + }); } catch (err) { console.log('Primary device error:', err); Alert.alert( @@ -87,23 +86,17 @@ 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 = () => { diff --git a/native/profile/secondary-device-qr-code-scanner.react.js b/native/profile/secondary-device-qr-code-scanner.react.js --- a/native/profile/secondary-device-qr-code-scanner.react.js +++ b/native/profile/secondary-device-qr-code-scanner.react.js @@ -8,18 +8,10 @@ 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 { @@ -40,7 +32,6 @@ 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'; @@ -83,10 +74,8 @@ 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(); @@ -130,30 +119,6 @@ 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 }, @@ -190,16 +155,7 @@ { text: 'OK', onPress: goBack }, ]); }, - [ - identityContext, - broadcastDeviceListUpdates, - foreignPeerDevices, - getAndUpdateDeviceListsForUsers, - getBackupSecret, - tunnelbrokerContext, - goBack, - retrieveLatestBackupInfo, - ], + [getBackupSecret, goBack, retrieveLatestBackupInfo, tunnelbrokerContext], ); React.useEffect(() => { @@ -259,12 +215,11 @@ 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); @@ -282,11 +237,10 @@ !keyserverDeviceID || keyserverDeviceID === targetDeviceID ) { - await addDeviceToDeviceList( - identityContext.identityClient, - userID, - targetDeviceID, - ); + await runDeviceListUpdate({ + type: 'add', + deviceID: targetDeviceID, + }); await sendDeviceListUpdateSuccessMessage(); return; } @@ -314,7 +268,13 @@ ]); goBack(); } - }, [goBack, identityContext, keyserverDeviceID, tunnelbrokerContext]); + }, [ + goBack, + identityContext, + keyserverDeviceID, + runDeviceListUpdate, + tunnelbrokerContext, + ]); const onPressSave = React.useCallback(async () => { if (!urlInput) {