diff --git a/web/account/account-hooks.js b/web/account/account-hooks.js index c1063ba5c..ecb3950c1 100644 --- a/web/account/account-hooks.js +++ b/web/account/account-hooks.js @@ -1,411 +1,435 @@ // @flow import olm from '@commapp/olm'; import invariant from 'invariant'; import localforage from 'localforage'; import * as React from 'react'; import uuid from 'uuid'; import { initialEncryptedMessageContent } from 'lib/shared/crypto-utils.js'; import { OlmSessionCreatorContext } from 'lib/shared/olm-session-creator-context.js'; +import { + hasMinCodeVersion, + NEXT_CODE_VERSION, +} from 'lib/shared/version-utils.js'; import type { SignedIdentityKeysBlob, CryptoStore, IdentityKeysBlob, CryptoStoreContextType, OLMIdentityKeys, NotificationsOlmDataType, } from 'lib/types/crypto-types.js'; import { type IdentityDeviceKeyUpload } from 'lib/types/identity-service-types.js'; import type { OlmSessionInitializationInfo } from 'lib/types/request-types.js'; +import { getConfig } from 'lib/utils/config.js'; import { retrieveAccountKeysSet } from 'lib/utils/olm-utils.js'; import { useDispatch } from 'lib/utils/redux-utils.js'; import { generateCryptoKey, encryptData, exportKeyToJWK, } from '../crypto/aes-gcm-crypto-utils.js'; import { initOlm } from '../olm/olm-utils.js'; import { getOlmDataContentKeyForCookie, getOlmEncryptionKeyDBLabelForCookie, } from '../push-notif/notif-crypto-utils.js'; import { setCryptoStore } from '../redux/crypto-store-reducer.js'; import { useSelector } from '../redux/redux-utils.js'; import { isDesktopSafari } from '../shared-worker/utils/db-utils.js'; const CryptoStoreContext: React.Context = React.createContext(null); type Props = { +children: React.Node, }; function GetOrCreateCryptoStoreProvider(props: Props): React.Node { const dispatch = useDispatch(); const createCryptoStore = React.useCallback(async () => { await initOlm(); const identityAccount = new olm.Account(); identityAccount.create(); const { ed25519: identityED25519, curve25519: identityCurve25519 } = JSON.parse(identityAccount.identity_keys()); const identityAccountPicklingKey = uuid.v4(); const pickledIdentityAccount = identityAccount.pickle( identityAccountPicklingKey, ); const notificationAccount = new olm.Account(); notificationAccount.create(); const { ed25519: notificationED25519, curve25519: notificationCurve25519 } = JSON.parse(notificationAccount.identity_keys()); const notificationAccountPicklingKey = uuid.v4(); const pickledNotificationAccount = notificationAccount.pickle( notificationAccountPicklingKey, ); const newCryptoStore = { primaryAccount: { picklingKey: identityAccountPicklingKey, pickledAccount: pickledIdentityAccount, }, primaryIdentityKeys: { ed25519: identityED25519, curve25519: identityCurve25519, }, notificationAccount: { picklingKey: notificationAccountPicklingKey, pickledAccount: pickledNotificationAccount, }, notificationIdentityKeys: { ed25519: notificationED25519, curve25519: notificationCurve25519, }, }; dispatch({ type: setCryptoStore, payload: newCryptoStore }); return newCryptoStore; }, [dispatch]); const currentCryptoStore = useSelector(state => state.cryptoStore); const createCryptoStorePromiseRef = React.useRef>(null); const getCryptoStorePromise = React.useCallback(() => { if (currentCryptoStore) { return Promise.resolve(currentCryptoStore); } const currentCreateCryptoStorePromiseRef = createCryptoStorePromiseRef.current; if (currentCreateCryptoStorePromiseRef) { return currentCreateCryptoStorePromiseRef; } const newCreateCryptoStorePromise = (async () => { try { return await createCryptoStore(); } catch (e) { createCryptoStorePromiseRef.current = undefined; throw e; } })(); createCryptoStorePromiseRef.current = newCreateCryptoStorePromise; return newCreateCryptoStorePromise; }, [createCryptoStore, currentCryptoStore]); const isCryptoStoreSet = !!currentCryptoStore; React.useEffect(() => { if (!isCryptoStoreSet) { createCryptoStorePromiseRef.current = undefined; } }, [isCryptoStoreSet]); const contextValue = React.useMemo( () => ({ getInitializedCryptoStore: getCryptoStorePromise, }), [getCryptoStorePromise], ); return ( {props.children} ); } function useGetOrCreateCryptoStore(): () => Promise { const context = React.useContext(CryptoStoreContext); invariant(context, 'CryptoStoreContext not found'); return context.getInitializedCryptoStore; } function useGetSignedIdentityKeysBlob(): () => Promise { const getOrCreateCryptoStore = useGetOrCreateCryptoStore(); return React.useCallback(async () => { const [{ primaryAccount, primaryIdentityKeys, notificationIdentityKeys }] = await Promise.all([getOrCreateCryptoStore(), initOlm()]); const primaryOLMAccount = new olm.Account(); primaryOLMAccount.unpickle( primaryAccount.picklingKey, primaryAccount.pickledAccount, ); const identityKeysBlob: IdentityKeysBlob = { primaryIdentityPublicKeys: primaryIdentityKeys, notificationIdentityPublicKeys: notificationIdentityKeys, }; const payloadToBeSigned: string = JSON.stringify(identityKeysBlob); const signedIdentityKeysBlob: SignedIdentityKeysBlob = { payload: payloadToBeSigned, signature: primaryOLMAccount.sign(payloadToBeSigned), }; return signedIdentityKeysBlob; }, [getOrCreateCryptoStore]); } function useGetDeviceKeyUpload(): () => Promise { const getOrCreateCryptoStore = useGetOrCreateCryptoStore(); // `getSignedIdentityKeysBlob()` will initialize OLM, so no need to do it // again const getSignedIdentityKeysBlob = useGetSignedIdentityKeysBlob(); const dispatch = useDispatch(); return React.useCallback(async () => { const [signedIdentityKeysBlob, cryptoStore] = await Promise.all([ getSignedIdentityKeysBlob(), getOrCreateCryptoStore(), ]); const primaryOLMAccount = new olm.Account(); const notificationOLMAccount = new olm.Account(); primaryOLMAccount.unpickle( cryptoStore.primaryAccount.picklingKey, cryptoStore.primaryAccount.pickledAccount, ); notificationOLMAccount.unpickle( cryptoStore.notificationAccount.picklingKey, cryptoStore.notificationAccount.pickledAccount, ); const primaryAccountKeysSet = retrieveAccountKeysSet(primaryOLMAccount); const notificationAccountKeysSet = retrieveAccountKeysSet( notificationOLMAccount, ); const pickledPrimaryAccount = primaryOLMAccount.pickle( cryptoStore.primaryAccount.picklingKey, ); const pickledNotificationAccount = notificationOLMAccount.pickle( cryptoStore.notificationAccount.picklingKey, ); const updatedCryptoStore = { primaryAccount: { picklingKey: cryptoStore.primaryAccount.picklingKey, pickledAccount: pickledPrimaryAccount, }, primaryIdentityKeys: cryptoStore.primaryIdentityKeys, notificationAccount: { picklingKey: cryptoStore.notificationAccount.picklingKey, pickledAccount: pickledNotificationAccount, }, notificationIdentityKeys: cryptoStore.notificationIdentityKeys, }; dispatch({ type: setCryptoStore, payload: updatedCryptoStore }); return { keyPayload: signedIdentityKeysBlob.payload, keyPayloadSignature: signedIdentityKeysBlob.signature, contentPrekey: primaryAccountKeysSet.prekey, contentPrekeySignature: primaryAccountKeysSet.prekeySignature, notifPrekey: notificationAccountKeysSet.prekey, notifPrekeySignature: notificationAccountKeysSet.prekeySignature, contentOneTimeKeys: primaryAccountKeysSet.oneTimeKeys, notifOneTimeKeys: notificationAccountKeysSet.oneTimeKeys, }; }, [dispatch, getOrCreateCryptoStore, getSignedIdentityKeysBlob]); } function OlmSessionCreatorProvider(props: Props): React.Node { const getOrCreateCryptoStore = useGetOrCreateCryptoStore(); const currentCryptoStore = useSelector(state => state.cryptoStore); + const platformDetails = getConfig().platformDetails; const createNewNotificationsSession = React.useCallback( async ( cookie: ?string, notificationsIdentityKeys: OLMIdentityKeys, notificationsInitializationInfo: OlmSessionInitializationInfo, keyserverID: string, ) => { const [{ notificationAccount }, encryptionKey] = await Promise.all([ getOrCreateCryptoStore(), generateCryptoKey({ extractable: isDesktopSafari }), initOlm(), ]); const account = new olm.Account(); const { picklingKey, pickledAccount } = notificationAccount; account.unpickle(picklingKey, pickledAccount); const notificationsPrekey = notificationsInitializationInfo.prekey; const session = new olm.Session(); session.create_outbound( account, notificationsIdentityKeys.curve25519, notificationsIdentityKeys.ed25519, notificationsPrekey, notificationsInitializationInfo.prekeySignature, notificationsInitializationInfo.oneTimeKey, ); const { body: initialNotificationsEncryptedMessage } = session.encrypt( JSON.stringify(initialEncryptedMessageContent), ); const mainSession = session.pickle(picklingKey); const notificationsOlmData: NotificationsOlmDataType = { mainSession, pendingSessionUpdate: mainSession, updateCreationTimestamp: Date.now(), picklingKey, }; const encryptedOlmData = await encryptData( new TextEncoder().encode(JSON.stringify(notificationsOlmData)), encryptionKey, ); - const notifsOlmDataEncryptionKeyDBLabel = - getOlmEncryptionKeyDBLabelForCookie(cookie, keyserverID); - const notifsOlmDataContentKey = getOlmDataContentKeyForCookie( - cookie, - keyserverID, - ); + let notifsOlmDataContentKey; + let notifsOlmDataEncryptionKeyDBLabel; + + if ( + hasMinCodeVersion(platformDetails, { majorDesktop: NEXT_CODE_VERSION }) + ) { + notifsOlmDataEncryptionKeyDBLabel = getOlmEncryptionKeyDBLabelForCookie( + cookie, + keyserverID, + ); + notifsOlmDataContentKey = getOlmDataContentKeyForCookie( + cookie, + keyserverID, + ); + } else { + notifsOlmDataEncryptionKeyDBLabel = + getOlmEncryptionKeyDBLabelForCookie(cookie); + notifsOlmDataContentKey = getOlmDataContentKeyForCookie(cookie); + } const persistEncryptionKeyPromise = (async () => { 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( notifsOlmDataEncryptionKeyDBLabel, cryptoKeyPersistentForm, ); })(); await Promise.all([ localforage.setItem(notifsOlmDataContentKey, encryptedOlmData), persistEncryptionKeyPromise, ]); return initialNotificationsEncryptedMessage; }, - [getOrCreateCryptoStore], + [getOrCreateCryptoStore, platformDetails], ); const createNewContentSession = React.useCallback( async ( contentIdentityKeys: OLMIdentityKeys, contentInitializationInfo: OlmSessionInitializationInfo, ) => { const [{ primaryAccount }] = await Promise.all([ getOrCreateCryptoStore(), initOlm(), ]); const account = new olm.Account(); const { picklingKey, pickledAccount } = primaryAccount; account.unpickle(picklingKey, pickledAccount); const contentPrekey = contentInitializationInfo.prekey; const session = new olm.Session(); session.create_outbound( account, contentIdentityKeys.curve25519, contentIdentityKeys.ed25519, contentPrekey, contentInitializationInfo.prekeySignature, contentInitializationInfo.oneTimeKey, ); const { body: initialContentEncryptedMessage } = session.encrypt( JSON.stringify(initialEncryptedMessageContent), ); return initialContentEncryptedMessage; }, [getOrCreateCryptoStore], ); - const notificationsSessionPromise = React.useRef>(null); + const perKeyserverNotificationsSessionPromises = React.useRef<{ + [keyserverID: string]: ?Promise, + }>({}); + const createNotificationsSession = React.useCallback( async ( cookie: ?string, notificationsIdentityKeys: OLMIdentityKeys, notificationsInitializationInfo: OlmSessionInitializationInfo, keyserverID: string, ) => { - if (notificationsSessionPromise.current) { - return notificationsSessionPromise.current; + if (perKeyserverNotificationsSessionPromises.current[keyserverID]) { + return perKeyserverNotificationsSessionPromises.current[keyserverID]; } const newNotificationsSessionPromise = (async () => { try { return await createNewNotificationsSession( cookie, notificationsIdentityKeys, notificationsInitializationInfo, keyserverID, ); } catch (e) { - notificationsSessionPromise.current = undefined; + perKeyserverNotificationsSessionPromises.current[keyserverID] = + undefined; throw e; } })(); - notificationsSessionPromise.current = newNotificationsSessionPromise; + perKeyserverNotificationsSessionPromises.current[keyserverID] = + newNotificationsSessionPromise; return newNotificationsSessionPromise; }, [createNewNotificationsSession], ); const isCryptoStoreSet = !!currentCryptoStore; React.useEffect(() => { if (!isCryptoStoreSet) { - notificationsSessionPromise.current = undefined; + perKeyserverNotificationsSessionPromises.current = {}; } }, [isCryptoStoreSet]); const contextValue = React.useMemo( () => ({ notificationsSessionCreator: createNotificationsSession, contentSessionCreator: createNewContentSession, }), [createNewContentSession, createNotificationsSession], ); return ( {props.children} ); } export { useGetSignedIdentityKeysBlob, useGetOrCreateCryptoStore, OlmSessionCreatorProvider, GetOrCreateCryptoStoreProvider, useGetDeviceKeyUpload, }; diff --git a/web/push-notif/notif-crypto-utils.js b/web/push-notif/notif-crypto-utils.js index 376f9998a..8c6a0cc4b 100644 --- a/web/push-notif/notif-crypto-utils.js +++ b/web/push-notif/notif-crypto-utils.js @@ -1,384 +1,472 @@ // @flow import olm from '@commapp/olm'; import localforage from 'localforage'; import { olmEncryptedMessageTypes, type NotificationsOlmDataType, } from 'lib/types/crypto-types.js'; import type { PlainTextWebNotification, EncryptedWebNotification, } from 'lib/types/notif-types.js'; import { getCookieIDFromCookie } from 'lib/utils/cookie-utils.js'; import { type EncryptedData, decryptData, encryptData, importJWKKey, } 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, }; 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 = ':'; + +// 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'; async function decryptWebNotification( encryptedNotification: EncryptedWebNotification, ): Promise { - const { id, encryptedPayload } = encryptedNotification; + const { id, keyserverID, encryptedPayload } = encryptedNotification; 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; try { - olmDBKeys = await getNotifsOlmSessionDBKeys(); + olmDBKeys = await getNotifsOlmSessionDBKeys(keyserverID); } catch (e) { return { id, error: e.message, displayErrorMessage: staffCanSee, }; } const { olmDataContentKey, encryptionKeyDBKey } = olmDBKeys; const [encryptedOlmData, encryptionKey] = await Promise.all([ localforage.getItem(olmDataContentKey), 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 decryptedNotification = await commonDecrypt( encryptedOlmData, olmDataContentKey, encryptionKey, encryptedPayload, ); return { id, ...decryptedNotification }; } catch (e) { return { id, error: e.message, displayErrorMessage: staffCanSee, }; } } async function decryptDesktopNotification( encryptedPayload: string, staffCanSee: boolean, - // eslint-disable-next-line no-unused-vars keyserverID?: string, ): Promise<{ +[string]: mixed }> { let encryptedOlmData, encryptionKey, olmDataContentKey; try { const { olmDataContentKey: olmDataContentKeyValue, encryptionKeyDBKey } = - await getNotifsOlmSessionDBKeys(); + await getNotifsOlmSessionDBKeys(keyserverID); olmDataContentKey = olmDataContentKeyValue; [encryptedOlmData, encryptionKey] = await Promise.all([ localforage.getItem(olmDataContentKey), retrieveEncryptionKey(encryptionKeyDBKey), 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, }; } try { return await commonDecrypt( encryptedOlmData, olmDataContentKey, encryptionKey, encryptedPayload, ); } catch (e) { return { error: e.message, staffCanSee, }; } } async function commonDecrypt( encryptedOlmData: EncryptedData, olmDataContentKey: string, 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(olmDataContentKey, updatedEncryptedSession); 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 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 getNotifsOlmSessionDBKeys(): Promise<{ +async function getNotifsOlmSessionDBKeys(keyserverID?: string): Promise<{ +olmDataContentKey: string, +encryptionKeyDBKey: string, }> { + const olmDataContentKeyForKeyserverPrefix = getOlmDataContentKeyForCookie( + undefined, + keyserverID, + ); + + const olmEncryptionKeyDBLabelForKeyserverPrefix = + getOlmEncryptionKeyDBLabelForCookie(undefined, keyserverID); + const dbKeys = await localforage.keys(); const olmDataContentKeys = sortOlmDBKeysArray( - dbKeys.filter(key => key.startsWith(NOTIFICATIONS_OLM_DATA_CONTENT)), + dbKeys.filter(key => key.startsWith(olmDataContentKeyForKeyserverPrefix)), ); const encryptionKeyDBLabels = sortOlmDBKeysArray( - dbKeys.filter(key => key.startsWith(NOTIFICATIONS_OLM_DATA_ENCRYPTION_KEY)), + dbKeys.filter(key => + key.startsWith(olmEncryptionKeyDBLabelForKeyserverPrefix), + ), ); if (olmDataContentKeys.length === 0 || encryptionKeyDBLabels.length === 0) { throw new Error( 'Received encrypted notification but olm session was not created', ); } const latestDataContentKey = olmDataContentKeys[olmDataContentKeys.length - 1]; const latestEncryptionKeyDBKey = encryptionKeyDBLabels[encryptionKeyDBLabels.length - 1]; const latestDataContentCookieID = getCookieIDFromOlmDBKey(latestDataContentKey); const latestEncryptionKeyCookieID = getCookieIDFromOlmDBKey( latestEncryptionKeyDBKey, ); if (latestDataContentCookieID !== latestEncryptionKeyCookieID) { throw new Error( 'Olm sessions and their encryption keys out of sync. Latest cookie ' + `id for olm sessions ${latestDataContentCookieID}. Latest cookie ` + `id for olm session encryption keys ${latestEncryptionKeyCookieID}`, ); } const olmDBKeys = { olmDataContentKey: latestDataContentKey, encryptionKeyDBKey: latestEncryptionKeyDBKey, }; const keysToDelete: $ReadOnlyArray = [ ...olmDataContentKeys.slice(0, olmDataContentKeys.length - 1), ...encryptionKeyDBLabels.slice(0, encryptionKeyDBLabels.length - 1), ]; await Promise.all(keysToDelete.map(key => localforage.removeItem(key))); return olmDBKeys; } function getOlmDataContentKeyForCookie( cookie: ?string, - // eslint-disable-next-line no-unused-vars - keyserverID: string, + keyserverID?: string, ): string { + let olmDataContentKeyBase; + if (keyserverID) { + olmDataContentKeyBase = [ + INDEXED_DB_KEYSERVER_PREFIX, + keyserverID, + NOTIFICATIONS_OLM_DATA_CONTENT, + ].join(INDEXED_DB_KEY_SEPARATOR); + } else { + olmDataContentKeyBase = NOTIFICATIONS_OLM_DATA_CONTENT; + } + if (!cookie) { - return NOTIFICATIONS_OLM_DATA_CONTENT; + return olmDataContentKeyBase; } const cookieID = getCookieIDFromCookie(cookie); - return `${NOTIFICATIONS_OLM_DATA_CONTENT}:${cookieID}`; + return [olmDataContentKeyBase, cookieID].join(INDEXED_DB_KEY_SEPARATOR); } function getOlmEncryptionKeyDBLabelForCookie( cookie: ?string, - // eslint-disable-next-line no-unused-vars - keyserverID: 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 NOTIFICATIONS_OLM_DATA_ENCRYPTION_KEY; + return olmEncryptionKeyDBLabelBase; } const cookieID = getCookieIDFromCookie(cookie); - return `${NOTIFICATIONS_OLM_DATA_ENCRYPTION_KEY}:${cookieID}`; + return [olmEncryptionKeyDBLabelBase, cookieID].join(INDEXED_DB_KEY_SEPARATOR); } function getCookieIDFromOlmDBKey(olmDBKey: string): string | '0' { - const cookieID = olmDBKey.split(':')[1]; + // 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 = getOlmDataContentKeyForCookie( + 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]); +} + export { decryptWebNotification, decryptDesktopNotification, getOlmDataContentKeyForCookie, getOlmEncryptionKeyDBLabelForCookie, + migrateLegacyOlmNotificationsSessions, }; diff --git a/web/push-notif/push-notifs-handler.js b/web/push-notif/push-notifs-handler.js index fad1305c9..dc37e789a 100644 --- a/web/push-notif/push-notifs-handler.js +++ b/web/push-notif/push-notifs-handler.js @@ -1,231 +1,261 @@ // @flow import * as React from 'react'; 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 { + hasMinCodeVersion, + NEXT_CODE_VERSION, +} from 'lib/shared/version-utils.js'; +import { isDesktopPlatform } from 'lib/types/device-types.js'; +import { getConfig } from 'lib/utils/config.js'; import { convertNonPendingIDToNewSchema } from 'lib/utils/migration-utils.js'; import { shouldSkipPushPermissionAlert, recordNotifPermissionAlertActionType, } 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 } from './notif-crypto-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: NEXT_CODE_VERSION }) + ) { + return; + } + void (async () => { + await migrateLegacyOlmNotificationsSessions(); + setNotifsSessionsMigrated(true); + })(); + }, [platformDetails]); React.useEffect( () => electron?.onDeviceTokenRegistered?.((token: ?string) => { void dispatchActionPromise( setDeviceTokenActionTypes, callSetDeviceToken(token), ); }), [callSetDeviceToken, dispatchActionPromise], ); React.useEffect(() => { electron?.fetchDeviceToken?.(); }, []); - React.useEffect( - () => - electron?.onEncryptedNotification?.( - async ({ + React.useEffect(() => { + if ( + hasMinCodeVersion(platformDetails, { majorDesktop: NEXT_CODE_VERSION }) && + !notifsOlmSessionMigrated + ) { + return undefined; + } + + return electron?.onEncryptedNotification?.( + async ({ + encryptedPayload, + keyserverID, + }: { + encryptedPayload: string, + keyserverID?: string, + }) => { + const decryptedPayload = await decryptDesktopNotification( encryptedPayload, + staffCanSee, keyserverID, - }: { - encryptedPayload: string, - keyserverID?: string, - }) => { - const decryptedPayload = await decryptDesktopNotification( - encryptedPayload, - staffCanSee, - keyserverID, - ); - electron?.showDecryptedNotification(decryptedPayload); - }, - ), - [staffCanSee], - ); + ); + 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], ); } function useCreatePushSubscription(): () => Promise { const publicKey = useSelector(state => state.pushApiPublicKey); const dispatchActionPromise = useDispatchActionPromise(); const callSetDeviceToken = useSetDeviceTokenFanout(); const staffCanSee = useStaffCanSee(); return React.useCallback(async () => { if (!publicKey) { return; } const workerRegistration = await navigator.serviceWorker?.ready; if (!workerRegistration || !workerRegistration.pushManager) { return; } workerRegistration.active?.postMessage({ olmWasmPath: getOlmWasmPath(), staffCanSee, }); const subscription = await workerRegistration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: publicKey, }); void dispatchActionPromise( setDeviceTokenActionTypes, callSetDeviceToken(JSON.stringify(subscription)), ); }, [callSetDeviceToken, dispatchActionPromise, publicKey, staffCanSee]); } function PushNotificationsHandler(): React.Node { useCreateDesktopPushSubscription(); const createPushSubscription = useCreatePushSubscription(); const notifPermissionAlertInfo = useSelector( state => state.notifPermissionAlertInfo, ); 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(); dispatch({ type: recordNotifPermissionAlertActionType, payload: { time: Date.now() }, }); } })(); // 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(); dispatch({ type: recordNotifPermissionAlertActionType, payload: { time: Date.now() }, }); } } 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]); return null; } export { PushNotificationsHandler, useCreatePushSubscription }; diff --git a/web/push-notif/service-worker.js b/web/push-notif/service-worker.js index 69597eaaa..6acefa352 100644 --- a/web/push-notif/service-worker.js +++ b/web/push-notif/service-worker.js @@ -1,176 +1,179 @@ // @flow import localforage from 'localforage'; import type { PlainTextWebNotification, WebNotification, } from 'lib/types/notif-types.js'; import { convertNonPendingIDToNewSchema } from 'lib/utils/migration-utils.js'; import { decryptWebNotification, + migrateLegacyOlmNotificationsSessions, WEB_NOTIFS_SERVICE_UTILS_KEY, type WebNotifsServiceUtilsData, type WebNotifDecryptionError, } from './notif-crypto-utils.js'; import { authoritativeKeyserverID } from '../authoritative-keyserver.js'; import { localforageConfig } from '../shared-worker/utils/constants.js'; declare class PushMessageData { json(): Object; } declare class PushEvent extends ExtendableEvent { +data: PushMessageData; } declare class CommAppMessage extends ExtendableEvent { +data: { +olmWasmPath?: string, +staffCanSee?: boolean }; } 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); self.addEventListener('activate', (event: ExtendableEvent) => { event.waitUntil(clients.claim()); }); self.addEventListener('message', (event: CommAppMessage) => { localforage.config(localforageConfig); event.waitUntil( (async () => { if (!event.data.olmWasmPath || event.data.staffCanSee === undefined) { return; } const webNotifsServiceUtils: WebNotifsServiceUtilsData = { olmWasmPath: event.data.olmWasmPath, staffCanSee: event.data.staffCanSee, }; await localforage.setItem( WEB_NOTIFS_SERVICE_UTILS_KEY, webNotifsServiceUtils, ); + + await migrateLegacyOlmNotificationsSessions(); })(), ); }); self.addEventListener('push', (event: PushEvent) => { localforage.config(localforageConfig); const data: WebNotification = event.data.json(); event.waitUntil( (async () => { 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(plainTextData.title, { body, badge: commIconUrl, icon: commIconUrl, tag: plainTextData.id, data: { unreadCount: plainTextData.unreadCount, threadID: plainTextData.threadID, }, }); })(), ); }); self.addEventListener('notificationclick', (event: NotificationEvent) => { event.notification.close(); event.waitUntil( (async () => { const clientList: Array = (await clients.matchAll({ type: 'window', }): any); const selectedClient = clientList.find(client => client.focused) ?? clientList[0]; // 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, authoritativeKeyserverID, ); } if (selectedClient) { if (!selectedClient.focused) { await selectedClient.focus(); } if (threadID) { selectedClient.postMessage({ targetThreadID: threadID, }); } } else { const baseURL = process.env.NODE_ENV === 'production' ? 'https://web.comm.app' : 'http://localhost:3000/webapp'; const url = threadID ? baseURL + `/chat/thread/${threadID}/` : baseURL; await clients.openWindow(url); } })(), ); });