diff --git a/desktop/flow-typed/npm/electron_v22.0.0.js b/desktop/flow-typed/npm/electron_v22.0.0.js --- a/desktop/flow-typed/npm/electron_v22.0.0.js +++ b/desktop/flow-typed/npm/electron_v22.0.0.js @@ -336,7 +336,7 @@ declare type PushNotificationsEvents = { 'received-apns-notification': ( event: Event, - userInfo: { +[string]: mixed }, + userInfo: { +[string]: mixed, +encryptedPayload?: string }, ) => void, }; diff --git a/desktop/src/main.js b/desktop/src/main.js --- a/desktop/src/main.js +++ b/desktop/src/main.js @@ -20,6 +20,7 @@ import { listenForNotifications, registerForNotifications, + showNewNotification, } from './push-notifications.js'; const isDev = process.env.ENV === 'dev'; @@ -291,6 +292,26 @@ (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) => { + if (mainWindow) { + mainWindow.webContents.send('on-encrypted-notification', { + encryptedPayload, + }); + } + }; + if (app.isPackaged) { try { initAutoUpdate(); @@ -298,15 +319,10 @@ console.error(error); } - listenForNotifications(threadID => { - if (mainWindow) { - mainWindow.webContents.send('on-notification-clicked', { - threadID, - }); - } else { - show(`chat/thread/${threadID}/`); - } - }); + listenForNotifications( + handleNotificationClick, + handleEncryptedNotification, + ); ipcMain.on('fetch-device-token', sendDeviceTokenToWebApp); } @@ -318,6 +334,12 @@ ipcMain.on('get-version', event => { event.returnValue = app.getVersion().toString(); }); + ipcMain.on( + 'show-decrypted-notification', + (event, decryptedNotification) => { + showNewNotification(decryptedNotification, handleNotificationClick); + }, + ); show(); diff --git a/desktop/src/preload.js b/desktop/src/preload.js --- a/desktop/src/preload.js +++ b/desktop/src/preload.js @@ -41,6 +41,19 @@ ipcRenderer.removeListener('on-notification-clicked', withEvent); }, fetchDeviceToken: () => ipcRenderer.send('fetch-device-token'), + onEncryptedNotification: callback => { + const withEvent = ( + event: IpcRendererEvent, + ...args: $ReadOnlyArray + ) => { + callback(...args); + }; + ipcRenderer.on('on-encrypted-notification', withEvent); + return () => + ipcRenderer.removeListener('on-encrypted-notification', withEvent); + }, + showDecryptedNotification: decryptedPayload => + ipcRenderer.send('show-decrypted-notification', decryptedPayload), }; contextBridge.exposeInMainWorld('electronContextBridge', bridge); 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 @@ -92,8 +92,24 @@ function showNewNotification( payload: { +[string]: mixed }, - handleClick: (threadID: string) => void, + 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' || @@ -102,7 +118,6 @@ return; } const { title, body, threadID } = payload; - const windowsIconPath = resolve(__dirname, '../icons/icon.ico'); const notif = new Notification({ title, body, @@ -112,10 +127,17 @@ notif.show(); } -function listenForNotifications(handleClick: (threadID: string) => void) { +function listenForNotifications( + handleClick: (threadID?: string) => void, + handleEncryptedNotification: (encryptedPayload: string) => void, +) { if (process.platform === 'darwin') { pushNotifications.on('received-apns-notification', (event, userInfo) => { - showNewNotification(userInfo, handleClick); + if (userInfo.encryptedPayload) { + handleEncryptedNotification(userInfo.encryptedPayload); + } else { + showNewNotification(userInfo, handleClick); + } }); } else if (process.platform === 'win32') { windowsPushNotifEventEmitter.on('received-wns-notification', payload => { @@ -123,4 +145,8 @@ }); } } -export { listenForNotifications, registerForNotifications }; +export { + listenForNotifications, + registerForNotifications, + showNewNotification, +}; 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 @@ -11,6 +11,10 @@ type OnNotificationClickedListener = (data: { threadID: string }) => void; +type OnEncryptedNotificationListener = (data: { + encryptedPayload: string, +}) => mixed; + export type ElectronBridge = { // Returns a callback that you can call to remove the listener +onNavigate: OnNavigateListener => () => void, @@ -25,4 +29,6 @@ +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 --- a/web/push-notif/notif-crypto-utils.js +++ b/web/push-notif/notif-crypto-utils.js @@ -9,21 +9,21 @@ } from 'lib/types/crypto-types.js'; import type { PlainTextWebNotification, - PlainTextWebNotificationPayload, EncryptedWebNotification, } from 'lib/types/notif-types.js'; import { + type EncryptedData, decryptData, encryptData, importJWKKey, - type EncryptedData, } from '../crypto/aes-gcm-crypto-utils.js'; import { NOTIFICATIONS_OLM_DATA_CONTENT, NOTIFICATIONS_OLM_DATA_ENCRYPTION_KEY, } from '../database/utils/constants.js'; import { isDesktopSafari } from '../database/utils/db-utils.js'; +import { initOlm } from '../olm/olm-utils.js'; export type WebNotifDecryptionError = { +id: string, @@ -36,10 +36,10 @@ +staffCanSee: boolean, }; -type DecryptionResult = { +type DecryptionResult = { +newPendingSessionUpdate: string, +newUpdateCreationTimestamp: number, - +decryptedNotification: PlainTextWebNotificationPayload, + +decryptedNotification: T, }; export const WEB_NOTIFS_SERVICE_UTILS_KEY = 'webNotifsServiceUtils'; @@ -51,21 +51,9 @@ ): Promise { const { id, encryptedPayload } = encryptedNotification; - const retrieveEncryptionKeyPromise: Promise = (async () => { - const persistedCryptoKey = await localforage.getItem( - NOTIFICATIONS_OLM_DATA_ENCRYPTION_KEY, - ); - if (isDesktopSafari && persistedCryptoKey) { - // Safari doesn't support structured clone algorithm in service - // worker context so we have to store CryptoKey as JSON - return await importJWKKey(persistedCryptoKey); - } - return persistedCryptoKey; - })(); - const [encryptedOlmData, encryptionKey, utilsData] = await Promise.all([ localforage.getItem(NOTIFICATIONS_OLM_DATA_CONTENT), - retrieveEncryptionKeyPromise, + retrieveEncryptionKey(), localforage.getItem( WEB_NOTIFS_SERVICE_UTILS_KEY, ), @@ -87,91 +75,139 @@ try { await olm.init({ locateFile: () => olmWasmPath }); - const serializedOlmData = await decryptData( + const decryptedNotification = await commonDecrypt( encryptedOlmData, encryptionKey, - ); - const { - mainSession, - picklingKey, - pendingSessionUpdate, - updateCreationTimestamp, - }: NotificationsOlmDataType = JSON.parse( - new TextDecoder().decode(serializedOlmData), + encryptedPayload, ); - let updatedOlmData: NotificationsOlmDataType; - let decryptedNotification: PlainTextWebNotificationPayload; + return { id, ...decryptedNotification }; + } catch (e) { + return { + id, + error: e.message, + displayErrorMessage: staffCanSee, + }; + } +} - const shouldUpdateMainSession = - Date.now() - updateCreationTimestamp > SESSION_UPDATE_MAX_PENDING_TIME; +async function decryptDesktopNotification( + encryptedPayload: string, + staffCanSee: boolean, +): Promise<{ +[string]: mixed }> { + let encryptedOlmData, encryptionKey; + try { + [encryptedOlmData, encryptionKey] = await Promise.all([ + localforage.getItem(NOTIFICATIONS_OLM_DATA_CONTENT), + retrieveEncryptionKey(), + initOlm(), + ]); + } catch (e) { + return { + error: e.message, + displayErrorMessage: staffCanSee, + }; + } - const decryptionWithPendingSessionResult = decryptWithPendingSession( - pendingSessionUpdate, - picklingKey, - encryptedPayload, - ); + if (!encryptionKey || !encryptedOlmData) { + return { + error: 'Received encrypted notification but olm session was not created', + displayErrorMessage: staffCanSee, + }; + } - 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)), + try { + return await commonDecrypt( + encryptedOlmData, encryptionKey, + encryptedPayload, ); - - await localforage.setItem( - NOTIFICATIONS_OLM_DATA_CONTENT, - updatedEncryptedSession, - ); - - return { id, ...decryptedNotification }; } catch (e) { return { - id, error: e.message, - displayErrorMessage: staffCanSee, + staffCanSee, + }; + } +} + +async function commonDecrypt( + encryptedOlmData: EncryptedData, + encryptionKey: CryptoKey, + encryptedPayload: string, +): Promise { + const serializedOlmData = await decryptData(encryptedOlmData, encryptionKey); + const { + mainSession, + picklingKey, + pendingSessionUpdate, + updateCreationTimestamp, + }: NotificationsOlmDataType = JSON.parse( + new TextDecoder().decode(serializedOlmData), + ); + + 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, + ); + + await localforage.setItem( + NOTIFICATIONS_OLM_DATA_CONTENT, + updatedEncryptedSession, + ); + + return decryptedNotification; } -function decryptWithSession( +function decryptWithSession( pickledSession: string, picklingKey: string, encryptedPayload: string, -): DecryptionResult { +): DecryptionResult { const session = new olm.Session(); session.unpickle(picklingKey, pickledSession); - const decryptedNotification: PlainTextWebNotificationPayload = JSON.parse( + const decryptedNotification: T = JSON.parse( session.decrypt(olmEncryptedMessageTypes.TEXT, encryptedPayload), ); @@ -185,17 +221,21 @@ }; } -function decryptWithPendingSession( +function decryptWithPendingSession( pendingSessionUpdate: string, picklingKey: string, encryptedPayload: string, -): DecryptionResult | { +error: string } { +): DecryptionResult | { +error: string } { try { const { decryptedNotification, newPendingSessionUpdate, newUpdateCreationTimestamp, - } = decryptWithSession(pendingSessionUpdate, picklingKey, encryptedPayload); + } = decryptWithSession( + pendingSessionUpdate, + picklingKey, + encryptedPayload, + ); return { newPendingSessionUpdate, newUpdateCreationTimestamp, @@ -205,4 +245,17 @@ return { error: e.message }; } } -export { decryptWebNotification }; + +async function retrieveEncryptionKey(): Promise { + const persistedCryptoKey = await localforage.getItem( + NOTIFICATIONS_OLM_DATA_ENCRYPTION_KEY, + ); + if (isDesktopSafari && persistedCryptoKey) { + // Safari doesn't support structured clone algorithm in service + // worker context so we have to store CryptoKey as JSON + return await importJWKKey(persistedCryptoKey); + } + return persistedCryptoKey; +} + +export { decryptWebNotification, decryptDesktopNotification }; 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 @@ import { useDispatch } from 'lib/utils/redux-utils.js'; import { ashoatKeyserverID } from 'lib/utils/validation-utils.js'; +import { decryptDesktopNotification } from './notif-crypto-utils.js'; import { WORKERS_MODULES_DIR_PATH, DEFAULT_OLM_FILENAME, @@ -33,6 +34,7 @@ function useCreateDesktopPushSubscription() { const dispatchActionPromise = useDispatchActionPromise(); const callSetDeviceToken = useSetDeviceTokenFanout(); + const staffCanSee = useStaffCanSee(); React.useEffect( () => @@ -49,6 +51,20 @@ electron?.fetchDeviceToken(); }, []); + React.useEffect( + () => + electron?.onEncryptedNotification?.( + async ({ encryptedPayload }: { +encryptedPayload: string }) => { + const decryptedPayload = await decryptDesktopNotification( + encryptedPayload, + staffCanSee, + ); + electron?.showDecryptedNotification(decryptedPayload); + }, + ), + [staffCanSee], + ); + const dispatch = useDispatch(); React.useEffect(