diff --git a/lib/tunnelbroker/tunnelbroker-context.js b/lib/tunnelbroker/tunnelbroker-context.js --- a/lib/tunnelbroker/tunnelbroker-context.js +++ b/lib/tunnelbroker/tunnelbroker-context.js @@ -1,6 +1,7 @@ // @flow import invariant from 'invariant'; +import _isEqual from 'lodash/fp/isEqual.js'; import * as React from 'react'; import uuid from 'uuid'; @@ -9,6 +10,7 @@ import { peerToPeerMessageHandler } from '../handlers/peer-to-peer-message-handler.js'; import { IdentityClientContext } from '../shared/identity-client-context.js'; import { tunnelbrokerHeartbeatTimeout } from '../shared/timeouts.js'; +import { isWebPlatform } from '../types/device-types.js'; import type { MessageReceiveConfirmation } from '../types/tunnelbroker/message-receive-confirmation-types.js'; import type { MessageSentStatus } from '../types/tunnelbroker/message-to-device-request-status-types.js'; import type { MessageToDeviceRequest } from '../types/tunnelbroker/message-to-device-request-types.js'; @@ -23,10 +25,14 @@ } from '../types/tunnelbroker/peer-to-peer-message-types.js'; import type { AnonymousInitializationMessage, - TunnelbrokerInitializationMessage, ConnectionInitializationMessage, + TunnelbrokerInitializationMessage, + TunnelbrokerDeviceTypes, } from '../types/tunnelbroker/session-types.js'; import type { Heartbeat } from '../types/websocket/heartbeat-types.js'; +import { getConfig } from '../utils/config.js'; +import { getContentSigningKey } from '../utils/crypto-utils.js'; +import { useSelector } from '../utils/redux-utils.js'; export type ClientMessageToDevice = { +deviceID: string, @@ -59,55 +65,72 @@ +children: React.Node, +shouldBeClosed?: boolean, +onClose?: () => mixed, - +initMessage: ?ConnectionInitializationMessage, +secondaryTunnelbrokerConnection?: SecondaryTunnelbrokerConnection, }; +function getTunnelbrokerDeviceType(): TunnelbrokerDeviceTypes { + return isWebPlatform(getConfig().platformDetails.platform) ? 'web' : 'mobile'; +} + function createAnonymousInitMessage( deviceID: string, ): AnonymousInitializationMessage { return ({ type: 'AnonymousInitializationMessage', deviceID, - deviceType: 'mobile', + deviceType: getTunnelbrokerDeviceType(), }: AnonymousInitializationMessage); } function TunnelbrokerProvider(props: Props): React.Node { - const { - children, - shouldBeClosed, - onClose, - initMessage: initMessageProp, - secondaryTunnelbrokerConnection, - } = props; - const [connected, setConnected] = React.useState(false); - const listeners = React.useRef>(new Set()); - const socket = React.useRef(null); - const promises = React.useRef({}); - const heartbeatTimeoutID = React.useRef(); + const { children, shouldBeClosed, onClose, secondaryTunnelbrokerConnection } = + props; + + const accessToken = useSelector(state => state.commServicesAccessToken); + const userID = useSelector(state => state.currentUserInfo?.id); + const [unauthorizedDeviceID, setUnauthorizedDeviceID] = React.useState(null); const isAuthorized = !unauthorizedDeviceID; - const identityContext = React.useContext(IdentityClientContext); - invariant(identityContext, 'Identity context should be set'); - const { identityClient } = identityContext; - - const initMessage = React.useMemo(() => { + const createInitMessage = React.useCallback(async () => { if (shouldBeClosed) { return null; } - if (!unauthorizedDeviceID) { - return initMessageProp; + + if (unauthorizedDeviceID) { + return createAnonymousInitMessage(unauthorizedDeviceID); + } + + if (!accessToken || !userID) { + return null; + } + + const deviceID = await getContentSigningKey(); + if (!deviceID) { + return null; } - return createAnonymousInitMessage(unauthorizedDeviceID); - }, [shouldBeClosed, unauthorizedDeviceID, initMessageProp]); + return ({ + type: 'ConnectionInitializationMessage', + deviceID, + accessToken, + userID, + deviceType: getTunnelbrokerDeviceType(), + }: ConnectionInitializationMessage); + }, [accessToken, shouldBeClosed, unauthorizedDeviceID, userID]); const previousInitMessage = - React.useRef(initMessage); - const initMessageChanged = initMessage !== previousInitMessage.current; - previousInitMessage.current = initMessage; + React.useRef(null); + + const [connected, setConnected] = React.useState(false); + const listeners = React.useRef>(new Set()); + const socket = React.useRef(null); + const promises = React.useRef({}); + const heartbeatTimeoutID = React.useRef(); + + const identityContext = React.useContext(IdentityClientContext); + invariant(identityContext, 'Identity context should be set'); + const { identityClient } = identityContext; const stopHeartbeatTimeout = React.useCallback(() => { if (heartbeatTimeoutID.current) { @@ -129,6 +152,7 @@ socket.current?.readyState === WebSocket.OPEN || socket.current?.readyState === WebSocket.CONNECTING; + const connectionChangePromise = React.useRef>(null); // The Tunnelbroker connection can have 4 states: // - DISCONNECTED: isSocketActive = false, connected = false // Should be in this state when initMessage is null @@ -138,144 +162,161 @@ // - DISCONNECTING: isSocketActive = false, connected = true // This lasts between socket.close() and socket.onclose() React.useEffect(() => { - // when initMessage changes, we need to close the socket and open a new one - if ( - (!initMessage || initMessageChanged) && - isSocketActive && - socket.current - ) { - socket.current?.close(); - return; - } - - // when we're already connected (or pending disconnection), - // or there's no init message to start with, we don't need to do anything - if (connected || !initMessage || socket.current) { - return; - } - - const tunnelbrokerSocket = new WebSocket(tunnnelbrokerURL); - - tunnelbrokerSocket.onopen = () => { - tunnelbrokerSocket.send(JSON.stringify(initMessage)); - }; - - tunnelbrokerSocket.onclose = () => { - // this triggers the effect hook again and reconnect - setConnected(false); - onClose?.(); - socket.current = null; - console.log('Connection to Tunnelbroker closed'); - }; - tunnelbrokerSocket.onerror = e => { - console.log('Tunnelbroker socket error:', e.message); - }; - tunnelbrokerSocket.onmessage = (event: MessageEvent) => { - if (typeof event.data !== 'string') { - console.log('socket received a non-string message'); - return; - } - let rawMessage; + connectionChangePromise.current = (async () => { + await connectionChangePromise.current; try { - rawMessage = JSON.parse(event.data); - } catch (e) { - console.log('error while parsing Tunnelbroker message:', e.message); - return; - } + const initMessage = await createInitMessage(); + const initMessageChanged = !_isEqual( + previousInitMessage.current, + initMessage, + ); + previousInitMessage.current = initMessage; + + // when initMessage changes, we need to close the socket + // and open a new one + if ( + (!initMessage || initMessageChanged) && + isSocketActive && + socket.current + ) { + socket.current?.close(); + return; + } - if (!tunnelbrokerMessageValidator.is(rawMessage)) { - console.log('invalid TunnelbrokerMessage'); - return; - } - const message: TunnelbrokerMessage = rawMessage; + // when we're already connected (or pending disconnection), + // or there's no init message to start with, we don't need + // to do anything + if (connected || !initMessage || socket.current) { + return; + } - resetHeartbeatTimeout(); + const tunnelbrokerSocket = new WebSocket(tunnnelbrokerURL); - for (const listener of listeners.current) { - listener(message); - } + tunnelbrokerSocket.onopen = () => { + tunnelbrokerSocket.send(JSON.stringify(initMessage)); + }; - if ( - message.type === - tunnelbrokerMessageTypes.CONNECTION_INITIALIZATION_RESPONSE - ) { - if (message.status.type === 'Success' && !connected) { - setConnected(true); - console.log( - 'session with Tunnelbroker created. isAuthorized:', - isAuthorized, - ); - } else if (message.status.type === 'Success' && connected) { - console.log( - 'received ConnectionInitializationResponse with status: Success for already connected socket', - ); - } else { + tunnelbrokerSocket.onclose = () => { + // this triggers the effect hook again and reconnect setConnected(false); - console.log( - 'creating session with Tunnelbroker error:', - message.status.data, - ); - } - } else if (message.type === tunnelbrokerMessageTypes.MESSAGE_TO_DEVICE) { - const confirmation: MessageReceiveConfirmation = { - type: tunnelbrokerMessageTypes.MESSAGE_RECEIVE_CONFIRMATION, - messageIDs: [message.messageID], + onClose?.(); + socket.current = null; + console.log('Connection to Tunnelbroker closed'); }; - socket.current?.send(JSON.stringify(confirmation)); - - let rawPeerToPeerMessage; - try { - rawPeerToPeerMessage = JSON.parse(message.payload); - } catch (e) { - console.log( - 'error while parsing Tunnelbroker peer-to-peer message:', - e.message, - ); - return; - } + tunnelbrokerSocket.onerror = e => { + console.log('Tunnelbroker socket error:', e.message); + }; + tunnelbrokerSocket.onmessage = (event: MessageEvent) => { + if (typeof event.data !== 'string') { + console.log('socket received a non-string message'); + return; + } + let rawMessage; + try { + rawMessage = JSON.parse(event.data); + } catch (e) { + console.log('error while parsing Tunnelbroker message:', e.message); + return; + } - if (!peerToPeerMessageValidator.is(rawPeerToPeerMessage)) { - console.log('invalid Tunnelbroker PeerToPeerMessage'); - return; - } - const peerToPeerMessage: PeerToPeerMessage = rawPeerToPeerMessage; - void peerToPeerMessageHandler(peerToPeerMessage, identityClient); - } else if ( - message.type === - tunnelbrokerMessageTypes.MESSAGE_TO_DEVICE_REQUEST_STATUS - ) { - for (const status: MessageSentStatus of message.clientMessageIDs) { - if (status.type === 'Success') { - promises.current[status.data]?.resolve(); - delete promises.current[status.data]; - } else if (status.type === 'Error') { - promises.current[status.data.id]?.reject(status.data.error); - delete promises.current[status.data.id]; - } else if (status.type === 'SerializationError') { - console.log('SerializationError for message: ', status.data); - } else if (status.type === 'InvalidRequest') { - console.log('Tunnelbroker recorded InvalidRequest'); + if (!tunnelbrokerMessageValidator.is(rawMessage)) { + console.log('invalid TunnelbrokerMessage'); + return; + } + const message: TunnelbrokerMessage = rawMessage; + + resetHeartbeatTimeout(); + + for (const listener of listeners.current) { + listener(message); + } + + if ( + message.type === + tunnelbrokerMessageTypes.CONNECTION_INITIALIZATION_RESPONSE + ) { + if (message.status.type === 'Success' && !connected) { + setConnected(true); + console.log( + 'session with Tunnelbroker created. isAuthorized:', + isAuthorized, + ); + } else if (message.status.type === 'Success' && connected) { + console.log( + 'received ConnectionInitializationResponse with status: Success for already connected socket', + ); + } else { + setConnected(false); + console.log( + 'creating session with Tunnelbroker error:', + message.status.data, + ); + } + } else if ( + message.type === tunnelbrokerMessageTypes.MESSAGE_TO_DEVICE + ) { + const confirmation: MessageReceiveConfirmation = { + type: tunnelbrokerMessageTypes.MESSAGE_RECEIVE_CONFIRMATION, + messageIDs: [message.messageID], + }; + socket.current?.send(JSON.stringify(confirmation)); + + let rawPeerToPeerMessage; + try { + rawPeerToPeerMessage = JSON.parse(message.payload); + } catch (e) { + console.log( + 'error while parsing Tunnelbroker peer-to-peer message:', + e.message, + ); + return; + } + + if (!peerToPeerMessageValidator.is(rawPeerToPeerMessage)) { + console.log('invalid Tunnelbroker PeerToPeerMessage'); + return; + } + const peerToPeerMessage: PeerToPeerMessage = rawPeerToPeerMessage; + void peerToPeerMessageHandler(peerToPeerMessage, identityClient); + } else if ( + message.type === + tunnelbrokerMessageTypes.MESSAGE_TO_DEVICE_REQUEST_STATUS + ) { + for (const status: MessageSentStatus of message.clientMessageIDs) { + if (status.type === 'Success') { + promises.current[status.data]?.resolve(); + delete promises.current[status.data]; + } else if (status.type === 'Error') { + promises.current[status.data.id]?.reject(status.data.error); + delete promises.current[status.data.id]; + } else if (status.type === 'SerializationError') { + console.log('SerializationError for message: ', status.data); + } else if (status.type === 'InvalidRequest') { + console.log('Tunnelbroker recorded InvalidRequest'); + } + } + } else if (message.type === tunnelbrokerMessageTypes.HEARTBEAT) { + const heartbeat: Heartbeat = { + type: tunnelbrokerMessageTypes.HEARTBEAT, + }; + socket.current?.send(JSON.stringify(heartbeat)); } - } - } else if (message.type === tunnelbrokerMessageTypes.HEARTBEAT) { - const heartbeat: Heartbeat = { - type: tunnelbrokerMessageTypes.HEARTBEAT, }; - socket.current?.send(JSON.stringify(heartbeat)); - } - }; - socket.current = tunnelbrokerSocket; + socket.current = tunnelbrokerSocket; + } catch (err) { + console.log('Tunnelbroker connection error:', err); + } + })(); }, [ connected, - initMessage, - initMessageChanged, isSocketActive, isAuthorized, resetHeartbeatTimeout, stopHeartbeatTimeout, identityClient, onClose, + createInitMessage, ]); const sendMessageToDeviceRequest: ( diff --git a/native/root.react.js b/native/root.react.js --- a/native/root.react.js +++ b/native/root.react.js @@ -83,7 +83,6 @@ import ThemeHandler from './themes/theme-handler.react.js'; import { provider } from './utils/ethers-utils.js'; import { neynarKey } from './utils/neynar-utils.js'; -import { useTunnelbrokerInitMessage } from './utils/tunnelbroker-utils.js'; // Add custom items to expo-dev-menu import './dev-menu.js'; @@ -265,8 +264,6 @@ return undefined; })(); - const tunnelbrokerInitMessage = useTunnelbrokerInitMessage(); - const gated: React.Node = ( <> @@ -306,7 +303,7 @@ - + diff --git a/native/utils/tunnelbroker-utils.js b/native/utils/tunnelbroker-utils.js deleted file mode 100644 --- a/native/utils/tunnelbroker-utils.js +++ /dev/null @@ -1,38 +0,0 @@ -// @flow - -import * as React from 'react'; - -import type { ConnectionInitializationMessage } from 'lib/types/tunnelbroker/session-types.js'; - -import { commCoreModule } from '../native-modules.js'; -import { useSelector } from '../redux/redux-utils.js'; - -function useTunnelbrokerInitMessage(): ?ConnectionInitializationMessage { - const [deviceID, setDeviceID] = React.useState(); - const [userID, setUserID] = React.useState(); - const accessToken = useSelector(state => state.commServicesAccessToken); - - React.useEffect(() => { - void (async () => { - const { userID: identityUserID, deviceID: contentSigningKey } = - await commCoreModule.getCommServicesAuthMetadata(); - setDeviceID(contentSigningKey); - setUserID(identityUserID); - })(); - }, [accessToken]); - - return React.useMemo(() => { - if (!deviceID || !accessToken || !userID) { - return null; - } - return ({ - type: 'ConnectionInitializationMessage', - deviceID, - accessToken, - userID, - deviceType: 'mobile', - }: ConnectionInitializationMessage); - }, [accessToken, deviceID, userID]); -} - -export { useTunnelbrokerInitMessage }; diff --git a/web/app.react.js b/web/app.react.js --- a/web/app.react.js +++ b/web/app.react.js @@ -71,7 +71,6 @@ import VisibilityHandler from './redux/visibility-handler.react.js'; import history from './router-history.js'; import { MessageSearchStateProvider } from './search/message-search-state-provider.react.js'; -import { createTunnelbrokerInitMessage } from './selectors/tunnelbroker-selectors.js'; import AccountSettings from './settings/account-settings.react.js'; import DangerZone from './settings/danger-zone.react.js'; import KeyserverSelectionList from './settings/keyserver-selection-list.react.js'; @@ -520,8 +519,6 @@ [modalContext.modals], ); - const tunnelbrokerInitMessage = useSelector(createTunnelbrokerInitMessage); - const { lockStatus, releaseLockOrAbortRequest } = useWebLock( TUNNELBROKER_LOCK_NAME, ); @@ -532,7 +529,6 @@ return ( ?ConnectionInitializationMessage = - createSelector( - (state: AppState) => state.cryptoStore?.primaryIdentityKeys?.ed25519, - (state: AppState) => state.commServicesAccessToken, - (state: AppState) => state.currentUserInfo?.id, - ( - deviceID: ?string, - accessToken: ?string, - userID: ?string, - ): ?ConnectionInitializationMessage => { - if (!deviceID || !accessToken || !userID) { - return null; - } - return ({ - type: 'ConnectionInitializationMessage', - deviceID, - accessToken, - userID, - deviceType: 'web', - }: ConnectionInitializationMessage); - }, - );