diff --git a/web/crypto/aes-gcm-crypto-utils.js b/web/crypto/aes-gcm-crypto-utils.js index d96c194d4..ebe4f91ec 100644 --- a/web/crypto/aes-gcm-crypto-utils.js +++ b/web/crypto/aes-gcm-crypto-utils.js @@ -1,92 +1,135 @@ // @flow +import t, { type TInterface, type TUnion } from 'tcomb'; + +import { tShape } from 'lib/utils/validation-utils.js'; + const ENCRYPTION_ALGORITHM = 'AES-GCM'; const ENCRYPTION_KEY_USAGES: $ReadOnlyArray = [ 'encrypt', 'decrypt', ]; export type EncryptedData = { +iv: BufferSource, +ciphertext: Uint8Array, }; +export const encryptedAESDataValidator: TInterface = + tShape({ + iv: t.irreducible('Uint8Array', x => x instanceof Uint8Array), + ciphertext: t.irreducible('Uint8Array', x => x instanceof Uint8Array), + }); + +export const cryptoKeyValidator: TInterface = tShape({ + algorithm: t.Object, + extractable: t.Boolean, + type: t.String, + usages: t.list(t.String), +}); + +export const subtleCrypto$JsonWebKeyValidator: TInterface = + tShape({ + alg: t.maybe(t.String), + crv: t.maybe(t.String), + d: t.maybe(t.String), + dp: t.maybe(t.String), + dq: t.maybe(t.String), + e: t.maybe(t.String), + ext: t.maybe(t.Boolean), + k: t.maybe(t.String), + key_ops: t.maybe(t.list(t.String)), + kty: t.maybe(t.String), + n: t.maybe(t.String), + oth: t.maybe(t.list(t.Object)), + p: t.maybe(t.String), + q: t.maybe(t.String), + qi: t.maybe(t.String), + use: t.maybe(t.String), + x: t.maybe(t.String), + y: t.maybe(t.String), + }); + +export const extendedCryptoKeyValidator: TUnion< + CryptoKey | SubtleCrypto$JsonWebKey, +> = t.union([cryptoKeyValidator, subtleCrypto$JsonWebKeyValidator]); + function generateCryptoKey({ extractable, }: { +extractable: boolean, }): Promise { return crypto.subtle.generateKey( { name: ENCRYPTION_ALGORITHM, length: 256, }, extractable, ENCRYPTION_KEY_USAGES, ); } -function generateIV(): BufferSource { +function generateIV(): Uint8Array { return crypto.getRandomValues(new Uint8Array(12)); } async function encryptData( data: Uint8Array, key: CryptoKey, ): Promise { const iv = generateIV(); const ciphertext = await crypto.subtle.encrypt( { name: ENCRYPTION_ALGORITHM, iv: iv, }, key, data, ); return { ciphertext: new Uint8Array(ciphertext), iv, }; } async function decryptData( encryptedData: EncryptedData, key: CryptoKey, ): Promise { const { ciphertext, iv } = encryptedData; const decrypted = await crypto.subtle.decrypt( { name: ENCRYPTION_ALGORITHM, iv, }, key, ciphertext, ); return new Uint8Array(decrypted); } async function exportKeyToJWK( key: CryptoKey, ): Promise { return await crypto.subtle.exportKey('jwk', key); } async function importJWKKey( jwkKey: SubtleCrypto$JsonWebKey, ): Promise { return await crypto.subtle.importKey( 'jwk', jwkKey, ENCRYPTION_ALGORITHM, true, ENCRYPTION_KEY_USAGES, ); } export { generateCryptoKey, encryptData, decryptData, exportKeyToJWK, importJWKKey, }; diff --git a/web/push-notif/notif-crypto-utils.js b/web/push-notif/notif-crypto-utils.js index ffe844126..74a60575a 100644 --- a/web/push-notif/notif-crypto-utils.js +++ b/web/push-notif/notif-crypto-utils.js @@ -1,646 +1,900 @@ // @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, } 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 { 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 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 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 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 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(keyserverID); } 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, }; } try { await olm.init({ locateFile: () => olmWasmPath }); const decryptedNotification = await commonDecrypt( encryptedOlmData, olmDataKey, encryptionKey, encryptedPayload, ); const { unreadCount } = decryptedNotification; invariant(keyserverID, 'Keyserver ID must be set to update badge counts'); await updateNotifsUnreadCountStorage({ [keyserverID]: unreadCount, }); return { id, ...decryptedNotification }; } catch (e) { return { id, error: e.message, displayErrorMessage: staffCanSee, }; } } async function decryptDesktopNotification( encryptedPayload: string, staffCanSee: boolean, keyserverID?: string, ): Promise<{ +[string]: mixed }> { let encryptedOlmData, encryptionKey, olmDataKey; try { const { olmDataKey: olmDataKeyValue, encryptionKeyDBKey } = await getNotifsOlmSessionDBKeys(keyserverID); olmDataKey = olmDataKeyValue; [encryptedOlmData, encryptionKey] = await Promise.all([ localforage.getItem(olmDataKey), 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, }; } let decryptedNotification; try { decryptedNotification = await commonDecrypt<{ +[string]: mixed }>( encryptedOlmData, olmDataKey, encryptionKey, encryptedPayload, ); } 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, 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(olmDataKey, 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 encryptNotification( payload: string, deviceID: string, ): Promise { const olmDataKey = getOlmDataKeyForDeviceID(deviceID); const olmEncryptionKeyDBLabel = getOlmEncryptionKeyDBLabelForDeviceID(deviceID); - let encryptedOlmData, encryptionKey; + let encryptedOlmData, encryptionKey, synchronizationValue; try { - [encryptedOlmData, encryptionKey] = await Promise.all([ - localforage.getItem(olmDataKey), - retrieveEncryptionKey(olmEncryptionKeyDBLabel), - initOlm(), - ]); + 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, - encryptedOlmData, + validatedEncryptedOlmData, olmDataKey, - encryptionKey, + 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, ); - await localforage.setItem(olmDataKey, updatedEncryptedSession); + 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/shared-worker/worker/worker-crypto.js b/web/shared-worker/worker/worker-crypto.js index 85bc9d796..6e11c7bd9 100644 --- a/web/shared-worker/worker/worker-crypto.js +++ b/web/shared-worker/worker/worker-crypto.js @@ -1,963 +1,1011 @@ // @flow import olm, { type Utility } from '@commapp/olm'; import localforage from 'localforage'; import uuid from 'uuid'; import { initialEncryptedMessageContent } from 'lib/shared/crypto-utils.js'; import { hasMinCodeVersion } from 'lib/shared/version-utils.js'; import { type OLMIdentityKeys, type PickledOLMAccount, type IdentityKeysBlob, type SignedIdentityKeysBlob, type OlmAPI, type OneTimeKeysResultValues, type ClientPublicKeys, type NotificationsOlmDataType, type EncryptedData, type OutboundSessionCreationResult, } from 'lib/types/crypto-types.js'; import type { PlatformDetails } from 'lib/types/device-types.js'; import type { IdentityNewDeviceKeyUpload, IdentityExistingDeviceKeyUpload, } from 'lib/types/identity-service-types.js'; import type { OlmSessionInitializationInfo } from 'lib/types/request-types.js'; import type { InboundP2PMessage } from 'lib/types/sqlite-types.js'; import { getMessageForException } from 'lib/utils/errors.js'; import { entries } from 'lib/utils/objects.js'; import { retrieveAccountKeysSet, getAccountOneTimeKeys, getAccountPrekeysSet, shouldForgetPrekey, shouldRotatePrekey, retrieveIdentityKeysAndPrekeys, olmSessionErrors, } from 'lib/utils/olm-utils.js'; import { getIdentityClient } from './identity-client.js'; import { getProcessingStoreOpsExceptionMessage } from './process-operations.js'; import { getDBModule, getSQLiteQueryExecutor, getPlatformDetails, } from './worker-database.js'; -import { - encryptData, - exportKeyToJWK, - generateCryptoKey, -} from '../../crypto/aes-gcm-crypto-utils.js'; import { getOlmDataKeyForCookie, getOlmEncryptionKeyDBLabelForCookie, getOlmDataKeyForDeviceID, getOlmEncryptionKeyDBLabelForDeviceID, encryptNotification, + type NotificationAccountWithPicklingKey, + getNotifsCryptoAccount, + persistNotifsAccountWithOlmData, } from '../../push-notif/notif-crypto-utils.js'; import { type WorkerRequestMessage, type WorkerResponseMessage, workerRequestMessageTypes, workerResponseMessageTypes, type LegacyCryptoStore, } from '../../types/worker-types.js'; import type { OlmPersistSession } from '../types/sqlite-query-executor.js'; -import { isDesktopSafari } from '../utils/db-utils.js'; type OlmSession = { +session: olm.Session, +version: number }; type OlmSessions = { [deviceID: string]: OlmSession, }; type WorkerCryptoStore = { +contentAccountPickleKey: string, +contentAccount: olm.Account, +contentSessions: OlmSessions, - +notificationAccountPickleKey: string, - +notificationAccount: olm.Account, }; let cryptoStore: ?WorkerCryptoStore = null; let olmUtility: ?Utility = null; function clearCryptoStore() { cryptoStore = null; } -function persistCryptoStore(withoutTransaction: boolean = false) { +async function persistCryptoStore( + notifsCryptoAccount?: NotificationAccountWithPicklingKey, + withoutTransaction: boolean = false, +) { const sqliteQueryExecutor = getSQLiteQueryExecutor(); const dbModule = getDBModule(); if (!sqliteQueryExecutor || !dbModule) { throw new Error( "Couldn't persist crypto store because database is not initialized", ); } if (!cryptoStore) { throw new Error("Couldn't persist crypto store because it doesn't exist"); } - const { - contentAccountPickleKey, - contentAccount, - contentSessions, - notificationAccountPickleKey, - notificationAccount, - } = cryptoStore; + const { contentAccountPickleKey, contentAccount, contentSessions } = + cryptoStore; const pickledContentAccount: PickledOLMAccount = { picklingKey: contentAccountPickleKey, pickledAccount: contentAccount.pickle(contentAccountPickleKey), }; const pickledContentSessions: OlmPersistSession[] = entries( contentSessions, ).map(([targetDeviceID, sessionData]) => ({ targetDeviceID, sessionData: sessionData.session.pickle(contentAccountPickleKey), version: sessionData.version, })); - const pickledNotificationAccount: PickledOLMAccount = { - picklingKey: notificationAccountPickleKey, - pickledAccount: notificationAccount.pickle(notificationAccountPickleKey), - }; - try { if (!withoutTransaction) { sqliteQueryExecutor.beginTransaction(); } sqliteQueryExecutor.storeOlmPersistAccount( sqliteQueryExecutor.getContentAccountID(), JSON.stringify(pickledContentAccount), ); for (const pickledSession of pickledContentSessions) { sqliteQueryExecutor.storeOlmPersistSession(pickledSession); } - sqliteQueryExecutor.storeOlmPersistAccount( - sqliteQueryExecutor.getNotifsAccountID(), - JSON.stringify(pickledNotificationAccount), - ); + if (notifsCryptoAccount) { + const { + notificationAccount, + picklingKey, + synchronizationValue, + accountEncryptionKey, + } = notifsCryptoAccount; + + const pickledAccount = notificationAccount.pickle(picklingKey); + const accountWithPicklingKey: PickledOLMAccount = { + pickledAccount, + picklingKey, + }; + + await persistNotifsAccountWithOlmData({ + accountEncryptionKey, + accountWithPicklingKey, + synchronizationValue, + forceWrite: true, + }); + } if (!withoutTransaction) { sqliteQueryExecutor.commitTransaction(); } } catch (err) { if (!withoutTransaction) { sqliteQueryExecutor.rollbackTransaction(); } throw new Error(getProcessingStoreOpsExceptionMessage(err, dbModule)); } } async function createAndPersistNotificationsOutboundSession( notificationsIdentityKeys: OLMIdentityKeys, notificationsInitializationInfo: OlmSessionInitializationInfo, dataPersistenceKey: string, dataEncryptionKeyDBLabel: string, ): Promise { if (!cryptoStore) { throw new Error('Crypto account not initialized'); } - const { notificationAccountPickleKey, notificationAccount } = cryptoStore; - const encryptionKey = await generateCryptoKey({ - extractable: isDesktopSafari, - }); + const notificationAccountWithPicklingKey = await getNotifsCryptoAccount(); + const { + notificationAccount, + picklingKey, + synchronizationValue, + accountEncryptionKey, + } = notificationAccountWithPicklingKey; const notificationsPrekey = notificationsInitializationInfo.prekey; const session = new olm.Session(); if (notificationsInitializationInfo.oneTimeKey) { session.create_outbound( notificationAccount, notificationsIdentityKeys.curve25519, notificationsIdentityKeys.ed25519, notificationsPrekey, notificationsInitializationInfo.prekeySignature, notificationsInitializationInfo.oneTimeKey, ); } else { session.create_outbound_without_otk( notificationAccount, notificationsIdentityKeys.curve25519, notificationsIdentityKeys.ed25519, notificationsPrekey, notificationsInitializationInfo.prekeySignature, ); } const { body: message, type: messageType } = session.encrypt( JSON.stringify(initialEncryptedMessageContent), ); - const mainSession = session.pickle(notificationAccountPickleKey); + const mainSession = session.pickle( + notificationAccountWithPicklingKey.picklingKey, + ); const notificationsOlmData: NotificationsOlmDataType = { mainSession, pendingSessionUpdate: mainSession, updateCreationTimestamp: Date.now(), - picklingKey: notificationAccountPickleKey, + picklingKey, }; - const encryptedOlmData = await encryptData( - new TextEncoder().encode(JSON.stringify(notificationsOlmData)), - encryptionKey, - ); - - 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( - dataEncryptionKeyDBLabel, - cryptoKeyPersistentForm, - ); - })(); + const pickledAccount = notificationAccount.pickle(picklingKey); + const accountWithPicklingKey: PickledOLMAccount = { + pickledAccount, + picklingKey, + }; - await Promise.all([ - localforage.setItem(dataPersistenceKey, encryptedOlmData), - persistEncryptionKeyPromise, - ]); + await persistNotifsAccountWithOlmData({ + accountEncryptionKey, + accountWithPicklingKey, + olmDataKey: dataPersistenceKey, + olmData: notificationsOlmData, + olmEncryptionKeyDBLabel: dataEncryptionKeyDBLabel, + synchronizationValue, + forceWrite: true, + }); return { message, messageType }; } -function getOrCreateOlmAccount(accountIDInDB: number): { +async function getOrCreateOlmAccount(accountIDInDB: number): Promise<{ +picklingKey: string, +account: olm.Account, -} { + +synchronizationValue?: ?string, +}> { const sqliteQueryExecutor = getSQLiteQueryExecutor(); const dbModule = getDBModule(); if (!sqliteQueryExecutor || !dbModule) { throw new Error('Database not initialized'); } const account = new olm.Account(); let picklingKey; let accountDBString; try { accountDBString = sqliteQueryExecutor.getOlmPersistAccountDataWeb(accountIDInDB); } catch (err) { throw new Error(getProcessingStoreOpsExceptionMessage(err, dbModule)); } + const maybeNotifsCryptoAccount: ?NotificationAccountWithPicklingKey = + await (async () => { + if (accountIDInDB !== sqliteQueryExecutor.getNotifsAccountID()) { + return undefined; + } + try { + return await getNotifsCryptoAccount(); + } catch (e) { + return undefined; + } + })(); + + if (maybeNotifsCryptoAccount) { + const { + notificationAccount, + picklingKey: notificationAccountPicklingKey, + synchronizationValue, + } = maybeNotifsCryptoAccount; + return { + account: notificationAccount, + picklingKey: notificationAccountPicklingKey, + synchronizationValue, + }; + } + if (accountDBString.isNull) { picklingKey = uuid.v4(); account.create(); } else { const dbAccount: PickledOLMAccount = JSON.parse(accountDBString.value); picklingKey = dbAccount.picklingKey; account.unpickle(picklingKey, dbAccount.pickledAccount); } + if (accountIDInDB === sqliteQueryExecutor.getNotifsAccountID()) { + return { picklingKey, account, synchronizationValue: uuid.v4() }; + } + return { picklingKey, account }; } function getOlmSessions(picklingKey: string): OlmSessions { const sqliteQueryExecutor = getSQLiteQueryExecutor(); const dbModule = getDBModule(); if (!sqliteQueryExecutor || !dbModule) { throw new Error( "Couldn't get olm sessions because database is not initialized", ); } let dbSessionsData; try { dbSessionsData = sqliteQueryExecutor.getOlmPersistSessionsData(); } catch (err) { throw new Error(getProcessingStoreOpsExceptionMessage(err, dbModule)); } const sessionsData: OlmSessions = {}; for (const persistedSession: OlmPersistSession of dbSessionsData) { const { sessionData, version } = persistedSession; const session = new olm.Session(); session.unpickle(picklingKey, sessionData); sessionsData[persistedSession.targetDeviceID] = { session, version, }; } return sessionsData; } function unpickleInitialCryptoStoreAccount( account: PickledOLMAccount, ): olm.Account { const { picklingKey, pickledAccount } = account; const olmAccount = new olm.Account(); olmAccount.unpickle(picklingKey, pickledAccount); return olmAccount; } async function initializeCryptoAccount( olmWasmPath: string, initialCryptoStore: ?LegacyCryptoStore, ) { const sqliteQueryExecutor = getSQLiteQueryExecutor(); if (!sqliteQueryExecutor) { throw new Error('Database not initialized'); } await olm.init({ locateFile: () => olmWasmPath }); olmUtility = new olm.Utility(); if (initialCryptoStore) { cryptoStore = { contentAccountPickleKey: initialCryptoStore.primaryAccount.picklingKey, contentAccount: unpickleInitialCryptoStoreAccount( initialCryptoStore.primaryAccount, ), contentSessions: {}, - notificationAccountPickleKey: - initialCryptoStore.notificationAccount.picklingKey, + }; + const notifsCryptoAccount = { + picklingKey: initialCryptoStore.notificationAccount.picklingKey, notificationAccount: unpickleInitialCryptoStoreAccount( initialCryptoStore.notificationAccount, ), + synchronizationValue: uuid.v4(), }; - persistCryptoStore(); + await persistCryptoStore(notifsCryptoAccount); return; } await olmAPI.initializeCryptoAccount(); } async function processAppOlmApiRequest( message: WorkerRequestMessage, ): Promise { if (message.type === workerRequestMessageTypes.INITIALIZE_CRYPTO_ACCOUNT) { await initializeCryptoAccount( message.olmWasmPath, message.initialCryptoStore, ); } else if (message.type === workerRequestMessageTypes.CALL_OLM_API_METHOD) { const method: (...$ReadOnlyArray) => mixed = (olmAPI[ message.method ]: any); // Flow doesn't allow us to bind the (stringified) method name with // the argument types so we need to pass the args as mixed. const result = await method(...message.args); return { type: workerResponseMessageTypes.CALL_OLM_API_METHOD, result, }; } return undefined; } -function getSignedIdentityKeysBlob(): SignedIdentityKeysBlob { +async function getSignedIdentityKeysBlob(): Promise { if (!cryptoStore) { throw new Error('Crypto account not initialized'); } - const { contentAccount, notificationAccount } = cryptoStore; + const { contentAccount } = cryptoStore; + const { notificationAccount } = await getNotifsCryptoAccount(); const identityKeysBlob: IdentityKeysBlob = { notificationIdentityPublicKeys: JSON.parse( notificationAccount.identity_keys(), ), primaryIdentityPublicKeys: JSON.parse(contentAccount.identity_keys()), }; const payloadToBeSigned: string = JSON.stringify(identityKeysBlob); const signedIdentityKeysBlob: SignedIdentityKeysBlob = { payload: payloadToBeSigned, signature: contentAccount.sign(payloadToBeSigned), }; return signedIdentityKeysBlob; } -function getNewDeviceKeyUpload(): IdentityNewDeviceKeyUpload { +async function getNewDeviceKeyUpload(): Promise { if (!cryptoStore) { throw new Error('Crypto account not initialized'); } - const { contentAccount, notificationAccount } = cryptoStore; - - const signedIdentityKeysBlob = getSignedIdentityKeysBlob(); + const { contentAccount } = cryptoStore; + const [notifsCryptoAccount, signedIdentityKeysBlob] = await Promise.all([ + getNotifsCryptoAccount(), + getSignedIdentityKeysBlob(), + ]); const primaryAccountKeysSet = retrieveAccountKeysSet(contentAccount); - const notificationAccountKeysSet = - retrieveAccountKeysSet(notificationAccount); + const notificationAccountKeysSet = retrieveAccountKeysSet( + notifsCryptoAccount.notificationAccount, + ); contentAccount.mark_keys_as_published(); - notificationAccount.mark_keys_as_published(); + notifsCryptoAccount.notificationAccount.mark_keys_as_published(); - persistCryptoStore(); + await persistCryptoStore(notifsCryptoAccount); 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, }; } -function getExistingDeviceKeyUpload(): IdentityExistingDeviceKeyUpload { +async function getExistingDeviceKeyUpload(): Promise { if (!cryptoStore) { throw new Error('Crypto account not initialized'); } - const { contentAccount, notificationAccount } = cryptoStore; - - const signedIdentityKeysBlob = getSignedIdentityKeysBlob(); + const { contentAccount } = cryptoStore; + const [notifsCryptoAccount, signedIdentityKeysBlob] = await Promise.all([ + getNotifsCryptoAccount(), + getSignedIdentityKeysBlob(), + ]); const { prekey: contentPrekey, prekeySignature: contentPrekeySignature } = retrieveIdentityKeysAndPrekeys(contentAccount); const { prekey: notifPrekey, prekeySignature: notifPrekeySignature } = - retrieveIdentityKeysAndPrekeys(notificationAccount); + retrieveIdentityKeysAndPrekeys(notifsCryptoAccount.notificationAccount); - persistCryptoStore(); + await persistCryptoStore(notifsCryptoAccount); return { keyPayload: signedIdentityKeysBlob.payload, keyPayloadSignature: signedIdentityKeysBlob.signature, contentPrekey, contentPrekeySignature, notifPrekey, notifPrekeySignature, }; } function getNotifsPersistenceKeys( cookie: ?string, keyserverID: string, platformDetails: PlatformDetails, ) { if (hasMinCodeVersion(platformDetails, { majorDesktop: 12 })) { return { notifsOlmDataEncryptionKeyDBLabel: getOlmEncryptionKeyDBLabelForCookie( cookie, keyserverID, ), notifsOlmDataContentKey: getOlmDataKeyForCookie(cookie, keyserverID), }; } else { return { notifsOlmDataEncryptionKeyDBLabel: getOlmEncryptionKeyDBLabelForCookie(cookie), notifsOlmDataContentKey: getOlmDataKeyForCookie(cookie), }; } } async function reassignLocalForageItem(source: string, destination: string) { const value = await localforage.getItem(source); if (!value) { return; } const valueAtDestination = await localforage.getItem(destination); if (!valueAtDestination) { await localforage.setItem(destination, value); } await localforage.removeItem(source); } const olmAPI: OlmAPI = { async initializeCryptoAccount(): Promise { const sqliteQueryExecutor = getSQLiteQueryExecutor(); if (!sqliteQueryExecutor) { throw new Error('Database not initialized'); } - const contentAccountResult = getOrCreateOlmAccount( - sqliteQueryExecutor.getContentAccountID(), - ); - const notificationAccountResult = getOrCreateOlmAccount( - sqliteQueryExecutor.getNotifsAccountID(), + const [contentAccountResult, notificationAccountResult] = await Promise.all( + [ + getOrCreateOlmAccount(sqliteQueryExecutor.getContentAccountID()), + getOrCreateOlmAccount(sqliteQueryExecutor.getNotifsAccountID()), + ], ); + const contentSessions = getOlmSessions(contentAccountResult.picklingKey); cryptoStore = { contentAccountPickleKey: contentAccountResult.picklingKey, contentAccount: contentAccountResult.account, contentSessions, - notificationAccountPickleKey: notificationAccountResult.picklingKey, + }; + const notifsCryptoAccount = { + picklingKey: notificationAccountResult.picklingKey, notificationAccount: notificationAccountResult.account, + synchronizationValue: notificationAccountResult.synchronizationValue, }; - persistCryptoStore(); + await persistCryptoStore(notifsCryptoAccount); }, async getUserPublicKey(): Promise { if (!cryptoStore) { throw new Error('Crypto account not initialized'); } - const { contentAccount, notificationAccount } = cryptoStore; - const { payload, signature } = getSignedIdentityKeysBlob(); + const { contentAccount } = cryptoStore; + const [{ notificationAccount }, { payload, signature }] = await Promise.all( + [getNotifsCryptoAccount(), getSignedIdentityKeysBlob()], + ); return { primaryIdentityPublicKeys: JSON.parse(contentAccount.identity_keys()), notificationIdentityPublicKeys: JSON.parse( notificationAccount.identity_keys(), ), blobPayload: payload, signature, }; }, async encrypt(content: string, deviceID: string): Promise { if (!cryptoStore) { throw new Error('Crypto account not initialized'); } const olmSession = cryptoStore.contentSessions[deviceID]; if (!olmSession) { throw new Error(`No session for deviceID: ${deviceID}`); } const encryptedContent = olmSession.session.encrypt(content); - persistCryptoStore(); + await persistCryptoStore(); return { message: encryptedContent.body, messageType: encryptedContent.type, }; }, async encryptAndPersist( content: string, deviceID: string, messageID: string, ): Promise { if (!cryptoStore) { throw new Error('Crypto account not initialized'); } const olmSession = cryptoStore.contentSessions[deviceID]; if (!olmSession) { throw new Error(`No session for deviceID: ${deviceID}`); } const encryptedContent = olmSession.session.encrypt(content); const sqliteQueryExecutor = getSQLiteQueryExecutor(); const dbModule = getDBModule(); if (!sqliteQueryExecutor || !dbModule) { throw new Error( "Couldn't persist crypto store because database is not initialized", ); } const result: EncryptedData = { message: encryptedContent.body, messageType: encryptedContent.type, }; sqliteQueryExecutor.beginTransaction(); try { sqliteQueryExecutor.setCiphertextForOutboundP2PMessage( messageID, deviceID, JSON.stringify(result), ); - persistCryptoStore(true); + await persistCryptoStore(undefined, true); sqliteQueryExecutor.commitTransaction(); } catch (e) { sqliteQueryExecutor.rollbackTransaction(); throw e; } return result; }, async encryptNotification( payload: string, deviceID: string, ): Promise { const { body: message, type: messageType } = await encryptNotification( payload, deviceID, ); return { message, messageType }; }, async decrypt( encryptedData: EncryptedData, deviceID: string, ): Promise { if (!cryptoStore) { throw new Error('Crypto account not initialized'); } const olmSession = cryptoStore.contentSessions[deviceID]; if (!olmSession) { throw new Error(`No session for deviceID: ${deviceID}`); } const result = olmSession.session.decrypt( encryptedData.messageType, encryptedData.message, ); - persistCryptoStore(); + await persistCryptoStore(); return result; }, async decryptAndPersist( encryptedData: EncryptedData, deviceID: string, messageID: string, ): Promise { if (!cryptoStore) { throw new Error('Crypto account not initialized'); } const olmSession = cryptoStore.contentSessions[deviceID]; if (!olmSession) { throw new Error(`No session for deviceID: ${deviceID}`); } const result = olmSession.session.decrypt( encryptedData.messageType, encryptedData.message, ); const sqliteQueryExecutor = getSQLiteQueryExecutor(); const dbModule = getDBModule(); if (!sqliteQueryExecutor || !dbModule) { throw new Error( "Couldn't persist crypto store because database is not initialized", ); } const receivedMessage: InboundP2PMessage = { messageID, senderDeviceID: deviceID, plaintext: result, status: 'decrypted', }; sqliteQueryExecutor.beginTransaction(); try { sqliteQueryExecutor.addInboundP2PMessage(receivedMessage); - persistCryptoStore(true); + await persistCryptoStore(undefined, true); sqliteQueryExecutor.commitTransaction(); } catch (e) { sqliteQueryExecutor.rollbackTransaction(); throw e; } return result; }, async contentInboundSessionCreator( contentIdentityKeys: OLMIdentityKeys, initialEncryptedData: EncryptedData, sessionVersion: number, overwrite: boolean, ): Promise { if (!cryptoStore) { throw new Error('Crypto account not initialized'); } const { contentAccount, contentSessions } = cryptoStore; const existingSession = contentSessions[contentIdentityKeys.ed25519]; if (existingSession) { if (!overwrite && existingSession.version > sessionVersion) { throw new Error(olmSessionErrors.alreadyCreated); } else if (!overwrite && existingSession.version === sessionVersion) { throw new Error(olmSessionErrors.raceCondition); } } const session = new olm.Session(); session.create_inbound_from( contentAccount, contentIdentityKeys.curve25519, initialEncryptedData.message, ); contentAccount.remove_one_time_keys(session); const initialEncryptedMessage = session.decrypt( initialEncryptedData.messageType, initialEncryptedData.message, ); contentSessions[contentIdentityKeys.ed25519] = { session, version: sessionVersion, }; - persistCryptoStore(); + await persistCryptoStore(); return initialEncryptedMessage; }, async contentOutboundSessionCreator( contentIdentityKeys: OLMIdentityKeys, contentInitializationInfo: OlmSessionInitializationInfo, ): Promise { if (!cryptoStore) { throw new Error('Crypto account not initialized'); } const { contentAccount, contentSessions } = cryptoStore; const existingSession = contentSessions[contentIdentityKeys.ed25519]; const session = new olm.Session(); if (contentInitializationInfo.oneTimeKey) { session.create_outbound( contentAccount, contentIdentityKeys.curve25519, contentIdentityKeys.ed25519, contentInitializationInfo.prekey, contentInitializationInfo.prekeySignature, contentInitializationInfo.oneTimeKey, ); } else { session.create_outbound_without_otk( contentAccount, contentIdentityKeys.curve25519, contentIdentityKeys.ed25519, contentInitializationInfo.prekey, contentInitializationInfo.prekeySignature, ); } const initialEncryptedData = session.encrypt( JSON.stringify(initialEncryptedMessageContent), ); const newSessionVersion = existingSession ? existingSession.version + 1 : 1; contentSessions[contentIdentityKeys.ed25519] = { session, version: newSessionVersion, }; - persistCryptoStore(); + await persistCryptoStore(); const encryptedData: EncryptedData = { message: initialEncryptedData.body, messageType: initialEncryptedData.type, }; return { encryptedData, sessionVersion: newSessionVersion }; }, async isContentSessionInitialized(deviceID: string) { if (!cryptoStore) { throw new Error('Crypto account not initialized'); } return !!cryptoStore.contentSessions[deviceID]; }, async notificationsOutboundSessionCreator( deviceID: string, notificationsIdentityKeys: OLMIdentityKeys, notificationsInitializationInfo: OlmSessionInitializationInfo, ): Promise { const dataPersistenceKey = getOlmDataKeyForDeviceID(deviceID); const dataEncryptionKeyDBLabel = getOlmEncryptionKeyDBLabelForDeviceID(deviceID); return createAndPersistNotificationsOutboundSession( notificationsIdentityKeys, notificationsInitializationInfo, dataPersistenceKey, dataEncryptionKeyDBLabel, ); }, async isDeviceNotificationsSessionInitialized(deviceID: string) { const dataPersistenceKey = getOlmDataKeyForDeviceID(deviceID); const dataEncryptionKeyDBLabel = getOlmEncryptionKeyDBLabelForDeviceID(deviceID); const allKeys = await localforage.keys(); const allKeysSet = new Set(allKeys); return ( allKeysSet.has(dataPersistenceKey) && allKeysSet.has(dataEncryptionKeyDBLabel) ); }, async isNotificationsSessionInitializedWithDevices( deviceIDs: $ReadOnlyArray, ) { const allKeys = await localforage.keys(); const allKeysSet = new Set(allKeys); const deviceInfoPairs = deviceIDs.map(deviceID => { const dataPersistenceKey = getOlmDataKeyForDeviceID(deviceID); const dataEncryptionKeyDBLabel = getOlmEncryptionKeyDBLabelForDeviceID(deviceID); return [ deviceID, allKeysSet.has(dataPersistenceKey) && allKeysSet.has(dataEncryptionKeyDBLabel), ]; }); return Object.fromEntries(deviceInfoPairs); }, async keyserverNotificationsSessionCreator( cookie: ?string, notificationsIdentityKeys: OLMIdentityKeys, notificationsInitializationInfo: OlmSessionInitializationInfo, keyserverID: string, ): Promise { const platformDetails = getPlatformDetails(); if (!platformDetails) { throw new Error('Worker not initialized'); } const { notifsOlmDataContentKey, notifsOlmDataEncryptionKeyDBLabel } = getNotifsPersistenceKeys(cookie, keyserverID, platformDetails); const { message } = await createAndPersistNotificationsOutboundSession( notificationsIdentityKeys, notificationsInitializationInfo, notifsOlmDataContentKey, notifsOlmDataEncryptionKeyDBLabel, ); return message; }, async reassignNotificationsSession( prevCookie: ?string, newCookie: ?string, keyserverID: string, ): Promise { const platformDetails = getPlatformDetails(); if (!platformDetails) { throw new Error('Worker not initialized'); } const prevPersistenceKeys = getNotifsPersistenceKeys( prevCookie, keyserverID, platformDetails, ); const newPersistenceKeys = getNotifsPersistenceKeys( newCookie, keyserverID, platformDetails, ); await Promise.all([ reassignLocalForageItem( prevPersistenceKeys.notifsOlmDataContentKey, newPersistenceKeys.notifsOlmDataContentKey, ), reassignLocalForageItem( prevPersistenceKeys.notifsOlmDataEncryptionKeyDBLabel, newPersistenceKeys.notifsOlmDataEncryptionKeyDBLabel, ), ]); }, async getOneTimeKeys(numberOfKeys: number): Promise { if (!cryptoStore) { throw new Error('Crypto account not initialized'); } - const { contentAccount, notificationAccount } = cryptoStore; + const { contentAccount } = cryptoStore; + const notifsCryptoAccount = await getNotifsCryptoAccount(); const contentOneTimeKeys = getAccountOneTimeKeys( contentAccount, numberOfKeys, ); contentAccount.mark_keys_as_published(); const notificationsOneTimeKeys = getAccountOneTimeKeys( - notificationAccount, + notifsCryptoAccount.notificationAccount, numberOfKeys, ); - notificationAccount.mark_keys_as_published(); + notifsCryptoAccount.notificationAccount.mark_keys_as_published(); - persistCryptoStore(); + await persistCryptoStore(notifsCryptoAccount); return { contentOneTimeKeys, notificationsOneTimeKeys }; }, async validateAndUploadPrekeys(authMetadata): Promise { const { userID, deviceID, accessToken } = authMetadata; if (!userID || !deviceID || !accessToken) { return; } const identityClient = getIdentityClient(); if (!identityClient) { throw new Error('Identity client not initialized'); } if (!cryptoStore) { throw new Error('Crypto account not initialized'); } - const { contentAccount, notificationAccount } = cryptoStore; + const { contentAccount } = cryptoStore; + const notifsCryptoAccount = await getNotifsCryptoAccount(); // Content and notification accounts' keys are always rotated at the same // time so we only need to check one of them. if (shouldRotatePrekey(contentAccount)) { contentAccount.generate_prekey(); - notificationAccount.generate_prekey(); + notifsCryptoAccount.notificationAccount.generate_prekey(); } if (shouldForgetPrekey(contentAccount)) { contentAccount.forget_old_prekey(); - notificationAccount.forget_old_prekey(); + notifsCryptoAccount.notificationAccount.forget_old_prekey(); } - persistCryptoStore(); + await persistCryptoStore(notifsCryptoAccount); if (!contentAccount.unpublished_prekey()) { return; } const { prekey: notifPrekey, prekeySignature: notifPrekeySignature } = - getAccountPrekeysSet(notificationAccount); + getAccountPrekeysSet(notifsCryptoAccount.notificationAccount); const { prekey: contentPrekey, prekeySignature: contentPrekeySignature } = getAccountPrekeysSet(contentAccount); if (!notifPrekeySignature || !contentPrekeySignature) { throw new Error('Prekey signature is missing'); } await identityClient.publishWebPrekeys({ contentPrekey, contentPrekeySignature, notifPrekey, notifPrekeySignature, }); contentAccount.mark_prekey_as_published(); - notificationAccount.mark_prekey_as_published(); + notifsCryptoAccount.notificationAccount.mark_prekey_as_published(); - persistCryptoStore(); + await persistCryptoStore(notifsCryptoAccount); }, async signMessage(message: string): Promise { if (!cryptoStore) { throw new Error('Crypto account not initialized'); } const { contentAccount } = cryptoStore; return contentAccount.sign(message); }, async verifyMessage( message: string, signature: string, signingPublicKey: string, ): Promise { if (!olmUtility) { throw new Error('Crypto account not initialized'); } try { olmUtility.ed25519_verify(signingPublicKey, message, signature); return true; } catch (err) { const isSignatureInvalid = getMessageForException(err)?.includes('BAD_MESSAGE_MAC'); if (isSignatureInvalid) { return false; } throw err; } }, async markPrekeysAsPublished(): Promise { if (!cryptoStore) { throw new Error('Crypto account not initialized'); } - const { contentAccount, notificationAccount } = cryptoStore; + const { contentAccount } = cryptoStore; + const notifsCryptoAccount = await getNotifsCryptoAccount(); contentAccount.mark_prekey_as_published(); - notificationAccount.mark_prekey_as_published(); + notifsCryptoAccount.notificationAccount.mark_prekey_as_published(); - persistCryptoStore(); + await persistCryptoStore(notifsCryptoAccount); }, }; export { clearCryptoStore, processAppOlmApiRequest, getSignedIdentityKeysBlob, getNewDeviceKeyUpload, getExistingDeviceKeyUpload, };