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,102 @@ // @flow +import olm from '@commapp/olm'; +import localforage from 'localforage'; + +import type { PickledOLMSession } from 'lib/types/crypto-types.js'; +import type { + PlainTextWebNotification, + EncryptedWebNotification, +} from 'lib/types/notif-types.js'; + +import { decryptData, encryptData } from '../crypto/aes-gcm-crypto-utils.js'; +import { + NOTIFICATIONS_OLM_SESSION_CONTENT, + NOTIFICATIONS_OLM_SESSION_ENCRYPTION_KEY, +} from '../database/utils/constants.js'; + +type PlainTextWebNotificationPayload = $Diff< + PlainTextWebNotification, + { +id: string }, +>; + +export type WebNotifDecryptionError = { + +id: string, + +error: string, + +displayErrorMessage?: boolean, +}; + export type WebNotifsServiceUtilsData = { +olmWasmPath: string, +staffCanSee: boolean, }; export const WEB_NOTIFS_SERVICE_UTILS_KEY = 'webNotifsServiceUtils'; + +async function decryptWebNotification( + encryptedNotification: EncryptedWebNotification, +): Promise { + const { id, encryptedPayload } = encryptedNotification; + + const [encryptedOlmSession, encryptionKey, utilsData] = await Promise.all([ + localforage.getItem(NOTIFICATIONS_OLM_SESSION_CONTENT), + localforage.getItem(NOTIFICATIONS_OLM_SESSION_ENCRYPTION_KEY), + localforage.getItem(WEB_NOTIFS_SERVICE_UTILS_KEY), + ]); + + if (!utilsData) { + return { id, error: 'Necessary data not received from the main app' }; + } + + const { olmWasmPath, staffCanSee } = (utilsData: WebNotifsServiceUtilsData); + if (!encryptionKey || !encryptedOlmSession) { + return { + id, + error: 'Received encrypted notification but olm session was not created', + displayErrorMessage: staffCanSee, + }; + } + + try { + await olm.init({ locateFile: () => olmWasmPath }); + + const serializedSession = await decryptData( + encryptedOlmSession, + encryptionKey, + ); + const { picklingKey, pickledSession }: PickledOLMSession = JSON.parse( + new TextDecoder().decode(serializedSession), + ); + + const session = new olm.Session(); + session.unpickle(picklingKey, pickledSession); + + const decryptedNotification: PlainTextWebNotificationPayload = JSON.parse( + session.decrypt(1, encryptedPayload), + ); + + const updatedPickledSession = { + picklingKey, + pickledSession: session.pickle(picklingKey), + }; + const updatedEncryptedSession = await encryptData( + new TextEncoder().encode(JSON.stringify(updatedPickledSession)), + encryptionKey, + ); + + await localforage.setItem( + NOTIFICATIONS_OLM_SESSION_CONTENT, + updatedEncryptedSession, + ); + + return { id, ...decryptedNotification }; + } catch (e) { + return { + id, + error: e, + displayErrorMessage: staffCanSee, + }; + } +} + +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,28 @@ declare var clients: Clients; declare function skipWaiting(): Promise; +function buildDecryptionErrorNotification( + decryptionError: WebNotifDecryptionError, +) { + const baseErrorPayload = { + badge: 'https://web.comm.app/favicon.ico', + icon: 'https://web.comm.app/favicon.ico', + tag: decryptionError.id, + data: { + isError: true, + }, + }; + + if (decryptionError.displayErrorMessage && decryptionError.error) { + return { + body: decryptionError.error, + ...baseErrorPayload, + }; + } + + return baseErrorPayload; +} + self.addEventListener('install', () => { skipWaiting(); }); @@ -41,6 +68,7 @@ if (!event.data.olmWasmPath || event.data.staffCanSee === undefined) { return; } + const webNotifsServiceUtils: WebNotifsServiceUtilsData = { olmWasmPath: event.data.olmWasmPath, staffCanSee: event.data.staffCanSee, @@ -55,11 +83,38 @@ }); 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}`; } @@ -67,10 +122,10 @@ body, badge: 'https://web.comm.app/favicon.ico', icon: 'https://web.comm.app/favicon.ico', - tag: data.id, + tag: plainTextData.id, data: { - unreadCount: data.unreadCount, - threadID: data.threadID, + unreadCount: plainTextData.unreadCount, + threadID: plainTextData.threadID, }, }); })(), @@ -88,23 +143,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); } })(),