diff --git a/desktop/src/main.js b/desktop/src/main.js --- a/desktop/src/main.js +++ b/desktop/src/main.js @@ -307,11 +307,13 @@ const handleEncryptedNotification = ( encryptedPayload: string, keyserverID: string, + type: string, ) => { if (mainWindow) { mainWindow.webContents.send('on-encrypted-notification', { encryptedPayload, keyserverID, + 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 @@ -132,28 +132,31 @@ handleEncryptedNotification: ( encryptedPayload: string, keyserverID: string, + type: string, ) => void, ) { if (process.platform === 'darwin') { pushNotifications.on('received-apns-notification', (event, userInfo) => { - const { keyserverID, encryptedPayload } = userInfo; + const { keyserverID, encryptedPayload, type } = userInfo; if ( typeof keyserverID === 'string' && - typeof encryptedPayload === 'string' + typeof encryptedPayload === 'string' && + typeof type === 'string' ) { - handleEncryptedNotification(encryptedPayload, keyserverID); + handleEncryptedNotification(encryptedPayload, keyserverID, type); return; } showNewNotification(userInfo, handleClick); }); } else if (process.platform === 'win32') { windowsPushNotifEventEmitter.on('received-wns-notification', payload => { - const { keyserverID, encryptedPayload } = payload; + const { keyserverID, encryptedPayload, type } = payload; if ( typeof keyserverID === 'string' && - typeof encryptedPayload === 'string' + typeof encryptedPayload === 'string' && + typeof type === 'string' ) { - handleEncryptedNotification(encryptedPayload, keyserverID); + handleEncryptedNotification(encryptedPayload, keyserverID, 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 @@ -13,7 +13,8 @@ type OnEncryptedNotificationListener = (data: { encryptedPayload: string, - keyserverID?: string, + type: string, + keyserverID: string, }) => 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,10 +14,16 @@ 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 { + fetchAuthMetadata, + getNotifsInboundKeysForDeviceID, +} from './services-client.js'; import { type EncryptedData, decryptData, @@ -130,11 +136,199 @@ return await exportKeyToJWK(cryptoKey); } +async function getOlmDataForKeyserverSession(keyserverID?: string): Promise<{ + +encryptionKey: ?CryptoKey, + +encryptedOlmData: ?EncryptedData, + +olmDataKey: string, + +encryptionKeyDBLabel: string, +}> { + const olmDBKeys = await getNotifsOlmSessionDBKeys(keyserverID); + const { olmDataKey, encryptionKeyDBKey } = olmDBKeys; + const [encryptedOlmData, encryptionKey] = await Promise.all([ + localforage.getItem(olmDataKey), + retrieveEncryptionKey(encryptionKeyDBKey), + ]); + return { + encryptedOlmData, + encryptionKey, + olmDataKey, + encryptionKeyDBLabel: encryptionKeyDBKey, + }; +} + +async function getOlmDataForSessionWithDevice(senderDeviceID: string): Promise<{ + +encryptedOlmData: ?EncryptedData, + +encryptionKey: ?CryptoKey, + +olmDataKey: string, + +encryptionKeyDBLabel: string, + +encryptedOlmAccount: ?EncryptedData, + +accountEncryptionKey: ?CryptoKey, + +synchronizationValue: ?string, +}> { + const olmDataKey = getOlmDataKeyForDeviceID(senderDeviceID); + const olmDataEncryptionKeyDBLabel = + getOlmEncryptionKeyDBLabelForDeviceID(senderDeviceID); + + const queryResult = await localforage.getMultipleItems<{ + notificationAccount: ?EncryptedData, + notificationAccountEncryptionKey: ?CryptoKey, + synchronizationValue: ?number, + [string]: ?CryptoKey | ?EncryptedData, + }>( + [ + INDEXED_DB_NOTIFS_ACCOUNT_KEY, + INDEXED_DB_NOTIFS_ACCOUNT_ENCRYPTION_KEY_DB_LABEL, + olmDataEncryptionKeyDBLabel, + olmDataKey, + ], + INDEXED_DB_NOTIFS_SYNC_KEY, + ); + + const { + notificationAccount, + notificationAccountEncryptionKey, + synchronizationValue, + } = queryResult; + + if (!notificationAccount || !notificationAccountEncryptionKey) { + throw new Error( + 'Attempt to decrypt notification but olm account not initialized.', + ); + } + + const olmData = queryResult[olmDataKey]; + const olmDataEncryptionKey = queryResult[olmDataEncryptionKeyDBLabel]; + + // type refinement + if ( + (olmData && !olmData.ciphertext) || + (olmDataEncryptionKey && !olmDataEncryptionKey.algorithm) + ) { + throw new Error( + 'IndexedDB returned invalid data types for olm data and olm data encryption key', + ); + } + + const [encryptionKey, accountEncryptionKey] = await Promise.all([ + validateCryptoKey(olmDataEncryptionKey), + validateCryptoKey(notificationAccountEncryptionKey), + ]); + + return { + encryptedOlmData: olmData, + encryptionKey, + encryptionKeyDBLabel: olmDataEncryptionKeyDBLabel, + encryptedOlmAccount: notificationAccount, + olmDataKey, + accountEncryptionKey, + synchronizationValue, + }; +} + +async function persistKeyserverOlmData( + olmDataKey: string, + encryptionKey: CryptoKey, + olmData: NotificationsOlmDataType, +): Promise { + const updatedEncryptedSession = + await serializeUnencryptedData( + olmData, + encryptionKey, + ); + + await localforage.setItem(olmDataKey, updatedEncryptedSession); +} + +async function persistPeerOlmData(input: { + olmDataKey: string, + olmEncryptionKeyDBLabel: string, + encryptionKey: ?CryptoKey, + olmData: ?NotificationsOlmDataType, + accountEncryptionKey: ?CryptoKey, + accountWithPicklingKey?: PickledOLMAccount, + synchronizationValue: ?string, +}): Promise { + const { + olmData, + olmDataKey, + accountEncryptionKey, + accountWithPicklingKey, + encryptionKey, + synchronizationValue, + olmEncryptionKeyDBLabel, + } = input; + + const shouldPersistOlmData = olmData && encryptionKey; + const shouldPersistAccount = accountWithPicklingKey && accountEncryptionKey; + + if (!shouldPersistOlmData && !shouldPersistAccount) { + return; + } + + const serializationPromises: { + [string]: Promise, + } = {}; + + if (!olmData && !accountWithPicklingKey) { + return; + } + + if (olmData && encryptionKey) { + serializationPromises[olmDataKey] = + serializeUnencryptedData( + olmData, + encryptionKey, + ); + } else if (olmData) { + const newEncryptionKey = await generateCryptoKey({ + extractable: isDesktopSafari, + }); + + serializationPromises[olmDataKey] = + serializeUnencryptedData( + olmData, + newEncryptionKey, + ); + + serializationPromises[olmEncryptionKeyDBLabel] = + getCryptoKeyPersistentForm(newEncryptionKey); + } + + if (accountWithPicklingKey && accountEncryptionKey) { + serializationPromises[INDEXED_DB_NOTIFS_ACCOUNT_KEY] = + serializeUnencryptedData( + accountWithPicklingKey, + accountEncryptionKey, + ); + } + + const setMultipleItemsInput = await promiseAll(serializationPromises); + const newSynchronizationValue = uuid.v4(); + try { + await localforage.setMultipleItems( + setMultipleItemsInput, + INDEXED_DB_NOTIFS_SYNC_KEY, + synchronizationValue, + newSynchronizationValue, + false, + ); + } catch (e) { + // likely worker crypt persisted its own data + console.log(e); + } +} + async function decryptWebNotification( encryptedNotification: EncryptedWebNotification, ): Promise { - const { id, keyserverID, encryptedPayload } = encryptedNotification; - invariant(keyserverID, 'KeyserverID must be present to decrypt a notif'); + const { + id, + keyserverID, + senderDeviceID, + encryptedPayload, + type: messageType, + } = encryptedNotification; + const utilsData = await localforage.getItem( WEB_NOTIFS_SERVICE_UTILS_KEY, ); @@ -144,9 +338,43 @@ } const { olmWasmPath, staffCanSee } = (utilsData: WebNotifsServiceUtilsData); - let olmDBKeys; + let encryptionKey; + let olmEncryptionKeyDBLabel; + let encryptedOlmData; + let olmDataKey; + let accountEncryptionKey; + let encryptedOlmAccount; + let synchronizationValue; + + let getOlmDataPromise; + if (keyserverID) { + getOlmDataPromise = getOlmDataForKeyserverSession(keyserverID); + } else if (senderDeviceID) { + getOlmDataPromise = getOlmDataForSessionWithDevice(senderDeviceID); + } else { + // we will never reach this branch + throw new Error( + 'keyserverID or SenderDeviceID must be present to decrypt a notif', + ); + } + try { - olmDBKeys = await getNotifsOlmSessionDBKeys(keyserverID); + const { + encryptionKey: fetchedEncryptionKey, + encryptedOlmData: fetchedEncryptedOlmData, + olmDataKey: fetchedOlmDataKey, + encryptionKeyDBLabel: fetchedOlmEncryptionKeyDBLabel, + accountEncryptionKey: fetchedAccountEncryptionKey, + encryptedOlmAccount: fetchedEncryptedOlmAccount, + synchronizationValue: fetchedSynchronizationValue, + } = await getOlmDataPromise; + encryptionKey = fetchedEncryptionKey; + encryptedOlmData = fetchedEncryptedOlmData; + olmDataKey = fetchedOlmDataKey; + accountEncryptionKey = fetchedAccountEncryptionKey; + encryptedOlmAccount = fetchedEncryptedOlmAccount; + synchronizationValue = fetchedSynchronizationValue; + olmEncryptionKeyDBLabel = fetchedOlmEncryptionKeyDBLabel; } catch (e) { return { id, @@ -154,38 +382,90 @@ 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, - }; - } try { - await olm.init({ locateFile: () => olmWasmPath }); + const [notificationsOlmData, accountWithPicklingKey] = await Promise.all([ + deserializeEncryptedData( + encryptedOlmData, + encryptionKey, + ), + deserializeEncryptedData( + encryptedOlmAccount, + accountEncryptionKey, + ), + olm.init({ locateFile: () => olmWasmPath }), + ]); - const decryptedNotification = await commonDecrypt( - encryptedOlmData, - olmDataKey, - encryptionKey, - encryptedPayload, - ); + let decryptedNotification; + let updatedOlmData; + let updatedNotifsAccount; - const { unreadCount } = decryptedNotification; + if (keyserverID) { + invariant( + notificationsOlmData && encryptionKey, + 'Received encrypted notification but keyserver olm session was not created', + ); - invariant(keyserverID, 'Keyserver ID must be set to update badge counts'); - await updateNotifsUnreadCountStorage({ - [keyserverID]: unreadCount, - }); + 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([ + persistKeyserverOlmData(olmDataKey, encryptionKey, updatedOlmData), + updateNotifsUnreadCountStorage({ + [keyserverID]: unreadCount, + }), + ]); + + return { id, ...decryptedNotification }; + } else if (senderDeviceID) { + 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 persistPeerOlmData({ + accountWithPicklingKey: updatedNotifsAccount, + accountEncryptionKey, + encryptionKey, + olmData: updatedOlmData, + olmDataKey, + olmEncryptionKeyDBLabel, + synchronizationValue, + }); + + return { id, ...decryptedNotification }; + } else { + // we will never reach this branch + throw new Error( + 'keyserverID or SenderDeviceID must be present to decrypt a notif', + ); + } } catch (e) { return { id, @@ -197,87 +477,163 @@ 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; + let encryptionKey; + let olmEncryptionKeyDBLabel; + let encryptedOlmData; + let olmDataKey; + let accountEncryptionKey; + let encryptedOlmAccount; + let synchronizationValue; - [encryptedOlmData, encryptionKey] = await Promise.all([ - localforage.getItem(olmDataKey), - retrieveEncryptionKey(encryptionKeyDBKey), - initOlm(), - ]); - } catch (e) { - return { - error: e.message, - displayErrorMessage: staffCanSee, - }; + let getOlmDataPromise; + if (keyserverID) { + getOlmDataPromise = getOlmDataForKeyserverSession(keyserverID); + } else if (senderDeviceID) { + getOlmDataPromise = getOlmDataForSessionWithDevice(senderDeviceID); + } else { + // we will never reach this branch + throw new Error( + 'keyserverID or SenderDeviceID must be present to decrypt a notif', + ); } - if (!encryptionKey || !encryptedOlmData) { + try { + const { + encryptionKey: fetchedEncryptionKey, + encryptedOlmData: fetchedEncryptedOlmData, + olmDataKey: fetchedOlmDataKey, + encryptionKeyDBLabel: fetchedOlmEncryptionKeyDBLabel, + accountEncryptionKey: fetchedAccountEncryptionKey, + encryptedOlmAccount: fetchedEncryptedOlmAccount, + synchronizationValue: fetchedSynchronizationValue, + } = await getOlmDataPromise; + await initOlm(); + + encryptionKey = fetchedEncryptionKey; + encryptedOlmData = fetchedEncryptedOlmData; + olmDataKey = fetchedOlmDataKey; + accountEncryptionKey = fetchedAccountEncryptionKey; + encryptedOlmAccount = fetchedEncryptedOlmAccount; + synchronizationValue = fetchedSynchronizationValue; + olmEncryptionKeyDBLabel = fetchedOlmEncryptionKeyDBLabel; + } catch (e) { return { - error: 'Received encrypted notification but olm session was not created', + error: e.message, displayErrorMessage: staffCanSee, }; } - let decryptedNotification; try { - decryptedNotification = await commonDecrypt<{ +[string]: mixed }>( - encryptedOlmData, - olmDataKey, - encryptionKey, - encryptedPayload, - ); + const [notificationsOlmData, accountWithPicklingKey] = await Promise.all([ + deserializeEncryptedData( + encryptedOlmData, + encryptionKey, + ), + deserializeEncryptedData( + 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 = persistKeyserverOlmData( + olmDataKey, + encryptionKey, + updatedOlmData, + ); + // 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 if (senderDeviceID) { + 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 persistPeerOlmData({ + accountWithPicklingKey: updatedNotifsAccount, + accountEncryptionKey, + encryptionKey, + olmData: updatedOlmData, + olmDataKey, + olmEncryptionKeyDBLabel, + synchronizationValue, + }); + + return decryptedNotification; + } else { + // we will never reach this branch + throw new Error( + 'keyserverID or SenderDeviceID must be present to decrypt a notif', + ); + } } 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; @@ -320,14 +676,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, ); - await localforage.setItem(olmDataKey, updatedEncryptedSession); + const account = new olm.Account(); + const session = new olm.Session(); + + account.unpickle( + notificationAccount.picklingKey, + notificationAccount.pickledAccount, + ); + + 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 @@ -85,14 +85,17 @@ async ({ encryptedPayload, keyserverID, + type: messageType, }: { encryptedPayload: string, - keyserverID?: string, + type: string, + keyserverID: string, }) => { const decryptedPayload = await decryptDesktopNotification( encryptedPayload, + messageType, staffCanSee, - keyserverID, + { keyserverID }, ); electron?.showDecryptedNotification(decryptedPayload); },