diff --git a/lib/types/crypto-types.js b/lib/types/crypto-types.js index d83b58629..546c4521b 100644 --- a/lib/types/crypto-types.js +++ b/lib/types/crypto-types.js @@ -1,209 +1,214 @@ // @flow -import t, { type TInterface } from 'tcomb'; +import t, { type TInterface, type TEnums } from 'tcomb'; import type { OlmSessionInitializationInfo } from './olm-session-types.js'; import { type AuthMetadata } from '../shared/identity-client-context.js'; +import { values } from '../utils/objects.js'; import { tShape } from '../utils/validation-utils.js'; export type OLMIdentityKeys = { +ed25519: string, +curve25519: string, }; const olmIdentityKeysValidator: TInterface = tShape({ ed25519: t.String, curve25519: t.String, }); export type OLMPrekey = { +curve25519: { +[key: string]: string, }, }; export type SignedPrekeys = { +contentPrekey: string, +contentPrekeySignature: string, +notifPrekey: string, +notifPrekeySignature: string, }; export const signedPrekeysValidator: TInterface = tShape({ contentPrekey: t.String, contentPrekeySignature: t.String, notifPrekey: t.String, notifPrekeySignature: t.String, }); export type OLMOneTimeKeys = { +curve25519: { +[string]: string }, }; export type OneTimeKeysResult = { +contentOneTimeKeys: OLMOneTimeKeys, +notificationsOneTimeKeys: OLMOneTimeKeys, }; export type OneTimeKeysResultValues = { +contentOneTimeKeys: $ReadOnlyArray, +notificationsOneTimeKeys: $ReadOnlyArray, }; export type PickledOLMAccount = { +picklingKey: string, +pickledAccount: string, }; export type NotificationsOlmDataType = { +mainSession: string, +picklingKey: string, +pendingSessionUpdate: string, +updateCreationTimestamp: number, }; export type IdentityKeysBlob = { +primaryIdentityPublicKeys: OLMIdentityKeys, +notificationIdentityPublicKeys: OLMIdentityKeys, }; export const identityKeysBlobValidator: TInterface = tShape({ primaryIdentityPublicKeys: olmIdentityKeysValidator, notificationIdentityPublicKeys: olmIdentityKeysValidator, }); export type SignedIdentityKeysBlob = { +payload: string, +signature: string, }; export const signedIdentityKeysBlobValidator: TInterface = tShape({ payload: t.String, signature: t.String, }); export type UserDetail = { +username: string, +userID: string, }; // This type should not be changed without making equivalent changes to // `Message` in Identity service's `reserved_users` module export type ReservedUsernameMessage = | { +statement: 'Add the following usernames to reserved list', +payload: $ReadOnlyArray, +issuedAt: string, } | { +statement: 'Remove the following username from reserved list', +payload: string, +issuedAt: string, } | { +statement: 'This user is the owner of the following username and user ID', +payload: UserDetail, +issuedAt: string, }; export const olmEncryptedMessageTypes = Object.freeze({ PREKEY: 0, TEXT: 1, }); export type OlmEncryptedMessageTypes = $Values; +export const olmEncryptedMessageTypesValidator: TEnums = t.enums.of( + values(olmEncryptedMessageTypes), +); + export type EncryptedData = { +message: string, +messageType: OlmEncryptedMessageTypes, +sessionVersion?: number, }; export const encryptedDataValidator: TInterface = tShape({ message: t.String, messageType: t.Number, sessionVersion: t.maybe(t.Number), }); export type ClientPublicKeys = { +primaryIdentityPublicKeys: { +ed25519: string, +curve25519: string, }, +notificationIdentityPublicKeys: { +ed25519: string, +curve25519: string, }, +blobPayload: string, +signature: string, }; export type OutboundSessionCreationResult = { +encryptedData: EncryptedData, +sessionVersion: number, }; export type OlmAPI = { +initializeCryptoAccount: () => Promise, +getUserPublicKey: () => Promise, +encrypt: (content: string, deviceID: string) => Promise, +encryptAndPersist: ( content: string, deviceID: string, messageID: string, ) => Promise, +encryptNotification: ( payload: string, deviceID: string, ) => Promise, +decrypt: (encryptedData: EncryptedData, deviceID: string) => Promise, +decryptAndPersist: ( encryptedData: EncryptedData, deviceID: string, userID: string, messageID: string, ) => Promise, +contentInboundSessionCreator: ( contentIdentityKeys: OLMIdentityKeys, initialEncryptedData: EncryptedData, sessionVersion: number, overwrite: boolean, ) => Promise, +contentOutboundSessionCreator: ( contentIdentityKeys: OLMIdentityKeys, contentInitializationInfo: OlmSessionInitializationInfo, ) => Promise, +isContentSessionInitialized: (deviceID: string) => Promise, +keyserverNotificationsSessionCreator: ( cookie: ?string, notificationsIdentityKeys: OLMIdentityKeys, notificationsInitializationInfo: OlmSessionInitializationInfo, keyserverID: string, ) => Promise, +notificationsOutboundSessionCreator: ( deviceID: string, notificationsIdentityKeys: OLMIdentityKeys, notificationsInitializationInfo: OlmSessionInitializationInfo, ) => Promise, +isDeviceNotificationsSessionInitialized: ( deviceID: string, ) => Promise, +isNotificationsSessionInitializedWithDevices: ( deviceIDs: $ReadOnlyArray, ) => Promise<{ +[deviceID: string]: boolean }>, +reassignNotificationsSession?: ( prevCookie: ?string, newCookie: ?string, keyserverID: string, ) => Promise, +getOneTimeKeys: (numberOfKeys: number) => Promise, +validateAndUploadPrekeys: (authMetadata: AuthMetadata) => Promise, +signMessage: (message: string) => Promise, +verifyMessage: ( message: string, signature: string, signingPublicKey: string, ) => Promise, +markPrekeysAsPublished: () => Promise, }; diff --git a/web/push-notif/notif-crypto-utils.js b/web/push-notif/notif-crypto-utils.js index 32a3914e4..9edcebbf6 100644 --- a/web/push-notif/notif-crypto-utils.js +++ b/web/push-notif/notif-crypto-utils.js @@ -1,1410 +1,1421 @@ // @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, + type OlmEncryptedMessageTypes, } from 'lib/types/crypto-types.js'; +import { olmEncryptedMessageTypesValidator } from 'lib/types/crypto-types.js'; import type { PlainTextWebNotification, EncryptedWebNotification, SenderDeviceDescriptor, } 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 { fetchAuthMetadata, getNotifsInboundKeysForDeviceID, } from './services-client.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'; // thick threads unread count const INDEXED_DB_UNREAD_THICK_THREAD_IDS = 'unreadThickThreadIDs'; const INDEXED_DB_UNREAD_THICK_THREAD_IDS_ENCRYPTION_KEY_DB_LABEL = 'unreadThickThreadIDsEncryptionKey'; const INDEXED_DB_UNREAD_THICK_THREADS_SYNC_KEY = 'unreadThickThreadIDsSyncKey'; +function stringToOlmEncryptedMessageType( + messageType: string, +): OlmEncryptedMessageTypes { + const messageTypeAsNumber = Number(messageType); + return assertWithValidator( + messageTypeAsNumber, + olmEncryptedMessageTypesValidator, + ); +} 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 deserializeEncryptedDataOptional( encryptedData: ?EncryptedData, encryptionKey: ?CryptoKey, ): Promise { if (!encryptedData || !encryptionKey) { return undefined; } return deserializeEncryptedData(encryptedData, encryptionKey); } 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 validateCryptoKeyOptional( cryptoKey: ?CryptoKey | ?SubtleCrypto$JsonWebKey, ): Promise { if (!cryptoKey) { return undefined; } return validateCryptoKey(cryptoKey); } 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 getNotifsAccountWithOlmData( senderDeviceDescriptor: SenderDeviceDescriptor, ): Promise<{ +encryptedOlmData: ?EncryptedData, +encryptionKey: ?CryptoKey, +olmDataKey: string, +encryptionKeyDBLabel: string, +encryptedOlmAccount: ?EncryptedData, +accountEncryptionKey: ?CryptoKey, +synchronizationValue: ?string, }> { let olmDataKey; let olmDataEncryptionKeyDBLabel; const { keyserverID, senderDeviceID } = senderDeviceDescriptor; if (keyserverID) { const olmDBKeys = await getNotifsOlmSessionDBKeys(keyserverID); const { olmDataKey: fetchedOlmDataKey, encryptionKeyDBKey } = olmDBKeys; olmDataKey = fetchedOlmDataKey; olmDataEncryptionKeyDBLabel = encryptionKeyDBKey; } else { invariant( senderDeviceID, 'keyserverID or SenderDeviceID must be present to decrypt a notif', ); olmDataKey = getOlmDataKeyForDeviceID(senderDeviceID); olmDataEncryptionKeyDBLabel = getOlmEncryptionKeyDBLabelForDeviceID(senderDeviceID); } const queryResult = await localforage.getMultipleItems<{ notificationAccount: ?EncryptedData, notificationAccountEncryptionKey: ?CryptoKey, synchronizationValue: ?number, [string]: ?EncryptedData | ?CryptoKey | ?SubtleCrypto$JsonWebKey, }>( [ INDEXED_DB_NOTIFS_ACCOUNT_KEY, INDEXED_DB_NOTIFS_ACCOUNT_ENCRYPTION_KEY_DB_LABEL, olmDataEncryptionKeyDBLabel, olmDataKey, ], INDEXED_DB_NOTIFS_SYNC_KEY, ); const { values: { notificationAccount, notificationAccountEncryptionKey, [olmDataKey]: maybeEncryptedOlmData, [olmDataEncryptionKeyDBLabel]: maybeOlmDataEncryptionKey, }, synchronizationValue, } = queryResult; const encryptedOlmData: ?EncryptedData = maybeEncryptedOlmData ? assertWithValidator(maybeEncryptedOlmData, encryptedAESDataValidator) : undefined; const olmDataEncryptionKey: ?CryptoKey | ?SubtleCrypto$JsonWebKey = maybeOlmDataEncryptionKey ? assertWithValidator( maybeOlmDataEncryptionKey, extendedCryptoKeyValidator, ) : undefined; const [encryptionKey, accountEncryptionKey] = await Promise.all([ validateCryptoKeyOptional(olmDataEncryptionKey), validateCryptoKeyOptional(notificationAccountEncryptionKey), ]); return { encryptedOlmData, encryptionKey, encryptionKeyDBLabel: olmDataEncryptionKeyDBLabel, encryptedOlmAccount: notificationAccount, olmDataKey, accountEncryptionKey, synchronizationValue, }; } 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) { if ( !e.message?.includes( localforage.getSetMultipleItemsRaceConditionErrorMessage(), ) ) { throw e; } // likely shared worker persisted its own data console.log(e); } } async function decryptWebNotification( encryptedNotification: EncryptedWebNotification, ): Promise { const { id, encryptedPayload, - type: messageType, + type: rawMessageType, ...rest } = encryptedNotification; const senderDeviceDescriptor: SenderDeviceDescriptor = rest; - + const messageType = stringToOlmEncryptedMessageType(rawMessageType); 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 notifsAccountWithOlmData; try { notifsAccountWithOlmData = await getNotifsAccountWithOlmData( senderDeviceDescriptor, ); } catch (e) { return { id, error: e.message, displayErrorMessage: staffCanSee, }; } const { encryptionKey, encryptedOlmData, olmDataKey, encryptionKeyDBLabel: olmEncryptionKeyDBLabel, accountEncryptionKey, encryptedOlmAccount, synchronizationValue, } = notifsAccountWithOlmData; try { const [notificationsOlmData, accountWithPicklingKey] = await Promise.all([ deserializeEncryptedDataOptional( encryptedOlmData, encryptionKey, ), deserializeEncryptedDataOptional( encryptedOlmAccount, accountEncryptionKey, ), olm.init({ locateFile: () => olmWasmPath }), ]); let decryptedNotification; let updatedOlmData; let updatedNotifsAccount; const { senderDeviceID, keyserverID } = senderDeviceDescriptor; if (keyserverID) { invariant( notificationsOlmData && encryptionKey, 'Received encrypted notification but keyserver olm session was not created', ); const { decryptedNotification: resultDecryptedNotification, updatedOlmData: resultUpdatedOlmData, } = await commonDecrypt( notificationsOlmData, encryptedPayload, + messageType, ); decryptedNotification = resultDecryptedNotification; updatedOlmData = resultUpdatedOlmData; const { unreadCount } = decryptedNotification; invariant(keyserverID, 'Keyserver ID must be set to update badge counts'); await Promise.all([ persistNotifsAccountWithOlmData({ olmDataKey, olmData: updatedOlmData, olmEncryptionKeyDBLabel, encryptionKey, forceWrite: false, synchronizationValue, }), updateNotifsUnreadCountStorage({ [keyserverID]: unreadCount, }), ]); return { id, ...decryptedNotification }; } else { invariant( senderDeviceID, 'keyserverID or SenderDeviceID must be present to decrypt a notif', ); invariant( accountWithPicklingKey, 'Received encrypted notification but notifs olm account not created', ); const { decryptedNotification: resultDecryptedNotification, updatedOlmData: resultUpdatedOlmData, updatedNotifsAccount: resultUpdatedNotifsAccount, } = await commonPeerDecrypt( senderDeviceID, notificationsOlmData, accountWithPicklingKey, messageType, encryptedPayload, ); decryptedNotification = resultDecryptedNotification; updatedOlmData = resultUpdatedOlmData; updatedNotifsAccount = resultUpdatedNotifsAccount; const { threadID } = decryptedNotification; await Promise.all([ persistNotifsAccountWithOlmData({ accountWithPicklingKey: updatedNotifsAccount, accountEncryptionKey, encryptionKey, olmData: updatedOlmData, olmDataKey, olmEncryptionKeyDBLabel, synchronizationValue, forceWrite: false, }), updateNotifsUnreadThickThreadIDsStorage({ type: 'add', threadIDs: [threadID], forceWrite: false, }), ]); return { id, ...decryptedNotification }; } } catch (e) { return { id, error: e.message, displayErrorMessage: staffCanSee, }; } } async function decryptDesktopNotification( encryptedPayload: string, - messageType: string, + rawMessageType: string, staffCanSee: boolean, senderDeviceDescriptor: SenderDeviceDescriptor, ): Promise<{ +[string]: mixed }> { const { keyserverID, senderDeviceID } = senderDeviceDescriptor; - + const messageType = stringToOlmEncryptedMessageType(rawMessageType); let notifsAccountWithOlmData; try { [notifsAccountWithOlmData] = await Promise.all([ getNotifsAccountWithOlmData(senderDeviceDescriptor), initOlm(), ]); } catch (e) { return { error: e.message, displayErrorMessage: staffCanSee, }; } const { encryptionKey, encryptedOlmData, olmDataKey, encryptionKeyDBLabel: olmEncryptionKeyDBLabel, accountEncryptionKey, encryptedOlmAccount, synchronizationValue, } = notifsAccountWithOlmData; try { const [notificationsOlmData, accountWithPicklingKey] = await Promise.all([ deserializeEncryptedDataOptional( encryptedOlmData, encryptionKey, ), deserializeEncryptedDataOptional( encryptedOlmAccount, accountEncryptionKey, ), ]); if (keyserverID) { invariant( notificationsOlmData && encryptionKey, 'Received encrypted notification but keyserver olm session was not created', ); const { decryptedNotification, updatedOlmData } = await commonDecrypt<{ +[string]: mixed, - }>(notificationsOlmData, encryptedPayload); + }>(notificationsOlmData, encryptedPayload, olmEncryptedMessageTypes.TEXT); const updatedOlmDataPersistencePromise = persistNotifsAccountWithOlmData({ olmDataKey, olmData: updatedOlmData, olmEncryptionKeyDBLabel, encryptionKey, forceWrite: false, synchronizationValue, }); // 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 Promise.all([ updateNotifsUnreadCountStorage({ [(keyserverID: string)]: badge }), updatedOlmDataPersistencePromise, ]); return decryptedNotification; } const { unreadCount } = decryptedNotification; if (typeof unreadCount === 'number') { await Promise.all([ updateNotifsUnreadCountStorage({ [(keyserverID: string)]: unreadCount, }), updatedOlmDataPersistencePromise, ]); } return decryptedNotification; } else { invariant( senderDeviceID, 'keyserverID or SenderDeviceID must be present to decrypt a notif', ); invariant( accountWithPicklingKey, 'Received encrypted notification but notifs olm account not created', ); const { decryptedNotification, updatedOlmData, updatedNotifsAccount } = await commonPeerDecrypt<{ +[string]: mixed, }>( senderDeviceID, notificationsOlmData, accountWithPicklingKey, messageType, encryptedPayload, ); const { threadID } = decryptedNotification; invariant(typeof threadID === 'string', 'threadID should be string'); await Promise.all([ persistNotifsAccountWithOlmData({ accountWithPicklingKey: updatedNotifsAccount, accountEncryptionKey, encryptionKey, olmData: updatedOlmData, olmDataKey, olmEncryptionKeyDBLabel, synchronizationValue, forceWrite: false, }), updateNotifsUnreadThickThreadIDsStorage({ type: 'add', threadIDs: [threadID], forceWrite: false, }), ]); return decryptedNotification; } } catch (e) { return { error: e.message, staffCanSee, }; } } async function commonDecrypt( notificationsOlmData: NotificationsOlmDataType, encryptedPayload: string, + type: OlmEncryptedMessageTypes, ): Promise<{ +decryptedNotification: T, +updatedOlmData: NotificationsOlmDataType, }> { const { mainSession, picklingKey, pendingSessionUpdate, updateCreationTimestamp, } = notificationsOlmData; let updatedOlmData: NotificationsOlmDataType; let decryptedNotification: T; const shouldUpdateMainSession = Date.now() - updateCreationTimestamp > SESSION_UPDATE_MAX_PENDING_TIME; const decryptionWithPendingSessionResult = decryptWithPendingSession( pendingSessionUpdate, picklingKey, encryptedPayload, + type, ); 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); + } = decryptWithSession(mainSession, picklingKey, encryptedPayload, type); decryptedNotification = notifDecryptedWithMainSession; updatedOlmData = { mainSession: mainSession, pendingSessionUpdate, updateCreationTimestamp: newUpdateCreationTimestamp, picklingKey, }; } return { decryptedNotification, updatedOlmData }; } async function commonPeerDecrypt( senderDeviceID: string, notificationsOlmData: ?NotificationsOlmDataType, notificationAccount: PickledOLMAccount, - messageType: string, + messageType: OlmEncryptedMessageTypes, encryptedPayload: string, ): Promise<{ +decryptedNotification: T, +updatedOlmData?: NotificationsOlmDataType, +updatedNotifsAccount?: PickledOLMAccount, }> { - if ( - messageType !== olmEncryptedMessageTypes.PREKEY.toString() && - messageType !== olmEncryptedMessageTypes.TEXT.toString() - ) { - throw new Error( - `Received message of invalid type from device: ${senderDeviceID}`, - ); - } - let isSenderChainEmpty = true; let hasReceivedMessage = false; const sessionExists = !!notificationsOlmData; if (notificationsOlmData) { const session = new olm.Session(); session.unpickle( notificationsOlmData.picklingKey, notificationsOlmData.pendingSessionUpdate, ); isSenderChainEmpty = session.is_sender_chain_empty(); hasReceivedMessage = session.has_received_message(); } // regular message const isRegularMessage = - !!notificationsOlmData && - messageType === olmEncryptedMessageTypes.TEXT.toString(); + !!notificationsOlmData && messageType === olmEncryptedMessageTypes.TEXT; const isRegularPrekeyMessage = !!notificationsOlmData && - messageType === olmEncryptedMessageTypes.PREKEY.toString() && + messageType === olmEncryptedMessageTypes.PREKEY && isSenderChainEmpty && hasReceivedMessage; if (!!notificationsOlmData && (isRegularMessage || isRegularPrekeyMessage)) { - return await commonDecrypt(notificationsOlmData, encryptedPayload); + return await commonDecrypt( + notificationsOlmData, + encryptedPayload, + messageType, + ); } // At this point we either face race condition or session reset attempt or // session initialization attempt. For each of this scenario new inbound // session must be created in order to decrypt message const authMetadata = await fetchAuthMetadata(); const notifInboundKeys = await getNotifsInboundKeysForDeviceID( senderDeviceID, authMetadata, ); const account = new olm.Account(); const session = new olm.Session(); account.unpickle( notificationAccount.picklingKey, notificationAccount.pickledAccount, ); if (notifInboundKeys.error) { throw new Error(notifInboundKeys.error); } invariant( notifInboundKeys.curve25519, 'curve25519 must be present in notifs inbound keys', ); session.create_inbound_from( account, notifInboundKeys.curve25519, encryptedPayload, ); const decryptedNotification: T = JSON.parse( - session.decrypt(Number(messageType), encryptedPayload), + session.decrypt(messageType, encryptedPayload), ); // session reset attempt or session initialization - handled the same const sessionResetAttempt = sessionExists && !isSenderChainEmpty && hasReceivedMessage; // race condition const raceCondition = sessionExists && !isSenderChainEmpty && !hasReceivedMessage; const { deviceID: ourDeviceID } = authMetadata; invariant(ourDeviceID, 'Session creation attempt but no device id'); const thisDeviceWinsRaceCondition = ourDeviceID > senderDeviceID; if ( !sessionExists || sessionResetAttempt || (raceCondition && !thisDeviceWinsRaceCondition) ) { const pickledOlmSession = session.pickle(notificationAccount.picklingKey); const updatedOlmData = { mainSession: pickledOlmSession, pendingSessionUpdate: pickledOlmSession, updateCreationTimestamp: Date.now(), picklingKey: notificationAccount.picklingKey, }; const updatedNotifsAccount = { pickledAccount: account.pickle(notificationAccount.picklingKey), picklingKey: notificationAccount.picklingKey, }; return { decryptedNotification, updatedOlmData, updatedNotifsAccount, }; } // If there is a race condition but we win device id comparison // we return object that carries decrypted data but won't persist // any session state return { decryptedNotification }; } function decryptWithSession( pickledSession: string, picklingKey: string, encryptedPayload: string, + type: OlmEncryptedMessageTypes, ): DecryptionResult { const session = new olm.Session(); session.unpickle(picklingKey, pickledSession); const decryptedNotification: T = JSON.parse( - session.decrypt(olmEncryptedMessageTypes.TEXT, encryptedPayload), + session.decrypt(type, encryptedPayload), ); const newPendingSessionUpdate = session.pickle(picklingKey); const newUpdateCreationTimestamp = Date.now(); return { decryptedNotification, newUpdateCreationTimestamp, newPendingSessionUpdate, }; } function decryptWithPendingSession( pendingSessionUpdate: string, picklingKey: string, encryptedPayload: string, + type: OlmEncryptedMessageTypes, ): DecryptionResult | { +error: string } { try { const { decryptedNotification, newPendingSessionUpdate, newUpdateCreationTimestamp, } = decryptWithSession( pendingSessionUpdate, picklingKey, encryptedPayload, + type, ); 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, synchronizationValue; try { 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, validatedEncryptedOlmData, olmDataKey, 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, ); 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); } async function updateNotifsUnreadThickThreadIDsStorage(input: { +type: 'add' | 'remove' | 'set', +threadIDs: $ReadOnlyArray, +forceWrite: boolean, }): Promise { const { type, threadIDs, forceWrite } = input; const { values: { [INDEXED_DB_UNREAD_THICK_THREAD_IDS]: encryptedData, [INDEXED_DB_UNREAD_THICK_THREAD_IDS_ENCRYPTION_KEY_DB_LABEL]: encryptionKey, }, synchronizationValue, } = await localforage.getMultipleItems<{ unreadThickThreadIDs: ?EncryptedData, unreadThickThreadIDsEncryptionKey: ?(CryptoKey | SubtleCrypto$JsonWebKey), }>( [ INDEXED_DB_UNREAD_THICK_THREAD_IDS, INDEXED_DB_UNREAD_THICK_THREAD_IDS_ENCRYPTION_KEY_DB_LABEL, ], INDEXED_DB_UNREAD_THICK_THREADS_SYNC_KEY, ); let unreadThickThreadIDs; let unreadThickThreadIDsEncryptionKey; if (encryptedData && encryptionKey) { unreadThickThreadIDsEncryptionKey = await validateCryptoKey(encryptionKey); unreadThickThreadIDs = new Set( await deserializeEncryptedData>( encryptedData, unreadThickThreadIDsEncryptionKey, ), ); } else { unreadThickThreadIDs = new Set(); unreadThickThreadIDsEncryptionKey = await generateCryptoKey({ extractable: isDesktopSafari, }); } if (type === 'add') { for (const threadID of threadIDs) { unreadThickThreadIDs.add(threadID); } } else if (type === 'remove') { for (const threadID of threadIDs) { unreadThickThreadIDs.delete(threadID); } } else { unreadThickThreadIDs = new Set(threadIDs); } const [encryptionKeyPersistentForm, updatedEncryptedData] = await Promise.all( [ getCryptoKeyPersistentForm(unreadThickThreadIDsEncryptionKey), serializeUnencryptedData( [...unreadThickThreadIDs], unreadThickThreadIDsEncryptionKey, ), ], ); const newSynchronizationValue = uuid.v4(); await localforage.setMultipleItems( { [INDEXED_DB_UNREAD_THICK_THREAD_IDS]: updatedEncryptedData, [INDEXED_DB_UNREAD_THICK_THREAD_IDS_ENCRYPTION_KEY_DB_LABEL]: encryptionKeyPersistentForm, }, INDEXED_DB_UNREAD_THICK_THREADS_SYNC_KEY, synchronizationValue, newSynchronizationValue, forceWrite, ); } async function getNotifsUnreadThickThreadIDs(): Promise< $ReadOnlyArray, > { const { values: { [INDEXED_DB_UNREAD_THICK_THREAD_IDS]: encryptedData, [INDEXED_DB_UNREAD_THICK_THREAD_IDS_ENCRYPTION_KEY_DB_LABEL]: encryptionKey, }, } = await localforage.getMultipleItems<{ unreadThickThreadIDs: ?EncryptedData, unreadThickThreadIDsEncryptionKey: ?(CryptoKey | SubtleCrypto$JsonWebKey), }>( [ INDEXED_DB_UNREAD_THICK_THREAD_IDS, INDEXED_DB_UNREAD_THICK_THREAD_IDS_ENCRYPTION_KEY_DB_LABEL, ], INDEXED_DB_UNREAD_THICK_THREADS_SYNC_KEY, ); if (!encryptionKey || !encryptedData) { return []; } const unreadThickThreadIDsEncryptionKey = await validateCryptoKey(encryptionKey); return await deserializeEncryptedData>( encryptedData, unreadThickThreadIDsEncryptionKey, ); } export { decryptWebNotification, decryptDesktopNotification, encryptNotification, getOlmDataKeyForCookie, getOlmEncryptionKeyDBLabelForCookie, getOlmDataKeyForDeviceID, getOlmEncryptionKeyDBLabelForDeviceID, migrateLegacyOlmNotificationsSessions, updateNotifsUnreadCountStorage, queryNotifsUnreadCountStorage, getNotifsCryptoAccount, persistEncryptionKey, retrieveEncryptionKey, persistNotifsAccountWithOlmData, updateNotifsUnreadThickThreadIDsStorage, getNotifsUnreadThickThreadIDs, };