diff --git a/lib/shared/device-list-utils.js b/lib/shared/device-list-utils.js index 4d9f1bed5..a4dd0e385 100644 --- a/lib/shared/device-list-utils.js +++ b/lib/shared/device-list-utils.js @@ -1,137 +1,203 @@ // @flow +import invariant from 'invariant'; + import type { IdentityServiceClient, RawDeviceList, SignedDeviceList, } from '../types/identity-service-types.js'; import { getConfig } from '../utils/config.js'; +import { getContentSigningKey } from '../utils/crypto-utils.js'; import { composeRawDeviceList, rawDeviceListFromSignedList, } from '../utils/device-list-utils.js'; export type DeviceListVerificationResult = | { +valid: true, +deviceList: RawDeviceList } | DeviceListVerificationFailure; type DeviceListVerificationFailure = | { +valid: false, +reason: 'empty_device_list_history' } | { +valid: false, +reason: 'empty_device_list_update', +timestamp: number } | { +valid: false, +reason: 'invalid_timestamp_order', +timestamp: number } | { +valid: false, +reason: 'invalid_cur_primary_signature', +timestamp: number, } | { +valid: false, +reason: 'invalid_last_primary_signature', +timestamp: number, }; // Verifies all device list updates for given `userID` since // last known (and valid) device list. The updates are fetched // from Identity Service. If `lastKnownDeviceList` is not provided, // the whole device list history will be verified. // Returns latest device list from Identity Service. async function verifyAndGetDeviceList( identityClient: IdentityServiceClient, userID: string, lastKnownDeviceList: ?SignedDeviceList, ): Promise { let since; if (lastKnownDeviceList) { const rawList = rawDeviceListFromSignedList(lastKnownDeviceList); since = rawList.timestamp; } const history = await identityClient.getDeviceListHistoryForUser( userID, since, ); if (history.length < 1) { return { valid: false, reason: 'empty_device_list_history' }; } const [firstUpdate, ...updates] = history; const deviceListUpdates = lastKnownDeviceList ? history : updates; let previousDeviceList = lastKnownDeviceList ?? firstUpdate; const { olmAPI } = getConfig(); for (const deviceList of deviceListUpdates) { const currentPayload = rawDeviceListFromSignedList(deviceList); const previousPayload = rawDeviceListFromSignedList(previousDeviceList); // verify timestamp order const { timestamp } = currentPayload; if (previousPayload.timestamp >= timestamp) { return { valid: false, reason: 'invalid_timestamp_order', timestamp, }; } const currentPrimaryDeviceID = currentPayload.devices[0]; const previousPrimaryDeviceID = previousPayload.devices[0]; if (!currentPrimaryDeviceID || !previousPrimaryDeviceID) { return { valid: false, reason: 'empty_device_list_update', timestamp }; } // verify signatures if (deviceList.curPrimarySignature) { // verify signature using previous primary device signature const signatureValid = await olmAPI.verifyMessage( deviceList.rawDeviceList, deviceList.curPrimarySignature, currentPrimaryDeviceID, ); if (!signatureValid) { return { valid: false, reason: 'invalid_cur_primary_signature', timestamp, }; } } if ( currentPrimaryDeviceID !== previousPrimaryDeviceID && deviceList.lastPrimarySignature ) { // verify signature using previous primary device signature const signatureValid = await olmAPI.verifyMessage( deviceList.rawDeviceList, deviceList.lastPrimarySignature, previousPrimaryDeviceID, ); if (!signatureValid) { return { valid: false, reason: 'invalid_last_primary_signature', timestamp, }; } } previousDeviceList = deviceList; } const deviceList = rawDeviceListFromSignedList(previousDeviceList); return { valid: true, deviceList }; } async function createAndSignInitialDeviceList( primaryDeviceID: string, ): Promise { const initialDeviceList = composeRawDeviceList([primaryDeviceID]); const rawDeviceList = JSON.stringify(initialDeviceList); const { olmAPI } = getConfig(); const curPrimarySignature = await olmAPI.signMessage(rawDeviceList); return { rawDeviceList, curPrimarySignature, }; } -export { verifyAndGetDeviceList, createAndSignInitialDeviceList }; +async function signDeviceListUpdate( + deviceListPayload: RawDeviceList, +): Promise { + const deviceID = await getContentSigningKey(); + const rawDeviceList = JSON.stringify(deviceListPayload); + + // don't sign device list if current device is not a primary one + if (deviceListPayload.devices[0] !== deviceID) { + return { + rawDeviceList, + }; + } + + const { olmAPI } = getConfig(); + const curPrimarySignature = await olmAPI.signMessage(rawDeviceList); + return { + rawDeviceList, + curPrimarySignature, + }; +} + +async function fetchLatestDeviceList( + identityClient: IdentityServiceClient, + userID: string, +): Promise { + const deviceLists = await identityClient.getDeviceListHistoryForUser(userID); + if (deviceLists.length < 1) { + throw new Error('received empty device list history'); + } + + const lastSignedDeviceList = deviceLists[deviceLists.length - 1]; + return rawDeviceListFromSignedList(lastSignedDeviceList); +} + +async function addDeviceToDeviceList( + identityClient: IdentityServiceClient, + userID: string, + newDeviceID: string, +) { + const { updateDeviceList } = identityClient; + invariant( + updateDeviceList, + 'updateDeviceList() should be defined on native. ' + + 'Are you calling it on a non-primary device?', + ); + + const { devices } = await fetchLatestDeviceList(identityClient, userID); + if (devices.includes(newDeviceID)) { + // the device was already on the device list + return; + } + + const newDeviceList = composeRawDeviceList([...devices, newDeviceID]); + const signedDeviceList = await signDeviceListUpdate(newDeviceList); + await updateDeviceList(signedDeviceList); +} + +export { + verifyAndGetDeviceList, + createAndSignInitialDeviceList, + fetchLatestDeviceList, + addDeviceToDeviceList, + signDeviceListUpdate, +}; diff --git a/native/profile/secondary-device-qr-code-scanner.react.js b/native/profile/secondary-device-qr-code-scanner.react.js index 288cea0f0..5f9c15808 100644 --- a/native/profile/secondary-device-qr-code-scanner.react.js +++ b/native/profile/secondary-device-qr-code-scanner.react.js @@ -1,328 +1,295 @@ // @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 { addDeviceToDeviceList } 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 { 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 { - composeRawDeviceList, - rawDeviceListFromSignedList, -} from 'lib/utils/device-list-utils.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'; import { getBackupSecret } from '../backup/use-client-backup.js'; import { commCoreModule } from '../native-modules.js'; import type { NavigationRoute } from '../navigation/route-names.js'; import { composeTunnelbrokerQRAuthMessage, parseTunnelbrokerQRAuthMessage, - signDeviceListUpdate, } 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 = rawDeviceListFromSignedList(lastSignedDeviceList); 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 = rawDeviceListFromSignedList(lastSignedDeviceList); - - const { devices } = deviceList; - if (devices.includes(newDeviceID)) { - return; - } - - const newDeviceList = composeRawDeviceList([...devices, newDeviceID]); - const signedDeviceList = await signDeviceListUpdate(newDeviceList); - await updateDeviceList(signedDeviceList); - }, - [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 = await parseTunnelbrokerQRAuthMessage( encryptionKey, innerMessage, ); if ( payload?.type !== qrCodeAuthMessageTypes.SECONDARY_DEVICE_REGISTRATION_SUCCESS ) { return; } void broadcastDeviceListUpdate(); const backupSecret = await getBackupSecret(); const backupKeysResponse = await commCoreModule.retrieveBackupKeys(backupSecret); const backupKeys = assertWithValidator( JSON.parse(backupKeysResponse), backupKeysValidator, ); const backupKeyMessage = await composeTunnelbrokerQRAuthMessage( encryptionKey, { type: qrCodeAuthMessageTypes.BACKUP_DATA_KEY_MESSAGE, ...backupKeys, }, ); 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); + await addDeviceToDeviceList( + identityContext.identityClient, + userID, + ed25519, + ); 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], + [tunnelbrokerContext, 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-utils.js b/native/qr-code/qr-code-utils.js index 1c0cc5f41..c3833e6cc 100644 --- a/native/qr-code/qr-code-utils.js +++ b/native/qr-code/qr-code-utils.js @@ -1,86 +1,55 @@ // @flow import { hexToUintArray } from 'lib/media/data-utils.js'; -import type { - RawDeviceList, - SignedDeviceList, -} from 'lib/types/identity-service-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 { getConfig } from 'lib/utils/config.js'; -import { getContentSigningKey } from 'lib/utils/crypto-utils.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); } -async function signDeviceListUpdate( - deviceListPayload: RawDeviceList, -): Promise { - const deviceID = await getContentSigningKey(); - const rawDeviceList = JSON.stringify(deviceListPayload); - - // don't sign device list if current device is not a primary one - if (deviceListPayload.devices[0] !== deviceID) { - return { - rawDeviceList, - }; - } - - const { olmAPI } = getConfig(); - const curPrimarySignature = await olmAPI.signMessage(rawDeviceList); - return { - rawDeviceList, - curPrimarySignature, - }; -} - -export { - composeTunnelbrokerQRAuthMessage, - parseTunnelbrokerQRAuthMessage, - signDeviceListUpdate, -}; +export { composeTunnelbrokerQRAuthMessage, parseTunnelbrokerQRAuthMessage };