diff --git a/desktop/src/main.js b/desktop/src/main.js index 26cd70ae6..804714599 100644 --- a/desktop/src/main.js +++ b/desktop/src/main.js @@ -1,383 +1,387 @@ // @flow import type { Event, MenuItemConstructorOptions } from 'electron'; import { app, BrowserWindow, shell, Menu, ipcMain, systemPreferences, autoUpdater, // eslint-disable-next-line import/extensions } from 'electron/main'; import contextMenu from 'electron-context-menu'; import fs from 'fs'; import path from 'path'; import { initAutoUpdate } from './auto-update.js'; import { handleSquirrelEvent } from './handle-squirrel-event.js'; import { listenForNotifications, registerForNotifications, showNewNotification, } from './push-notifications.js'; const isDev = process.env.ENV === 'dev'; const url = isDev ? 'http://localhost:3000/webapp/' : 'https://web.comm.app'; const isMac = process.platform === 'darwin'; const scrollbarCSS = fs.promises.readFile( path.resolve(__dirname, '../scrollbar.css'), 'utf8', ); let mainWindow = null; const setApplicationMenu = () => { let mainMenu: MenuItemConstructorOptions[] = []; if (isMac) { mainMenu = [ { label: app.name, submenu: [ { role: 'about' }, { type: 'separator' }, { role: 'services' }, { type: 'separator' }, { role: 'hide' }, { role: 'hideOthers' }, { role: 'unhide' }, { type: 'separator' }, { role: 'quit' }, ], }, ]; } const viewMenu = { label: 'View', submenu: [ { role: 'reload' }, { type: 'separator' }, { role: 'resetZoom' }, { role: 'zoomIn' }, { role: 'zoomOut' }, { type: 'separator' }, { role: 'togglefullscreen' }, { role: 'toggleDevTools' }, { label: 'Toggle Shared Worker Developer Tools', click: () => { if (mainWindow) { mainWindow.webContents.inspectSharedWorker(); } }, }, ], }; const windowMenu = { label: 'Window', submenu: [ { role: 'minimize' }, ...(isMac ? [ { type: 'separator' }, { role: 'front' }, { type: 'separator' }, { role: 'window' }, ] : [{ role: 'close' }]), ], }; const menu = Menu.buildFromTemplate([ ...mainMenu, { role: 'fileMenu' }, { role: 'editMenu' }, viewMenu, windowMenu, ]); Menu.setApplicationMenu(menu); }; const createMainWindow = (urlPath?: string) => { const win = new BrowserWindow({ show: false, width: 1300, height: 800, minWidth: 1100, minHeight: 600, titleBarStyle: 'hidden', trafficLightPosition: { x: 20, y: 24 }, titleBarOverlay: { color: '#0A0A0A', symbolColor: '#FFFFFF', height: 64, }, backgroundColor: '#0A0A0A', webPreferences: { preload: path.resolve(__dirname, 'preload.js'), }, }); const updateNavigationState = () => { win.webContents.send('on-navigate', { canGoBack: win.webContents.canGoBack(), canGoForward: win.webContents.canGoForward(), }); }; win.webContents.on('did-navigate-in-page', updateNavigationState); const clearHistory = () => { win.webContents.clearHistory(); updateNavigationState(); }; ipcMain.on('clear-history', clearHistory); const doubleClickTopBar = () => { if (isMac) { // Possible values for AppleActionOnDoubleClick are Maximize, // Minimize or None. We handle the last two inside this if. // Maximize (which is the only behaviour for other platforms) // is handled in the later block. const action = systemPreferences.getUserDefault( 'AppleActionOnDoubleClick', 'string', ); if (action === 'None') { return; } else if (action === 'Minimize') { win.minimize(); return; } } if (win.isMaximized()) { win.unmaximize(); } else { win.maximize(); } }; ipcMain.on('double-click-top-bar', doubleClickTopBar); const updateDownloaded = ( event: Event, releaseNotes?: string, releaseName: string, ) => { win.webContents.send('on-new-version-available', releaseName); }; autoUpdater.on('update-downloaded', updateDownloaded); win.on('closed', () => { mainWindow = null; ipcMain.removeListener('clear-history', clearHistory); ipcMain.removeListener('double-click-top-bar', doubleClickTopBar); autoUpdater.removeListener('update-downloaded', updateDownloaded); }); win.webContents.setWindowOpenHandler(({ url: openURL }) => { void shell.openExternal(openURL); // Returning 'deny' prevents a new electron window from being created return { action: 'deny' }; }); void (async () => { const css = await scrollbarCSS; await win.webContents.insertCSS(css); })(); void win.loadURL(url + (urlPath ?? '')); mainWindow = win; return win; }; const createSplashWindow = () => { const win = new BrowserWindow({ width: 300, height: 300, resizable: false, frame: false, alwaysOnTop: true, center: true, backgroundColor: '#111827', }); void win.loadFile(path.resolve(__dirname, '../pages/splash.html')); return win; }; const createErrorWindow = () => { const win = new BrowserWindow({ show: false, width: 400, height: 300, resizable: false, center: true, titleBarStyle: 'hidden', trafficLightPosition: { x: 20, y: 24 }, backgroundColor: '#111827', }); win.on('close', () => { app.quit(); }); void win.loadFile(path.resolve(__dirname, '../pages/error.html')); return win; }; const sendDeviceTokenToWebApp = async () => { if (!mainWindow) { return; } const token = await registerForNotifications(); mainWindow?.webContents.send('on-device-token-registered', token); }; const show = (urlPath?: string) => { const splash = createSplashWindow(); const error = createErrorWindow(); const main = createMainWindow(urlPath); let loadedSuccessfully = true; const failedLoadHandler = () => { loadedSuccessfully = false; if (!splash.isDestroyed()) { splash.destroy(); } if (!error.isDestroyed()) { error.show(); } setTimeout(() => { loadedSuccessfully = true; void main.loadURL(url); }, 1000); }; main.webContents.on('did-fail-load', failedLoadHandler); main.webContents.on('did-finish-load', () => { if (loadedSuccessfully) { if (!splash.isDestroyed()) { splash.destroy(); } if (!error.isDestroyed()) { error.destroy(); } main.webContents.removeListener('did-fail-load', failedLoadHandler); main.show(); if (app.isPackaged) { void sendDeviceTokenToWebApp(); } } }); }; const run = () => { app.setName('Comm'); contextMenu({ showSaveImageAs: true, showSaveVideoAs: true, }); if (process.platform === 'win32') { app.setAppUserModelId('Comm'); } setApplicationMenu(); void (async () => { await app.whenReady(); const handleNotificationClick = (threadID?: string) => { if (mainWindow && threadID) { mainWindow.webContents.send('on-notification-clicked', { threadID, }); } else if (threadID) { show(`chat/thread/${threadID}/`); } else { show(); } }; 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, }); } }; if (app.isPackaged) { try { initAutoUpdate(); } catch (error) { console.error(error); } listenForNotifications( handleNotificationClick, handleEncryptedNotification, ); ipcMain.on('fetch-device-token', sendDeviceTokenToWebApp); } ipcMain.on('set-badge', (event, value) => { if (isMac) { app.dock.setBadge(value?.toString() ?? ''); } }); ipcMain.on('get-version', event => { event.returnValue = app.getVersion().toString(); }); ipcMain.on( 'show-decrypted-notification', (event, decryptedNotification) => { showNewNotification(decryptedNotification, handleNotificationClick); }, ); show(); app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) { show(); } }); })(); app.on('window-all-closed', () => { if (!isMac) { app.quit(); } }); app.on('render-process-gone', (event, webContents, details) => { console.error( `EVENT: render-process-gone. Reason: '${details.reason}'. ` + `ExitCode: '${details.exitCode}'.`, ); }); app.on('child-process-gone', (event, details) => { console.error( `EVENT: child-process-gone. Process type: '${details.type}'. ` + `Reason: '${details.reason}'. ExitCode: '${details.exitCode}'.`, ); }); }; if (app.isPackaged && process.platform === 'win32') { if (!handleSquirrelEvent()) { run(); } } else { run(); } diff --git a/desktop/src/push-notifications.js b/desktop/src/push-notifications.js index 2afa98b19..17899e90a 100644 --- a/desktop/src/push-notifications.js +++ b/desktop/src/push-notifications.js @@ -1,167 +1,190 @@ // @flow import type { PushNotificationManager as PushNotificationManagerType, PushNotificationReceivedEventArgs, } from '@commapp/windowspush'; // eslint-disable-next-line import/extensions import { app, pushNotifications, Notification } from 'electron/main'; import EventEmitter from 'events'; import { resolve } from 'path'; import { isNormalStartup } from './handle-squirrel-event.js'; let windowsPushNotificationManager; const windowsPushNotifEventEmitter = new EventEmitter(); if (process.platform === 'win32' && app.isPackaged && isNormalStartup()) { void (async () => { try { const { PushNotificationManager } = await import('@commapp/windowspush'); if (!PushNotificationManager.isSupported()) { return; } windowsPushNotificationManager = PushNotificationManager.default; const handleEvent = ( manager: PushNotificationManagerType, event: PushNotificationReceivedEventArgs, ) => { const byteArray = []; for (let i = 0; i < event.payload.length; i++) { byteArray.push(event.payload[i]); } const payload = Buffer.from(byteArray).toString('utf-8'); windowsPushNotifEventEmitter.emit( 'received-wns-notification', JSON.parse(payload), ); }; // Windows requires that this must be called before the call to `register` windowsPushNotificationManager.addListener('PushReceived', handleEvent); windowsPushNotificationManager.register(); app.on('quit', () => { windowsPushNotificationManager.removeListener( 'PushReceived', handleEvent, ); windowsPushNotificationManager.unregisterAll(); }); } catch (err) { console.error( `Error while loading windows push notifications ${err.message}`, ); } })(); } async function registerForNotifications(): Promise { if (process.platform === 'darwin') { try { const token = await pushNotifications.registerForAPNSNotifications(); return token; } catch (err) { console.error(err); } } else if (process.platform === 'win32' && windowsPushNotificationManager) { try { const token = await new Promise((resolvePromise, reject) => { windowsPushNotificationManager.createChannelAsync( 'f09f4211-a998-40c1-a515-689e3faecb62', (error, result) => { if (error) { reject(error); } resolvePromise(result.channel.uri); }, ); }); return token; } catch (err) { console.error(err); } } return null; } function showNewNotification( payload: { +[string]: mixed }, handleClick: (threadID?: string) => void, ) { const windowsIconPath = resolve(__dirname, '../icons/icon.ico'); if ( typeof payload.error === 'string' && typeof payload.displayErrorMessage === 'boolean' ) { const notif = new Notification({ title: 'Comm notification', body: payload.displayErrorMessage ? payload.error : undefined, icon: process.platform === 'win32' ? windowsIconPath : undefined, }); notif.on('click', () => handleClick()); notif.show(); return; } if ( typeof payload.title !== 'string' || typeof payload.body !== 'string' || typeof payload.threadID !== 'string' ) { return; } const { title, body, threadID } = payload; const notif = new Notification({ title, body, icon: process.platform === 'win32' ? windowsIconPath : undefined, }); notif.on('click', () => handleClick(threadID)); notif.show(); } function listenForNotifications( 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); }); } } export { listenForNotifications, registerForNotifications, showNewNotification, }; diff --git a/lib/types/electron-types.js b/lib/types/electron-types.js index 2d380fade..cd85ed723 100644 --- a/lib/types/electron-types.js +++ b/lib/types/electron-types.js @@ -1,35 +1,38 @@ // @flow +import type { SenderDeviceDescriptor } from './notif-types'; + type OnNavigateListener = ({ +canGoBack: boolean, +canGoForward: boolean, }) => void; type OnNewVersionAvailableListener = (version: string) => void; type OnDeviceTokenRegisteredListener = (token: ?string) => void; type OnNotificationClickedListener = (data: { threadID: string }) => void; type OnEncryptedNotificationListener = (data: { encryptedPayload: string, - keyserverID?: string, + type: string, + senderDeviceDescriptor: SenderDeviceDescriptor, }) => mixed; export type ElectronBridge = { // Returns a callback that you can call to remove the listener +onNavigate: OnNavigateListener => () => void, +clearHistory: () => void, +doubleClickTopBar: () => void, +setBadge: (number | string | null) => void, +version?: string, // Returns a callback that you can call to remove the listener +onNewVersionAvailable?: OnNewVersionAvailableListener => () => void, +updateToNewVersion?: () => void, +platform?: 'windows' | 'macos', +onDeviceTokenRegistered?: OnDeviceTokenRegisteredListener => () => void, +onNotificationClicked?: OnNotificationClickedListener => () => void, +fetchDeviceToken: () => void, +onEncryptedNotification?: OnEncryptedNotificationListener => () => void, +showDecryptedNotification: (decryptedPayload: { +[string]: mixed }) => void, }; diff --git a/web/push-notif/notif-crypto-utils.js b/web/push-notif/notif-crypto-utils.js index 74a60575a..f8364f3aa 100644 --- a/web/push-notif/notif-crypto-utils.js +++ b/web/push-notif/notif-crypto-utils.js @@ -1,900 +1,1270 @@ // @flow import olm from '@commapp/olm'; import type { EncryptResult } from '@commapp/olm'; import invariant from 'invariant'; import localforage from 'localforage'; import uuid from 'uuid'; import { olmEncryptedMessageTypes, type NotificationsOlmDataType, type PickledOLMAccount, } from 'lib/types/crypto-types.js'; 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, encryptData, importJWKKey, exportKeyToJWK, generateCryptoKey, encryptedAESDataValidator, extendedCryptoKeyValidator, } from '../crypto/aes-gcm-crypto-utils.js'; import { initOlm } from '../olm/olm-utils.js'; import { NOTIFICATIONS_OLM_DATA_CONTENT, NOTIFICATIONS_OLM_DATA_ENCRYPTION_KEY, } from '../shared-worker/utils/constants.js'; import { isDesktopSafari } from '../shared-worker/utils/db-utils.js'; export type WebNotifDecryptionError = { +id: string, +error: string, +displayErrorMessage?: boolean, }; export type WebNotifsServiceUtilsData = { +olmWasmPath: string, +staffCanSee: boolean, }; export type NotificationAccountWithPicklingKey = { +notificationAccount: olm.Account, +picklingKey: string, +synchronizationValue: ?string, +accountEncryptionKey?: CryptoKey, }; type DecryptionResult = { +newPendingSessionUpdate: string, +newUpdateCreationTimestamp: number, +decryptedNotification: T, }; export const WEB_NOTIFS_SERVICE_UTILS_KEY = 'webNotifsServiceUtils'; const SESSION_UPDATE_MAX_PENDING_TIME = 10 * 1000; const INDEXED_DB_KEYSERVER_PREFIX = 'keyserver'; const INDEXED_DB_KEY_SEPARATOR = ':'; const INDEXED_DB_DEVICE_PREFIX = 'device'; const INDEXED_DB_NOTIFS_SYNC_KEY = 'notifsSyncKey'; // This constant is only used to migrate the existing notifications // session with production keyserver to new IndexedDB key format. This // migration will fire when user updates the app. It will also fire // on dev env provided old keyserver set up is used. Developers willing // to use new keyserver set up must log out before updating the app. // Do not introduce new usages of this constant in the code!!! const ASHOAT_KEYSERVER_ID_USED_ONLY_FOR_MIGRATION_FROM_LEGACY_NOTIF_STORAGE = '256'; const INDEXED_DB_UNREAD_COUNT_SUFFIX = 'unreadCount'; const INDEXED_DB_NOTIFS_ACCOUNT_KEY = 'notificationAccount'; const INDEXED_DB_NOTIFS_ACCOUNT_ENCRYPTION_KEY_DB_LABEL = 'notificationAccountEncryptionKey'; async function deserializeEncryptedData( encryptedData: EncryptedData, encryptionKey: CryptoKey, ): Promise { const serializedData = await decryptData(encryptedData, encryptionKey); const data: T = JSON.parse(new TextDecoder().decode(serializedData)); 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, ): Promise { const dataAsString = JSON.stringify(data); invariant( dataAsString, 'Attempt to serialize null or undefined is forbidden', ); return await encryptData( new TextEncoder().encode(dataAsString), encryptionKey, ); } async function validateCryptoKey( cryptoKey: CryptoKey | SubtleCrypto$JsonWebKey, ): Promise { if (!isDesktopSafari) { return ((cryptoKey: any): CryptoKey); } 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 { if (!isDesktopSafari) { return cryptoKey; } // Safari doesn't support structured clone algorithm in service // worker context so we have to store CryptoKey as JSON 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, +olmData?: ?NotificationsOlmDataType, +encryptionKey?: ?CryptoKey, +accountEncryptionKey?: ?CryptoKey, +accountWithPicklingKey?: PickledOLMAccount, +synchronizationValue: ?string, +forceWrite: boolean, }): Promise { const { olmData, olmDataKey, accountEncryptionKey, accountWithPicklingKey, encryptionKey, synchronizationValue, olmEncryptionKeyDBLabel, forceWrite, } = input; const shouldPersistOlmData = olmDataKey && olmData && (encryptionKey || olmEncryptionKeyDBLabel); const shouldPersistAccount = !!accountWithPicklingKey; if (!shouldPersistOlmData && !shouldPersistAccount) { return; } const serializationPromises: { [string]: Promise, } = {}; if (olmDataKey && olmData && encryptionKey) { serializationPromises[olmDataKey] = serializeUnencryptedData( olmData, encryptionKey, ); } else if (olmData && olmDataKey && olmEncryptionKeyDBLabel) { 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, ); } else if (accountWithPicklingKey) { const newEncryptionKey = await generateCryptoKey({ extractable: isDesktopSafari, }); serializationPromises[INDEXED_DB_NOTIFS_ACCOUNT_KEY] = serializeUnencryptedData( accountWithPicklingKey, newEncryptionKey, ); serializationPromises[INDEXED_DB_NOTIFS_ACCOUNT_ENCRYPTION_KEY_DB_LABEL] = getCryptoKeyPersistentForm(newEncryptionKey); } const setMultipleItemsInput = await promiseAll(serializationPromises); const newSynchronizationValue = uuid.v4(); try { await localforage.setMultipleItems( setMultipleItemsInput, INDEXED_DB_NOTIFS_SYNC_KEY, synchronizationValue, newSynchronizationValue, forceWrite, ); } catch (e) { // likely shared worker 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, + encryptedPayload, + type: messageType, + ...rest + } = encryptedNotification; + const senderDeviceDescriptor: SenderDeviceDescriptor = rest; + const utilsData = await localforage.getItem( WEB_NOTIFS_SERVICE_UTILS_KEY, ); if (!utilsData) { return { id, error: 'Necessary data not found in IndexedDB' }; } const { olmWasmPath, staffCanSee } = (utilsData: WebNotifsServiceUtilsData); - let olmDBKeys; + let notifsAccountWithOlmData; try { - olmDBKeys = await getNotifsOlmSessionDBKeys(keyserverID); + notifsAccountWithOlmData = await getNotifsAccountWithOlmData( + senderDeviceDescriptor, + ); } catch (e) { return { id, error: e.message, 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, error: e.message, displayErrorMessage: staffCanSee, }; } } 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) { return { error: e.message, displayErrorMessage: staffCanSee, }; } - 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; const shouldUpdateMainSession = Date.now() - updateCreationTimestamp > SESSION_UPDATE_MAX_PENDING_TIME; const decryptionWithPendingSessionResult = decryptWithPendingSession( pendingSessionUpdate, picklingKey, encryptedPayload, ); if (decryptionWithPendingSessionResult.decryptedNotification) { const { decryptedNotification: notifDecryptedWithPendingSession, newPendingSessionUpdate, newUpdateCreationTimestamp, } = decryptionWithPendingSessionResult; decryptedNotification = notifDecryptedWithPendingSession; updatedOlmData = { mainSession: shouldUpdateMainSession ? pendingSessionUpdate : mainSession, pendingSessionUpdate: newPendingSessionUpdate, updateCreationTimestamp: newUpdateCreationTimestamp, picklingKey, }; } else { const { newUpdateCreationTimestamp, decryptedNotification: notifDecryptedWithMainSession, } = decryptWithSession(mainSession, picklingKey, encryptedPayload); decryptedNotification = notifDecryptedWithMainSession; updatedOlmData = { mainSession: mainSession, pendingSessionUpdate, updateCreationTimestamp: newUpdateCreationTimestamp, picklingKey, }; } - 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( pickledSession: string, picklingKey: string, encryptedPayload: string, ): DecryptionResult { const session = new olm.Session(); session.unpickle(picklingKey, pickledSession); const decryptedNotification: T = JSON.parse( session.decrypt(olmEncryptedMessageTypes.TEXT, encryptedPayload), ); const newPendingSessionUpdate = session.pickle(picklingKey); const newUpdateCreationTimestamp = Date.now(); return { decryptedNotification, newUpdateCreationTimestamp, newPendingSessionUpdate, }; } function decryptWithPendingSession( pendingSessionUpdate: string, picklingKey: string, encryptedPayload: string, ): DecryptionResult | { +error: string } { try { const { decryptedNotification, newPendingSessionUpdate, newUpdateCreationTimestamp, } = decryptWithSession( pendingSessionUpdate, picklingKey, encryptedPayload, ); return { newPendingSessionUpdate, newUpdateCreationTimestamp, decryptedNotification, }; } catch (e) { return { error: e.message }; } } async function encryptNotification( payload: string, deviceID: string, ): Promise { const olmDataKey = getOlmDataKeyForDeviceID(deviceID); const olmEncryptionKeyDBLabel = getOlmEncryptionKeyDBLabelForDeviceID(deviceID); let encryptedOlmData, encryptionKey, synchronizationValue; try { const { values: { [olmDataKey]: fetchedEncryptedOlmData, [olmEncryptionKeyDBLabel]: fetchedEncryptionKey, }, synchronizationValue: fetchedSynchronizationValue, } = await localforage.getMultipleItems<{ +[string]: ?EncryptedData | ?CryptoKey | ?SubtleCrypto$JsonWebKey, }>([olmDataKey, olmEncryptionKeyDBLabel], INDEXED_DB_NOTIFS_SYNC_KEY); encryptedOlmData = fetchedEncryptedOlmData; encryptionKey = fetchedEncryptionKey; synchronizationValue = fetchedSynchronizationValue; } catch (e) { throw new Error( `Failed to fetch olm session from IndexedDB for device: ${deviceID}. Details: ${ getMessageForException(e) ?? '' }`, ); } if (!encryptionKey || !encryptedOlmData) { throw new Error(`Session with device: ${deviceID} not initialized.`); } const validatedEncryptedOlmData = assertWithValidator( encryptedOlmData, encryptedAESDataValidator, ); const validatedEncryptionKey = await validateCryptoKey( assertWithValidator(encryptionKey, extendedCryptoKeyValidator), ); let encryptedNotification; try { encryptedNotification = await encryptNotificationWithOlmSession( payload, validatedEncryptedOlmData, olmDataKey, validatedEncryptionKey, synchronizationValue, ); } catch (e) { throw new Error( `Failed encrypt notification for device: ${deviceID}. Details: ${ getMessageForException(e) ?? '' }`, ); } return encryptedNotification; } async function encryptNotificationWithOlmSession( payload: string, encryptedOlmData: EncryptedData, olmDataKey: string, encryptionKey: CryptoKey, synchronizationValue: ?string, ): Promise { const serializedOlmData = await decryptData(encryptedOlmData, encryptionKey); const { mainSession, picklingKey, pendingSessionUpdate, updateCreationTimestamp, }: NotificationsOlmDataType = JSON.parse( new TextDecoder().decode(serializedOlmData), ); const session = new olm.Session(); session.unpickle(picklingKey, pendingSessionUpdate); const encryptedNotification = session.encrypt(payload); const newPendingSessionUpdate = session.pickle(picklingKey); const updatedOlmData: NotificationsOlmDataType = { mainSession, pendingSessionUpdate: newPendingSessionUpdate, picklingKey, updateCreationTimestamp, }; const updatedEncryptedSession = await encryptData( new TextEncoder().encode(JSON.stringify(updatedOlmData)), encryptionKey, ); const newSynchronizationValue = uuid.v4(); await localforage.setMultipleItems( { [olmDataKey]: updatedEncryptedSession }, INDEXED_DB_NOTIFS_SYNC_KEY, synchronizationValue, newSynchronizationValue, // This method (encryptNotification) is expected to be called // exclusively from the shared worker which must always win race // condition against push notifications service-worker. true, ); return encryptedNotification; } // notifications account manipulation async function getNotifsCryptoAccount(): Promise { const { values: { [INDEXED_DB_NOTIFS_ACCOUNT_KEY]: encryptedNotifsAccount, [INDEXED_DB_NOTIFS_ACCOUNT_ENCRYPTION_KEY_DB_LABEL]: notifsAccountEncryptionKey, }, synchronizationValue, } = await localforage.getMultipleItems<{ +notificationAccount: ?EncryptedData, +notificationAccountEncryptionKey: ?CryptoKey | ?SubtleCrypto$JsonWebKey, }>( [ INDEXED_DB_NOTIFS_ACCOUNT_KEY, INDEXED_DB_NOTIFS_ACCOUNT_ENCRYPTION_KEY_DB_LABEL, ], INDEXED_DB_NOTIFS_SYNC_KEY, ); if (!encryptedNotifsAccount || !notifsAccountEncryptionKey) { throw new Error( 'Attempt to retrieve notifs olm account but account not created.', ); } const validatedNotifsAccountEncryptionKey = await validateCryptoKey( notifsAccountEncryptionKey, ); const pickledOLMAccount = await deserializeEncryptedData( encryptedNotifsAccount, validatedNotifsAccountEncryptionKey, ); const { pickledAccount, picklingKey } = pickledOLMAccount; const notificationAccount = new olm.Account(); notificationAccount.unpickle(picklingKey, pickledAccount); return { notificationAccount, picklingKey, synchronizationValue, accountEncryptionKey: validatedNotifsAccountEncryptionKey, }; } async function retrieveEncryptionKey( encryptionKeyDBLabel: string, ): Promise { if (!isDesktopSafari) { return await localforage.getItem(encryptionKeyDBLabel); } // Safari doesn't support structured clone algorithm in service // worker context so we have to store CryptoKey as JSON const persistedCryptoKey = await localforage.getItem(encryptionKeyDBLabel); if (!persistedCryptoKey) { return null; } return await importJWKKey(persistedCryptoKey); } async function persistEncryptionKey( encryptionKeyDBLabel: string, encryptionKey: CryptoKey, ): Promise { let cryptoKeyPersistentForm; if (isDesktopSafari) { // Safari doesn't support structured clone algorithm in service // worker context so we have to store CryptoKey as JSON cryptoKeyPersistentForm = await exportKeyToJWK(encryptionKey); } else { cryptoKeyPersistentForm = encryptionKey; } await localforage.setItem(encryptionKeyDBLabel, cryptoKeyPersistentForm); } async function getNotifsOlmSessionDBKeys(keyserverID?: string): Promise<{ +olmDataKey: string, +encryptionKeyDBKey: string, }> { const olmDataKeyForKeyserverPrefix = getOlmDataKeyForCookie( undefined, keyserverID, ); const olmEncryptionKeyDBLabelForKeyserverPrefix = getOlmEncryptionKeyDBLabelForCookie(undefined, keyserverID); const dbKeys = await localforage.keys(); const olmDataKeys = sortOlmDBKeysArray( dbKeys.filter(key => key.startsWith(olmDataKeyForKeyserverPrefix)), ); const encryptionKeyDBLabels = sortOlmDBKeysArray( dbKeys.filter(key => key.startsWith(olmEncryptionKeyDBLabelForKeyserverPrefix), ), ); if (olmDataKeys.length === 0 || encryptionKeyDBLabels.length === 0) { throw new Error( 'Received encrypted notification but olm session was not created', ); } const latestDataKey = olmDataKeys[olmDataKeys.length - 1]; const latestEncryptionKeyDBKey = encryptionKeyDBLabels[encryptionKeyDBLabels.length - 1]; const latestDataCookieID = getCookieIDFromOlmDBKey(latestDataKey); const latestEncryptionKeyCookieID = getCookieIDFromOlmDBKey( latestEncryptionKeyDBKey, ); if (latestDataCookieID !== latestEncryptionKeyCookieID) { throw new Error( 'Olm sessions and their encryption keys out of sync. Latest cookie ' + `id for olm sessions ${latestDataCookieID}. Latest cookie ` + `id for olm session encryption keys ${latestEncryptionKeyCookieID}`, ); } const olmDBKeys = { olmDataKey: latestDataKey, encryptionKeyDBKey: latestEncryptionKeyDBKey, }; const keysToDelete: $ReadOnlyArray = [ ...olmDataKeys.slice(0, olmDataKeys.length - 1), ...encryptionKeyDBLabels.slice(0, encryptionKeyDBLabels.length - 1), ]; await Promise.all(keysToDelete.map(key => localforage.removeItem(key))); return olmDBKeys; } function getOlmDataKeyForCookie(cookie: ?string, keyserverID?: string): string { let olmDataKeyBase; if (keyserverID) { olmDataKeyBase = [ INDEXED_DB_KEYSERVER_PREFIX, keyserverID, NOTIFICATIONS_OLM_DATA_CONTENT, ].join(INDEXED_DB_KEY_SEPARATOR); } else { olmDataKeyBase = NOTIFICATIONS_OLM_DATA_CONTENT; } if (!cookie) { return olmDataKeyBase; } const cookieID = getCookieIDFromCookie(cookie); return [olmDataKeyBase, cookieID].join(INDEXED_DB_KEY_SEPARATOR); } function getOlmDataKeyForDeviceID(deviceID: string): string { return [ INDEXED_DB_DEVICE_PREFIX, deviceID, NOTIFICATIONS_OLM_DATA_CONTENT, ].join(INDEXED_DB_KEY_SEPARATOR); } function getOlmEncryptionKeyDBLabelForCookie( cookie: ?string, keyserverID?: string, ): string { let olmEncryptionKeyDBLabelBase; if (keyserverID) { olmEncryptionKeyDBLabelBase = [ INDEXED_DB_KEYSERVER_PREFIX, keyserverID, NOTIFICATIONS_OLM_DATA_ENCRYPTION_KEY, ].join(INDEXED_DB_KEY_SEPARATOR); } else { olmEncryptionKeyDBLabelBase = NOTIFICATIONS_OLM_DATA_ENCRYPTION_KEY; } if (!cookie) { return olmEncryptionKeyDBLabelBase; } const cookieID = getCookieIDFromCookie(cookie); return [olmEncryptionKeyDBLabelBase, cookieID].join(INDEXED_DB_KEY_SEPARATOR); } function getOlmEncryptionKeyDBLabelForDeviceID(deviceID: string): string { return [ INDEXED_DB_DEVICE_PREFIX, deviceID, NOTIFICATIONS_OLM_DATA_ENCRYPTION_KEY, ].join(INDEXED_DB_KEY_SEPARATOR); } function getCookieIDFromOlmDBKey(olmDBKey: string): string | '0' { // Olm DB keys comply to one of the following formats: // KEYSERVER::(OLM_CONTENT | OLM_ENCRYPTION_KEY): // or legacy (OLM_CONTENT | OLM_ENCRYPTION_KEY):. // Legacy format may be used in case a new version of the web app // is running on a old desktop version that uses legacy key format. const cookieID = olmDBKey.split(INDEXED_DB_KEY_SEPARATOR).slice(-1)[0]; return cookieID ?? '0'; } function sortOlmDBKeysArray( olmDBKeysArray: $ReadOnlyArray, ): $ReadOnlyArray { return olmDBKeysArray .map(key => ({ cookieID: Number(getCookieIDFromOlmDBKey(key)), key, })) .sort( ({ cookieID: cookieID1 }, { cookieID: cookieID2 }) => cookieID1 - cookieID2, ) .map(({ key }) => key); } async function migrateLegacyOlmNotificationsSessions() { const keyValuePairsToInsert: { [key: string]: EncryptedData | CryptoKey } = {}; const keysToDelete = []; await localforage.iterate((value: EncryptedData | CryptoKey, key) => { let keyToInsert; if (key.startsWith(NOTIFICATIONS_OLM_DATA_CONTENT)) { const cookieID = getCookieIDFromOlmDBKey(key); keyToInsert = getOlmDataKeyForCookie( cookieID, ASHOAT_KEYSERVER_ID_USED_ONLY_FOR_MIGRATION_FROM_LEGACY_NOTIF_STORAGE, ); } else if (key.startsWith(NOTIFICATIONS_OLM_DATA_ENCRYPTION_KEY)) { const cookieID = getCookieIDFromOlmDBKey(key); keyToInsert = getOlmEncryptionKeyDBLabelForCookie( cookieID, ASHOAT_KEYSERVER_ID_USED_ONLY_FOR_MIGRATION_FROM_LEGACY_NOTIF_STORAGE, ); } else { return undefined; } keyValuePairsToInsert[keyToInsert] = value; keysToDelete.push(key); return undefined; }); const insertionPromises = Object.entries(keyValuePairsToInsert).map( ([key, value]) => (async () => { await localforage.setItem(key, value); })(), ); const deletionPromises = keysToDelete.map(key => (async () => await localforage.removeItem(key))(), ); await Promise.all([...insertionPromises, ...deletionPromises]); } // Multiple keyserver unread count utilities function getKeyserverUnreadCountKey(keyserverID: string) { return [ INDEXED_DB_KEYSERVER_PREFIX, keyserverID, INDEXED_DB_UNREAD_COUNT_SUFFIX, ].join(INDEXED_DB_KEY_SEPARATOR); } async function updateNotifsUnreadCountStorage(perKeyserverUnreadCount: { +[keyserverID: string]: number, }) { const unreadCountUpdatePromises: Array> = Object.entries( perKeyserverUnreadCount, ).map(([keyserverID, unreadCount]) => { const keyserverUnreadCountKey = getKeyserverUnreadCountKey(keyserverID); return localforage.setItem(keyserverUnreadCountKey, unreadCount); }); await Promise.all(unreadCountUpdatePromises); } async function queryNotifsUnreadCountStorage( keyserverIDs: $ReadOnlyArray, ): Promise<{ +[keyserverID: string]: ?number, }> { const queryUnreadCountPromises: Array> = keyserverIDs.map(async keyserverID => { const keyserverUnreadCountKey = getKeyserverUnreadCountKey(keyserverID); const unreadCount = await localforage.getItem( keyserverUnreadCountKey, ); return [keyserverID, unreadCount]; }); const queriedUnreadCounts: $ReadOnlyArray<[string, ?number]> = await Promise.all(queryUnreadCountPromises); return Object.fromEntries(queriedUnreadCounts); } export { decryptWebNotification, decryptDesktopNotification, encryptNotification, getOlmDataKeyForCookie, getOlmEncryptionKeyDBLabelForCookie, getOlmDataKeyForDeviceID, getOlmEncryptionKeyDBLabelForDeviceID, migrateLegacyOlmNotificationsSessions, updateNotifsUnreadCountStorage, queryNotifsUnreadCountStorage, getNotifsCryptoAccount, persistEncryptionKey, retrieveEncryptionKey, persistNotifsAccountWithOlmData, }; diff --git a/web/push-notif/push-notifs-handler.js b/web/push-notif/push-notifs-handler.js index d366d932d..0be9b790c 100644 --- a/web/push-notif/push-notifs-handler.js +++ b/web/push-notif/push-notifs-handler.js @@ -1,329 +1,333 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { recordAlertActionType } from 'lib/actions/alert-actions.js'; import { useSetDeviceTokenFanout, setDeviceTokenActionTypes, } from 'lib/actions/device-actions.js'; import { useModalContext } from 'lib/components/modal-provider.react.js'; import { isLoggedIn } from 'lib/selectors/user-selectors.js'; import { IdentityClientContext } from 'lib/shared/identity-client-context.js'; import { hasMinCodeVersion } from 'lib/shared/version-utils.js'; import { alertTypes, 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'; import { useDispatchActionPromise } from 'lib/utils/redux-promise-utils.js'; import { useDispatch } from 'lib/utils/redux-utils.js'; import { decryptDesktopNotification, migrateLegacyOlmNotificationsSessions, } from './notif-crypto-utils.js'; import { authoritativeKeyserverID } from '../authoritative-keyserver.js'; import electron from '../electron.js'; import PushNotifModal from '../modals/push-notif-modal.react.js'; import { updateNavInfoActionType } from '../redux/action-types.js'; import { useSelector } from '../redux/redux-utils.js'; import { getOlmWasmPath } from '../shared-worker/utils/constants.js'; import { useStaffCanSee } from '../utils/staff-utils.js'; function useCreateDesktopPushSubscription() { const dispatchActionPromise = useDispatchActionPromise(); const callSetDeviceToken = useSetDeviceTokenFanout(); const staffCanSee = useStaffCanSee(); const [notifsOlmSessionMigrated, setNotifsSessionsMigrated] = React.useState(false); const platformDetails = getConfig().platformDetails; React.useEffect(() => { if ( !isDesktopPlatform(platformDetails.platform) || !hasMinCodeVersion(platformDetails, { majorDesktop: 12 }) ) { return; } void (async () => { await migrateLegacyOlmNotificationsSessions(); setNotifsSessionsMigrated(true); })(); }, [platformDetails]); React.useEffect( () => electron?.onDeviceTokenRegistered?.((token: ?string) => { void dispatchActionPromise( setDeviceTokenActionTypes, callSetDeviceToken(token), undefined, { type: 'device_token', deviceToken: token }, ); }), [callSetDeviceToken, dispatchActionPromise], ); React.useEffect(() => { electron?.fetchDeviceToken?.(); }, []); React.useEffect(() => { if ( hasMinCodeVersion(platformDetails, { majorDesktop: 12 }) && !notifsOlmSessionMigrated ) { return undefined; } 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); }, ); }, [staffCanSee, notifsOlmSessionMigrated, platformDetails]); const dispatch = useDispatch(); React.useEffect( () => electron?.onNotificationClicked?.( ({ threadID }: { +threadID: string }) => { const convertedThreadID = convertNonPendingIDToNewSchema( threadID, authoritativeKeyserverID, ); const payload = { chatMode: 'view', activeChatThreadID: convertedThreadID, tab: 'chat', }; dispatch({ type: updateNavInfoActionType, payload }); }, ), [dispatch], ); // Handle invalid device token const localToken = useSelector( state => state.tunnelbrokerDeviceToken.localToken, ); const prevLocalToken = React.useRef(localToken); React.useEffect(() => { if (prevLocalToken.current && !localToken) { electron?.fetchDeviceToken?.(); } prevLocalToken.current = localToken; }, [localToken]); } function useCreatePushSubscription(): () => Promise { const publicKey = useSelector(state => state.pushApiPublicKey); const dispatchActionPromise = useDispatchActionPromise(); const callSetDeviceToken = useSetDeviceTokenFanout(); const staffCanSee = useStaffCanSee(); const identityContext = React.useContext(IdentityClientContext); invariant(identityContext, 'Identity context should be set'); const { getAuthMetadata } = identityContext; return React.useCallback(async () => { if (!publicKey) { return; } const workerRegistration = await navigator.serviceWorker?.ready; const authMetadata = await getAuthMetadata(); if ( !workerRegistration || !workerRegistration.pushManager || !authMetadata ) { return; } workerRegistration.active?.postMessage({ olmWasmPath: getOlmWasmPath(), staffCanSee, authMetadata, }); const subscription = await workerRegistration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: publicKey, }); const token = JSON.stringify(subscription); void dispatchActionPromise( setDeviceTokenActionTypes, callSetDeviceToken(token), undefined, { type: 'device_token', deviceToken: token }, ); }, [ callSetDeviceToken, dispatchActionPromise, publicKey, staffCanSee, getAuthMetadata, ]); } function PushNotificationsHandler(): React.Node { useCreateDesktopPushSubscription(); const createPushSubscription = useCreatePushSubscription(); const notifPermissionAlertInfo = useSelector( state => state.alertStore.alertInfos[alertTypes.NOTIF_PERMISSION], ); const modalContext = useModalContext(); const loggedIn = useSelector(isLoggedIn); const dispatch = useDispatch(); const supported = 'Notification' in window && !electron; React.useEffect(() => { void (async () => { if (!navigator.serviceWorker || !supported) { return; } await navigator.serviceWorker.register('worker/notif', { scope: '/' }); if (Notification.permission === 'granted') { // Make sure the subscription is current if we have the permissions await createPushSubscription(); } else if ( Notification.permission === 'default' && loggedIn && !shouldSkipPushPermissionAlert(notifPermissionAlertInfo) ) { // Ask existing users that are already logged in for permission modalContext.pushModal(); const payload: RecordAlertActionPayload = { alertType: alertTypes.NOTIF_PERMISSION, time: Date.now(), }; dispatch({ type: recordAlertActionType, payload, }); } })(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Ask for permission on login const prevLoggedIn = React.useRef(loggedIn); React.useEffect(() => { if (!navigator.serviceWorker || !supported) { return; } if (!prevLoggedIn.current && loggedIn) { if (Notification.permission === 'granted') { void createPushSubscription(); } else if ( Notification.permission === 'default' && !shouldSkipPushPermissionAlert(notifPermissionAlertInfo) ) { modalContext.pushModal(); const payload: RecordAlertActionPayload = { alertType: alertTypes.NOTIF_PERMISSION, time: Date.now(), }; dispatch({ type: recordAlertActionType, payload, }); } } prevLoggedIn.current = loggedIn; }, [ createPushSubscription, dispatch, loggedIn, modalContext, notifPermissionAlertInfo, prevLoggedIn, supported, ]); // Redirect to thread on notification click React.useEffect(() => { if (!navigator.serviceWorker || !supported) { return undefined; } const callback = (event: MessageEvent) => { if (typeof event.data !== 'object' || !event.data) { return; } if (event.data.targetThreadID) { const payload = { chatMode: 'view', activeChatThreadID: event.data.targetThreadID, tab: 'chat', }; dispatch({ type: updateNavInfoActionType, payload }); } }; navigator.serviceWorker.addEventListener('message', callback); return () => navigator.serviceWorker?.removeEventListener('message', callback); }, [dispatch, supported]); // Handle invalid device token const localToken = useSelector( state => state.tunnelbrokerDeviceToken.localToken, ); const prevLocalToken = React.useRef(localToken); React.useEffect(() => { if ( !navigator.serviceWorker || !supported || Notification.permission !== 'granted' ) { return; } if (prevLocalToken.current && !localToken) { void createPushSubscription(); } prevLocalToken.current = localToken; }, [createPushSubscription, localToken, supported]); return null; } export { PushNotificationsHandler, useCreatePushSubscription };