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 @@ -1,8 +1,205 @@ // @flow +import olm from '@commapp/olm'; +import localforage from 'localforage'; + +import { + olmEncryptedMessageTypes, + type NotificationsOlmDataType, +} from 'lib/types/crypto-types.js'; +import type { + PlainTextWebNotification, + PlainTextWebNotificationPayload, + EncryptedWebNotification, +} from 'lib/types/notif-types.js'; + +import { + decryptData, + encryptData, + importJWKKey, +} 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'; + +export type WebNotifDecryptionError = { + +id: string, + +error: string, + +displayErrorMessage?: boolean, +}; + export type WebNotifsServiceUtilsData = { +olmWasmPath: string, +staffCanSee: boolean, }; +type DecryptionResult = { + +newPendingSessionUpdate: string, + +newUpdateCreationTimestamp: number, + +decryptedNotification: PlainTextWebNotificationPayload, +}; + export const WEB_NOTIFS_SERVICE_UTILS_KEY = 'webNotifsServiceUtils'; + +const SESSION_UPDATE_MAX_PENDING_TIME = 10 * 1000; + +async function decryptWebNotification( + encryptedNotification: EncryptedWebNotification, +): 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, + localforage.getItem(WEB_NOTIFS_SERVICE_UTILS_KEY), + ]); + + if (!utilsData) { + return { id, error: 'Necessary data not found in IndexedDB' }; + } + + const { olmWasmPath, staffCanSee } = (utilsData: WebNotifsServiceUtilsData); + if (!encryptionKey || !encryptedOlmData) { + return { + id, + error: 'Received encrypted notification but olm session was not created', + displayErrorMessage: staffCanSee, + }; + } + + try { + await olm.init({ locateFile: () => olmWasmPath }); + + const serializedOlmData = await decryptData( + encryptedOlmData, + encryptionKey, + ); + const { + mainSession, + picklingKey, + pendingSessionUpdate, + updateCreationTimestamp, + }: NotificationsOlmDataType = JSON.parse( + new TextDecoder().decode(serializedOlmData), + ); + + let updatedOlmData: NotificationsOlmDataType; + let decryptedNotification: PlainTextWebNotificationPayload; + + 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 { id, ...decryptedNotification }; + } catch (e) { + return { + id, + error: e.message, + displayErrorMessage: staffCanSee, + }; + } +} + +function decryptWithSession( + pickledSession: string, + picklingKey: string, + encryptedPayload: string, +): DecryptionResult { + const session = new olm.Session(); + + session.unpickle(picklingKey, pickledSession); + const decryptedNotification: PlainTextWebNotificationPayload = 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 }; + } +} +export { decryptWebNotification }; diff --git a/web/push-notif/service-worker.js b/web/push-notif/service-worker.js --- a/web/push-notif/service-worker.js +++ b/web/push-notif/service-worker.js @@ -2,13 +2,18 @@ import localforage from 'localforage'; -import type { PlainTextWebNotification } from 'lib/types/notif-types.js'; +import type { + PlainTextWebNotification, + WebNotification, +} from 'lib/types/notif-types.js'; import { convertNonPendingIDToNewSchema } from 'lib/utils/migration-utils.js'; import { ashoatKeyserverID } from 'lib/utils/validation-utils.js'; import { + decryptWebNotification, WEB_NOTIFS_SERVICE_UTILS_KEY, type WebNotifsServiceUtilsData, + type WebNotifDecryptionError, } from './notif-crypto-utils.js'; import { localforageConfig } from '../database/utils/constants.js'; @@ -26,6 +31,30 @@ declare var clients: Clients; declare function skipWaiting(): Promise; +const commIconUrl = 'https://web.comm.app/favicon.ico'; + +function buildDecryptionErrorNotification( + decryptionError: WebNotifDecryptionError, +) { + const baseErrorPayload = { + badge: commIconUrl, + icon: commIconUrl, + tag: decryptionError.id, + data: { + isError: true, + }, + }; + + if (decryptionError.displayErrorMessage && decryptionError.error) { + return { + body: decryptionError.error, + ...baseErrorPayload, + }; + } + + return baseErrorPayload; +} + self.addEventListener('install', () => { skipWaiting(); }); @@ -41,6 +70,7 @@ if (!event.data.olmWasmPath || event.data.staffCanSee === undefined) { return; } + const webNotifsServiceUtils: WebNotifsServiceUtilsData = { olmWasmPath: event.data.olmWasmPath, staffCanSee: event.data.staffCanSee, @@ -55,22 +85,49 @@ }); self.addEventListener('push', (event: PushEvent) => { - const data: PlainTextWebNotification = event.data.json(); + localforage.config(localforageConfig); + const data: WebNotification = event.data.json(); event.waitUntil( (async () => { - let body = data.body; + let plainTextData: PlainTextWebNotification; + let decryptionResult: PlainTextWebNotification | WebNotifDecryptionError; + + if (data.encryptedPayload) { + decryptionResult = await decryptWebNotification(data); + } + + if (decryptionResult && decryptionResult.error) { + const decryptionErrorNotification = + buildDecryptionErrorNotification(decryptionResult); + await self.registration.showNotification( + 'Comm notification', + decryptionErrorNotification, + ); + return; + } else if (decryptionResult && decryptionResult.body) { + plainTextData = decryptionResult; + } else if (data.body) { + plainTextData = data; + } else { + // We will never enter ths branch. It is + // necessary since flow doesn't differentiate + // between union types out-of-the-box. + return; + } + + let body = plainTextData.body; if (data.prefix) { body = `${data.prefix} ${body}`; } - await self.registration.showNotification(data.title, { + await self.registration.showNotification(plainTextData.title, { body, - badge: 'https://web.comm.app/favicon.ico', - icon: 'https://web.comm.app/favicon.ico', - tag: data.id, + badge: commIconUrl, + icon: commIconUrl, + tag: plainTextData.id, data: { - unreadCount: data.unreadCount, - threadID: data.threadID, + unreadCount: plainTextData.unreadCount, + threadID: plainTextData.threadID, }, }); })(), @@ -88,23 +145,32 @@ const selectedClient = clientList.find(client => client.focused) ?? clientList[0]; - const threadID = convertNonPendingIDToNewSchema( - event.notification.data.threadID, - ashoatKeyserverID, - ); + // Decryption error notifications don't contain threadID + // but we still want them to be interactive in terms of basic + // navigation. + let threadID; + if (!event.notification.data.isError) { + threadID = convertNonPendingIDToNewSchema( + event.notification.data.threadID, + ashoatKeyserverID, + ); + } if (selectedClient) { if (!selectedClient.focused) { await selectedClient.focus(); } - selectedClient.postMessage({ - targetThreadID: threadID, - }); + if (threadID) { + selectedClient.postMessage({ + targetThreadID: threadID, + }); + } } else { - const url = - (process.env.NODE_ENV === 'production' + const baseURL = + process.env.NODE_ENV === 'production' ? 'https://web.comm.app' - : 'http://localhost:3000/webapp') + `/chat/thread/${threadID}/`; + : 'http://localhost:3000/webapp'; + const url = threadID ? baseURL + `/chat/thread/${threadID}/` : baseURL; clients.openWindow(url); } })(),