diff --git a/keyserver/src/socket/tunnelbroker.js b/keyserver/src/socket/tunnelbroker.js index 2f057491e..7dcd292d4 100644 --- a/keyserver/src/socket/tunnelbroker.js +++ b/keyserver/src/socket/tunnelbroker.js @@ -1,480 +1,479 @@ // @flow import invariant from 'invariant'; import _debounce from 'lodash/debounce.js'; import { getRustAPI } from 'rust-node-addon'; import uuid from 'uuid'; import WebSocket from 'ws'; import { hexToUintArray } from 'lib/media/data-utils.js'; import { clientTunnelbrokerSocketReconnectDelay, tunnelbrokerHeartbeatTimeout, } from 'lib/shared/timeouts.js'; import type { TunnelbrokerClientMessageToDevice } from 'lib/tunnelbroker/tunnelbroker-context.js'; import type { MessageSentStatus } from 'lib/types/tunnelbroker/device-to-tunnelbroker-request-status-types.js'; import type { MessageReceiveConfirmation } from 'lib/types/tunnelbroker/message-receive-confirmation-types.js'; import type { MessageToDeviceRequest } from 'lib/types/tunnelbroker/message-to-device-request-types.js'; import { deviceToTunnelbrokerMessageTypes, tunnelbrokerToDeviceMessageTypes, tunnelbrokerToDeviceMessageValidator, type TunnelbrokerToDeviceMessage, } from 'lib/types/tunnelbroker/messages.js'; import { qrCodeAuthMessageValidator, type RefreshKeyRequest, refreshKeysRequestValidator, type QRCodeAuthMessage, } from 'lib/types/tunnelbroker/peer-to-peer-message-types.js'; import { peerToPeerMessageTypes } from 'lib/types/tunnelbroker/peer-to-peer-message-types.js'; import { type QRCodeAuthMessagePayload, qrCodeAuthMessagePayloadValidator, qrCodeAuthMessageTypes, } from 'lib/types/tunnelbroker/qr-code-auth-message-types.js'; import type { ConnectionInitializationMessage, AnonymousInitializationMessage, } from 'lib/types/tunnelbroker/session-types.js'; import type { Heartbeat } from 'lib/types/websocket/heartbeat-types.js'; import { getCommConfig } from 'lib/utils/comm-config.js'; import { convertBytesToObj, convertObjToBytes, } from 'lib/utils/conversion-utils.js'; import { getMessageForException } from 'lib/utils/errors.js'; import sleep from 'lib/utils/sleep.js'; import { fetchOlmAccount } from '../updaters/olm-account-updater.js'; import { fetchIdentityInfo, saveIdentityInfo } from '../user/identity.js'; import type { IdentityInfo } from '../user/identity.js'; import { encrypt, decrypt } from '../utils/aes-crypto-utils.js'; import { getContentSigningKey, uploadNewOneTimeKeys, getNewDeviceKeyUpload, markPrekeysAsPublished, } from '../utils/olm-utils.js'; type TBConnectionInfo = { +url: string, }; async function getTBConnectionInfo(): Promise { const tbConfig = await getCommConfig({ folder: 'facts', name: 'tunnelbroker', }); if (tbConfig) { return tbConfig; } console.warn('Defaulting to staging Tunnelbroker'); return { url: 'wss://tunnelbroker.staging.commtechnologies.org:51001', }; } async function createAndMaintainTunnelbrokerWebsocket(encryptionKey: ?string) { const [deviceID, tbConnectionInfo] = await Promise.all([ getContentSigningKey(), getTBConnectionInfo(), ]); const createNewTunnelbrokerSocket = async ( shouldNotifyPrimaryAfterReopening: boolean, primaryDeviceID: ?string, ) => { const identityInfo = await fetchIdentityInfo(); new TunnelbrokerSocket({ socketURL: tbConnectionInfo.url, onClose: async (successfullyAuthed: boolean, primaryID: ?string) => { await sleep(clientTunnelbrokerSocketReconnectDelay); await createNewTunnelbrokerSocket(successfullyAuthed, primaryID); }, identityInfo, deviceID, qrAuthEncryptionKey: encryptionKey, primaryDeviceID, shouldNotifyPrimaryAfterReopening, }); }; await createNewTunnelbrokerSocket(false, null); } type TunnelbrokerSocketParams = { +socketURL: string, +onClose: (boolean, ?string) => mixed, +identityInfo: ?IdentityInfo, +deviceID: string, +qrAuthEncryptionKey: ?string, +primaryDeviceID: ?string, +shouldNotifyPrimaryAfterReopening: boolean, }; type PromiseCallbacks = { +resolve: () => void, +reject: (error: string) => void, }; type Promises = { [clientMessageID: string]: PromiseCallbacks }; class TunnelbrokerSocket { ws: WebSocket; connected: boolean = false; closed: boolean = false; promises: Promises = {}; heartbeatTimeoutID: ?TimeoutID; oneTimeKeysPromise: ?Promise; identityInfo: ?IdentityInfo; qrAuthEncryptionKey: ?string; primaryDeviceID: ?string; shouldNotifyPrimaryAfterReopening: boolean = false; shouldNotifyPrimary: boolean = false; constructor(tunnelbrokerSocketParams: TunnelbrokerSocketParams) { const { socketURL, onClose, identityInfo, deviceID, qrAuthEncryptionKey, primaryDeviceID, shouldNotifyPrimaryAfterReopening, } = tunnelbrokerSocketParams; this.identityInfo = identityInfo; this.qrAuthEncryptionKey = qrAuthEncryptionKey; this.primaryDeviceID = primaryDeviceID; if (shouldNotifyPrimaryAfterReopening) { this.shouldNotifyPrimary = true; } const socket = new WebSocket(socketURL); socket.on('open', () => { this.onOpen(socket, deviceID); }); socket.on('close', async () => { if (this.closed) { return; } this.closed = true; this.connected = false; this.stopHeartbeatTimeout(); console.error('Connection to Tunnelbroker closed'); onClose(this.shouldNotifyPrimaryAfterReopening, this.primaryDeviceID); }); socket.on('error', (error: Error) => { console.error('Tunnelbroker socket error:', error.message); }); socket.on('message', this.onMessage); this.ws = socket; } onOpen: (socket: WebSocket, deviceID: string) => void = ( socket, deviceID, ) => { if (this.closed) { return; } if (this.identityInfo) { const initMessage: ConnectionInitializationMessage = { type: 'ConnectionInitializationMessage', deviceID, accessToken: this.identityInfo.accessToken, userID: this.identityInfo.userId, deviceType: 'keyserver', }; socket.send(JSON.stringify(initMessage)); } else { const initMessage: AnonymousInitializationMessage = { type: 'AnonymousInitializationMessage', deviceID, deviceType: 'keyserver', }; socket.send(JSON.stringify(initMessage)); } }; onMessage: (event: ArrayBuffer) => Promise = async ( event: ArrayBuffer, ) => { let rawMessage; try { rawMessage = JSON.parse(event.toString()); } catch (e) { console.error('error while parsing Tunnelbroker message:', e.message); return; } if (!tunnelbrokerToDeviceMessageValidator.is(rawMessage)) { console.error( 'invalid tunnelbrokerToDeviceMessage: ', rawMessage.toString(), ); return; } const message: TunnelbrokerToDeviceMessage = rawMessage; this.resetHeartbeatTimeout(); if ( message.type === tunnelbrokerToDeviceMessageTypes.CONNECTION_INITIALIZATION_RESPONSE ) { if (message.status.type === 'Success' && !this.connected) { this.connected = true; console.info( this.identityInfo ? 'session with Tunnelbroker created' : 'anonymous session with Tunnelbroker created', ); if (!this.shouldNotifyPrimary) { return; } const { primaryDeviceID } = this; invariant( primaryDeviceID, 'Primary device ID is not set but should be', ); const payload = await this.encodeQRAuthMessage({ type: qrCodeAuthMessageTypes.SECONDARY_DEVICE_REGISTRATION_SUCCESS, - requestBackupKeys: false, }); if (!payload) { this.closeConnection(); return; } await this.sendMessageToDevice({ deviceID: primaryDeviceID, payload: JSON.stringify(payload), }); } else if (message.status.type === 'Success' && this.connected) { console.info( 'received ConnectionInitializationResponse with status: Success for already connected socket', ); } else { this.connected = false; console.error( 'creating session with Tunnelbroker error:', message.status.data, ); } } else if ( message.type === tunnelbrokerToDeviceMessageTypes.MESSAGE_TO_DEVICE ) { const confirmation: MessageReceiveConfirmation = { type: deviceToTunnelbrokerMessageTypes.MESSAGE_RECEIVE_CONFIRMATION, messageIDs: [message.messageID], }; this.ws.send(JSON.stringify(confirmation)); const { payload } = message; try { const messageToKeyserver = JSON.parse(payload); if (qrCodeAuthMessageValidator.is(messageToKeyserver)) { const request: QRCodeAuthMessage = messageToKeyserver; const [qrCodeAuthMessage, rustAPI, accountInfo] = await Promise.all([ this.parseQRCodeAuthMessage(request), getRustAPI(), fetchOlmAccount('content'), ]); if ( !qrCodeAuthMessage || qrCodeAuthMessage.type !== qrCodeAuthMessageTypes.DEVICE_LIST_UPDATE_SUCCESS ) { return; } const { primaryDeviceID, userID } = qrCodeAuthMessage; this.primaryDeviceID = primaryDeviceID; const [nonce, deviceKeyUpload] = await Promise.all([ rustAPI.generateNonce(), getNewDeviceKeyUpload(), ]); const signedIdentityKeysBlob = { payload: deviceKeyUpload.keyPayload, signature: deviceKeyUpload.keyPayloadSignature, }; const nonceSignature = accountInfo.account.sign(nonce); const identityInfo = await rustAPI.uploadSecondaryDeviceKeysAndLogIn( userID, nonce, nonceSignature, signedIdentityKeysBlob, deviceKeyUpload.contentPrekey, deviceKeyUpload.contentPrekeySignature, deviceKeyUpload.notifPrekey, deviceKeyUpload.notifPrekeySignature, deviceKeyUpload.contentOneTimeKeys, deviceKeyUpload.notifOneTimeKeys, ); await Promise.all([ markPrekeysAsPublished(), saveIdentityInfo(identityInfo), ]); this.shouldNotifyPrimaryAfterReopening = true; this.closeConnection(); } else if (refreshKeysRequestValidator.is(messageToKeyserver)) { const request: RefreshKeyRequest = messageToKeyserver; this.debouncedRefreshOneTimeKeys(request.numberOfKeys); } } catch (e) { console.error( 'error while processing message to keyserver:', e.message, ); } } else if ( message.type === tunnelbrokerToDeviceMessageTypes.DEVICE_TO_TUNNELBROKER_REQUEST_STATUS ) { for (const status: MessageSentStatus of message.clientMessageIDs) { if (status.type === 'Success') { if (this.promises[status.data]) { this.promises[status.data].resolve(); delete this.promises[status.data]; } else { console.log( 'received successful response for a non-existent request', ); } } else if (status.type === 'Error') { if (this.promises[status.data.id]) { this.promises[status.data.id].reject(status.data.error); delete this.promises[status.data.id]; } else { console.log('received error response for a non-existent request'); } } else if (status.type === 'SerializationError') { console.error('SerializationError for message: ', status.data); } else if (status.type === 'InvalidRequest') { console.log('Tunnelbroker recorded InvalidRequest'); } } } else if (message.type === tunnelbrokerToDeviceMessageTypes.HEARTBEAT) { const heartbeat: Heartbeat = { type: deviceToTunnelbrokerMessageTypes.HEARTBEAT, }; this.ws.send(JSON.stringify(heartbeat)); } }; refreshOneTimeKeys: (numberOfKeys: number) => void = numberOfKeys => { const oldOneTimeKeysPromise = this.oneTimeKeysPromise; this.oneTimeKeysPromise = (async () => { await oldOneTimeKeysPromise; await uploadNewOneTimeKeys(numberOfKeys); })(); }; debouncedRefreshOneTimeKeys: (numberOfKeys: number) => void = _debounce( this.refreshOneTimeKeys, 100, { leading: true, trailing: true }, ); sendMessageToDevice: ( message: TunnelbrokerClientMessageToDevice, ) => Promise = (message: TunnelbrokerClientMessageToDevice) => { if (!this.connected) { throw new Error('Tunnelbroker not connected'); } const clientMessageID = uuid.v4(); const messageToDevice: MessageToDeviceRequest = { type: deviceToTunnelbrokerMessageTypes.MESSAGE_TO_DEVICE_REQUEST, clientMessageID, deviceID: message.deviceID, payload: message.payload, }; return new Promise((resolve, reject) => { this.promises[clientMessageID] = { resolve, reject, }; this.ws.send(JSON.stringify(messageToDevice)); }); }; stopHeartbeatTimeout() { if (this.heartbeatTimeoutID) { clearTimeout(this.heartbeatTimeoutID); this.heartbeatTimeoutID = null; } } resetHeartbeatTimeout() { this.stopHeartbeatTimeout(); this.heartbeatTimeoutID = setTimeout(() => { this.ws.close(); this.connected = false; }, tunnelbrokerHeartbeatTimeout); } closeConnection() { this.ws.close(); this.connected = false; } parseQRCodeAuthMessage: ( message: QRCodeAuthMessage, ) => Promise = async message => { const encryptionKey = this.qrAuthEncryptionKey; if (!encryptionKey) { return null; } const encryptedData = Buffer.from(message.encryptedContent, 'base64'); const decryptedData = await decrypt( hexToUintArray(encryptionKey), new Uint8Array(encryptedData), ); const payload = convertBytesToObj(decryptedData); if (!qrCodeAuthMessagePayloadValidator.is(payload)) { return null; } return payload; }; encodeQRAuthMessage: ( payload: QRCodeAuthMessagePayload, ) => Promise = async payload => { const encryptionKey = this.qrAuthEncryptionKey; if (!encryptionKey) { console.error('Encryption key missing - cannot send QR auth message.'); return null; } let encryptedContent; try { const payloadBytes = convertObjToBytes(payload); const keyBytes = hexToUintArray(encryptionKey); const encryptedBytes = await encrypt(keyBytes, payloadBytes); encryptedContent = Buffer.from(encryptedBytes).toString('base64'); } catch (e) { console.error( 'Error encoding QRCodeAuthMessagePayload:', getMessageForException(e), ); return null; } return { type: peerToPeerMessageTypes.QR_CODE_AUTH_MESSAGE, encryptedContent, }; }; } export { createAndMaintainTunnelbrokerWebsocket }; diff --git a/lib/components/qr-auth-provider.react.js b/lib/components/qr-auth-provider.react.js index b2d841b1d..fba340b09 100644 --- a/lib/components/qr-auth-provider.react.js +++ b/lib/components/qr-auth-provider.react.js @@ -1,231 +1,230 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { useSecondaryDeviceLogIn } from '../hooks/login-hooks.js'; import { uintArrayToHexString } from '../media/data-utils.js'; import { IdentityClientContext } from '../shared/identity-client-context.js'; import { useTunnelbroker } from '../tunnelbroker/tunnelbroker-context.js'; import type { BackupKeys } from '../types/backup-types.js'; import { tunnelbrokerToDeviceMessageTypes, type TunnelbrokerToDeviceMessage, } from '../types/tunnelbroker/messages.js'; import { type QRCodeAuthMessage, peerToPeerMessageTypes, peerToPeerMessageValidator, } from '../types/tunnelbroker/peer-to-peer-message-types.js'; import { qrCodeAuthMessageTypes, type QRCodeAuthMessagePayload, } from '../types/tunnelbroker/qr-code-auth-message-types.js'; import { getContentSigningKey } from '../utils/crypto-utils.js'; type Props = { +children: React.Node, +onLogInError: (error: mixed) => void, +generateAESKey: () => Promise, +composeTunnelbrokerQRAuthMessage: ( encryptionKey: string, payload: QRCodeAuthMessagePayload, ) => Promise, +parseTunnelbrokerQRAuthMessage: ( encryptionKey: string, message: QRCodeAuthMessage, ) => Promise, +performBackupRestore?: (backupKeys: BackupKeys) => Promise, }; type QRData = ?{ +deviceID: string, +aesKey: string }; type QRAuthContextType = { +qrData: QRData, +generateQRCode: () => Promise, }; const QRAuthContext: React.Context = React.createContext({ qrData: null, generateQRCode: async () => {}, }); function QRAuthProvider(props: Props): React.Node { const { children, onLogInError, generateAESKey, composeTunnelbrokerQRAuthMessage, parseTunnelbrokerQRAuthMessage, } = props; const [primaryDeviceID, setPrimaryDeviceID] = React.useState(); const [qrData, setQRData] = React.useState(); const [qrAuthFinished, setQRAuthFinished] = React.useState(false); const { setUnauthorizedDeviceID, addListener, removeListener, socketState, sendMessageToDevice, } = useTunnelbroker(); const identityContext = React.useContext(IdentityClientContext); const identityClient = identityContext?.identityClient; const generateQRCode = React.useCallback(async () => { try { const [ed25519, rawAESKey] = await Promise.all([ getContentSigningKey(), generateAESKey(), ]); const aesKeyAsHexString: string = uintArrayToHexString(rawAESKey); setUnauthorizedDeviceID(ed25519); setQRData({ deviceID: ed25519, aesKey: aesKeyAsHexString }); setQRAuthFinished(false); } catch (err) { console.error('Failed to generate QR Code:', err); } }, [generateAESKey, setUnauthorizedDeviceID]); const logInSecondaryDevice = useSecondaryDeviceLogIn(); const performLogIn = React.useCallback( async (userID: string) => { try { await logInSecondaryDevice(userID); } catch (err) { onLogInError(err); void generateQRCode(); } }, [logInSecondaryDevice, onLogInError, generateQRCode], ); React.useEffect(() => { if ( !qrData || !socketState.isAuthorized || !primaryDeviceID || qrAuthFinished ) { return; } void (async () => { const message = await composeTunnelbrokerQRAuthMessage(qrData?.aesKey, { type: qrCodeAuthMessageTypes.SECONDARY_DEVICE_REGISTRATION_SUCCESS, - requestBackupKeys: true, }); await sendMessageToDevice({ deviceID: primaryDeviceID, payload: JSON.stringify(message), }); setQRAuthFinished(true); })(); }, [ sendMessageToDevice, primaryDeviceID, qrData, socketState, composeTunnelbrokerQRAuthMessage, qrAuthFinished, ]); const tunnelbrokerMessageListener = React.useCallback( async (message: TunnelbrokerToDeviceMessage) => { invariant(identityClient, 'identity context not set'); if ( !qrData?.aesKey || message.type !== tunnelbrokerToDeviceMessageTypes.MESSAGE_TO_DEVICE ) { return; } let innerMessage; try { innerMessage = JSON.parse(message.payload); } catch { return; } if ( !peerToPeerMessageValidator.is(innerMessage) || innerMessage.type !== peerToPeerMessageTypes.QR_CODE_AUTH_MESSAGE ) { return; } let qrCodeAuthMessage; try { qrCodeAuthMessage = await parseTunnelbrokerQRAuthMessage( qrData?.aesKey, innerMessage, ); } catch (err) { console.warn('Failed to decrypt Tunnelbroker QR auth message:', err); return; } if ( !qrCodeAuthMessage || qrCodeAuthMessage.type !== qrCodeAuthMessageTypes.DEVICE_LIST_UPDATE_SUCCESS ) { return; } const { primaryDeviceID: receivedPrimaryDeviceID, userID } = qrCodeAuthMessage; setPrimaryDeviceID(receivedPrimaryDeviceID); await performLogIn(userID); setUnauthorizedDeviceID(null); }, [ identityClient, qrData?.aesKey, performLogIn, setUnauthorizedDeviceID, parseTunnelbrokerQRAuthMessage, ], ); React.useEffect(() => { if (!qrData?.deviceID || qrAuthFinished) { return undefined; } addListener(tunnelbrokerMessageListener); return () => { removeListener(tunnelbrokerMessageListener); }; }, [ addListener, removeListener, tunnelbrokerMessageListener, qrData?.deviceID, qrAuthFinished, ]); const value = React.useMemo( () => ({ qrData, generateQRCode, }), [qrData, generateQRCode], ); return ( {children} ); } function useQRAuthContext(): QRAuthContextType { const context = React.useContext(QRAuthContext); invariant(context, 'QRAuthContext not found'); return context; } export { QRAuthProvider, useQRAuthContext }; diff --git a/lib/types/tunnelbroker/qr-code-auth-message-types.js b/lib/types/tunnelbroker/qr-code-auth-message-types.js index c2eb230cb..454d09575 100644 --- a/lib/types/tunnelbroker/qr-code-auth-message-types.js +++ b/lib/types/tunnelbroker/qr-code-auth-message-types.js @@ -1,60 +1,58 @@ // @flow import type { TInterface, TUnion } from 'tcomb'; import t from 'tcomb'; import { tShape, tString, tUserID } from '../../utils/validation-utils.js'; export const qrCodeAuthMessageTypes = Object.freeze({ // Sent by primary device DEVICE_LIST_UPDATE_SUCCESS: 'DeviceListUpdateSuccess', // Sent by secondary device SECONDARY_DEVICE_REGISTRATION_SUCCESS: 'SecondaryDeviceRegistrationSuccess', }); type QRAuthBackupData = { +backupID: string, +backupDataKey: string, +backupLogDataKey: string, }; export const qrAuthBackupDataValidator: TInterface = tShape({ backupID: t.String, backupDataKey: t.String, backupLogDataKey: t.String, }); export type DeviceListUpdateSuccess = { +type: 'DeviceListUpdateSuccess', +userID: string, +primaryDeviceID: string, // We don't need `backupData` for the keyserver +backupData: ?QRAuthBackupData, }; export const deviceListUpdateSuccessValidator: TInterface = tShape({ type: tString(qrCodeAuthMessageTypes.DEVICE_LIST_UPDATE_SUCCESS), userID: tUserID, primaryDeviceID: t.String, backupData: t.maybe(qrAuthBackupDataValidator), }); export type SecondaryDeviceRegistrationSuccess = { +type: 'SecondaryDeviceRegistrationSuccess', - +requestBackupKeys: boolean, }; export const secondaryDeviceRegistrationSuccessValidator: TInterface = tShape({ type: tString(qrCodeAuthMessageTypes.SECONDARY_DEVICE_REGISTRATION_SUCCESS), - requestBackupKeys: t.Boolean, }); export type QRCodeAuthMessagePayload = | DeviceListUpdateSuccess | SecondaryDeviceRegistrationSuccess; export const qrCodeAuthMessagePayloadValidator: TUnion = t.union([ deviceListUpdateSuccessValidator, secondaryDeviceRegistrationSuccessValidator, ]); diff --git a/native/profile/secondary-device-qr-code-scanner.react.js b/native/profile/secondary-device-qr-code-scanner.react.js index 8356ccdb8..6cf669ff8 100644 --- a/native/profile/secondary-device-qr-code-scanner.react.js +++ b/native/profile/secondary-device-qr-code-scanner.react.js @@ -1,462 +1,455 @@ // @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 { getOwnPeerDevices, getKeyserverDeviceID, } from 'lib/selectors/user-selectors.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 { 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 runDeviceListUpdate = useDeviceListUpdate(); 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; } - if (!payload.requestBackupKeys) { - Alert.alert('Device added', 'Device registered successfully', [ - { text: 'OK', onPress: goBack }, - ]); - return; - } - Alert.alert('Device added', 'Device registered successfully', [ { text: 'OK', onPress: goBack }, ]); }, [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 () => { let backupData = null; if (deviceType !== identityDeviceTypes.KEYSERVER) { const [backupSecret, latestBackupInfo] = await Promise.all([ getBackupSecret(), retrieveLatestBackupInfo(), ]); const backupKeysResponse = await commCoreModule.retrieveBackupKeys( backupSecret, latestBackupInfo.backupID, ); backupData = assertWithValidator( JSON.parse(backupKeysResponse), backupKeysValidator, ); } const message = await composeTunnelbrokerQRAuthMessage(encryptionKey, { type: qrCodeAuthMessageTypes.DEVICE_LIST_UPDATE_SUCCESS, userID, primaryDeviceID, backupData, }); await tunnelbrokerContext.sendMessageToDevice({ deviceID: targetDeviceID, payload: JSON.stringify(message), }); }; const handleReplaceDevice = async () => { try { if (!keyserverDeviceID) { throw new Error('missing keyserver device ID'); } 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 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(); } }, [ getBackupSecret, goBack, identityContext, keyserverDeviceID, retrieveLatestBackupInfo, 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;