diff --git a/keyserver/src/push/crypto.js b/keyserver/src/push/crypto.js index b51ad04d2..ab49cdd44 100644 --- a/keyserver/src/push/crypto.js +++ b/keyserver/src/push/crypto.js @@ -1,525 +1,574 @@ // @flow import type { EncryptResult } from '@commapp/olm'; import apn from '@parse/node-apn'; import crypto from 'crypto'; import invariant from 'invariant'; import _cloneDeep from 'lodash/fp/cloneDeep.js'; import type { PlainTextWebNotification, PlainTextWebNotificationPayload, WebNotification, PlainTextWNSNotification, - PlainTextWNSNotificationPayload, WNSNotification, AndroidVisualNotification, AndroidVisualNotificationPayload, AndroidBadgeOnlyNotification, AndroidNotificationRescind, NotificationTargetDevice, + SenderDeviceDescriptor, } from 'lib/types/notif-types.js'; import { toBase64URL } from 'lib/utils/base64.js'; import { encryptAndUpdateOlmSession } from '../updaters/olm-session-updater.js'; import { encrypt, generateKey } from '../utils/aes-crypto-utils.js'; import { getOlmUtility } from '../utils/olm-utils.js'; async function encryptAPNsNotification( cookieID: string, + senderDeviceID: SenderDeviceDescriptor, notification: apn.Notification, codeVersion?: ?number, notificationSizeValidator?: apn.Notification => boolean, blobHolder?: ?string, ): Promise<{ +notification: apn.Notification, +payloadSizeExceeded: boolean, +encryptedPayloadHash?: string, +encryptionOrder?: number, }> { invariant( !notification.collapseId, `Collapse ID can't be directly stored in apn.Notification object due ` + `to security reasons. Please put it in payload property`, ); const encryptedNotification = new apn.Notification(); encryptedNotification.id = notification.id; encryptedNotification.payload.id = notification.id; if (blobHolder) { encryptedNotification.payload.blobHolder = blobHolder; } encryptedNotification.payload.keyserverID = notification.payload.keyserverID; encryptedNotification.topic = notification.topic; encryptedNotification.sound = notification.aps.sound; encryptedNotification.pushType = 'alert'; encryptedNotification.mutableContent = true; - const { id, keyserverID, ...payloadSansUnencryptedData } = - notification.payload; + const { id, ...payloadSansUnencryptedData } = notification.payload; const unencryptedPayload = { ...payloadSansUnencryptedData, badge: notification.aps.badge.toString(), merged: notification.body, }; try { const unencryptedSerializedPayload = JSON.stringify(unencryptedPayload); let dbPersistCondition; if (notificationSizeValidator) { dbPersistCondition = ({ serializedPayload, }: { +[string]: EncryptResult, }) => { const notifCopy = _cloneDeep(encryptedNotification); notifCopy.payload.encryptedPayload = serializedPayload.body; return notificationSizeValidator(notifCopy); }; } const { encryptedMessages: { serializedPayload }, dbPersistConditionViolated, encryptionOrder, } = await encryptAndUpdateOlmSession( cookieID, 'notifications', { serializedPayload: unencryptedSerializedPayload, }, dbPersistCondition, ); encryptedNotification.payload.encryptedPayload = serializedPayload.body; + encryptedNotification.payload = { + ...senderDeviceID, + ...encryptedNotification.payload, + }; if (codeVersion && codeVersion >= 254 && codeVersion % 2 === 0) { encryptedNotification.aps = { alert: { body: 'ENCRYPTED' }, ...encryptedNotification.aps, }; } const encryptedPayloadHash = getOlmUtility().sha256(serializedPayload.body); return { notification: encryptedNotification, payloadSizeExceeded: !!dbPersistConditionViolated, encryptedPayloadHash, encryptionOrder, }; } catch (e) { console.log('Notification encryption failed: ' + e); encryptedNotification.body = notification.body; encryptedNotification.threadId = notification.payload.threadID; invariant( typeof notification.aps.badge === 'number', 'Unencrypted notification must have badge as a number', ); encryptedNotification.badge = notification.aps.badge; encryptedNotification.payload = { ...encryptedNotification.payload, ...notification.payload, encryptionFailed: 1, }; return { notification: encryptedNotification, payloadSizeExceeded: notificationSizeValidator ? notificationSizeValidator(_cloneDeep(encryptedNotification)) : false, }; } } async function encryptAndroidNotificationPayload( cookieID: string, + senderDeviceID: SenderDeviceDescriptor, unencryptedPayload: T, - payloadSizeValidator?: (T | { +encryptedPayload: string }) => boolean, + payloadSizeValidator?: ( + T | $ReadOnly<{ ...SenderDeviceDescriptor, +encryptedPayload: string }>, + ) => boolean, ): Promise<{ - +resultPayload: T | { +encryptedPayload: string }, + +resultPayload: + | T + | $ReadOnly<{ ...SenderDeviceDescriptor, +encryptedPayload: string }>, +payloadSizeExceeded: boolean, +encryptionOrder?: number, }> { try { const unencryptedSerializedPayload = JSON.stringify(unencryptedPayload); if (!unencryptedSerializedPayload) { return { resultPayload: unencryptedPayload, payloadSizeExceeded: payloadSizeValidator ? payloadSizeValidator(unencryptedPayload) : false, }; } let dbPersistCondition; if (payloadSizeValidator) { dbPersistCondition = ({ serializedPayload, }: { +[string]: EncryptResult, - }) => payloadSizeValidator({ encryptedPayload: serializedPayload.body }); + }) => + payloadSizeValidator({ + encryptedPayload: serializedPayload.body, + ...senderDeviceID, + }); } const { encryptedMessages: { serializedPayload }, dbPersistConditionViolated, encryptionOrder, } = await encryptAndUpdateOlmSession( cookieID, 'notifications', { serializedPayload: unencryptedSerializedPayload, }, dbPersistCondition, ); return { - resultPayload: { encryptedPayload: serializedPayload.body }, + resultPayload: { + encryptedPayload: serializedPayload.body, + ...senderDeviceID, + }, payloadSizeExceeded: !!dbPersistConditionViolated, encryptionOrder, }; } catch (e) { console.log('Notification encryption failed: ' + e); const resultPayload = { encryptionFailed: '1', ...unencryptedPayload, }; return { resultPayload, payloadSizeExceeded: payloadSizeValidator ? payloadSizeValidator(resultPayload) : false, }; } } async function encryptAndroidVisualNotification( + senderDeviceID: SenderDeviceDescriptor, cookieID: string, notification: AndroidVisualNotification, notificationSizeValidator?: AndroidVisualNotification => boolean, blobHolder?: ?string, ): Promise<{ +notification: AndroidVisualNotification, +payloadSizeExceeded: boolean, +encryptionOrder?: number, }> { - const { id, keyserverID, ...rest } = notification.data; + const { id, ...rest } = notification.data; - let unencryptedData = { keyserverID }; + let unencryptedData = {}; if (id) { - unencryptedData = { ...unencryptedData, id }; + unencryptedData = { id }; } let unencryptedPayload = rest; if (blobHolder) { unencryptedPayload = { ...unencryptedPayload, blobHolder }; } let payloadSizeValidator; if (notificationSizeValidator) { payloadSizeValidator = ( - payload: AndroidVisualNotificationPayload | { +encryptedPayload: string }, + payload: + | AndroidVisualNotificationPayload + | $ReadOnly<{ ...SenderDeviceDescriptor, +encryptedPayload: string }>, ) => { return notificationSizeValidator({ data: { ...unencryptedData, ...payload }, }); }; } const { resultPayload, payloadSizeExceeded, encryptionOrder } = await encryptAndroidNotificationPayload( cookieID, + senderDeviceID, unencryptedPayload, payloadSizeValidator, ); return { notification: { data: { ...unencryptedData, ...resultPayload, }, }, payloadSizeExceeded, encryptionOrder, }; } async function encryptAndroidSilentNotification( cookieID: string, + senderDeviceID: SenderDeviceDescriptor, notification: AndroidNotificationRescind | AndroidBadgeOnlyNotification, ): Promise { // We don't validate payload size for rescind // since they are expected to be small and // never exceed any FCM limit - const { keyserverID, ...unencryptedPayload } = notification.data; + const { ...unencryptedPayload } = notification.data; const { resultPayload } = await encryptAndroidNotificationPayload( cookieID, + senderDeviceID, unencryptedPayload, ); if (resultPayload.encryptedPayload) { return { - data: { keyserverID, ...resultPayload }, + data: { ...resultPayload }, }; } if (resultPayload.rescind) { return { - data: { keyserverID, ...resultPayload }, + data: { ...resultPayload }, }; } return { data: { - keyserverID, ...resultPayload, }, }; } async function encryptBasicPayload( cookieID: string, + senderDeviceID: SenderDeviceDescriptor, basicPayload: T, ): Promise< - | { +encryptedPayload: string, +encryptionOrder?: number } + | $ReadOnly<{ + ...SenderDeviceDescriptor, + +encryptedPayload: string, + +encryptionOrder?: number, + }> | { ...T, +encryptionFailed: '1' }, > { const unencryptedSerializedPayload = JSON.stringify(basicPayload); if (!unencryptedSerializedPayload) { return { ...basicPayload, encryptionFailed: '1' }; } try { const { encryptedMessages: { serializedPayload }, encryptionOrder, } = await encryptAndUpdateOlmSession(cookieID, 'notifications', { serializedPayload: unencryptedSerializedPayload, }); return { + ...senderDeviceID, encryptedPayload: serializedPayload.body, encryptionOrder, }; } catch (e) { console.log('Notification encryption failed: ' + e); return { ...basicPayload, encryptionFailed: '1', }; } } async function encryptWebNotification( cookieID: string, + senderDeviceID: SenderDeviceDescriptor, notification: PlainTextWebNotification, ): Promise<{ +notification: WebNotification, +encryptionOrder?: number }> { - const { id, keyserverID, ...payloadSansId } = notification; + const { id, ...payloadSansId } = notification; const { encryptionOrder, ...encryptionResult } = await encryptBasicPayload( cookieID, + senderDeviceID, payloadSansId, ); + return { - notification: { id, keyserverID, ...encryptionResult }, + notification: { id, ...encryptionResult }, encryptionOrder, }; } async function encryptWNSNotification( cookieID: string, + senderDeviceID: SenderDeviceDescriptor, notification: PlainTextWNSNotification, ): Promise<{ +notification: WNSNotification, +encryptionOrder?: number }> { - const { keyserverID, ...payloadSansKeyserverID } = notification; const { encryptionOrder, ...encryptionResult } = - await encryptBasicPayload( + await encryptBasicPayload( cookieID, - payloadSansKeyserverID, + senderDeviceID, + notification, ); return { - notification: { keyserverID, ...encryptionResult }, + notification: { ...encryptionResult }, encryptionOrder, }; } function prepareEncryptedAPNsNotifications( + senderDeviceID: SenderDeviceDescriptor, devices: $ReadOnlyArray, notification: apn.Notification, codeVersion?: ?number, notificationSizeValidator?: apn.Notification => boolean, ): Promise< $ReadOnlyArray<{ +cookieID: string, +deviceToken: string, +notification: apn.Notification, +payloadSizeExceeded: boolean, +encryptedPayloadHash?: string, +encryptionOrder?: number, }>, > { const notificationPromises = devices.map( async ({ cookieID, deviceToken, blobHolder }) => { const notif = await encryptAPNsNotification( cookieID, + senderDeviceID, notification, codeVersion, notificationSizeValidator, blobHolder, ); return { cookieID, deviceToken, ...notif }; }, ); return Promise.all(notificationPromises); } function prepareEncryptedIOSNotificationRescind( + senderDeviceID: SenderDeviceDescriptor, devices: $ReadOnlyArray, notification: apn.Notification, codeVersion?: ?number, ): Promise< $ReadOnlyArray<{ +cookieID: string, +deviceToken: string, +notification: apn.Notification, }>, > { const notificationPromises = devices.map( async ({ deviceToken, cookieID }) => { const { notification: notif } = await encryptAPNsNotification( cookieID, + senderDeviceID, notification, codeVersion, ); return { deviceToken, cookieID, notification: notif }; }, ); return Promise.all(notificationPromises); } function prepareEncryptedAndroidVisualNotifications( + senderDeviceID: SenderDeviceDescriptor, devices: $ReadOnlyArray, notification: AndroidVisualNotification, notificationSizeValidator?: ( notification: AndroidVisualNotification, ) => boolean, ): Promise< $ReadOnlyArray<{ +cookieID: string, +deviceToken: string, +notification: AndroidVisualNotification, +payloadSizeExceeded: boolean, +encryptionOrder?: number, }>, > { const notificationPromises = devices.map( async ({ deviceToken, cookieID, blobHolder }) => { const notif = await encryptAndroidVisualNotification( + senderDeviceID, cookieID, notification, notificationSizeValidator, blobHolder, ); return { deviceToken, cookieID, ...notif }; }, ); return Promise.all(notificationPromises); } function prepareEncryptedAndroidSilentNotifications( + senderDeviceID: SenderDeviceDescriptor, devices: $ReadOnlyArray, notification: AndroidNotificationRescind | AndroidBadgeOnlyNotification, ): Promise< $ReadOnlyArray<{ +cookieID: string, +deviceToken: string, +notification: AndroidNotificationRescind | AndroidBadgeOnlyNotification, +encryptionOrder?: number, }>, > { const notificationPromises = devices.map( async ({ deviceToken, cookieID }) => { const notif = await encryptAndroidSilentNotification( cookieID, + senderDeviceID, notification, ); return { deviceToken, cookieID, notification: notif }; }, ); return Promise.all(notificationPromises); } function prepareEncryptedWebNotifications( + senderDeviceID: SenderDeviceDescriptor, devices: $ReadOnlyArray, notification: PlainTextWebNotification, ): Promise< $ReadOnlyArray<{ +deviceToken: string, +notification: WebNotification, +encryptionOrder?: number, }>, > { const notificationPromises = devices.map( async ({ deviceToken, cookieID }) => { - const notif = await encryptWebNotification(cookieID, notification); + const notif = await encryptWebNotification( + cookieID, + senderDeviceID, + notification, + ); return { ...notif, deviceToken }; }, ); return Promise.all(notificationPromises); } function prepareEncryptedWNSNotifications( + senderDeviceID: SenderDeviceDescriptor, devices: $ReadOnlyArray, notification: PlainTextWNSNotification, ): Promise< $ReadOnlyArray<{ +deviceToken: string, +notification: WNSNotification, +encryptionOrder?: number, }>, > { const notificationPromises = devices.map( async ({ deviceToken, cookieID }) => { - const notif = await encryptWNSNotification(cookieID, notification); + const notif = await encryptWNSNotification( + cookieID, + senderDeviceID, + notification, + ); return { ...notif, deviceToken }; }, ); return Promise.all(notificationPromises); } async function encryptBlobPayload(payload: string): Promise<{ +encryptionKey: string, +encryptedPayload: Blob, +encryptedPayloadHash: string, }> { const encryptionKey = await generateKey(); const encryptedPayload = await encrypt( encryptionKey, new TextEncoder().encode(payload), ); const encryptedPayloadBuffer = Buffer.from(encryptedPayload); const blobHashBase64 = await crypto .createHash('sha256') .update(encryptedPayloadBuffer) .digest('base64'); const blobHash = toBase64URL(blobHashBase64); const payloadBlob = new Blob([encryptedPayloadBuffer]); const encryptionKeyString = Buffer.from(encryptionKey).toString('base64'); return { encryptionKey: encryptionKeyString, encryptedPayload: payloadBlob, encryptedPayloadHash: blobHash, }; } export { prepareEncryptedAPNsNotifications, prepareEncryptedIOSNotificationRescind, prepareEncryptedAndroidVisualNotifications, prepareEncryptedAndroidSilentNotifications, prepareEncryptedWebNotifications, prepareEncryptedWNSNotifications, encryptBlobPayload, }; diff --git a/keyserver/src/push/rescind.js b/keyserver/src/push/rescind.js index bbe281437..32b335499 100644 --- a/keyserver/src/push/rescind.js +++ b/keyserver/src/push/rescind.js @@ -1,393 +1,398 @@ // @flow import apn from '@parse/node-apn'; import type { ResponseFailure } from '@parse/node-apn'; import type { FirebaseError } from 'firebase-admin'; import invariant from 'invariant'; import type { PlatformDetails } from 'lib/types/device-types.js'; import type { NotificationTargetDevice, TargetedAndroidNotification, + SenderDeviceDescriptor, } from 'lib/types/notif-types.js'; import { threadSubscriptions } from 'lib/types/subscription-types.js'; import { threadPermissions } from 'lib/types/thread-permission-types.js'; import { promiseAll } from 'lib/utils/promises.js'; import { tID } from 'lib/utils/validation-utils.js'; import { prepareEncryptedAndroidSilentNotifications, prepareEncryptedIOSNotificationRescind, } from './crypto.js'; import { getAPNsNotificationTopic } from './providers.js'; import type { TargetedAPNsNotification } from './types.js'; import { apnPush, fcmPush, type APNPushResult, type FCMPushResult, } from './utils.js'; import createIDs from '../creators/id-creator.js'; import { dbQuery, SQL } from '../database/database.js'; import type { SQLStatementType } from '../database/types.js'; import { thisKeyserverID } from '../user/identity.js'; import { validateOutput } from '../utils/validation-utils.js'; type ParsedDelivery = { +platform: 'ios' | 'macos' | 'android', +codeVersion: ?number, +stateVersion: ?number, +notificationID: string, +deviceTokens: $ReadOnlyArray, }; type RescindDelivery = { source: 'rescind', rescindedID: string, errors?: | $ReadOnlyArray | $ReadOnlyArray, }; async function rescindPushNotifs( notifCondition: SQLStatementType, inputCountCondition?: SQLStatementType, ) { const notificationExtractString = `$.${threadSubscriptions.home}`; const visPermissionExtractString = `$.${threadPermissions.VISIBLE}.value`; const fetchQuery = SQL` SELECT n.id, n.user, n.thread, n.message, n.delivery, n.collapse_key, COUNT( `; fetchQuery.append(inputCountCondition ? inputCountCondition : SQL`m.thread`); fetchQuery.append(SQL` ) AS unread_count FROM notifications n LEFT JOIN memberships m ON m.user = n.user AND m.last_message > m.last_read_message AND m.role > 0 AND JSON_EXTRACT(subscription, ${notificationExtractString}) AND JSON_EXTRACT(permissions, ${visPermissionExtractString}) WHERE n.rescinded = 0 AND `); fetchQuery.append(notifCondition); fetchQuery.append(SQL` GROUP BY n.id, m.user`); const [[fetchResult], keyserverID] = await Promise.all([ dbQuery(fetchQuery), thisKeyserverID(), ]); const allDeviceTokens = new Set(); const parsedDeliveries: { [string]: $ReadOnlyArray } = {}; for (const row of fetchResult) { const rawDelivery = JSON.parse(row.delivery); const deliveries = Array.isArray(rawDelivery) ? rawDelivery : [rawDelivery]; const id = row.id.toString(); const rowParsedDeliveries = []; for (const delivery of deliveries) { if ( delivery.iosID || delivery.deviceType === 'ios' || delivery.deviceType === 'macos' ) { const deviceTokens = delivery.iosDeviceTokens ?? delivery.deviceTokens; rowParsedDeliveries.push({ notificationID: delivery.iosID, codeVersion: delivery.codeVersion, stateVersion: delivery.stateVersion, platform: delivery.deviceType ?? 'ios', deviceTokens, }); deviceTokens.forEach(deviceToken => allDeviceTokens.add(deviceToken)); } else if (delivery.androidID || delivery.deviceType === 'android') { const deviceTokens = delivery.androidDeviceTokens ?? delivery.deviceTokens; rowParsedDeliveries.push({ notificationID: row.collapse_key ? row.collapse_key : id, codeVersion: delivery.codeVersion, stateVersion: delivery.stateVersion, platform: 'android', deviceTokens, }); deviceTokens.forEach(deviceToken => allDeviceTokens.add(deviceToken)); } } parsedDeliveries[id] = rowParsedDeliveries; } const deviceTokenToCookieID = await getDeviceTokenToCookieID(allDeviceTokens); const deliveryPromises: { [string]: Promise | Promise, } = {}; const notifInfo = {}; const rescindedIDs = []; for (const row of fetchResult) { const id = row.id.toString(); const threadID = row.thread.toString(); notifInfo[id] = { userID: row.user.toString(), threadID, messageID: row.message.toString(), }; for (const delivery of parsedDeliveries[id]) { let platformDetails: PlatformDetails = { platform: delivery.platform }; if (delivery.codeVersion) { platformDetails = { ...platformDetails, codeVersion: delivery.codeVersion, }; } if (delivery.stateVersion) { platformDetails = { ...platformDetails, stateVersion: delivery.stateVersion, }; } if (delivery.platform === 'ios') { const devices = delivery.deviceTokens.map(deviceToken => ({ deviceToken, cookieID: deviceTokenToCookieID[deviceToken], })); const deliveryPromise = (async () => { const targetedNotifications = await prepareIOSNotification( keyserverID, delivery.notificationID, row.unread_count, threadID, platformDetails, devices, ); return await apnPush({ targetedNotifications, platformDetails: { platform: 'ios', codeVersion: delivery.codeVersion, }, }); })(); deliveryPromises[id] = deliveryPromise; } else if (delivery.platform === 'android') { const devices = delivery.deviceTokens.map(deviceToken => ({ deviceToken, cookieID: deviceTokenToCookieID[deviceToken], })); const deliveryPromise = (async () => { const targetedNotifications = await prepareAndroidNotification( keyserverID, delivery.notificationID, row.unread_count, threadID, platformDetails, devices, ); return await fcmPush({ targetedNotifications, codeVersion: delivery.codeVersion, }); })(); deliveryPromises[id] = deliveryPromise; } } rescindedIDs.push(id); } const numRescinds = Object.keys(deliveryPromises).length; const dbIDsPromise: Promise> = (async () => { if (numRescinds === 0) { return undefined; } return await createIDs('notifications', numRescinds); })(); const rescindPromise: Promise = (async () => { if (rescindedIDs.length === 0) { return undefined; } const rescindQuery = SQL` UPDATE notifications SET rescinded = 1 WHERE id IN (${rescindedIDs}) `; return await dbQuery(rescindQuery); })(); const [deliveryResults, dbIDs] = await Promise.all([ promiseAll(deliveryPromises), dbIDsPromise, rescindPromise, ]); const newNotifRows = []; if (numRescinds > 0) { invariant(dbIDs, 'dbIDs should be set'); for (const rescindedID in deliveryResults) { const delivery: RescindDelivery = { source: 'rescind', rescindedID, }; const { errors } = deliveryResults[rescindedID]; if (errors) { delivery.errors = errors; } const dbID = dbIDs.shift(); const { userID, threadID, messageID } = notifInfo[rescindedID]; newNotifRows.push([ dbID, userID, threadID, messageID, null, JSON.stringify([delivery]), 1, ]); } } if (newNotifRows.length > 0) { const insertQuery = SQL` INSERT INTO notifications (id, user, thread, message, collapse_key, delivery, rescinded) VALUES ${newNotifRows} `; await dbQuery(insertQuery); } } async function getDeviceTokenToCookieID( deviceTokens: Set, ): Promise<{ +[string]: string }> { if (deviceTokens.size === 0) { return {}; } const deviceTokenToCookieID = {}; const fetchCookiesQuery = SQL` SELECT id, device_token FROM cookies WHERE device_token IN (${[...deviceTokens]}) `; const [fetchResult] = await dbQuery(fetchCookiesQuery); for (const row of fetchResult) { deviceTokenToCookieID[row.device_token.toString()] = row.id.toString(); } return deviceTokenToCookieID; } async function conditionallyEncryptNotification( + senderDeviceID: SenderDeviceDescriptor, notification: T, codeVersion: ?number, devices: $ReadOnlyArray, encryptCallback: ( + senderDeviceID: SenderDeviceDescriptor, devices: $ReadOnlyArray, notification: T, codeVersion?: ?number, ) => Promise< $ReadOnlyArray<{ +notification: T, +cookieID: string, +deviceToken: string, +encryptionOrder?: number, }>, >, ): Promise<$ReadOnlyArray<{ +deviceToken: string, +notification: T }>> { const shouldBeEncrypted = codeVersion && codeVersion >= 233; if (!shouldBeEncrypted) { return devices.map(({ deviceToken }) => ({ notification, deviceToken, })); } const notifications = await encryptCallback( + senderDeviceID, devices, notification, codeVersion, ); return notifications.map(({ deviceToken, notification: notif }) => ({ deviceToken, notification: notif, })); } async function prepareIOSNotification( keyserverID: string, iosID: string, unreadCount: number, threadID: string, platformDetails: PlatformDetails, devices: $ReadOnlyArray, ): Promise<$ReadOnlyArray> { threadID = await validateOutput(platformDetails, tID, threadID); const { codeVersion } = platformDetails; const notification = new apn.Notification(); notification.topic = getAPNsNotificationTopic({ platform: 'ios', codeVersion, }); if (codeVersion && codeVersion > 198) { notification.mutableContent = true; notification.pushType = 'alert'; notification.badge = unreadCount; } else { notification.priority = 5; notification.contentAvailable = true; notification.pushType = 'background'; } notification.payload = codeVersion && codeVersion > 135 ? { backgroundNotifType: 'CLEAR', notificationId: iosID, setUnreadStatus: true, threadID, keyserverID, } : { managedAps: { action: 'CLEAR', notificationId: iosID, }, }; return await conditionallyEncryptNotification( + { keyserverID }, notification, codeVersion, devices, prepareEncryptedIOSNotificationRescind, ); } async function prepareAndroidNotification( keyserverID: string, notifID: string, unreadCount: number, threadID: string, platformDetails: PlatformDetails, devices: $ReadOnlyArray, ): Promise<$ReadOnlyArray> { threadID = await validateOutput(platformDetails, tID, threadID); const { codeVersion } = platformDetails; const notification = { data: { badge: unreadCount.toString(), rescind: 'true', rescindID: notifID, setUnreadStatus: 'true', threadID, - keyserverID, }, }; const targetedRescinds = await conditionallyEncryptNotification( + { keyserverID }, notification, codeVersion, devices, prepareEncryptedAndroidSilentNotifications, ); return targetedRescinds.map(targetedRescind => ({ ...targetedRescind, priority: 'normal', })); } export { rescindPushNotifs }; diff --git a/keyserver/src/push/send.js b/keyserver/src/push/send.js index 784a36d26..04401948c 100644 --- a/keyserver/src/push/send.js +++ b/keyserver/src/push/send.js @@ -1,1949 +1,1963 @@ // @flow import type { ResponseFailure } from '@parse/node-apn'; import apn from '@parse/node-apn'; import invariant from 'invariant'; import _cloneDeep from 'lodash/fp/cloneDeep.js'; import _flow from 'lodash/fp/flow.js'; import _groupBy from 'lodash/fp/groupBy.js'; import _mapValues from 'lodash/fp/mapValues.js'; import _pickBy from 'lodash/fp/pickBy.js'; import type { QueryResults } from 'mysql'; import t from 'tcomb'; import uuidv4 from 'uuid/v4.js'; import { oldValidUsernameRegex } from 'lib/shared/account-utils.js'; import { isUserMentioned } from 'lib/shared/mention-utils.js'; import { createMessageInfo, shimUnsupportedRawMessageInfos, sortMessageInfoList, } from 'lib/shared/message-utils.js'; import { messageSpecs } from 'lib/shared/messages/message-specs.js'; import { notifTextsForMessageInfo } from 'lib/shared/notif-utils.js'; import { rawThreadInfoFromServerThreadInfo, threadInfoFromRawThreadInfo, } from 'lib/shared/thread-utils.js'; import { hasMinCodeVersion } from 'lib/shared/version-utils.js'; import type { Platform, PlatformDetails } from 'lib/types/device-types.js'; import { messageTypes } from 'lib/types/message-types-enum.js'; import { type MessageData, type RawMessageInfo, rawMessageInfoValidator, } from 'lib/types/message-types.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import type { AndroidVisualNotification, NotificationTargetDevice, TargetedAndroidNotification, TargetedWebNotification, TargetedWNSNotification, ResolvedNotifTexts, } from 'lib/types/notif-types.js'; import { resolvedNotifTextsValidator } from 'lib/types/notif-types.js'; import type { ServerThreadInfo } from 'lib/types/thread-types.js'; import { updateTypes } from 'lib/types/update-types-enum.js'; import { type GlobalUserInfo } from 'lib/types/user-types.js'; import { values } from 'lib/utils/objects.js'; import { tID, tPlatformDetails, tShape } from 'lib/utils/validation-utils.js'; import { prepareEncryptedAndroidVisualNotifications, prepareEncryptedAndroidSilentNotifications, prepareEncryptedAPNsNotifications, prepareEncryptedWebNotifications, prepareEncryptedWNSNotifications, } from './crypto.js'; import { getAPNsNotificationTopic } from './providers.js'; import { rescindPushNotifs } from './rescind.js'; import type { TargetedAPNsNotification } from './types.js'; import { apnMaxNotificationPayloadByteSize, apnPush, fcmMaxNotificationPayloadByteSize, fcmPush, getUnreadCounts, webPush, type WebPushError, wnsMaxNotificationPayloadByteSize, wnsPush, type WNSPushError, blobServiceUpload, } from './utils.js'; import createIDs from '../creators/id-creator.js'; import { createUpdates } from '../creators/update-creator.js'; import { dbQuery, mergeOrConditions, SQL } from '../database/database.js'; import type { CollapsableNotifInfo } from '../fetchers/message-fetchers.js'; import { fetchCollapsableNotifs } from '../fetchers/message-fetchers.js'; import { fetchServerThreadInfos } from '../fetchers/thread-fetchers.js'; import { fetchUserInfos } from '../fetchers/user-fetchers.js'; import type { Viewer } from '../session/viewer.js'; import { thisKeyserverID } from '../user/identity.js'; import { getENSNames } from '../utils/ens-cache.js'; import { getFCNames } from '../utils/fc-cache.js'; import { validateOutput } from '../utils/validation-utils.js'; export type Device = { +platform: Platform, +deviceToken: string, +cookieID: string, +codeVersion: ?number, +stateVersion: ?number, +majorDesktopVersion: ?number, }; export type PushUserInfo = { +devices: Device[], // messageInfos and messageDatas have the same key +messageInfos: RawMessageInfo[], +messageDatas: MessageData[], }; type Delivery = PushDelivery | { collapsedInto: string }; type NotificationRow = { +dbID: string, +userID: string, +threadID?: ?string, +messageID?: ?string, +collapseKey?: ?string, +deliveries: Delivery[], }; export type PushInfo = { [userID: string]: PushUserInfo }; async function sendPushNotifs(pushInfo: PushInfo) { if (Object.keys(pushInfo).length === 0) { return; } const keyserverID = await thisKeyserverID(); const [ unreadCounts, { usersToCollapsableNotifInfo, serverThreadInfos, userInfos }, dbIDs, ] = await Promise.all([ getUnreadCounts(Object.keys(pushInfo)), fetchInfos(pushInfo), createDBIDs(pushInfo), ]); const preparePromises: Array>> = []; const notifications: Map = new Map(); for (const userID in usersToCollapsableNotifInfo) { const threadInfos = _flow( _mapValues((serverThreadInfo: ServerThreadInfo) => { const rawThreadInfo = rawThreadInfoFromServerThreadInfo( serverThreadInfo, userID, { minimallyEncodePermissions: true }, ); if (!rawThreadInfo) { return null; } invariant( rawThreadInfo.minimallyEncoded, 'rawThreadInfo from rawThreadInfoFromServerThreadInfo must be ' + 'minimallyEncoded when minimallyEncodePermissions option is set', ); return threadInfoFromRawThreadInfo(rawThreadInfo, userID, userInfos); }), _pickBy(threadInfo => threadInfo), )(serverThreadInfos); for (const notifInfo of usersToCollapsableNotifInfo[userID]) { preparePromises.push( preparePushNotif({ keyserverID, notifInfo, userID, pushUserInfo: pushInfo[userID], unreadCount: unreadCounts[userID], threadInfos, userInfos, dbIDs, rowsToSave: notifications, }), ); } } const prepareResults = await Promise.all(preparePromises); const flattenedPrepareResults = prepareResults.filter(Boolean).flat(); const deliveryResults = await deliverPushNotifsInEncryptionOrder( flattenedPrepareResults, ); const cleanUpPromise = (async () => { if (dbIDs.length === 0) { return; } const query = SQL`DELETE FROM ids WHERE id IN (${dbIDs})`; await dbQuery(query); })(); await Promise.all([ cleanUpPromise, saveNotifResults(deliveryResults, notifications, true), ]); } type PreparePushResult = { +platform: Platform, +notificationInfo: NotificationInfo, +notification: | TargetedAPNsNotification | TargetedAndroidNotification | TargetedWebNotification | TargetedWNSNotification, }; async function preparePushNotif(input: { keyserverID: string, notifInfo: CollapsableNotifInfo, userID: string, pushUserInfo: PushUserInfo, unreadCount: number, threadInfos: { +[threadID: string]: ThreadInfo, }, userInfos: { +[userID: string]: GlobalUserInfo }, dbIDs: string[], // mutable rowsToSave: Map, // mutable }): Promise> { const { keyserverID, notifInfo, userID, pushUserInfo, unreadCount, threadInfos, userInfos, dbIDs, rowsToSave, } = input; const hydrateMessageInfo = (rawMessageInfo: RawMessageInfo) => createMessageInfo(rawMessageInfo, userID, userInfos, threadInfos); const newMessageInfos = []; const newRawMessageInfos = []; for (const newRawMessageInfo of notifInfo.newMessageInfos) { const newMessageInfo = hydrateMessageInfo(newRawMessageInfo); if (newMessageInfo) { newMessageInfos.push(newMessageInfo); newRawMessageInfos.push(newRawMessageInfo); } } if (newMessageInfos.length === 0) { return null; } const existingMessageInfos = notifInfo.existingMessageInfos .map(hydrateMessageInfo) .filter(Boolean); const allMessageInfos = sortMessageInfoList([ ...newMessageInfos, ...existingMessageInfos, ]); const [firstNewMessageInfo, ...remainingNewMessageInfos] = newMessageInfos; const { threadID } = firstNewMessageInfo; const threadInfo = threadInfos[threadID]; const parentThreadInfo = threadInfo.parentThreadID ? threadInfos[threadInfo.parentThreadID] : null; const updateBadge = threadInfo.currentUser.subscription.home; const displayBanner = threadInfo.currentUser.subscription.pushNotifs; const username = userInfos[userID] && userInfos[userID].username; let resolvedUsername; if (getENSNames) { const userInfosWithENSNames = await getENSNames([userInfos[userID]]); resolvedUsername = userInfosWithENSNames[0].username; } const userWasMentioned = username && threadInfo.currentUser.role && oldValidUsernameRegex.test(username) && newMessageInfos.some(newMessageInfo => { const unwrappedMessageInfo = newMessageInfo.type === messageTypes.SIDEBAR_SOURCE ? newMessageInfo.sourceMessage : newMessageInfo; return ( unwrappedMessageInfo.type === messageTypes.TEXT && (isUserMentioned(username, unwrappedMessageInfo.text) || (resolvedUsername && isUserMentioned(resolvedUsername, unwrappedMessageInfo.text))) ); }); if (!updateBadge && !displayBanner && !userWasMentioned) { return null; } const badgeOnly = !displayBanner && !userWasMentioned; const notifTargetUserInfo = { id: userID, username }; const notifTexts = await notifTextsForMessageInfo( allMessageInfos, threadInfo, parentThreadInfo, notifTargetUserInfo, getENSNames, getFCNames, ); if (!notifTexts) { return null; } const dbID = dbIDs.shift(); invariant(dbID, 'should have sufficient DB IDs'); const byPlatform = getDevicesByPlatform(pushUserInfo.devices); const firstMessageID = firstNewMessageInfo.id; invariant(firstMessageID, 'RawMessageInfo.id should be set on server'); const notificationInfo = { source: 'new_message', dbID, userID, threadID, messageID: firstMessageID, collapseKey: notifInfo.collapseKey, }; const preparePromises: Array>> = []; const iosVersionsToTokens = byPlatform.get('ios'); if (iosVersionsToTokens) { for (const [versionKey, devices] of iosVersionsToTokens) { const { codeVersion, stateVersion } = stringToVersionKey(versionKey); const platformDetails: PlatformDetails = { platform: 'ios', codeVersion, stateVersion, }; const shimmedNewRawMessageInfos = shimUnsupportedRawMessageInfos( newRawMessageInfos, platformDetails, ); const preparePromise: Promise<$ReadOnlyArray> = (async () => { const targetedNotifications = await prepareAPNsNotification( { keyserverID, notifTexts, newRawMessageInfos: shimmedNewRawMessageInfos, threadID: threadInfo.id, collapseKey: notifInfo.collapseKey, badgeOnly, unreadCount, platformDetails, }, devices, ); return targetedNotifications.map(notification => ({ notification, platform: 'ios', notificationInfo: { ...notificationInfo, codeVersion, stateVersion, }, })); })(); preparePromises.push(preparePromise); } } const androidVersionsToTokens = byPlatform.get('android'); if (androidVersionsToTokens) { for (const [versionKey, devices] of androidVersionsToTokens) { const { codeVersion, stateVersion } = stringToVersionKey(versionKey); const platformDetails = { platform: 'android', codeVersion, stateVersion, }; const shimmedNewRawMessageInfos = shimUnsupportedRawMessageInfos( newRawMessageInfos, platformDetails, ); const preparePromise: Promise<$ReadOnlyArray> = (async () => { const targetedNotifications = await prepareAndroidVisualNotification( { keyserverID, notifTexts, newRawMessageInfos: shimmedNewRawMessageInfos, threadID: threadInfo.id, collapseKey: notifInfo.collapseKey, badgeOnly, unreadCount, platformDetails, dbID, }, devices, ); return targetedNotifications.map(notification => ({ notification, platform: 'android', notificationInfo: { ...notificationInfo, codeVersion, stateVersion, }, })); })(); preparePromises.push(preparePromise); } } const webVersionsToTokens = byPlatform.get('web'); if (webVersionsToTokens) { for (const [versionKey, devices] of webVersionsToTokens) { const { codeVersion, stateVersion } = stringToVersionKey(versionKey); const platformDetails = { platform: 'web', codeVersion, stateVersion, }; const preparePromise: Promise<$ReadOnlyArray> = (async () => { const targetedNotifications = await prepareWebNotification( { notifTexts, threadID: threadInfo.id, keyserverID, unreadCount, platformDetails, }, devices, ); return targetedNotifications.map(notification => ({ notification, platform: 'web', notificationInfo: { ...notificationInfo, codeVersion, stateVersion, }, })); })(); preparePromises.push(preparePromise); } } const macosVersionsToTokens = byPlatform.get('macos'); if (macosVersionsToTokens) { for (const [versionKey, devices] of macosVersionsToTokens) { const { codeVersion, stateVersion, majorDesktopVersion } = stringToVersionKey(versionKey); const platformDetails = { platform: 'macos', codeVersion, stateVersion, majorDesktopVersion, }; const shimmedNewRawMessageInfos = shimUnsupportedRawMessageInfos( newRawMessageInfos, platformDetails, ); const preparePromise: Promise<$ReadOnlyArray> = (async () => { const targetedNotifications = await prepareAPNsNotification( { keyserverID, notifTexts, newRawMessageInfos: shimmedNewRawMessageInfos, threadID: threadInfo.id, collapseKey: notifInfo.collapseKey, badgeOnly, unreadCount, platformDetails, }, devices, ); return targetedNotifications.map(notification => ({ notification, platform: 'macos', notificationInfo: { ...notificationInfo, codeVersion, stateVersion, }, })); })(); preparePromises.push(preparePromise); } } const windowsVersionsToTokens = byPlatform.get('windows'); if (windowsVersionsToTokens) { for (const [versionKey, devices] of windowsVersionsToTokens) { const { codeVersion, stateVersion, majorDesktopVersion } = stringToVersionKey(versionKey); const platformDetails = { platform: 'windows', codeVersion, stateVersion, majorDesktopVersion, }; const preparePromise: Promise<$ReadOnlyArray> = (async () => { const targetedNotifications = await prepareWNSNotification(devices, { notifTexts, threadID: threadInfo.id, keyserverID, unreadCount, platformDetails, }); return targetedNotifications.map(notification => ({ notification, platform: 'windows', notificationInfo: { ...notificationInfo, codeVersion, stateVersion, }, })); })(); preparePromises.push(preparePromise); } } for (const newMessageInfo of remainingNewMessageInfos) { const newDBID = dbIDs.shift(); invariant(newDBID, 'should have sufficient DB IDs'); const messageID = newMessageInfo.id; invariant(messageID, 'RawMessageInfo.id should be set on server'); rowsToSave.set(newDBID, { dbID: newDBID, userID, threadID: newMessageInfo.threadID, messageID, collapseKey: notifInfo.collapseKey, deliveries: [{ collapsedInto: dbID }], }); } const prepareResults = await Promise.all(preparePromises); return prepareResults.flat(); } // For better readability we don't differentiate between // encrypted and unencrypted notifs and order them together function compareEncryptionOrder( pushNotif1: PreparePushResult, pushNotif2: PreparePushResult, ): number { const order1 = pushNotif1.notification.encryptionOrder ?? 0; const order2 = pushNotif2.notification.encryptionOrder ?? 0; return order1 - order2; } async function deliverPushNotifsInEncryptionOrder( preparedPushNotifs: $ReadOnlyArray, ): Promise<$ReadOnlyArray> { const deliveryPromises: Array>> = []; const groupedByDevice = _groupBy( preparedPushNotif => preparedPushNotif.deviceToken, )(preparedPushNotifs); for (const preparedPushNotifsForDevice of values(groupedByDevice)) { const orderedPushNotifsForDevice = preparedPushNotifsForDevice.sort( compareEncryptionOrder, ); const deviceDeliveryPromise = (async () => { const deliveries = []; for (const preparedPushNotif of orderedPushNotifsForDevice) { const { platform, notification, notificationInfo } = preparedPushNotif; let delivery: PushResult; if (platform === 'ios' || platform === 'macos') { delivery = await sendAPNsNotification( platform, [notification], notificationInfo, ); } else if (platform === 'android') { delivery = await sendAndroidNotification( [notification], notificationInfo, ); } else if (platform === 'web') { delivery = await sendWebNotifications( [notification], notificationInfo, ); } else if (platform === 'windows') { delivery = await sendWNSNotification( [notification], notificationInfo, ); } if (delivery) { deliveries.push(delivery); } } return deliveries; })(); deliveryPromises.push(deviceDeliveryPromise); } const deliveryResults = await Promise.all(deliveryPromises); return deliveryResults.flat(); } async function sendRescindNotifs(rescindInfo: PushInfo) { if (Object.keys(rescindInfo).length === 0) { return; } const usersToCollapsableNotifInfo = await fetchCollapsableNotifs(rescindInfo); const promises = []; for (const userID in usersToCollapsableNotifInfo) { for (const notifInfo of usersToCollapsableNotifInfo[userID]) { for (const existingMessageInfo of notifInfo.existingMessageInfos) { const rescindCondition = SQL` n.user = ${userID} AND n.thread = ${existingMessageInfo.threadID} AND n.message = ${existingMessageInfo.id} `; promises.push(rescindPushNotifs(rescindCondition)); } } } await Promise.all(promises); } // The results in deliveryResults will be combined with the rows // in rowsToSave and then written to the notifications table async function saveNotifResults( deliveryResults: $ReadOnlyArray, inputRowsToSave: Map, rescindable: boolean, ) { const rowsToSave = new Map(inputRowsToSave); const allInvalidTokens = []; for (const deliveryResult of deliveryResults) { const { info, delivery, invalidTokens } = deliveryResult; const { dbID, userID } = info; const curNotifRow = rowsToSave.get(dbID); if (curNotifRow) { curNotifRow.deliveries.push(delivery); } else { // Ternary expressions for Flow const threadID = info.threadID ? info.threadID : null; const messageID = info.messageID ? info.messageID : null; const collapseKey = info.collapseKey ? info.collapseKey : null; rowsToSave.set(dbID, { dbID, userID, threadID, messageID, collapseKey, deliveries: [delivery], }); } if (invalidTokens) { allInvalidTokens.push({ userID, tokens: invalidTokens, }); } } const notificationRows = []; for (const notification of rowsToSave.values()) { notificationRows.push([ notification.dbID, notification.userID, notification.threadID, notification.messageID, notification.collapseKey, JSON.stringify(notification.deliveries), Number(!rescindable), ]); } const dbPromises: Array> = []; if (allInvalidTokens.length > 0) { dbPromises.push(removeInvalidTokens(allInvalidTokens)); } if (notificationRows.length > 0) { const query = SQL` INSERT INTO notifications (id, user, thread, message, collapse_key, delivery, rescinded) VALUES ${notificationRows} `; dbPromises.push(dbQuery(query)); } if (dbPromises.length > 0) { await Promise.all(dbPromises); } } async function fetchInfos(pushInfo: PushInfo) { const usersToCollapsableNotifInfo = await fetchCollapsableNotifs(pushInfo); const threadIDs = new Set(); const threadWithChangedNamesToMessages = new Map>(); const addThreadIDsFromMessageInfos = (rawMessageInfo: RawMessageInfo) => { const threadID = rawMessageInfo.threadID; threadIDs.add(threadID); const messageSpec = messageSpecs[rawMessageInfo.type]; if (messageSpec.threadIDs) { for (const id of messageSpec.threadIDs(rawMessageInfo)) { threadIDs.add(id); } } if ( rawMessageInfo.type === messageTypes.CHANGE_SETTINGS && rawMessageInfo.field === 'name' ) { const messages = threadWithChangedNamesToMessages.get(threadID); if (messages) { messages.push(rawMessageInfo.id); } else { threadWithChangedNamesToMessages.set(threadID, [rawMessageInfo.id]); } } }; for (const userID in usersToCollapsableNotifInfo) { for (const notifInfo of usersToCollapsableNotifInfo[userID]) { for (const rawMessageInfo of notifInfo.existingMessageInfos) { addThreadIDsFromMessageInfos(rawMessageInfo); } for (const rawMessageInfo of notifInfo.newMessageInfos) { addThreadIDsFromMessageInfos(rawMessageInfo); } } } // These threadInfos won't have currentUser set const threadPromise = fetchServerThreadInfos({ threadIDs }); const oldNamesPromise: Promise = (async () => { if (threadWithChangedNamesToMessages.size === 0) { return undefined; } const typesThatAffectName = [ messageTypes.CHANGE_SETTINGS, messageTypes.CREATE_THREAD, ]; const oldNameQuery = SQL` SELECT IF( JSON_TYPE(JSON_EXTRACT(m.content, "$.name")) = 'NULL', "", JSON_UNQUOTE(JSON_EXTRACT(m.content, "$.name")) ) AS name, m.thread FROM ( SELECT MAX(id) AS id FROM messages WHERE type IN (${typesThatAffectName}) AND JSON_EXTRACT(content, "$.name") IS NOT NULL AND`; const threadClauses = []; for (const [threadID, messages] of threadWithChangedNamesToMessages) { threadClauses.push( SQL`(thread = ${threadID} AND id NOT IN (${messages}))`, ); } oldNameQuery.append(mergeOrConditions(threadClauses)); oldNameQuery.append(SQL` GROUP BY thread ) x LEFT JOIN messages m ON m.id = x.id `); return await dbQuery(oldNameQuery); })(); const [threadResult, oldNames] = await Promise.all([ threadPromise, oldNamesPromise, ]); const serverThreadInfos = { ...threadResult.threadInfos }; if (oldNames) { const [result] = oldNames; for (const row of result) { const threadID = row.thread.toString(); serverThreadInfos[threadID] = { ...serverThreadInfos[threadID], name: row.name, }; } } const userInfos = await fetchNotifUserInfos( serverThreadInfos, usersToCollapsableNotifInfo, ); return { usersToCollapsableNotifInfo, serverThreadInfos, userInfos }; } async function fetchNotifUserInfos( serverThreadInfos: { +[threadID: string]: ServerThreadInfo }, usersToCollapsableNotifInfo: { +[userID: string]: CollapsableNotifInfo[] }, ) { const missingUserIDs = new Set(); for (const threadID in serverThreadInfos) { const serverThreadInfo = serverThreadInfos[threadID]; for (const member of serverThreadInfo.members) { missingUserIDs.add(member.id); } } const addUserIDsFromMessageInfos = (rawMessageInfo: RawMessageInfo) => { missingUserIDs.add(rawMessageInfo.creatorID); const userIDs = messageSpecs[rawMessageInfo.type].userIDs?.(rawMessageInfo) ?? []; for (const userID of userIDs) { missingUserIDs.add(userID); } }; for (const userID in usersToCollapsableNotifInfo) { missingUserIDs.add(userID); for (const notifInfo of usersToCollapsableNotifInfo[userID]) { for (const rawMessageInfo of notifInfo.existingMessageInfos) { addUserIDsFromMessageInfos(rawMessageInfo); } for (const rawMessageInfo of notifInfo.newMessageInfos) { addUserIDsFromMessageInfos(rawMessageInfo); } } } return await fetchUserInfos([...missingUserIDs]); } async function createDBIDs(pushInfo: PushInfo): Promise { let numIDsNeeded = 0; for (const userID in pushInfo) { numIDsNeeded += pushInfo[userID].messageInfos.length; } return await createIDs('notifications', numIDsNeeded); } type VersionKey = { +codeVersion: number, +stateVersion: number, +majorDesktopVersion?: number, }; const versionKeyRegex: RegExp = new RegExp(/^-?\d+\|-?\d+(\|-?\d+)?$/); function versionKeyToString(versionKey: VersionKey): string { const baseStringVersionKey = `${versionKey.codeVersion}|${versionKey.stateVersion}`; if (!versionKey.majorDesktopVersion) { return baseStringVersionKey; } return `${baseStringVersionKey}|${versionKey.majorDesktopVersion}`; } function stringToVersionKey(versionKeyString: string): VersionKey { invariant( versionKeyRegex.test(versionKeyString), 'should pass correct version key string', ); const [codeVersion, stateVersion, majorDesktopVersion] = versionKeyString .split('|') .map(Number); return { codeVersion, stateVersion, majorDesktopVersion }; } function getDevicesByPlatform( devices: $ReadOnlyArray, ): Map>> { const byPlatform = new Map< Platform, Map>, >(); for (const device of devices) { let innerMap = byPlatform.get(device.platform); if (!innerMap) { innerMap = new Map>(); byPlatform.set(device.platform, innerMap); } const codeVersion: number = device.codeVersion !== null && device.codeVersion !== undefined ? device.codeVersion : -1; const stateVersion: number = device.stateVersion ?? -1; let versionsObject = { codeVersion, stateVersion }; if (device.majorDesktopVersion) { versionsObject = { ...versionsObject, majorDesktopVersion: device.majorDesktopVersion, }; } const versionKey = versionKeyToString(versionsObject); let innerMostArrayTmp: ?Array = innerMap.get(versionKey); if (!innerMostArrayTmp) { innerMostArrayTmp = []; innerMap.set(versionKey, innerMostArrayTmp); } const innerMostArray = innerMostArrayTmp; innerMostArray.push({ cookieID: device.cookieID, deviceToken: device.deviceToken, }); } return byPlatform; } type CommonNativeNotifInputData = { +keyserverID: string, +notifTexts: ResolvedNotifTexts, +newRawMessageInfos: RawMessageInfo[], +threadID: string, +collapseKey: ?string, +badgeOnly: boolean, +unreadCount: number, +platformDetails: PlatformDetails, }; const commonNativeNotifInputDataValidator = tShape({ keyserverID: t.String, notifTexts: resolvedNotifTextsValidator, newRawMessageInfos: t.list(rawMessageInfoValidator), threadID: tID, collapseKey: t.maybe(t.String), badgeOnly: t.Boolean, unreadCount: t.Number, platformDetails: tPlatformDetails, }); async function prepareAPNsNotification( inputData: CommonNativeNotifInputData, devices: $ReadOnlyArray, ): Promise<$ReadOnlyArray> { const convertedData = await validateOutput( inputData.platformDetails, commonNativeNotifInputDataValidator, inputData, ); const { keyserverID, notifTexts, newRawMessageInfos, threadID, collapseKey, badgeOnly, unreadCount, platformDetails, } = convertedData; const canDecryptNonCollapsibleTextIOSNotifs = platformDetails.codeVersion && platformDetails.codeVersion > 222; const isNonCollapsibleTextNotification = newRawMessageInfos.every( newRawMessageInfo => newRawMessageInfo.type === messageTypes.TEXT, ) && !collapseKey; const canDecryptAllIOSNotifs = platformDetails.codeVersion && platformDetails.codeVersion >= 267; const canDecryptIOSNotif = platformDetails.platform === 'ios' && (canDecryptAllIOSNotifs || (isNonCollapsibleTextNotification && canDecryptNonCollapsibleTextIOSNotifs)); const canDecryptMacOSNotifs = platformDetails.platform === 'macos' && hasMinCodeVersion(platformDetails, { web: 47, majorDesktop: 9, }); const shouldBeEncrypted = canDecryptIOSNotif || canDecryptMacOSNotifs; const uniqueID = uuidv4(); const notification = new apn.Notification(); notification.topic = getAPNsNotificationTopic(platformDetails); const { merged, ...rest } = notifTexts; // We don't include alert's body on macos because we // handle displaying the notification ourselves and // we don't want macOS to display it automatically. if (!badgeOnly && platformDetails.platform !== 'macos') { notification.body = merged; notification.sound = 'default'; } notification.payload = { ...notification.payload, ...rest, }; notification.badge = unreadCount; notification.threadId = threadID; notification.id = uniqueID; notification.pushType = 'alert'; notification.payload.id = uniqueID; notification.payload.threadID = threadID; - notification.payload.keyserverID = keyserverID; if (platformDetails.codeVersion && platformDetails.codeVersion > 198) { notification.mutableContent = true; } if (collapseKey && (canDecryptAllIOSNotifs || canDecryptMacOSNotifs)) { notification.payload.collapseID = collapseKey; } else if (collapseKey) { notification.collapseId = collapseKey; } const messageInfos = JSON.stringify(newRawMessageInfos); // We make a copy before checking notification's length, because calling // length compiles the notification and makes it immutable. Further // changes to its properties won't be reflected in the final plaintext // data that is sent. const copyWithMessageInfos = _cloneDeep(notification); copyWithMessageInfos.payload = { ...copyWithMessageInfos.payload, messageInfos, }; const notificationSizeValidator = (notif: apn.Notification) => notif.length() <= apnMaxNotificationPayloadByteSize; if (!shouldBeEncrypted) { const notificationToSend = notificationSizeValidator( _cloneDeep(copyWithMessageInfos), ) ? copyWithMessageInfos : notification; return devices.map(({ deviceToken }) => ({ notification: notificationToSend, deviceToken, })); } // The `messageInfos` field in notification payload is // not used on MacOS so we can return early. if (platformDetails.platform === 'macos') { const macOSNotifsWithoutMessageInfos = await prepareEncryptedAPNsNotifications( + { keyserverID }, devices, notification, platformDetails.codeVersion, ); return macOSNotifsWithoutMessageInfos.map( ({ notification: notif, deviceToken }) => ({ notification: notif, deviceToken, }), ); } const notifsWithMessageInfos = await prepareEncryptedAPNsNotifications( + { keyserverID }, devices, copyWithMessageInfos, platformDetails.codeVersion, notificationSizeValidator, ); const devicesWithExcessiveSizeNoHolders = notifsWithMessageInfos .filter(({ payloadSizeExceeded }) => payloadSizeExceeded) - .map(({ deviceToken, cookieID }) => ({ deviceToken, cookieID })); + .map(({ deviceToken, cookieID }) => ({ + deviceToken, + cookieID, + })); if (devicesWithExcessiveSizeNoHolders.length === 0) { return notifsWithMessageInfos.map( ({ notification: notif, deviceToken, encryptedPayloadHash, encryptionOrder, }) => ({ notification: notif, deviceToken, encryptedPayloadHash, encryptionOrder, }), ); } const canQueryBlobService = hasMinCodeVersion(platformDetails, { native: 331, }); let blobHash, blobHolders, encryptionKey, blobUploadError; if (canQueryBlobService) { ({ blobHash, blobHolders, encryptionKey, blobUploadError } = await blobServiceUpload( copyWithMessageInfos.compile(), devicesWithExcessiveSizeNoHolders.length, )); } if (blobUploadError) { console.warn( `Failed to upload payload of notification: ${uniqueID} ` + `due to error: ${blobUploadError}`, ); } let devicesWithExcessiveSize = devicesWithExcessiveSizeNoHolders; if ( blobHash && encryptionKey && blobHolders && blobHolders.length === devicesWithExcessiveSize.length ) { notification.payload = { ...notification.payload, blobHash, encryptionKey, }; devicesWithExcessiveSize = blobHolders.map((holder, idx) => ({ ...devicesWithExcessiveSize[idx], blobHolder: holder, })); } const notifsWithoutMessageInfos = await prepareEncryptedAPNsNotifications( + { keyserverID }, devicesWithExcessiveSize, notification, platformDetails.codeVersion, ); const targetedNotifsWithMessageInfos = notifsWithMessageInfos .filter(({ payloadSizeExceeded }) => !payloadSizeExceeded) .map( ({ notification: notif, deviceToken, encryptedPayloadHash, encryptionOrder, }) => ({ notification: notif, deviceToken, encryptedPayloadHash, encryptionOrder, }), ); const targetedNotifsWithoutMessageInfos = notifsWithoutMessageInfos.map( ({ notification: notif, deviceToken, encryptedPayloadHash, encryptionOrder, }) => ({ notification: notif, deviceToken, encryptedPayloadHash, encryptionOrder, }), ); return [ ...targetedNotifsWithMessageInfos, ...targetedNotifsWithoutMessageInfos, ]; } type AndroidNotifInputData = { ...CommonNativeNotifInputData, +dbID: string, }; const androidNotifInputDataValidator = tShape({ ...commonNativeNotifInputDataValidator.meta.props, dbID: t.String, }); async function prepareAndroidVisualNotification( inputData: AndroidNotifInputData, devices: $ReadOnlyArray, ): Promise<$ReadOnlyArray> { const convertedData = await validateOutput( inputData.platformDetails, androidNotifInputDataValidator, inputData, ); const { keyserverID, notifTexts, newRawMessageInfos, threadID, collapseKey, badgeOnly, unreadCount, platformDetails, dbID, } = convertedData; const canDecryptNonCollapsibleTextNotifs = hasMinCodeVersion( platformDetails, { native: 228 }, ); const isNonCollapsibleTextNotif = newRawMessageInfos.every( newRawMessageInfo => newRawMessageInfo.type === messageTypes.TEXT, ) && !collapseKey; const canDecryptAllNotifTypes = hasMinCodeVersion(platformDetails, { native: 267, }); const shouldBeEncrypted = canDecryptAllNotifTypes || (canDecryptNonCollapsibleTextNotifs && isNonCollapsibleTextNotif); const { merged, ...rest } = notifTexts; const notification = { data: { - keyserverID, badge: unreadCount.toString(), ...rest, threadID, }, }; let notifID; if (collapseKey && canDecryptAllNotifTypes) { notifID = dbID; notification.data = { ...notification.data, collapseKey, }; } else if (collapseKey) { notifID = collapseKey; } else { notifID = dbID; } notification.data = { ...notification.data, id: notifID, badgeOnly: badgeOnly ? '1' : '0', }; const messageInfos = JSON.stringify(newRawMessageInfos); const copyWithMessageInfos = { ...notification, data: { ...notification.data, messageInfos }, }; const priority = 'high'; if (!shouldBeEncrypted) { const notificationToSend = Buffer.byteLength(JSON.stringify(copyWithMessageInfos)) <= fcmMaxNotificationPayloadByteSize ? copyWithMessageInfos : notification; return devices.map(({ deviceToken }) => ({ priority, notification: notificationToSend, deviceToken, })); } const notificationsSizeValidator = (notif: AndroidVisualNotification) => { const serializedNotif = JSON.stringify(notif); return ( !serializedNotif || Buffer.byteLength(serializedNotif) <= fcmMaxNotificationPayloadByteSize ); }; const notifsWithMessageInfos = await prepareEncryptedAndroidVisualNotifications( + { keyserverID }, devices, copyWithMessageInfos, notificationsSizeValidator, ); const devicesWithExcessiveSizeNoHolders = notifsWithMessageInfos .filter(({ payloadSizeExceeded }) => payloadSizeExceeded) .map(({ cookieID, deviceToken }) => ({ cookieID, deviceToken })); if (devicesWithExcessiveSizeNoHolders.length === 0) { return notifsWithMessageInfos.map( ({ notification: notif, deviceToken, encryptionOrder }) => ({ priority, notification: notif, deviceToken, encryptionOrder, }), ); } const canQueryBlobService = hasMinCodeVersion(platformDetails, { native: 331, }); let blobHash, blobHolders, encryptionKey, blobUploadError; if (canQueryBlobService) { ({ blobHash, blobHolders, encryptionKey, blobUploadError } = await blobServiceUpload( JSON.stringify(copyWithMessageInfos.data), devicesWithExcessiveSizeNoHolders.length, )); } if (blobUploadError) { console.warn( `Failed to upload payload of notification: ${notifID} ` + `due to error: ${blobUploadError}`, ); } let devicesWithExcessiveSize = devicesWithExcessiveSizeNoHolders; if ( blobHash && encryptionKey && blobHolders && blobHolders.length === devicesWithExcessiveSizeNoHolders.length ) { notification.data = { ...notification.data, blobHash, encryptionKey, }; devicesWithExcessiveSize = blobHolders.map((holder, idx) => ({ ...devicesWithExcessiveSize[idx], blobHolder: holder, })); } const notifsWithoutMessageInfos = await prepareEncryptedAndroidVisualNotifications( + { keyserverID }, devicesWithExcessiveSize, notification, ); const targetedNotifsWithMessageInfos = notifsWithMessageInfos .filter(({ payloadSizeExceeded }) => !payloadSizeExceeded) .map(({ notification: notif, deviceToken, encryptionOrder }) => ({ priority, notification: notif, deviceToken, encryptionOrder, })); const targetedNotifsWithoutMessageInfos = notifsWithoutMessageInfos.map( ({ notification: notif, deviceToken, encryptionOrder }) => ({ priority, notification: notif, deviceToken, encryptionOrder, }), ); return [ ...targetedNotifsWithMessageInfos, ...targetedNotifsWithoutMessageInfos, ]; } type WebNotifInputData = { +notifTexts: ResolvedNotifTexts, +threadID: string, +keyserverID: string, +unreadCount: number, +platformDetails: PlatformDetails, }; const webNotifInputDataValidator = tShape({ notifTexts: resolvedNotifTextsValidator, threadID: tID, keyserverID: t.String, unreadCount: t.Number, platformDetails: tPlatformDetails, }); async function prepareWebNotification( inputData: WebNotifInputData, devices: $ReadOnlyArray, ): Promise<$ReadOnlyArray> { const convertedData = await validateOutput( inputData.platformDetails, webNotifInputDataValidator, inputData, ); const { notifTexts, threadID, unreadCount, keyserverID } = convertedData; const id = uuidv4(); const { merged, ...rest } = notifTexts; const notification = { ...rest, unreadCount, id, threadID, - keyserverID, }; const shouldBeEncrypted = hasMinCodeVersion(convertedData.platformDetails, { web: 43, }); if (!shouldBeEncrypted) { return devices.map(({ deviceToken }) => ({ deviceToken, notification })); } - return prepareEncryptedWebNotifications(devices, notification); + return prepareEncryptedWebNotifications( + { keyserverID }, + devices, + notification, + ); } type WNSNotifInputData = { +notifTexts: ResolvedNotifTexts, +threadID: string, +keyserverID: string, +unreadCount: number, +platformDetails: PlatformDetails, }; const wnsNotifInputDataValidator = tShape({ notifTexts: resolvedNotifTextsValidator, threadID: tID, keyserverID: t.String, unreadCount: t.Number, platformDetails: tPlatformDetails, }); async function prepareWNSNotification( devices: $ReadOnlyArray, inputData: WNSNotifInputData, ): Promise<$ReadOnlyArray> { const convertedData = await validateOutput( inputData.platformDetails, wnsNotifInputDataValidator, inputData, ); const { notifTexts, threadID, unreadCount, keyserverID } = convertedData; const { merged, ...rest } = notifTexts; const notification = { ...rest, unreadCount, threadID, - keyserverID, }; if ( Buffer.byteLength(JSON.stringify(notification)) > wnsMaxNotificationPayloadByteSize ) { console.warn('WNS notification exceeds size limit'); } const shouldBeEncrypted = hasMinCodeVersion(inputData.platformDetails, { majorDesktop: 10, }); if (!shouldBeEncrypted) { return devices.map(({ deviceToken }) => ({ deviceToken, notification, })); } - return await prepareEncryptedWNSNotifications(devices, notification); + return await prepareEncryptedWNSNotifications( + { keyserverID }, + devices, + notification, + ); } type NotificationInfo = | { +source: 'new_message', +dbID: string, +userID: string, +threadID: string, +messageID: string, +collapseKey: ?string, +codeVersion: number, +stateVersion: number, } | { +source: 'mark_as_unread' | 'mark_as_read' | 'activity_update', +dbID: string, +userID: string, +codeVersion: number, +stateVersion: number, }; type APNsDelivery = { +source: $PropertyType, +deviceType: 'ios' | 'macos', +iosID: string, +deviceTokens: $ReadOnlyArray, +codeVersion: number, +stateVersion: number, +errors?: $ReadOnlyArray, +encryptedPayloadHashes?: $ReadOnlyArray, +deviceTokensToPayloadHash?: { +[deviceToken: string]: string, }, }; type APNsResult = { info: NotificationInfo, delivery: APNsDelivery, invalidTokens?: $ReadOnlyArray, }; async function sendAPNsNotification( platform: 'ios' | 'macos', targetedNotifications: $ReadOnlyArray, notificationInfo: NotificationInfo, ): Promise { const { source, codeVersion, stateVersion } = notificationInfo; const response = await apnPush({ targetedNotifications, platformDetails: { platform, codeVersion }, }); invariant( new Set(targetedNotifications.map(({ notification }) => notification.id)) .size === 1, 'Encrypted versions of the same notification must share id value', ); const iosID = targetedNotifications[0].notification.id; const deviceTokens = targetedNotifications.map( ({ deviceToken }) => deviceToken, ); let delivery: APNsDelivery = { source, deviceType: platform, iosID, deviceTokens, codeVersion, stateVersion, }; if (response.errors) { delivery = { ...delivery, errors: response.errors, }; } const deviceTokensToPayloadHash: { [string]: string } = {}; for (const targetedNotification of targetedNotifications) { if (targetedNotification.encryptedPayloadHash) { deviceTokensToPayloadHash[targetedNotification.deviceToken] = targetedNotification.encryptedPayloadHash; } } if (Object.keys(deviceTokensToPayloadHash).length !== 0) { delivery = { ...delivery, deviceTokensToPayloadHash, }; } const result: APNsResult = { info: notificationInfo, delivery, }; if (response.invalidTokens) { result.invalidTokens = response.invalidTokens; } return result; } type PushResult = AndroidResult | APNsResult | WebResult | WNSResult; type PushDelivery = AndroidDelivery | APNsDelivery | WebDelivery | WNSDelivery; type AndroidDelivery = { source: $PropertyType, deviceType: 'android', androidIDs: $ReadOnlyArray, deviceTokens: $ReadOnlyArray, codeVersion: number, stateVersion: number, errors?: $ReadOnlyArray, }; type AndroidResult = { info: NotificationInfo, delivery: AndroidDelivery, invalidTokens?: $ReadOnlyArray, }; async function sendAndroidNotification( targetedNotifications: $ReadOnlyArray, notificationInfo: NotificationInfo, ): Promise { const collapseKey = notificationInfo.collapseKey ? notificationInfo.collapseKey : null; // for Flow... const { source, codeVersion, stateVersion } = notificationInfo; const response = await fcmPush({ targetedNotifications, collapseKey, codeVersion, }); const deviceTokens = targetedNotifications.map( ({ deviceToken }) => deviceToken, ); const androidIDs = response.fcmIDs ? response.fcmIDs : []; const delivery: AndroidDelivery = { source, deviceType: 'android', androidIDs, deviceTokens, codeVersion, stateVersion, }; if (response.errors) { delivery.errors = response.errors; } const result: AndroidResult = { info: notificationInfo, delivery, }; if (response.invalidTokens) { result.invalidTokens = response.invalidTokens; } return result; } type WebDelivery = { +source: $PropertyType, +deviceType: 'web', +deviceTokens: $ReadOnlyArray, +codeVersion?: number, +stateVersion: number, +errors?: $ReadOnlyArray, }; type WebResult = { +info: NotificationInfo, +delivery: WebDelivery, +invalidTokens?: $ReadOnlyArray, }; async function sendWebNotifications( targetedNotifications: $ReadOnlyArray, notificationInfo: NotificationInfo, ): Promise { const { source, codeVersion, stateVersion } = notificationInfo; const response = await webPush(targetedNotifications); const deviceTokens = targetedNotifications.map( ({ deviceToken }) => deviceToken, ); const delivery: WebDelivery = { source, deviceType: 'web', deviceTokens, codeVersion, errors: response.errors, stateVersion, }; const result: WebResult = { info: notificationInfo, delivery, invalidTokens: response.invalidTokens, }; return result; } type WNSDelivery = { +source: $PropertyType, +deviceType: 'windows', +wnsIDs: $ReadOnlyArray, +deviceTokens: $ReadOnlyArray, +codeVersion?: number, +stateVersion: number, +errors?: $ReadOnlyArray, }; type WNSResult = { +info: NotificationInfo, +delivery: WNSDelivery, +invalidTokens?: $ReadOnlyArray, }; async function sendWNSNotification( targetedNotifications: $ReadOnlyArray, notificationInfo: NotificationInfo, ): Promise { const { source, codeVersion, stateVersion } = notificationInfo; const response = await wnsPush(targetedNotifications); const deviceTokens = targetedNotifications.map( ({ deviceToken }) => deviceToken, ); const wnsIDs = response.wnsIDs ?? []; const delivery: WNSDelivery = { source, deviceType: 'windows', wnsIDs, deviceTokens, codeVersion, errors: response.errors, stateVersion, }; const result: WNSResult = { info: notificationInfo, delivery, invalidTokens: response.invalidTokens, }; return result; } type InvalidToken = { +userID: string, +tokens: $ReadOnlyArray, }; async function removeInvalidTokens( invalidTokens: $ReadOnlyArray, ): Promise { const sqlTuples = invalidTokens.map( invalidTokenUser => SQL`( user = ${invalidTokenUser.userID} AND device_token IN (${invalidTokenUser.tokens}) )`, ); const sqlCondition = mergeOrConditions(sqlTuples); const selectQuery = SQL` SELECT id, user, device_token FROM cookies WHERE `; selectQuery.append(sqlCondition); const [result] = await dbQuery(selectQuery); const userCookiePairsToInvalidDeviceTokens = new Map>(); for (const row of result) { const userCookiePair = `${row.user}|${row.id}`; const existing = userCookiePairsToInvalidDeviceTokens.get(userCookiePair); if (existing) { existing.add(row.device_token); } else { userCookiePairsToInvalidDeviceTokens.set( userCookiePair, new Set([row.device_token]), ); } } const time = Date.now(); const promises: Array> = []; for (const entry of userCookiePairsToInvalidDeviceTokens) { const [userCookiePair, deviceTokens] = entry; const [userID, cookieID] = userCookiePair.split('|'); const updateDatas = [...deviceTokens].map(deviceToken => ({ type: updateTypes.BAD_DEVICE_TOKEN, userID, time, deviceToken, targetCookie: cookieID, })); promises.push(createUpdates(updateDatas)); } const updateQuery = SQL` UPDATE cookies SET device_token = NULL WHERE `; updateQuery.append(sqlCondition); promises.push(dbQuery(updateQuery)); await Promise.all(promises); } async function updateBadgeCount( viewer: Viewer, source: 'mark_as_unread' | 'mark_as_read' | 'activity_update', ) { const { userID } = viewer; const deviceTokenQuery = SQL` SELECT platform, device_token, versions, id FROM cookies WHERE user = ${userID} AND device_token IS NOT NULL `; if (viewer.data.cookieID) { deviceTokenQuery.append(SQL`AND id != ${viewer.cookieID} `); } const [unreadCounts, [deviceTokenResult], [dbID], keyserverID] = await Promise.all([ getUnreadCounts([userID]), dbQuery(deviceTokenQuery), createIDs('notifications', 1), thisKeyserverID(), ]); const unreadCount = unreadCounts[userID]; const devices = deviceTokenResult.map(row => { const versions = JSON.parse(row.versions); return { platform: row.platform, cookieID: row.id, deviceToken: row.device_token, codeVersion: versions?.codeVersion, stateVersion: versions?.stateVersion, }; }); const byPlatform = getDevicesByPlatform(devices); const preparePromises: Array>> = []; const iosVersionsToTokens = byPlatform.get('ios'); if (iosVersionsToTokens) { for (const [versionKey, deviceInfos] of iosVersionsToTokens) { const { codeVersion, stateVersion } = stringToVersionKey(versionKey); const notification = new apn.Notification(); notification.topic = getAPNsNotificationTopic({ platform: 'ios', codeVersion, stateVersion, }); notification.badge = unreadCount; notification.pushType = 'alert'; - notification.payload.keyserverID = keyserverID; const preparePromise: Promise = (async () => { let targetedNotifications: $ReadOnlyArray; if (codeVersion > 222) { const notificationsArray = await prepareEncryptedAPNsNotifications( + { keyserverID }, deviceInfos, notification, codeVersion, ); targetedNotifications = notificationsArray.map( ({ notification: notif, deviceToken, encryptionOrder }) => ({ notification: notif, deviceToken, encryptionOrder, }), ); } else { targetedNotifications = deviceInfos.map(({ deviceToken }) => ({ notification, deviceToken, })); } return targetedNotifications.map(targetedNotification => ({ notification: targetedNotification, platform: 'ios', notificationInfo: { source, dbID, userID, codeVersion, stateVersion, }, })); })(); preparePromises.push(preparePromise); } } const androidVersionsToTokens = byPlatform.get('android'); if (androidVersionsToTokens) { for (const [versionKey, deviceInfos] of androidVersionsToTokens) { const { codeVersion, stateVersion } = stringToVersionKey(versionKey); const notificationData = { badge: unreadCount.toString(), badgeOnly: '1', }; const notification = { - data: { ...notificationData, keyserverID }, + data: { ...notificationData }, }; const preparePromise: Promise = (async () => { let targetedNotifications: $ReadOnlyArray; const priority = 'normal'; if (codeVersion > 222) { const notificationsArray = await prepareEncryptedAndroidSilentNotifications( + { keyserverID }, deviceInfos, notification, ); targetedNotifications = notificationsArray.map( ({ notification: notif, deviceToken, encryptionOrder }) => ({ priority, notification: notif, deviceToken, encryptionOrder, }), ); } else { targetedNotifications = deviceInfos.map(({ deviceToken }) => ({ priority, deviceToken, notification, })); } return targetedNotifications.map(targetedNotification => ({ notification: targetedNotification, platform: 'android', notificationInfo: { source, dbID, userID, codeVersion, stateVersion, }, })); })(); preparePromises.push(preparePromise); } } const macosVersionsToTokens = byPlatform.get('macos'); if (macosVersionsToTokens) { for (const [versionKey, deviceInfos] of macosVersionsToTokens) { const { codeVersion, stateVersion, majorDesktopVersion } = stringToVersionKey(versionKey); const notification = new apn.Notification(); notification.topic = getAPNsNotificationTopic({ platform: 'macos', codeVersion, stateVersion, majorDesktopVersion, }); notification.badge = unreadCount; notification.pushType = 'alert'; notification.payload.keyserverID = keyserverID; const preparePromise: Promise = (async () => { const shouldBeEncrypted = hasMinCodeVersion(viewer.platformDetails, { web: 47, majorDesktop: 9, }); let targetedNotifications: $ReadOnlyArray; if (shouldBeEncrypted) { const notificationsArray = await prepareEncryptedAPNsNotifications( + { keyserverID }, deviceInfos, notification, codeVersion, ); targetedNotifications = notificationsArray.map( ({ notification: notif, deviceToken, encryptionOrder }) => ({ notification: notif, deviceToken, encryptionOrder, }), ); } else { targetedNotifications = deviceInfos.map(({ deviceToken }) => ({ deviceToken, notification, })); } return targetedNotifications.map(targetedNotification => ({ notification: targetedNotification, platform: 'macos', notificationInfo: { source, dbID, userID, codeVersion, stateVersion, }, })); })(); preparePromises.push(preparePromise); } } const prepareResults = await Promise.all(preparePromises); const flattenedPrepareResults = prepareResults.filter(Boolean).flat(); const deliveryResults = await deliverPushNotifsInEncryptionOrder( flattenedPrepareResults, ); await saveNotifResults(deliveryResults, new Map(), false); } export { sendPushNotifs, sendRescindNotifs, updateBadgeCount }; diff --git a/lib/types/notif-types.js b/lib/types/notif-types.js index 91073cc02..d6cb707d3 100644 --- a/lib/types/notif-types.js +++ b/lib/types/notif-types.js @@ -1,179 +1,187 @@ // @flow import t, { type TInterface } from 'tcomb'; import type { EntityText, ThreadEntity } from '../utils/entity-text.js'; import { tShape } from '../utils/validation-utils.js'; export type NotifTexts = { +merged: string | EntityText, +body: string | EntityText, +title: string | ThreadEntity, +prefix?: string | EntityText, }; export type ResolvedNotifTexts = { +merged: string, +body: string, +title: string, +prefix?: string, }; export const resolvedNotifTextsValidator: TInterface = tShape({ merged: t.String, body: t.String, title: t.String, prefix: t.maybe(t.String), }); +export type SenderDeviceDescriptor = + | { +keyserverID: string } + | { +senderDeviceID: string }; + export type PlainTextWebNotificationPayload = { +body: string, +prefix?: string, +title: string, +unreadCount: number, +threadID: string, +encryptionFailed?: '1', }; -export type PlainTextWebNotification = { +export type PlainTextWebNotification = $ReadOnly<{ +id: string, - +keyserverID: string, ...PlainTextWebNotificationPayload, -}; +}>; -export type EncryptedWebNotification = { +export type EncryptedWebNotification = $ReadOnly<{ + ...SenderDeviceDescriptor, +id: string, - +keyserverID: string, +encryptedPayload: string, -}; + +type?: '0' | '1', +}>; export type WebNotification = | PlainTextWebNotification | EncryptedWebNotification; -export type PlainTextWNSNotificationPayload = { +export type PlainTextWNSNotification = { +body: string, +prefix?: string, +title: string, +unreadCount: number, +threadID: string, +encryptionFailed?: '1', }; -export type PlainTextWNSNotification = { - +keyserverID: string, - ...PlainTextWNSNotificationPayload, -}; - -export type EncryptedWNSNotification = { - +keyserverID: string, +export type EncryptedWNSNotification = $ReadOnly<{ + ...SenderDeviceDescriptor, +encryptedPayload: string, -}; + +type?: '0' | '1', +}>; export type WNSNotification = | PlainTextWNSNotification | EncryptedWNSNotification; export type AndroidVisualNotificationPayloadBase = $ReadOnly<{ +badge: string, +body: string, +title: string, +prefix?: string, +threadID: string, +collapseKey?: string, +badgeOnly?: '0', +encryptionFailed?: '1', }>; -export type AndroidVisualNotificationPayload = $ReadOnly< - | { - ...AndroidVisualNotificationPayloadBase, - +messageInfos?: string, - } - | { - ...AndroidVisualNotificationPayloadBase, - +blobHash: string, - +encryptionKey: string, - }, ->; +type AndroidSmallVisualNotificationPayload = $ReadOnly<{ + ...AndroidVisualNotificationPayloadBase, + +messageInfos?: string, +}>; + +type AndroidLargeVisualNotificationPayload = $ReadOnly<{ + ...AndroidVisualNotificationPayloadBase, + +blobHash: string, + +encryptionKey: string, +}>; + +export type AndroidVisualNotificationPayload = + | AndroidSmallVisualNotificationPayload + | AndroidLargeVisualNotificationPayload; + +type EncryptedThinThreadPayload = { + +keyserverID: string, + +encryptedPayload: string, + +type?: '0' | '1', +}; + +type EncryptedThickThreadPayload = { + +senderDeviceID: string, + +encryptedPayload: string, + +type?: '0' | '1', +}; export type AndroidVisualNotification = { +data: $ReadOnly<{ +id?: string, - +keyserverID: string, ... - | { - ...AndroidVisualNotificationPayloadBase, - +messageInfos?: string, - } - | { - ...AndroidVisualNotificationPayloadBase, - +blobHash: string, - +encryptionKey: string, - } - | { +encryptedPayload: string }, + | AndroidSmallVisualNotificationPayload + | AndroidLargeVisualNotificationPayload + | EncryptedThinThreadPayload + | EncryptedThickThreadPayload, }>, }; export type AndroidNotificationRescind = { +data: $ReadOnly<{ - +keyserverID: string, ... | { +badge: string, +rescind: 'true', +rescindID: string, +setUnreadStatus: 'true', +threadID: string, +encryptionFailed?: string, } - | { +encryptedPayload: string }, + | EncryptedThinThreadPayload + | EncryptedThickThreadPayload, }>, }; export type AndroidBadgeOnlyNotification = { +data: $ReadOnly<{ - +keyserverID: string, ... | { +badge: string, +badgeOnly: '1', +encryptionFailed?: string, } - | { +encryptedPayload: string }, + | EncryptedThinThreadPayload + | EncryptedThickThreadPayload, }>, }; type AndroidNotificationWithPriority = | { +notification: AndroidVisualNotification, +priority: 'high', } | { +notification: AndroidBadgeOnlyNotification | AndroidNotificationRescind, +priority: 'normal', }; export type TargetedAndroidNotification = $ReadOnly<{ ...AndroidNotificationWithPriority, +deviceToken: string, +encryptionOrder?: number, }>; export type TargetedWebNotification = { +notification: WebNotification, +deviceToken: string, +encryptionOrder?: number, }; export type TargetedWNSNotification = { +notification: WNSNotification, +deviceToken: string, +encryptionOrder?: number, }; export type NotificationTargetDevice = { +cookieID: string, +deviceToken: string, +blobHolder?: string, }; diff --git a/web/push-notif/notif-crypto-utils.js b/web/push-notif/notif-crypto-utils.js index b642eca7c..dbe2f7070 100644 --- a/web/push-notif/notif-crypto-utils.js +++ b/web/push-notif/notif-crypto-utils.js @@ -1,544 +1,548 @@ // @flow import olm from '@commapp/olm'; +import invariant from 'invariant'; import localforage from 'localforage'; import { olmEncryptedMessageTypes, type NotificationsOlmDataType, } from 'lib/types/crypto-types.js'; import type { PlainTextWebNotification, EncryptedWebNotification, } from 'lib/types/notif-types.js'; import { getCookieIDFromCookie } from 'lib/utils/cookie-utils.js'; import { type EncryptedData, decryptData, encryptData, importJWKKey, } from '../crypto/aes-gcm-crypto-utils.js'; import { initOlm } from '../olm/olm-utils.js'; import { NOTIFICATIONS_OLM_DATA_CONTENT, NOTIFICATIONS_OLM_DATA_ENCRYPTION_KEY, } from '../shared-worker/utils/constants.js'; import { isDesktopSafari } from '../shared-worker/utils/db-utils.js'; export type WebNotifDecryptionError = { +id: string, +error: string, +displayErrorMessage?: boolean, }; export type WebNotifsServiceUtilsData = { +olmWasmPath: string, +staffCanSee: boolean, }; type DecryptionResult = { +newPendingSessionUpdate: string, +newUpdateCreationTimestamp: number, +decryptedNotification: T, }; export const WEB_NOTIFS_SERVICE_UTILS_KEY = 'webNotifsServiceUtils'; const SESSION_UPDATE_MAX_PENDING_TIME = 10 * 1000; const INDEXED_DB_KEYSERVER_PREFIX = 'keyserver'; const INDEXED_DB_KEY_SEPARATOR = ':'; // This constant is only used to migrate the existing notifications // session with production keyserver to new IndexedDB key format. This // migration will fire when user updates the app. It will also fire // on dev env provided old keyserver set up is used. Developers willing // to use new keyserver set up must log out before updating the app. // Do not introduce new usages of this constant in the code!!! const ASHOAT_KEYSERVER_ID_USED_ONLY_FOR_MIGRATION_FROM_LEGACY_NOTIF_STORAGE = '256'; const INDEXED_DB_UNREAD_COUNT_SUFFIX = 'unreadCount'; 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 { olmDataContentKey, encryptionKeyDBKey } = olmDBKeys; const [encryptedOlmData, encryptionKey] = await Promise.all([ localforage.getItem(olmDataContentKey), retrieveEncryptionKey(encryptionKeyDBKey), ]); if (!encryptionKey || !encryptedOlmData) { return { id, error: 'Received encrypted notification but olm session was not created', displayErrorMessage: staffCanSee, }; } try { await olm.init({ locateFile: () => olmWasmPath }); const decryptedNotification = await commonDecrypt( encryptedOlmData, olmDataContentKey, encryptionKey, encryptedPayload, ); 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, olmDataContentKey; try { const { olmDataContentKey: olmDataContentKeyValue, encryptionKeyDBKey } = await getNotifsOlmSessionDBKeys(keyserverID); olmDataContentKey = olmDataContentKeyValue; [encryptedOlmData, encryptionKey] = await Promise.all([ localforage.getItem(olmDataContentKey), retrieveEncryptionKey(encryptionKeyDBKey), initOlm(), ]); } catch (e) { return { error: e.message, displayErrorMessage: staffCanSee, }; } if (!encryptionKey || !encryptedOlmData) { return { error: 'Received encrypted notification but olm session was not created', displayErrorMessage: staffCanSee, }; } let decryptedNotification; try { decryptedNotification = await commonDecrypt<{ +[string]: mixed }>( encryptedOlmData, olmDataContentKey, 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, olmDataContentKey: string, encryptionKey: CryptoKey, encryptedPayload: string, ): Promise { const serializedOlmData = await decryptData(encryptedOlmData, encryptionKey); const { mainSession, picklingKey, pendingSessionUpdate, updateCreationTimestamp, }: NotificationsOlmDataType = JSON.parse( new TextDecoder().decode(serializedOlmData), ); let updatedOlmData: NotificationsOlmDataType; let decryptedNotification: T; const shouldUpdateMainSession = Date.now() - updateCreationTimestamp > SESSION_UPDATE_MAX_PENDING_TIME; const decryptionWithPendingSessionResult = decryptWithPendingSession( pendingSessionUpdate, picklingKey, encryptedPayload, ); if (decryptionWithPendingSessionResult.decryptedNotification) { const { decryptedNotification: notifDecryptedWithPendingSession, newPendingSessionUpdate, newUpdateCreationTimestamp, } = decryptionWithPendingSessionResult; decryptedNotification = notifDecryptedWithPendingSession; updatedOlmData = { mainSession: shouldUpdateMainSession ? pendingSessionUpdate : mainSession, pendingSessionUpdate: newPendingSessionUpdate, updateCreationTimestamp: newUpdateCreationTimestamp, picklingKey, }; } else { const { newUpdateCreationTimestamp, decryptedNotification: notifDecryptedWithMainSession, } = decryptWithSession(mainSession, picklingKey, encryptedPayload); decryptedNotification = notifDecryptedWithMainSession; updatedOlmData = { mainSession: mainSession, pendingSessionUpdate, updateCreationTimestamp: newUpdateCreationTimestamp, picklingKey, }; } const updatedEncryptedSession = await encryptData( new TextEncoder().encode(JSON.stringify(updatedOlmData)), encryptionKey, ); await localforage.setItem(olmDataContentKey, updatedEncryptedSession); return decryptedNotification; } function decryptWithSession( pickledSession: string, picklingKey: string, encryptedPayload: string, ): DecryptionResult { const session = new olm.Session(); session.unpickle(picklingKey, pickledSession); const decryptedNotification: T = JSON.parse( session.decrypt(olmEncryptedMessageTypes.TEXT, encryptedPayload), ); const newPendingSessionUpdate = session.pickle(picklingKey); const newUpdateCreationTimestamp = Date.now(); return { decryptedNotification, newUpdateCreationTimestamp, newPendingSessionUpdate, }; } function decryptWithPendingSession( pendingSessionUpdate: string, picklingKey: string, encryptedPayload: string, ): DecryptionResult | { +error: string } { try { const { decryptedNotification, newPendingSessionUpdate, newUpdateCreationTimestamp, } = decryptWithSession( pendingSessionUpdate, picklingKey, encryptedPayload, ); return { newPendingSessionUpdate, newUpdateCreationTimestamp, decryptedNotification, }; } catch (e) { return { error: e.message }; } } async function retrieveEncryptionKey( encryptionKeyDBLabel: string, ): Promise { if (!isDesktopSafari) { return await localforage.getItem(encryptionKeyDBLabel); } // Safari doesn't support structured clone algorithm in service // worker context so we have to store CryptoKey as JSON const persistedCryptoKey = await localforage.getItem(encryptionKeyDBLabel); if (!persistedCryptoKey) { return null; } return await importJWKKey(persistedCryptoKey); } async function getNotifsOlmSessionDBKeys(keyserverID?: string): Promise<{ +olmDataContentKey: string, +encryptionKeyDBKey: string, }> { const olmDataContentKeyForKeyserverPrefix = getOlmDataContentKeyForCookie( undefined, keyserverID, ); const olmEncryptionKeyDBLabelForKeyserverPrefix = getOlmEncryptionKeyDBLabelForCookie(undefined, keyserverID); const dbKeys = await localforage.keys(); const olmDataContentKeys = sortOlmDBKeysArray( dbKeys.filter(key => key.startsWith(olmDataContentKeyForKeyserverPrefix)), ); const encryptionKeyDBLabels = sortOlmDBKeysArray( dbKeys.filter(key => key.startsWith(olmEncryptionKeyDBLabelForKeyserverPrefix), ), ); if (olmDataContentKeys.length === 0 || encryptionKeyDBLabels.length === 0) { throw new Error( 'Received encrypted notification but olm session was not created', ); } const latestDataContentKey = olmDataContentKeys[olmDataContentKeys.length - 1]; const latestEncryptionKeyDBKey = encryptionKeyDBLabels[encryptionKeyDBLabels.length - 1]; const latestDataContentCookieID = getCookieIDFromOlmDBKey(latestDataContentKey); const latestEncryptionKeyCookieID = getCookieIDFromOlmDBKey( latestEncryptionKeyDBKey, ); if (latestDataContentCookieID !== latestEncryptionKeyCookieID) { throw new Error( 'Olm sessions and their encryption keys out of sync. Latest cookie ' + `id for olm sessions ${latestDataContentCookieID}. Latest cookie ` + `id for olm session encryption keys ${latestEncryptionKeyCookieID}`, ); } const olmDBKeys = { olmDataContentKey: latestDataContentKey, encryptionKeyDBKey: latestEncryptionKeyDBKey, }; const keysToDelete: $ReadOnlyArray = [ ...olmDataContentKeys.slice(0, olmDataContentKeys.length - 1), ...encryptionKeyDBLabels.slice(0, encryptionKeyDBLabels.length - 1), ]; await Promise.all(keysToDelete.map(key => localforage.removeItem(key))); return olmDBKeys; } function getOlmDataContentKeyForCookie( cookie: ?string, keyserverID?: string, ): string { let olmDataContentKeyBase; if (keyserverID) { olmDataContentKeyBase = [ INDEXED_DB_KEYSERVER_PREFIX, keyserverID, NOTIFICATIONS_OLM_DATA_CONTENT, ].join(INDEXED_DB_KEY_SEPARATOR); } else { olmDataContentKeyBase = NOTIFICATIONS_OLM_DATA_CONTENT; } if (!cookie) { return olmDataContentKeyBase; } const cookieID = getCookieIDFromCookie(cookie); return [olmDataContentKeyBase, cookieID].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 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 = getOlmDataContentKeyForCookie( cookieID, ASHOAT_KEYSERVER_ID_USED_ONLY_FOR_MIGRATION_FROM_LEGACY_NOTIF_STORAGE, ); } else if (key.startsWith(NOTIFICATIONS_OLM_DATA_ENCRYPTION_KEY)) { const cookieID = getCookieIDFromOlmDBKey(key); keyToInsert = getOlmEncryptionKeyDBLabelForCookie( cookieID, ASHOAT_KEYSERVER_ID_USED_ONLY_FOR_MIGRATION_FROM_LEGACY_NOTIF_STORAGE, ); } else { return undefined; } keyValuePairsToInsert[keyToInsert] = value; keysToDelete.push(key); return undefined; }); const insertionPromises = Object.entries(keyValuePairsToInsert).map( ([key, value]) => (async () => { await localforage.setItem(key, value); })(), ); const deletionPromises = keysToDelete.map(key => (async () => await localforage.removeItem(key))(), ); await Promise.all([...insertionPromises, ...deletionPromises]); } // 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, getOlmDataContentKeyForCookie, getOlmEncryptionKeyDBLabelForCookie, migrateLegacyOlmNotificationsSessions, updateNotifsUnreadCountStorage, queryNotifsUnreadCountStorage, };