diff --git a/lib/components/qr-auth-handler.react.js b/lib/components/qr-auth-handler.react.js index 598627bc0..e8b9314d8 100644 --- a/lib/components/qr-auth-handler.react.js +++ b/lib/components/qr-auth-handler.react.js @@ -1,147 +1,159 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { IdentityClientContext } from '../shared/identity-client-context.js'; import { useTunnelbroker } from '../tunnelbroker/tunnelbroker-context.js'; import { tunnelbrokerMessageTypes, type TunnelbrokerMessage, } from '../types/tunnelbroker/messages.js'; import { peerToPeerMessageTypes, peerToPeerMessageValidator, + type QRCodeAuthMessage, } from '../types/tunnelbroker/peer-to-peer-message-types.js'; -import { qrCodeAuthMessageTypes } from '../types/tunnelbroker/qr-code-auth-message-types.js'; import { - createQRAuthTunnelbrokerMessage, - parseQRAuthTunnelbrokerMessage, -} from '../utils/qr-code-auth.js'; + qrCodeAuthMessageTypes, + type QRCodeAuthMessagePayload, +} from '../types/tunnelbroker/qr-code-auth-message-types.js'; type QRAuthHandlerProps = { +secondaryDeviceID: ?string, +aesKey: ?string, +performSecondaryDeviceRegistration: (userID: string) => Promise, + +composeMessage: ( + encryptionKey: string, + payload: QRCodeAuthMessagePayload, + ) => Promise, + +processMessage: ( + encryptionKey: string, + message: QRCodeAuthMessage, + ) => Promise, }; function QRAuthHandler(props: QRAuthHandlerProps): React.Node { - const { secondaryDeviceID, aesKey, performSecondaryDeviceRegistration } = - props; + const { + secondaryDeviceID, + aesKey, + processMessage, + composeMessage, + performSecondaryDeviceRegistration, + } = props; const [primaryDeviceID, setPrimaryDeviceID] = React.useState(); const { setUnauthorizedDeviceID, addListener, removeListener, connected: tunnelbrokerConnected, isAuthorized, sendMessage, } = useTunnelbroker(); const identityContext = React.useContext(IdentityClientContext); const identityClient = identityContext?.identityClient; React.useEffect(() => { if ( !secondaryDeviceID || !aesKey || !tunnelbrokerConnected || !isAuthorized || !primaryDeviceID ) { return; } void (async () => { - const message = createQRAuthTunnelbrokerMessage(aesKey, { + const message = await composeMessage(aesKey, { type: qrCodeAuthMessageTypes.SECONDARY_DEVICE_REGISTRATION_SUCCESS, }); await sendMessage({ deviceID: primaryDeviceID, payload: JSON.stringify(message), }); })(); }, [ tunnelbrokerConnected, isAuthorized, sendMessage, primaryDeviceID, aesKey, secondaryDeviceID, + composeMessage, ]); const tunnelbrokerMessageListener = React.useCallback( async (message: TunnelbrokerMessage) => { invariant(identityClient, 'identity context not set'); if ( !aesKey || message.type !== tunnelbrokerMessageTypes.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; } - const qrCodeAuthMessage = parseQRAuthTunnelbrokerMessage( - aesKey, - innerMessage, - ); + const qrCodeAuthMessage = await processMessage(aesKey, innerMessage); if ( qrCodeAuthMessage?.type === qrCodeAuthMessageTypes.BACKUP_DATA_KEY_MESSAGE ) { return; } if ( !qrCodeAuthMessage || qrCodeAuthMessage.type !== qrCodeAuthMessageTypes.DEVICE_LIST_UPDATE_SUCCESS ) { return; } const { primaryDeviceID: receivedPrimaryDeviceID, userID } = qrCodeAuthMessage; setPrimaryDeviceID(receivedPrimaryDeviceID); await performSecondaryDeviceRegistration(userID); setUnauthorizedDeviceID(null); }, [ setUnauthorizedDeviceID, identityClient, aesKey, performSecondaryDeviceRegistration, + processMessage, ], ); React.useEffect(() => { if (!secondaryDeviceID) { return undefined; } addListener(tunnelbrokerMessageListener); return () => { removeListener(tunnelbrokerMessageListener); }; }, [ secondaryDeviceID, addListener, removeListener, tunnelbrokerMessageListener, ]); return null; } export { QRAuthHandler }; diff --git a/lib/utils/qr-code-auth.js b/lib/utils/qr-code-auth.js deleted file mode 100644 index 732d803f8..000000000 --- a/lib/utils/qr-code-auth.js +++ /dev/null @@ -1,34 +0,0 @@ -// @flow - -import { - peerToPeerMessageTypes, - type QRCodeAuthMessage, -} from '../types/tunnelbroker/peer-to-peer-message-types.js'; -import { - qrCodeAuthMessagePayloadValidator, - type QRCodeAuthMessagePayload, -} from '../types/tunnelbroker/qr-code-auth-message-types.js'; - -function createQRAuthTunnelbrokerMessage( - encryptionKey: string, - payload: QRCodeAuthMessagePayload, -): QRCodeAuthMessage { - return { - type: peerToPeerMessageTypes.QR_CODE_AUTH_MESSAGE, - encryptedContent: JSON.stringify(payload), - }; -} - -function parseQRAuthTunnelbrokerMessage( - encryptionKey: string, - message: QRCodeAuthMessage, -): ?QRCodeAuthMessagePayload { - const payload = JSON.parse(message.encryptedContent); - if (!qrCodeAuthMessagePayloadValidator.is(payload)) { - return null; - } - - return payload; -} - -export { createQRAuthTunnelbrokerMessage, parseQRAuthTunnelbrokerMessage }; diff --git a/native/profile/secondary-device-qr-code-scanner.react.js b/native/profile/secondary-device-qr-code-scanner.react.js index fa13572a5..096a50555 100644 --- a/native/profile/secondary-device-qr-code-scanner.react.js +++ b/native/profile/secondary-device-qr-code-scanner.react.js @@ -1,316 +1,319 @@ // @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 } from 'react-native'; import { parseDataFromDeepLink } from 'lib/facts/links.js'; import { IdentityClientContext } from 'lib/shared/identity-client-context.js'; import { useTunnelbroker } from 'lib/tunnelbroker/tunnelbroker-context.js'; import type { RawDeviceList } from 'lib/types/identity-service-types.js'; import { tunnelbrokerMessageTypes, type TunnelbrokerMessage, } 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 { - createQRAuthTunnelbrokerMessage, - parseQRAuthTunnelbrokerMessage, -} from 'lib/utils/qr-code-auth.js'; import type { ProfileNavigationProp } from './profile.react.js'; import type { NavigationRoute } from '../navigation/route-names.js'; +import { + composeTunnelbrokerQRAuthMessage, + parseTunnelbrokerQRAuthMessage, +} from '../qr-code/qr-code-utils.js'; import { useStyles } from '../themes/colors.js'; import Alert from '../utils/alert.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 styles = useStyles(unboundStyles); const navigation = 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 broadcastDeviceListUpdate = React.useCallback(async () => { invariant(identityContext, 'identity context not set'); const { getAuthMetadata, identityClient } = identityContext; const { userID } = await getAuthMetadata(); if (!userID) { 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: RawDeviceList = JSON.parse( lastSignedDeviceList.rawDeviceList, ); const promises = deviceList.devices.map(recipient => tunnelbrokerContext.sendMessage({ deviceID: recipient, payload: JSON.stringify({ type: peerToPeerMessageTypes.DEVICE_LIST_UPDATED, userID, signedDeviceList: lastSignedDeviceList, }), }), ); await Promise.all(promises); }, [identityContext, tunnelbrokerContext]); const addDeviceToList = React.useCallback( async (newDeviceID: string) => { const { getDeviceListHistoryForUser, updateDeviceList } = identityContext.identityClient; invariant( updateDeviceList, 'updateDeviceList() should be defined for primary device', ); const authMetadata = await identityContext.getAuthMetadata(); if (!authMetadata?.userID) { throw new Error('missing auth metadata'); } const deviceLists = await getDeviceListHistoryForUser( authMetadata.userID, ); invariant(deviceLists.length > 0, 'received empty device list history'); const lastSignedDeviceList = deviceLists[deviceLists.length - 1]; const deviceList: RawDeviceList = JSON.parse( lastSignedDeviceList.rawDeviceList, ); const { devices } = deviceList; if (devices.includes(newDeviceID)) { return; } const newDeviceList: RawDeviceList = { devices: [...devices, newDeviceID], timestamp: Date.now(), }; await updateDeviceList({ rawDeviceList: JSON.stringify(newDeviceList), }); }, [identityContext], ); const tunnelbrokerMessageListener = React.useCallback( async (message: TunnelbrokerMessage) => { const encryptionKey = aes256Key.current; const targetDeviceID = secondaryDeviceID.current; if (!encryptionKey || !targetDeviceID) { return; } if (message.type !== tunnelbrokerMessageTypes.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 = parseQRAuthTunnelbrokerMessage( + const payload = await parseTunnelbrokerQRAuthMessage( encryptionKey, innerMessage, ); if ( payload?.type !== qrCodeAuthMessageTypes.SECONDARY_DEVICE_REGISTRATION_SUCCESS ) { return; } void broadcastDeviceListUpdate(); - const backupKeyMessage = createQRAuthTunnelbrokerMessage(encryptionKey, { - type: qrCodeAuthMessageTypes.BACKUP_DATA_KEY_MESSAGE, - backupID: 'stub', - backupDataKey: 'stub', - backupLogDataKey: 'stub', - }); + const backupKeyMessage = await composeTunnelbrokerQRAuthMessage( + encryptionKey, + { + type: qrCodeAuthMessageTypes.BACKUP_DATA_KEY_MESSAGE, + backupID: 'stub', + backupDataKey: 'stub', + backupLogDataKey: 'stub', + }, + ); await tunnelbrokerContext.sendMessage({ deviceID: targetDeviceID, payload: JSON.stringify(backupKeyMessage), }); Alert.alert('Device added', 'Device registered successfully', [ { text: 'OK' }, ]); }, [tunnelbrokerContext, broadcastDeviceListUpdate], ); 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' }], ); navigation.goBack(); } })(); }, [navigation]); 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; } const keys = JSON.parse(decodeURIComponent(keysMatch)); const { aes256, ed25519 } = keys; aes256Key.current = aes256; secondaryDeviceID.current = ed25519; try { const { deviceID: primaryDeviceID, userID } = await identityContext.getAuthMetadata(); if (!primaryDeviceID || !userID) { throw new Error('missing auth metadata'); } await addDeviceToList(ed25519); - const message = createQRAuthTunnelbrokerMessage(aes256, { + const message = await composeTunnelbrokerQRAuthMessage(aes256, { type: qrCodeAuthMessageTypes.DEVICE_LIST_UPDATE_SUCCESS, userID, primaryDeviceID, }); await tunnelbrokerContext.sendMessage({ deviceID: ed25519, payload: JSON.stringify(message), }); } catch (err) { console.log('Primary device error:', err); Alert.alert( 'Adding device failed', 'Failed to update the device list', [{ text: 'OK' }], ); navigation.goBack(); } }, [tunnelbrokerContext, addDeviceToList, identityContext, navigation], ); const onCancelScan = React.useCallback(() => setScanned(false), []); 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: onCancelScan, }, { text: 'Connect', onPress: () => onConnect(barCodeEvent), }, ], { cancelable: false }, ); }, [onCancelScan, onConnect], ); if (hasPermission === null) { return ; } // 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 = { container: { flex: 1, flexDirection: 'column', justifyContent: 'center', }, scanner: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, }, }; export default SecondaryDeviceQRCodeScanner; diff --git a/native/qr-code/qr-code-screen.react.js b/native/qr-code/qr-code-screen.react.js index 86e413bdd..05254c66c 100644 --- a/native/qr-code/qr-code-screen.react.js +++ b/native/qr-code/qr-code-screen.react.js @@ -1,164 +1,170 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { View, Text } from 'react-native'; import QRCode from 'react-native-qrcode-svg'; import { QRAuthHandler } from 'lib/components/qr-auth-handler.react.js'; import { qrCodeLinkURL } from 'lib/facts/links.js'; import { uintArrayToHexString } from 'lib/media/data-utils.js'; import { IdentityClientContext } from 'lib/shared/identity-client-context.js'; import { useTunnelbroker } from 'lib/tunnelbroker/tunnelbroker-context.js'; import type { NonceChallenge, SignedMessage, } from 'lib/types/identity-service-types.js'; import type { QRCodeSignInNavigationProp } from './qr-code-sign-in-navigator.react.js'; +import { + composeTunnelbrokerQRAuthMessage, + parseTunnelbrokerQRAuthMessage, +} from './qr-code-utils.js'; import { commCoreModule } from '../native-modules.js'; import type { NavigationRoute } from '../navigation/route-names.js'; import { useStyles } from '../themes/colors.js'; import * as AES from '../utils/aes-crypto-module.js'; import Alert from '../utils/alert.js'; import { getContentSigningKey } from '../utils/crypto-utils.js'; type QRCodeScreenProps = { +navigation: QRCodeSignInNavigationProp<'QRCodeScreen'>, +route: NavigationRoute<'QRCodeScreen'>, }; // eslint-disable-next-line no-unused-vars function QRCodeScreen(props: QRCodeScreenProps): React.Node { const [qrCodeValue, setQrCodeValue] = React.useState(); const [qrData, setQRData] = React.useState(); const { setUnauthorizedDeviceID } = useTunnelbroker(); const identityContext = React.useContext(IdentityClientContext); const identityClient = identityContext?.identityClient; const performRegistration = React.useCallback( async (userID: string) => { invariant(identityClient, 'identity context not set'); try { const nonce = await identityClient.generateNonce(); const nonceChallenge: NonceChallenge = { nonce }; const nonceMessage = JSON.stringify(nonceChallenge); const signature = await commCoreModule.signMessage(nonceMessage); const challengeResponse: SignedMessage = { message: nonceMessage, signature, }; await identityClient.uploadKeysForRegisteredDeviceAndLogIn( userID, challengeResponse, ); setUnauthorizedDeviceID(null); } catch (err) { console.error('Secondary device registration error:', err); Alert.alert('Registration failed', 'Failed to upload device keys', [ { text: 'OK' }, ]); } }, [setUnauthorizedDeviceID, identityClient], ); const generateQRCode = React.useCallback(async () => { try { const rawAESKey: Uint8Array = await AES.generateKey(); const aesKeyAsHexString: string = uintArrayToHexString(rawAESKey); const ed25519Key: string = await getContentSigningKey(); const url = qrCodeLinkURL(aesKeyAsHexString, ed25519Key); setUnauthorizedDeviceID(ed25519Key); setQrCodeValue(url); setQRData({ deviceID: ed25519Key, aesKey: aesKeyAsHexString }); } catch (err) { console.error('Failed to generate QR Code:', err); } }, [setUnauthorizedDeviceID]); React.useEffect(() => { void generateQRCode(); }, [generateQRCode]); const styles = useStyles(unboundStyles); return ( <> Log in to Comm Open the Comm app on your phone and scan the QR code below How to find the scanner: Go to Profile Select Linked devices Click Add on the top right ); } const unboundStyles = { container: { flex: 1, alignItems: 'center', marginTop: 125, }, heading: { fontSize: 24, color: 'panelForegroundLabel', paddingBottom: 12, }, headingSubtext: { fontSize: 12, color: 'panelForegroundLabel', paddingBottom: 30, }, instructionsBox: { alignItems: 'center', width: 300, marginTop: 40, padding: 15, borderColor: 'panelForegroundLabel', borderWidth: 2, borderRadius: 8, }, instructionsTitle: { fontSize: 12, color: 'panelForegroundLabel', paddingBottom: 15, }, instructionsStep: { fontSize: 12, padding: 1, color: 'panelForegroundLabel', }, instructionsBold: { fontWeight: 'bold', }, }; export default QRCodeScreen; diff --git a/native/qr-code/qr-code-utils.js b/native/qr-code/qr-code-utils.js new file mode 100644 index 000000000..c3833e6cc --- /dev/null +++ b/native/qr-code/qr-code-utils.js @@ -0,0 +1,55 @@ +// @flow + +import { hexToUintArray } from 'lib/media/data-utils.js'; +import { + peerToPeerMessageTypes, + type QRCodeAuthMessage, +} from 'lib/types/tunnelbroker/peer-to-peer-message-types.js'; +import { + qrCodeAuthMessagePayloadValidator, + type QRCodeAuthMessagePayload, +} from 'lib/types/tunnelbroker/qr-code-auth-message-types.js'; + +import { + convertBytesToObj, + convertObjToBytes, +} from '../backup/conversion-utils.js'; +import { commUtilsModule } from '../native-modules.js'; +import * as AES from '../utils/aes-crypto-module.js'; + +function composeTunnelbrokerQRAuthMessage( + encryptionKey: string, + obj: QRCodeAuthMessagePayload, +): Promise { + const objBytes = convertObjToBytes(obj); + const keyBytes = hexToUintArray(encryptionKey); + const encryptedBytes = AES.encrypt(keyBytes, objBytes); + const encryptedContent = commUtilsModule.base64EncodeBuffer( + encryptedBytes.buffer, + ); + return Promise.resolve({ + type: peerToPeerMessageTypes.QR_CODE_AUTH_MESSAGE, + encryptedContent, + }); +} + +function parseTunnelbrokerQRAuthMessage( + encryptionKey: string, + message: QRCodeAuthMessage, +): Promise { + const encryptedData = commUtilsModule.base64DecodeBuffer( + message.encryptedContent, + ); + const decryptedData = AES.decrypt( + hexToUintArray(encryptionKey), + new Uint8Array(encryptedData), + ); + const payload = convertBytesToObj(decryptedData); + if (!qrCodeAuthMessagePayloadValidator.is(payload)) { + return Promise.resolve(null); + } + + return Promise.resolve(payload); +} + +export { composeTunnelbrokerQRAuthMessage, parseTunnelbrokerQRAuthMessage }; diff --git a/web/account/qr-code-login.react.js b/web/account/qr-code-login.react.js index 47aa396f4..953b7d5af 100644 --- a/web/account/qr-code-login.react.js +++ b/web/account/qr-code-login.react.js @@ -1,147 +1,198 @@ // @flow import olm from '@commapp/olm'; import invariant from 'invariant'; import { QRCodeSVG } from 'qrcode.react'; import * as React from 'react'; import { createSelector } from 'reselect'; import { identityLogInActionTypes } from 'lib/actions/user-actions.js'; import { QRAuthHandler } from 'lib/components/qr-auth-handler.react.js'; import { qrCodeLinkURL } from 'lib/facts/links.js'; import { generateKeyCommon } from 'lib/media/aes-crypto-utils-common.js'; -import { uintArrayToHexString } from 'lib/media/data-utils.js'; +import * as AES from 'lib/media/aes-crypto-utils-common.js'; +import { hexToUintArray, uintArrayToHexString } from 'lib/media/data-utils.js'; import { IdentityClientContext } from 'lib/shared/identity-client-context.js'; import { useTunnelbroker } from 'lib/tunnelbroker/tunnelbroker-context.js'; import type { CryptoStore, PickledOLMAccount } from 'lib/types/crypto-types.js'; import type { NonceChallenge, SignedMessage, } from 'lib/types/identity-service-types.js'; import type { WebAppState } from 'lib/types/redux-types.js'; +import { + peerToPeerMessageTypes, + type QRCodeAuthMessage, +} from 'lib/types/tunnelbroker/peer-to-peer-message-types.js'; +import { + qrCodeAuthMessagePayloadValidator, + type QRCodeAuthMessagePayload, +} from 'lib/types/tunnelbroker/qr-code-auth-message-types.js'; import { useDispatchActionPromise } from 'lib/utils/redux-promise-utils.js'; import css from './qr-code-login.css'; import { initOlm } from '../olm/olm-utils.js'; import { useSelector } from '../redux/redux-utils.js'; +import { + base64DecodeBuffer, + base64EncodeBuffer, +} from '../utils/base64-utils.js'; +import { + convertBytesToObj, + convertObjToBytes, +} from '../utils/conversion-utils.js'; const deviceIDAndPrimaryAccountSelector: (state: WebAppState) => { ed25519Key: ?string, primaryAccount: ?PickledOLMAccount, } = createSelector( (state: WebAppState) => state.cryptoStore, (cryptoStore: ?CryptoStore) => ({ ed25519Key: cryptoStore?.primaryIdentityKeys.ed25519, primaryAccount: cryptoStore?.primaryAccount, }), ); +async function composeTunnelbrokerMessage( + encryptionKey: string, + obj: QRCodeAuthMessagePayload, +): Promise { + const objBytes = convertObjToBytes(obj); + const keyBytes = hexToUintArray(encryptionKey); + const encryptedBytes = await AES.encryptCommon(crypto, keyBytes, objBytes); + const encryptedContent = base64EncodeBuffer(encryptedBytes); + return { + type: peerToPeerMessageTypes.QR_CODE_AUTH_MESSAGE, + encryptedContent, + }; +} + +async function parseTunnelbrokerMessage( + encryptionKey: string, + message: QRCodeAuthMessage, +): Promise { + const encryptedData = base64DecodeBuffer(message.encryptedContent); + const decryptedData = await AES.decryptCommon( + crypto, + hexToUintArray(encryptionKey), + new Uint8Array(encryptedData), + ); + const payload = convertBytesToObj(decryptedData); + if (!qrCodeAuthMessagePayloadValidator.is(payload)) { + return null; + } + + return payload; +} + function QrCodeLogin(): React.Node { const [qrCodeValue, setQrCodeValue] = React.useState(); const { ed25519Key, primaryAccount } = useSelector( deviceIDAndPrimaryAccountSelector, ); const [deviceKeys, setDeviceKeys] = React.useState(); const { setUnauthorizedDeviceID } = useTunnelbroker(); const identityContext = React.useContext(IdentityClientContext); const identityClient = identityContext?.identityClient; const dispatchActionPromise = useDispatchActionPromise(); const performRegistration = React.useCallback( async (userID: string) => { if (!primaryAccount) { return; } invariant(identityClient, 'identity context not set'); try { await initOlm(); const primaryOLMAccount = new olm.Account(); primaryOLMAccount.unpickle( primaryAccount.picklingKey, primaryAccount.pickledAccount, ); const nonce = await identityClient.generateNonce(); const nonceChallenge: NonceChallenge = { nonce }; const nonceMessage = JSON.stringify(nonceChallenge); const signature = await primaryOLMAccount.sign(nonceMessage); const challengeResponse: SignedMessage = { message: nonceMessage, signature, }; await dispatchActionPromise( identityLogInActionTypes, identityClient.uploadKeysForRegisteredDeviceAndLogIn( userID, challengeResponse, ), ); setUnauthorizedDeviceID(null); } catch (err) { console.error('Secondary device registration error:', err); } }, [ dispatchActionPromise, identityClient, primaryAccount, setUnauthorizedDeviceID, ], ); const generateQRCode = React.useCallback(async () => { try { if (!ed25519Key) { return; } const rawAESKey: Uint8Array = await generateKeyCommon(crypto); const aesKeyAsHexString: string = uintArrayToHexString(rawAESKey); const url = qrCodeLinkURL(aesKeyAsHexString, ed25519Key); setUnauthorizedDeviceID(ed25519Key); setQrCodeValue(url); setDeviceKeys({ deviceID: ed25519Key, aesKey: aesKeyAsHexString }); } catch (err) { console.error('Failed to generate QR Code:', err); } }, [ed25519Key, setUnauthorizedDeviceID]); React.useEffect(() => { void generateQRCode(); }, [generateQRCode]); return ( <>
Log in to Comm
Open the Comm app on your phone and scan the QR code below
How to find the scanner:
Go to Profile
Select Linked devices
Click Add on the top right
); } export default QrCodeLogin; diff --git a/web/jest-setup.js b/web/jest-setup.js index fbd15edbf..ba4261ad9 100644 --- a/web/jest-setup.js +++ b/web/jest-setup.js @@ -1,8 +1,12 @@ // @flow +/* eslint-disable no-undef -- "global is not defined" */ import crypto from 'crypto'; +import util from 'util'; // crypto.webcrypto was introduced in Node 15.10.0. // It is not defined in Flow so we need a cast -// eslint-disable-next-line no-undef -- "global is not defined" global.crypto = (crypto: any).webcrypto; + +global.TextEncoder = util.TextEncoder; +global.TextDecoder = util.TextDecoder; diff --git a/web/utils/conversion-utils.js b/web/utils/conversion-utils.js new file mode 100644 index 000000000..386644486 --- /dev/null +++ b/web/utils/conversion-utils.js @@ -0,0 +1,13 @@ +// @flow + +function convertObjToBytes(obj: T): Uint8Array { + const objStr = JSON.stringify(obj); + return new TextEncoder().encode(objStr ?? ''); +} + +function convertBytesToObj(bytes: Uint8Array): T { + const str = new TextDecoder().decode(bytes.buffer); + return JSON.parse(str); +} + +export { convertObjToBytes, convertBytesToObj }; diff --git a/web/utils/conversion-utils.test.js b/web/utils/conversion-utils.test.js new file mode 100644 index 000000000..526255efe --- /dev/null +++ b/web/utils/conversion-utils.test.js @@ -0,0 +1,14 @@ +// @flow + +import { convertBytesToObj, convertObjToBytes } from './conversion-utils.js'; + +describe('convertObjToBytes and convertBytesToObj', () => { + it('should convert object to byte array and back', () => { + const obj = { hello: 'world', foo: 'bar', a: 2, b: false }; + + const bytes = convertObjToBytes(obj); + const restored = convertBytesToObj(bytes); + + expect(restored).toStrictEqual(obj); + }); +});