diff --git a/desktop/src/main.js b/desktop/src/main.js --- a/desktop/src/main.js +++ b/desktop/src/main.js @@ -306,12 +306,16 @@ const handleEncryptedNotification = ( encryptedPayload: string, - keyserverID: string, + senderDeviceDescriptor: + | { +keyserverID: string } + | { +senderDeviceID: string }, + type: string, ) => { if (mainWindow) { mainWindow.webContents.send('on-encrypted-notification', { encryptedPayload, - keyserverID, + senderDeviceDescriptor, + type, }); } }; diff --git a/desktop/src/push-notifications.js b/desktop/src/push-notifications.js --- a/desktop/src/push-notifications.js +++ b/desktop/src/push-notifications.js @@ -131,29 +131,52 @@ handleClick: (threadID?: string) => void, handleEncryptedNotification: ( encryptedPayload: string, - keyserverID: string, + senderDeviceDescriptor: + | { +keyserverID: string } + | { +senderDeviceID: string }, + type: string, ) => void, ) { if (process.platform === 'darwin') { pushNotifications.on('received-apns-notification', (event, userInfo) => { - const { keyserverID, encryptedPayload } = userInfo; + const { keyserverID, senderDeviceID, encryptedPayload, type } = userInfo; if ( typeof keyserverID === 'string' && - typeof encryptedPayload === 'string' + typeof encryptedPayload === 'string' && + typeof type === 'string' ) { - handleEncryptedNotification(encryptedPayload, keyserverID); + handleEncryptedNotification(encryptedPayload, { keyserverID }, type); + return; + } + + if ( + typeof senderDeviceID === 'string' && + typeof encryptedPayload === 'string' && + typeof type === 'string' + ) { + handleEncryptedNotification(encryptedPayload, { senderDeviceID }, type); return; } showNewNotification(userInfo, handleClick); }); } else if (process.platform === 'win32') { windowsPushNotifEventEmitter.on('received-wns-notification', payload => { - const { keyserverID, encryptedPayload } = payload; + const { keyserverID, senderDeviceID, encryptedPayload, type } = payload; if ( typeof keyserverID === 'string' && - typeof encryptedPayload === 'string' + typeof encryptedPayload === 'string' && + typeof type === 'string' + ) { + handleEncryptedNotification(encryptedPayload, { keyserverID }, type); + return; + } + + if ( + typeof senderDeviceID === 'string' && + typeof encryptedPayload === 'string' && + typeof type === 'string' ) { - handleEncryptedNotification(encryptedPayload, keyserverID); + handleEncryptedNotification(encryptedPayload, { senderDeviceID }, type); return; } showNewNotification(payload, handleClick); diff --git a/lib/types/electron-types.js b/lib/types/electron-types.js --- a/lib/types/electron-types.js +++ b/lib/types/electron-types.js @@ -1,5 +1,7 @@ // @flow +import type { SenderDeviceDescriptor } from './notif-types'; + type OnNavigateListener = ({ +canGoBack: boolean, +canGoForward: boolean, @@ -13,7 +15,8 @@ type OnEncryptedNotificationListener = (data: { encryptedPayload: string, - keyserverID?: string, + type: string, + senderDeviceDescriptor: SenderDeviceDescriptor, }) => mixed; export type ElectronBridge = { diff --git a/web/push-notif/notif-crypto-utils.js b/web/push-notif/notif-crypto-utils.js --- a/web/push-notif/notif-crypto-utils.js +++ b/web/push-notif/notif-crypto-utils.js @@ -14,12 +14,17 @@ import type { PlainTextWebNotification, EncryptedWebNotification, + SenderDeviceDescriptor, } from 'lib/types/notif-types.js'; import { getCookieIDFromCookie } from 'lib/utils/cookie-utils.js'; import { getMessageForException } from 'lib/utils/errors.js'; import { promiseAll } from 'lib/utils/promises.js'; import { assertWithValidator } from 'lib/utils/validation-utils.js'; +import { + fetchAuthMetadata, + getNotifsInboundKeysForDeviceID, +} from './services-client.js'; import { type EncryptedData, decryptData, @@ -92,6 +97,16 @@ return data; } +async function deserializeEncryptedDataOptional( + encryptedData: ?EncryptedData, + encryptionKey: ?CryptoKey, +): Promise { + if (!encryptedData || !encryptionKey) { + return undefined; + } + return deserializeEncryptedData(encryptedData, encryptionKey); +} + async function serializeUnencryptedData( data: T, encryptionKey: CryptoKey, @@ -116,6 +131,15 @@ return await importJWKKey(((cryptoKey: any): SubtleCrypto$JsonWebKey)); } +async function validateCryptoKeyOptional( + cryptoKey: ?CryptoKey | ?SubtleCrypto$JsonWebKey, +): Promise { + if (!cryptoKey) { + return undefined; + } + return validateCryptoKey(cryptoKey); +} + async function getCryptoKeyPersistentForm( cryptoKey: CryptoKey, ): Promise { @@ -128,6 +152,95 @@ return await exportKeyToJWK(cryptoKey); } +async function getNotifsAccountWithOlmData( + senderDeviceDescriptor: SenderDeviceDescriptor, +): Promise<{ + +encryptedOlmData: ?EncryptedData, + +encryptionKey: ?CryptoKey, + +olmDataKey: string, + +encryptionKeyDBLabel: string, + +encryptedOlmAccount: ?EncryptedData, + +accountEncryptionKey: ?CryptoKey, + +synchronizationValue: ?string, +}> { + let olmDataKey; + let olmDataEncryptionKeyDBLabel; + const { keyserverID, senderDeviceID } = senderDeviceDescriptor; + + if (keyserverID) { + const olmDBKeys = await getNotifsOlmSessionDBKeys(keyserverID); + const { olmDataKey: fetchedOlmDataKey, encryptionKeyDBKey } = olmDBKeys; + olmDataKey = fetchedOlmDataKey; + olmDataEncryptionKeyDBLabel = encryptionKeyDBKey; + } else { + invariant( + senderDeviceID, + 'keyserverID or SenderDeviceID must be present to decrypt a notif', + ); + olmDataKey = getOlmDataKeyForDeviceID(senderDeviceID); + olmDataEncryptionKeyDBLabel = + getOlmEncryptionKeyDBLabelForDeviceID(senderDeviceID); + } + + const queryResult = await localforage.getMultipleItems<{ + notificationAccount: ?EncryptedData, + notificationAccountEncryptionKey: ?CryptoKey, + synchronizationValue: ?number, + [string]: ?EncryptedData | ?CryptoKey | ?SubtleCrypto$JsonWebKey, + }>( + [ + INDEXED_DB_NOTIFS_ACCOUNT_KEY, + INDEXED_DB_NOTIFS_ACCOUNT_ENCRYPTION_KEY_DB_LABEL, + olmDataEncryptionKeyDBLabel, + olmDataKey, + ], + INDEXED_DB_NOTIFS_SYNC_KEY, + ); + + const { + values: { + notificationAccount, + notificationAccountEncryptionKey, + [olmDataKey]: maybeEncryptedOlmData, + [olmDataEncryptionKeyDBLabel]: maybeOlmDataEncryptionKey, + }, + synchronizationValue, + } = queryResult; + + if (!notificationAccount || !notificationAccountEncryptionKey) { + throw new Error( + 'Attempt to decrypt notification but olm account not initialized.', + ); + } + + const encryptedOlmData: ?EncryptedData = maybeEncryptedOlmData + ? assertWithValidator(maybeEncryptedOlmData, encryptedAESDataValidator) + : undefined; + + const olmDataEncryptionKey: ?CryptoKey | ?SubtleCrypto$JsonWebKey = + maybeOlmDataEncryptionKey + ? assertWithValidator( + maybeOlmDataEncryptionKey, + extendedCryptoKeyValidator, + ) + : undefined; + + const [encryptionKey, accountEncryptionKey] = await Promise.all([ + validateCryptoKeyOptional(olmDataEncryptionKey), + validateCryptoKey(notificationAccountEncryptionKey), + ]); + + return { + encryptedOlmData, + encryptionKey, + encryptionKeyDBLabel: olmDataEncryptionKeyDBLabel, + encryptedOlmAccount: notificationAccount, + olmDataKey, + accountEncryptionKey, + synchronizationValue, + }; +} + async function persistNotifsAccountWithOlmData(input: { +olmDataKey?: string, +olmEncryptionKeyDBLabel?: string, @@ -223,8 +336,14 @@ async function decryptWebNotification( encryptedNotification: EncryptedWebNotification, ): Promise { - const { id, keyserverID, encryptedPayload } = encryptedNotification; - invariant(keyserverID, 'KeyserverID must be present to decrypt a notif'); + const { + id, + encryptedPayload, + type: messageType, + ...rest + } = encryptedNotification; + const senderDeviceDescriptor: SenderDeviceDescriptor = rest; + const utilsData = await localforage.getItem( WEB_NOTIFS_SERVICE_UTILS_KEY, ); @@ -234,9 +353,11 @@ } const { olmWasmPath, staffCanSee } = (utilsData: WebNotifsServiceUtilsData); - let olmDBKeys; + let notifsAccountWithOlmData; try { - olmDBKeys = await getNotifsOlmSessionDBKeys(keyserverID); + notifsAccountWithOlmData = await getNotifsAccountWithOlmData( + senderDeviceDescriptor, + ); } catch (e) { return { id, @@ -244,38 +365,109 @@ displayErrorMessage: staffCanSee, }; } - const { olmDataKey, encryptionKeyDBKey } = olmDBKeys; - const [encryptedOlmData, encryptionKey] = await Promise.all([ - localforage.getItem(olmDataKey), - retrieveEncryptionKey(encryptionKeyDBKey), - ]); - if (!encryptionKey || !encryptedOlmData) { - return { - id, - error: 'Received encrypted notification but olm session was not created', - displayErrorMessage: staffCanSee, - }; - } + const { + encryptionKey, + encryptedOlmData, + olmDataKey, + encryptionKeyDBLabel: olmEncryptionKeyDBLabel, + accountEncryptionKey, + encryptedOlmAccount, + synchronizationValue, + } = notifsAccountWithOlmData; try { - await olm.init({ locateFile: () => olmWasmPath }); + const [notificationsOlmData, accountWithPicklingKey] = await Promise.all([ + deserializeEncryptedDataOptional( + encryptedOlmData, + encryptionKey, + ), + deserializeEncryptedDataOptional( + encryptedOlmAccount, + accountEncryptionKey, + ), + olm.init({ locateFile: () => olmWasmPath }), + ]); - const decryptedNotification = await commonDecrypt( - encryptedOlmData, - olmDataKey, - encryptionKey, - encryptedPayload, - ); + let decryptedNotification; + let updatedOlmData; + let updatedNotifsAccount; - const { unreadCount } = decryptedNotification; + const { senderDeviceID, keyserverID } = senderDeviceDescriptor; - invariant(keyserverID, 'Keyserver ID must be set to update badge counts'); - await updateNotifsUnreadCountStorage({ - [keyserverID]: unreadCount, - }); + if (keyserverID) { + invariant( + notificationsOlmData && encryptionKey, + 'Received encrypted notification but keyserver olm session was not created', + ); + + const { + decryptedNotification: resultDecryptedNotification, + updatedOlmData: resultUpdatedOlmData, + } = await commonDecrypt( + notificationsOlmData, + encryptedPayload, + ); + + decryptedNotification = resultDecryptedNotification; + updatedOlmData = resultUpdatedOlmData; + const { unreadCount } = decryptedNotification; + + invariant(keyserverID, 'Keyserver ID must be set to update badge counts'); + await Promise.all([ + persistNotifsAccountWithOlmData({ + olmDataKey, + olmData: updatedOlmData, + olmEncryptionKeyDBLabel, + encryptionKey, + forceWrite: false, + synchronizationValue, + }), + updateNotifsUnreadCountStorage({ + [keyserverID]: unreadCount, + }), + ]); + + return { id, ...decryptedNotification }; + } else { + invariant( + senderDeviceID, + 'keyserverID or SenderDeviceID must be present to decrypt a notif', + ); + invariant( + accountWithPicklingKey, + 'Received encrypted notification but notifs olm account not created', + ); - return { id, ...decryptedNotification }; + const { + decryptedNotification: resultDecryptedNotification, + updatedOlmData: resultUpdatedOlmData, + updatedNotifsAccount: resultUpdatedNotifsAccount, + } = await commonPeerDecrypt( + senderDeviceID, + notificationsOlmData, + accountWithPicklingKey, + messageType, + encryptedPayload, + ); + + decryptedNotification = resultDecryptedNotification; + updatedOlmData = resultUpdatedOlmData; + updatedNotifsAccount = resultUpdatedNotifsAccount; + + await persistNotifsAccountWithOlmData({ + accountWithPicklingKey: updatedNotifsAccount, + accountEncryptionKey, + encryptionKey, + olmData: updatedOlmData, + olmDataKey, + olmEncryptionKeyDBLabel, + synchronizationValue, + forceWrite: false, + }); + + return { id, ...decryptedNotification }; + } } catch (e) { return { id, @@ -287,19 +479,16 @@ async function decryptDesktopNotification( encryptedPayload: string, + messageType: string, staffCanSee: boolean, - keyserverID?: string, + senderDeviceDescriptor: SenderDeviceDescriptor, ): Promise<{ +[string]: mixed }> { - let encryptedOlmData, encryptionKey, olmDataKey; - try { - const { olmDataKey: olmDataKeyValue, encryptionKeyDBKey } = - await getNotifsOlmSessionDBKeys(keyserverID); + const { keyserverID, senderDeviceID } = senderDeviceDescriptor; - olmDataKey = olmDataKeyValue; - - [encryptedOlmData, encryptionKey] = await Promise.all([ - localforage.getItem(olmDataKey), - retrieveEncryptionKey(encryptionKeyDBKey), + let notifsAccountWithOlmData; + try { + [notifsAccountWithOlmData] = await Promise.all([ + getNotifsAccountWithOlmData(senderDeviceDescriptor), initOlm(), ]); } catch (e) { @@ -309,65 +498,126 @@ }; } - if (!encryptionKey || !encryptedOlmData) { - return { - error: 'Received encrypted notification but olm session was not created', - displayErrorMessage: staffCanSee, - }; - } + const { + encryptionKey, + encryptedOlmData, + olmDataKey, + encryptionKeyDBLabel: olmEncryptionKeyDBLabel, + accountEncryptionKey, + encryptedOlmAccount, + synchronizationValue, + } = notifsAccountWithOlmData; - let decryptedNotification; try { - decryptedNotification = await commonDecrypt<{ +[string]: mixed }>( - encryptedOlmData, - olmDataKey, - encryptionKey, - encryptedPayload, - ); + const [notificationsOlmData, accountWithPicklingKey] = await Promise.all([ + deserializeEncryptedDataOptional( + encryptedOlmData, + encryptionKey, + ), + deserializeEncryptedDataOptional( + encryptedOlmAccount, + accountEncryptionKey, + ), + ]); + + if (keyserverID) { + invariant( + notificationsOlmData && encryptionKey, + 'Received encrypted notification but keyserver olm session was not created', + ); + + const { decryptedNotification, updatedOlmData } = await commonDecrypt<{ + +[string]: mixed, + }>(notificationsOlmData, encryptedPayload); + + const updatedOlmDataPersistencePromise = persistNotifsAccountWithOlmData({ + olmDataKey, + olmData: updatedOlmData, + olmEncryptionKeyDBLabel, + encryptionKey, + forceWrite: false, + synchronizationValue, + }); + + // iOS notifications require that unread count is set under + // `badge` key. Since MacOS notifications are created by the + // same function the unread count is also set under `badge` key + const { badge } = decryptedNotification; + if (typeof badge === 'number') { + await Promise.all([ + updateNotifsUnreadCountStorage({ [(keyserverID: string)]: badge }), + updatedOlmDataPersistencePromise, + ]); + return decryptedNotification; + } + + const { unreadCount } = decryptedNotification; + if (typeof unreadCount === 'number') { + await Promise.all([ + updateNotifsUnreadCountStorage({ + [(keyserverID: string)]: unreadCount, + }), + updatedOlmDataPersistencePromise, + ]); + } + + return decryptedNotification; + } else { + invariant( + senderDeviceID, + 'keyserverID or SenderDeviceID must be present to decrypt a notif', + ); + + invariant( + accountWithPicklingKey, + 'Received encrypted notification but notifs olm account not created', + ); + + const { decryptedNotification, updatedOlmData, updatedNotifsAccount } = + await commonPeerDecrypt<{ + +[string]: mixed, + }>( + senderDeviceID, + notificationsOlmData, + accountWithPicklingKey, + messageType, + encryptedPayload, + ); + + await persistNotifsAccountWithOlmData({ + accountWithPicklingKey: updatedNotifsAccount, + accountEncryptionKey, + encryptionKey, + olmData: updatedOlmData, + olmDataKey, + olmEncryptionKeyDBLabel, + synchronizationValue, + forceWrite: false, + }); + + return decryptedNotification; + } } catch (e) { return { error: e.message, staffCanSee, }; } - - if (!keyserverID) { - return decryptedNotification; - } - - // iOS notifications require that unread count is set under - // `badge` key. Since MacOS notifications are created by the - // same function the unread count is also set under `badge` key - const { badge } = decryptedNotification; - if (typeof badge === 'number') { - await updateNotifsUnreadCountStorage({ [(keyserverID: string)]: badge }); - return decryptedNotification; - } - - const { unreadCount } = decryptedNotification; - if (typeof unreadCount === 'number') { - await updateNotifsUnreadCountStorage({ - [(keyserverID: string)]: unreadCount, - }); - } - return decryptedNotification; } async function commonDecrypt( - encryptedOlmData: EncryptedData, - olmDataKey: string, - encryptionKey: CryptoKey, + notificationsOlmData: NotificationsOlmDataType, encryptedPayload: string, -): Promise { - const serializedOlmData = await decryptData(encryptedOlmData, encryptionKey); +): Promise<{ + +decryptedNotification: T, + +updatedOlmData: NotificationsOlmDataType, +}> { const { mainSession, picklingKey, pendingSessionUpdate, updateCreationTimestamp, - }: NotificationsOlmDataType = JSON.parse( - new TextDecoder().decode(serializedOlmData), - ); + } = notificationsOlmData; let updatedOlmData: NotificationsOlmDataType; let decryptedNotification: T; @@ -410,14 +660,134 @@ }; } - const updatedEncryptedSession = await encryptData( - new TextEncoder().encode(JSON.stringify(updatedOlmData)), - encryptionKey, + return { decryptedNotification, updatedOlmData }; +} + +async function commonPeerDecrypt( + senderDeviceID: string, + notificationsOlmData: ?NotificationsOlmDataType, + notificationAccount: PickledOLMAccount, + messageType: string, + encryptedPayload: string, +): Promise<{ + +decryptedNotification: T, + +updatedOlmData?: NotificationsOlmDataType, + +updatedNotifsAccount?: PickledOLMAccount, +}> { + if ( + messageType !== olmEncryptedMessageTypes.PREKEY.toString() && + messageType !== olmEncryptedMessageTypes.TEXT.toString() + ) { + throw new Error( + `Received message of invalid type from device: ${senderDeviceID}`, + ); + } + + let isSenderChainEmpty = true; + let hasReceivedMessage = false; + const sessionExists = !!notificationsOlmData; + + if (notificationsOlmData) { + const session = new olm.Session(); + session.unpickle( + notificationsOlmData.picklingKey, + notificationsOlmData.pendingSessionUpdate, + ); + + isSenderChainEmpty = session.is_sender_chain_empty(); + hasReceivedMessage = session.has_received_message(); + } + + // regular message + const isRegularMessage = + !!notificationsOlmData && + messageType === olmEncryptedMessageTypes.TEXT.toString(); + + const isRegularPrekeyMessage = + !!notificationsOlmData && + messageType === olmEncryptedMessageTypes.PREKEY.toString() && + isSenderChainEmpty && + hasReceivedMessage; + + if (!!notificationsOlmData && (isRegularMessage || isRegularPrekeyMessage)) { + return await commonDecrypt(notificationsOlmData, encryptedPayload); + } + + // At this point we either face race condition or session reset attempt or + // session initialization attempt. For each of this scenario new inbound + // session must be created in order to decrypt message + const authMetadata = await fetchAuthMetadata(); + const notifInboundKeys = await getNotifsInboundKeysForDeviceID( + senderDeviceID, + authMetadata, + ); + + const account = new olm.Account(); + const session = new olm.Session(); + + account.unpickle( + notificationAccount.picklingKey, + notificationAccount.pickledAccount, ); - await localforage.setItem(olmDataKey, updatedEncryptedSession); + if (notifInboundKeys.error) { + throw new Error(notifInboundKeys.error); + } + + invariant( + notifInboundKeys.curve25519, + 'curve25519 must be present in notifs inbound keys', + ); + + session.create_inbound_from( + account, + notifInboundKeys.curve25519, + encryptedPayload, + ); + + const decryptedNotification: T = JSON.parse( + session.decrypt(Number(messageType), encryptedPayload), + ); + + // session reset attempt or session initialization - handled the same + const sessionResetAttempt = + sessionExists && !isSenderChainEmpty && hasReceivedMessage; + + // race condition + const raceCondition = + sessionExists && !isSenderChainEmpty && !hasReceivedMessage; + const { deviceID: ourDeviceID } = authMetadata; + invariant(ourDeviceID, 'Session creation attempt but no device id'); + + const thisDeviceWinsRaceCondition = ourDeviceID > senderDeviceID; + + if ( + !sessionExists || + sessionResetAttempt || + (raceCondition && !thisDeviceWinsRaceCondition) + ) { + const pickledOlmSession = session.pickle(notificationAccount.picklingKey); + const updatedOlmData = { + mainSession: pickledOlmSession, + pendingSessionUpdate: pickledOlmSession, + updateCreationTimestamp: Date.now(), + picklingKey: notificationAccount.picklingKey, + }; + const updatedNotifsAccount = { + pickledAccount: account.pickle(notificationAccount.picklingKey), + picklingKey: notificationAccount.picklingKey, + }; + return { + decryptedNotification, + updatedOlmData, + updatedNotifsAccount, + }; + } - return decryptedNotification; + // If there is a race condition but we win device id comparison + // we return object that carries decrypted data but won't persist + // any session state + return { decryptedNotification }; } function decryptWithSession( diff --git a/web/push-notif/push-notifs-handler.js b/web/push-notif/push-notifs-handler.js --- a/web/push-notif/push-notifs-handler.js +++ b/web/push-notif/push-notifs-handler.js @@ -17,6 +17,7 @@ type RecordAlertActionPayload, } from 'lib/types/alert-types.js'; import { isDesktopPlatform } from 'lib/types/device-types.js'; +import type { SenderDeviceDescriptor } from 'lib/types/notif-types.js'; import { getConfig } from 'lib/utils/config.js'; import { convertNonPendingIDToNewSchema } from 'lib/utils/migration-utils.js'; import { shouldSkipPushPermissionAlert } from 'lib/utils/push-alerts.js'; @@ -84,15 +85,18 @@ return electron?.onEncryptedNotification?.( async ({ encryptedPayload, - keyserverID, + senderDeviceDescriptor, + type: messageType, }: { encryptedPayload: string, - keyserverID?: string, + type: string, + senderDeviceDescriptor: SenderDeviceDescriptor, }) => { const decryptedPayload = await decryptDesktopNotification( encryptedPayload, + messageType, staffCanSee, - keyserverID, + senderDeviceDescriptor, ); electron?.showDecryptedNotification(decryptedPayload); },