diff --git a/lib/shared/dm-ops/process-dm-ops.js b/lib/shared/dm-ops/process-dm-ops.js new file mode 100644 index 000000000..5d1744f28 --- /dev/null +++ b/lib/shared/dm-ops/process-dm-ops.js @@ -0,0 +1,34 @@ +// @flow + +import * as React from 'react'; + +import { dmOpSpecs } from './dm-op-specs.js'; +import { useLoggedInUserInfo } from '../../hooks/account-hooks.js'; +import { useGetLatestMessageEdit } from '../../hooks/latest-message-edit.js'; +import type { DMOperation } from '../../types/dm-ops.js'; + +function useProcessDMOperation(): (dmOp: DMOperation) => Promise { + const fetchMessage = useGetLatestMessageEdit(); + const utilities = React.useMemo( + () => ({ + fetchMessage, + }), + [fetchMessage], + ); + + const loggedInUserInfo = useLoggedInUserInfo(); + const viewerID = loggedInUserInfo?.id; + return React.useCallback( + async (dmOp: DMOperation) => { + if (!viewerID) { + console.log('ignored DMOperation because logged out'); + return; + } + await dmOpSpecs[dmOp.type].processDMOperation(dmOp, viewerID, utilities); + // TODO: dispatch Redux action + }, + [viewerID, utilities], + ); +} + +export { useProcessDMOperation }; diff --git a/lib/tunnelbroker/use-peer-to-peer-message-handler.js b/lib/tunnelbroker/use-peer-to-peer-message-handler.js index d90bad72c..7a6551e54 100644 --- a/lib/tunnelbroker/use-peer-to-peer-message-handler.js +++ b/lib/tunnelbroker/use-peer-to-peer-message-handler.js @@ -1,327 +1,335 @@ // @flow import invariant from 'invariant'; import _isEqual from 'lodash/fp/isEqual.js'; import * as React from 'react'; import { logOutActionTypes, useLogOut } from '../actions/user-actions.js'; import { useBroadcastDeviceListUpdates, useGetAndUpdateDeviceListsForUsers, } from '../hooks/peer-list-hooks.js'; import { getAllPeerDevices, getForeignPeerDevices, } from '../selectors/user-selectors.js'; import { verifyAndGetDeviceList, removeDeviceFromDeviceList, } from '../shared/device-list-utils.js'; +import { useProcessDMOperation } from '../shared/dm-ops/process-dm-ops.js'; import { IdentityClientContext } from '../shared/identity-client-context.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 { getConfig } from '../utils/config.js'; import { getContentSigningKey } from '../utils/crypto-utils.js'; import { getMessageForException } from '../utils/errors.js'; import { hasHigherDeviceID, 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 { 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 }); // handles `peerToPeerMessageTypes.ENCRYPTED_MESSAGE` function useHandleOlmMessageToDevice() { const identityContext = React.useContext(IdentityClientContext); invariant(identityContext, 'Identity context should be set'); const { identityClient } = identityContext; const broadcastDeviceListUpdates = useBroadcastDeviceListUpdates(); const allPeerDevices = useSelector(getAllPeerDevices); const dispatchActionPromise = useDispatchActionPromise(); const primaryDeviceRequestedLogOut = useLogOut(primaryRequestLogoutOptions); + const processDMOperation = useProcessDMOperation(); + return React.useCallback( async (decryptedMessageContent: string, senderInfo: SenderInfo) => { const parsedMessageToDevice = JSON.parse(decryptedMessageContent); // Handle user-action messages if (!userActionP2PMessageValidator.is(parsedMessageToDevice)) { return; } const userActionMessage: UserActionP2PMessage = parsedMessageToDevice; if ( userActionMessage.type === userActionsP2PMessageTypes.LOG_OUT_PRIMARY_DEVICE ) { 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), ); + } else if ( + userActionMessage.type === userActionsP2PMessageTypes.DM_OPERATION + ) { + await processDMOperation(userActionMessage.op); } else { console.warn( 'Unsupported P2P user action message:', userActionMessage.type, ); } }, [ allPeerDevices, broadcastDeviceListUpdates, dispatchActionPromise, identityClient, primaryDeviceRequestedLogOut, + processDMOperation, ], ); } 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(getForeignPeerDevices); const broadcastDeviceListUpdates = useBroadcastDeviceListUpdates(); const getAndUpdateDeviceListsForUsers = useGetAndUpdateDeviceListsForUsers(); const handleOlmMessageToDevice = useHandleOlmMessageToDevice(); 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(e.message); } if (!deviceKeys) { console.log( '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, ); console.log( 'Created inbound session with device ' + `${senderDeviceID}: ${result}, ` + `session version: ${sessionVersion}`, ); } catch (e) { if (e.message?.includes(olmSessionErrors.alreadyCreated)) { console.log( 'Received session request with lower session version from ' + `${senderDeviceID}, session version: ${sessionVersion}`, ); } else if (e.message?.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}`, ); // Resend all not-yet confirmed messages that were encrypted // with overwrite session. Tracked in ENG-6982. } } else { console.log( 'Error creating inbound session with device ' + `${senderDeviceID}: ${e.message}, ` + `session version: ${sessionVersion}`, ); } } } else if (message.type === peerToPeerMessageTypes.ENCRYPTED_MESSAGE) { try { await olmAPI.initializeCryptoAccount(); const decrypted = await olmAPI.decryptSequentialAndPersist( message.encryptedData, message.senderInfo.deviceID, messageID, ); console.log( 'Decrypted message from device ' + `${message.senderInfo.deviceID}: ${decrypted}`, ); try { await handleOlmMessageToDevice(decrypted, message.senderInfo); } catch (e) { console.log('Failed processing Olm P2P message:', e); } } catch (e) { if (e.message?.includes(olmSessionErrors.messageAlreadyDecrypted)) { console.log( 'Received already decrypted message from device ' + `${message.senderInfo.deviceID}.`, ); } else if (e.message?.includes(olmSessionErrors.messageOutOfOrder)) { console.log( 'Received out-of-order message from device ' + `${message.senderInfo.deviceID}.`, ); } else { console.log( 'Error decrypting message from device ' + `${message.senderInfo.deviceID}: ${e.message}`, ); } } } 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: ${e.message}`); } } 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.removeOutboundP2PMessagesOlderThan( clientMessageID, deviceID, ); } catch (e) { console.log( `Error removing message after processing: ${ getMessageForException(e) ?? 'unknown error' }`, ); } } }, [ broadcastDeviceListUpdates, foreignPeerDevices, getAndUpdateDeviceListsForUsers, getAuthMetadata, handleOlmMessageToDevice, identityClient, olmAPI, sqliteAPI, ], ); } export { usePeerToPeerMessageHandler }; diff --git a/lib/types/dm-ops.js b/lib/types/dm-ops.js index 62b851c40..86b800474 100644 --- a/lib/types/dm-ops.js +++ b/lib/types/dm-ops.js @@ -1,96 +1,101 @@ // @flow -import type { TInterface } from 'tcomb'; +import type { TInterface, TUnion } from 'tcomb'; import t from 'tcomb'; import type { RawMessageInfo } from './message-types.js'; import { type NonSidebarThickThreadType, nonSidebarThickThreadTypes, } from './thread-types-enum.js'; import type { ClientUpdateInfo } from './update-types.js'; import { values } from '../utils/objects.js'; import { tShape, tString, tUserID } from '../utils/validation-utils.js'; export const dmOperationTypes = Object.freeze({ CREATE_THREAD: 'create_thread', CREATE_SIDEBAR: 'create_sidebar', SEND_TEXT_MESSAGE: 'send_text_message', }); export type DMOperationType = $Values; export type DMCreateThreadOperation = { +type: 'create_thread', +threadID: string, +creatorID: string, +time: number, +threadType: NonSidebarThickThreadType, +memberIDs: $ReadOnlyArray, +roleID: string, +newMessageID: string, }; export const dmCreateThreadOperationValidator: TInterface = tShape({ type: tString(dmOperationTypes.CREATE_THREAD), threadID: t.String, creatorID: tUserID, time: t.Number, threadType: t.enums.of(values(nonSidebarThickThreadTypes)), memberIDs: t.list(tUserID), roleID: t.String, newMessageID: t.String, }); export type DMCreateSidebarOperation = { +type: 'create_sidebar', +threadID: string, +creatorID: string, +time: number, +parentThreadID: string, +memberIDs: $ReadOnlyArray, +sourceMessageID: string, +roleID: string, +newSidebarSourceMessageID: string, +newCreateSidebarMessageID: string, }; export const dmCreateSidebarOperationValidator: TInterface = tShape({ type: tString(dmOperationTypes.CREATE_SIDEBAR), threadID: t.String, creatorID: tUserID, time: t.Number, parentThreadID: t.String, memberIDs: t.list(tUserID), sourceMessageID: t.String, roleID: t.String, newSidebarSourceMessageID: t.String, newCreateSidebarMessageID: t.String, }); export type DMSendTextMessageOperation = { +type: 'send_text_message', +threadID: string, +creatorID: string, +time: number, +messageID: string, +text: string, }; export const dmSendTextMessageOperationValidator: TInterface = tShape({ type: tString(dmOperationTypes.SEND_TEXT_MESSAGE), threadID: t.String, creatorID: tUserID, time: t.Number, messageID: t.String, text: t.String, }); export type DMOperation = | DMCreateThreadOperation | DMCreateSidebarOperation | DMSendTextMessageOperation; +export const dmOperationValidator: TUnion = t.union([ + dmCreateThreadOperationValidator, + dmCreateSidebarOperationValidator, + dmSendTextMessageOperationValidator, +]); export type DMOperationResult = { rawMessageInfos: Array, updateInfos: Array, }; diff --git a/lib/types/tunnelbroker/user-actions-peer-to-peer-message-types.js b/lib/types/tunnelbroker/user-actions-peer-to-peer-message-types.js index 38b617035..3069a1ae6 100644 --- a/lib/types/tunnelbroker/user-actions-peer-to-peer-message-types.js +++ b/lib/types/tunnelbroker/user-actions-peer-to-peer-message-types.js @@ -1,37 +1,51 @@ // @flow import t, { type TInterface, type TUnion } from 'tcomb'; import { tShape, tString } from '../../utils/validation-utils.js'; +import { type DMOperation, dmOperationValidator } from '../dm-ops.js'; export const userActionsP2PMessageTypes = Object.freeze({ LOG_OUT_PRIMARY_DEVICE: 'LOG_OUT_PRIMARY_DEVICE', LOG_OUT_SECONDARY_DEVICE: 'LOG_OUT_SECONDARY_DEVICE', + DM_OPERATION: 'DM_OPERATION', }); export type PrimaryDeviceLogoutP2PMessage = { +type: 'LOG_OUT_PRIMARY_DEVICE', }; export const primaryDeviceLogoutP2PMessageValidator: TInterface = tShape({ type: tString(userActionsP2PMessageTypes.LOG_OUT_PRIMARY_DEVICE), }); export type SecondaryDeviceLogoutP2PMessage = { +type: 'LOG_OUT_SECONDARY_DEVICE', // there is `senderID` so we don't have to add deviceID here }; export const secondaryDeviceLogoutP2PMessageValidator: TInterface = tShape({ type: tString(userActionsP2PMessageTypes.LOG_OUT_SECONDARY_DEVICE), }); +export type DMOperationP2PMessage = { + +type: 'DM_OPERATION', + +op: DMOperation, +}; +export const dmOperationP2PMessageValidator: TInterface = + tShape({ + type: tString(userActionsP2PMessageTypes.DM_OPERATION), + op: dmOperationValidator, + }); + export type UserActionP2PMessage = | PrimaryDeviceLogoutP2PMessage - | SecondaryDeviceLogoutP2PMessage; + | SecondaryDeviceLogoutP2PMessage + | DMOperationP2PMessage; export const userActionP2PMessageValidator: TUnion = t.union([ primaryDeviceLogoutP2PMessageValidator, secondaryDeviceLogoutP2PMessageValidator, + dmOperationP2PMessageValidator, ]);