diff --git a/keyserver/src/push/crypto.js b/keyserver/src/push/crypto.js index c4c1b6aae..3544896bf 100644 --- a/keyserver/src/push/crypto.js +++ b/keyserver/src/push/crypto.js @@ -1,369 +1,387 @@ // @flow import apn from '@parse/node-apn'; import invariant from 'invariant'; import _cloneDeep from 'lodash/fp/cloneDeep.js'; import type { PlainTextWebNotification, WebNotification, } from 'lib/types/notif-types.js'; import type { AndroidNotification, AndroidNotificationPayload, AndroidNotificationRescind, NotificationTargetDevice, } from './types.js'; import { encryptAndUpdateOlmSession } from '../updaters/olm-session-updater.js'; import { getOlmUtility } from '../utils/olm-utils.js'; async function encryptIOSNotification( cookieID: string, notification: apn.Notification, codeVersion?: ?number, notificationSizeValidator?: apn.Notification => boolean, ): Promise<{ +notification: apn.Notification, +payloadSizeExceeded: boolean, +encryptedPayloadHash?: string, + +encryptionOrder?: number, }> { invariant( !notification.collapseId, 'Collapsible notifications encryption currently not implemented', ); const encryptedNotification = new apn.Notification(); encryptedNotification.id = notification.id; encryptedNotification.payload.id = notification.id; encryptedNotification.topic = notification.topic; encryptedNotification.sound = notification.aps.sound; encryptedNotification.pushType = 'alert'; encryptedNotification.mutableContent = true; const { id, ...payloadSansId } = notification.payload; const unencryptedPayload = { ...payloadSansId, badge: notification.aps.badge.toString(), merged: notification.body, }; try { const unencryptedSerializedPayload = JSON.stringify(unencryptedPayload); let dbPersistCondition; if (notificationSizeValidator) { dbPersistCondition = ({ serializedPayload }) => { 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; 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, unencryptedPayload: T, payloadSizeValidator?: (T | { +encryptedPayload: string }) => boolean, ): Promise<{ +resultPayload: T | { +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 }) => payloadSizeValidator({ encryptedPayload: serializedPayload.body }); } const { encryptedMessages: { serializedPayload }, dbPersistConditionViolated, + encryptionOrder, } = await encryptAndUpdateOlmSession( cookieID, 'notifications', { serializedPayload: unencryptedSerializedPayload, }, dbPersistCondition, ); return { resultPayload: { encryptedPayload: serializedPayload.body }, 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 encryptAndroidNotification( cookieID: string, notification: AndroidNotification, notificationSizeValidator?: AndroidNotification => boolean, ): Promise<{ +notification: AndroidNotification, +payloadSizeExceeded: boolean, + +encryptionOrder?: number, }> { const { id, badgeOnly, ...unencryptedPayload } = notification.data; let payloadSizeValidator; if (notificationSizeValidator) { payloadSizeValidator = ( payload: AndroidNotificationPayload | { +encryptedPayload: string }, ) => { return notificationSizeValidator({ data: { id, badgeOnly, ...payload } }); }; } - const { resultPayload, payloadSizeExceeded } = + const { resultPayload, payloadSizeExceeded, encryptionOrder } = await encryptAndroidNotificationPayload( cookieID, unencryptedPayload, payloadSizeValidator, ); return { notification: { data: { id, badgeOnly, ...resultPayload, }, }, payloadSizeExceeded, + encryptionOrder, }; } async function encryptAndroidNotificationRescind( cookieID: string, notification: AndroidNotificationRescind, ): Promise { // We don't validate payload size for rescind // since they are expected to be small and // never exceed any FCM limit const { resultPayload } = await encryptAndroidNotificationPayload( cookieID, notification.data, ); return { data: resultPayload, }; } async function encryptWebNotification( cookieID: string, notification: PlainTextWebNotification, -): Promise { +): Promise<{ +notification: WebNotification, +encryptionOrder?: number }> { const { id, ...payloadSansId } = notification; const unencryptedSerializedPayload = JSON.stringify(payloadSansId); try { const { encryptedMessages: { serializedPayload }, + encryptionOrder, } = await encryptAndUpdateOlmSession(cookieID, 'notifications', { serializedPayload: unencryptedSerializedPayload, }); - return { id, encryptedPayload: serializedPayload.body }; + return { + notification: { id, encryptedPayload: serializedPayload.body }, + encryptionOrder, + }; } catch (e) { console.log('Notification encryption failed: ' + e); return { - id, - encryptionFailed: '1', - ...payloadSansId, + notification: { + id, + encryptionFailed: '1', + ...payloadSansId, + }, }; } } function prepareEncryptedIOSNotifications( 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 }) => { const notif = await encryptIOSNotification( cookieID, notification, codeVersion, notificationSizeValidator, ); return { cookieID, deviceToken, ...notif }; }, ); return Promise.all(notificationPromises); } function prepareEncryptedIOSNotificationRescind( 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 encryptIOSNotification( cookieID, notification, codeVersion, ); return { deviceToken, cookieID, notification: notif }; }, ); return Promise.all(notificationPromises); } function prepareEncryptedAndroidNotifications( devices: $ReadOnlyArray, notification: AndroidNotification, notificationSizeValidator?: (notification: AndroidNotification) => boolean, ): Promise< $ReadOnlyArray<{ +cookieID: string, +deviceToken: string, +notification: AndroidNotification, +payloadSizeExceeded: boolean, + +encryptionOrder?: number, }>, > { const notificationPromises = devices.map( async ({ deviceToken, cookieID }) => { const notif = await encryptAndroidNotification( cookieID, notification, notificationSizeValidator, ); return { deviceToken, cookieID, ...notif }; }, ); return Promise.all(notificationPromises); } function prepareEncryptedAndroidNotificationRescinds( devices: $ReadOnlyArray, notification: AndroidNotificationRescind, ): Promise< $ReadOnlyArray<{ +cookieID: string, +deviceToken: string, +notification: AndroidNotificationRescind, + +encryptionOrder?: number, }>, > { const notificationPromises = devices.map( async ({ deviceToken, cookieID }) => { const notif = await encryptAndroidNotificationRescind( cookieID, notification, ); return { deviceToken, cookieID, notification: notif }; }, ); return Promise.all(notificationPromises); } function prepareEncryptedWebNotifications( 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); - return { notification: notif, deviceToken }; + return { ...notif, deviceToken }; }, ); return Promise.all(notificationPromises); } export { prepareEncryptedIOSNotifications, prepareEncryptedIOSNotificationRescind, prepareEncryptedAndroidNotifications, prepareEncryptedAndroidNotificationRescinds, prepareEncryptedWebNotifications, }; diff --git a/keyserver/src/push/rescind.js b/keyserver/src/push/rescind.js index d05172c1c..b239dcdda 100644 --- a/keyserver/src/push/rescind.js +++ b/keyserver/src/push/rescind.js @@ -1,351 +1,352 @@ // @flow import apn from '@parse/node-apn'; import invariant from 'invariant'; import type { PlatformDetails } from 'lib/types/device-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 { prepareEncryptedAndroidNotificationRescinds, prepareEncryptedIOSNotificationRescind, } from './crypto.js'; import { getAPNsNotificationTopic } from './providers.js'; import type { NotificationTargetDevice, TargetedAndroidNotification, TargetedAPNsNotification, } from './types.js'; import { apnPush, fcmPush } 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 { validateOutput } from '../utils/validation-utils.js'; type ParsedDelivery = { +platform: 'ios' | 'macos' | 'android', +codeVersion: ?number, +notificationID: string, +deviceTokens: $ReadOnlyArray, }; type ParsedDeliveries = { +[id: string]: $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] = await dbQuery(fetchQuery); const allDeviceTokens = new Set(); const parsedDeliveries: ParsedDeliveries = {}; 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 = {}; 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( 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( 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 promises = [promiseAll(deliveryPromises)]; if (numRescinds > 0) { promises.push(createIDs('notifications', numRescinds)); } if (rescindedIDs.length > 0) { const rescindQuery = SQL` UPDATE notifications SET rescinded = 1 WHERE id IN (${rescindedIDs}) `; promises.push(dbQuery(rescindQuery)); } const [deliveryResults, dbIDs] = await Promise.all(promises); const newNotifRows = []; if (numRescinds > 0) { invariant(dbIDs, 'dbIDs should be set'); for (const rescindedID in deliveryResults) { const delivery = {}; delivery.source = 'rescind'; delivery.rescindedID = 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( notification: T, codeVersion: ?number, devices: $ReadOnlyArray, encryptCallback: ( 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( devices, notification, codeVersion, ); return notifications.map(({ deviceToken, notification: notif }) => ({ deviceToken, notification: notif, })); } async function prepareIOSNotification( iosID: string, unreadCount: number, threadID: string, platformDetails: PlatformDetails, devices: $ReadOnlyArray, ): Promise<$ReadOnlyArray> { threadID = 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, } : { managedAps: { action: 'CLEAR', notificationId: iosID, }, }; return await conditionallyEncryptNotification( notification, codeVersion, devices, prepareEncryptedIOSNotificationRescind, ); } async function prepareAndroidNotification( notifID: string, unreadCount: number, threadID: string, platformDetails: PlatformDetails, devices: $ReadOnlyArray, ): Promise<$ReadOnlyArray> { threadID = validateOutput(platformDetails, tID, threadID); const { codeVersion } = platformDetails; const notification = { data: { badge: unreadCount.toString(), rescind: 'true', rescindID: notifID, setUnreadStatus: 'true', threadID, }, }; return await conditionallyEncryptNotification( notification, codeVersion, devices, prepareEncryptedAndroidNotificationRescinds, ); } export { rescindPushNotifs }; diff --git a/keyserver/src/push/types.js b/keyserver/src/push/types.js index 2b0e819cc..ed22f1b48 100644 --- a/keyserver/src/push/types.js +++ b/keyserver/src/push/types.js @@ -1,68 +1,71 @@ // @flow import apn from '@parse/node-apn'; import type { WebNotification } from 'lib/types/notif-types.js'; export type TargetedAPNsNotification = { +notification: apn.Notification, +deviceToken: string, +encryptedPayloadHash?: string, + +encryptionOrder?: number, }; type AndroidNotificationPayloadBase = { +badge: string, +body?: string, +title?: string, +prefix?: string, +threadID?: string, +collapseKey?: string, +encryptionFailed?: '1', }; export type AndroidNotificationPayload = | { ...AndroidNotificationPayloadBase, +messageInfos?: string, } | { ...AndroidNotificationPayloadBase, +blobHash: string, +encryptionKey: string, }; export type AndroidNotification = { +data: { +id?: string, +badgeOnly?: string, ...AndroidNotificationPayload | { +encryptedPayload: string }, }, }; export type AndroidNotificationRescind = { +data: | { +badge: string, +rescind: 'true', +rescindID: string, +setUnreadStatus: 'true', +threadID: string, +encryptionFailed?: string, } | { +encryptedPayload: string }, }; export type TargetedAndroidNotification = { +notification: AndroidNotification | AndroidNotificationRescind, +deviceToken: string, + +encryptionOrder?: number, }; export type TargetedWebNotification = { +notification: WebNotification, +deviceToken: string, + +encryptionOrder?: number, }; export type NotificationTargetDevice = { +cookieID: string, +deviceToken: string, }; diff --git a/keyserver/src/updaters/olm-session-updater.js b/keyserver/src/updaters/olm-session-updater.js index 218b8a986..9d6233fba 100644 --- a/keyserver/src/updaters/olm-session-updater.js +++ b/keyserver/src/updaters/olm-session-updater.js @@ -1,98 +1,99 @@ // @flow import type { EncryptResult } from '@commapp/olm'; import { ServerError } from 'lib/utils/errors.js'; import sleep from 'lib/utils/sleep.js'; import { fetchOlmAccount } from './olm-account-updater.js'; import { SQL, dbQuery } from '../database/database.js'; import { unpickleOlmSession } from '../utils/olm-utils.js'; const maxOlmSessionUpdateAttemptTime = 30000; const olmSessionUpdateRetryDelay = 50; type OlmEncryptionResult = { +encryptedMessages: { +[string]: EncryptResult }, +dbPersistConditionViolated?: boolean, + +encryptionOrder?: number, }; async function encryptAndUpdateOlmSession( cookieID: string, olmSessionType: 'content' | 'notifications', messagesToEncrypt: $ReadOnly<{ [string]: string }>, dbPersistCondition?: ({ +[string]: EncryptResult }) => boolean, ): Promise { const isContent = olmSessionType === 'content'; const { picklingKey } = await fetchOlmAccount(olmSessionType); const olmUpdateAttemptStartTime = Date.now(); while ( Date.now() - olmUpdateAttemptStartTime < maxOlmSessionUpdateAttemptTime ) { const [olmSessionResult] = await dbQuery( SQL` SELECT version, pickled_olm_session FROM olm_sessions WHERE cookie_id = ${cookieID} AND is_content = ${isContent} `, ); if (olmSessionResult.length === 0) { throw new ServerError('missing_olm_session'); } const [{ version, pickled_olm_session: pickledSession }] = olmSessionResult; const session = await unpickleOlmSession(pickledSession, picklingKey); const encryptedMessages = {}; for (const messageName in messagesToEncrypt) { encryptedMessages[messageName] = session.encrypt( messagesToEncrypt[messageName], ); } if (dbPersistCondition && !dbPersistCondition(encryptedMessages)) { return { encryptedMessages, dbPersistConditionViolated: true }; } const updatedSession = session.pickle(picklingKey); const [transactionResult] = await dbQuery( SQL` START TRANSACTION; SELECT version INTO @currentVersion FROM olm_sessions WHERE cookie_id = ${cookieID} AND is_content = ${isContent} FOR UPDATE; UPDATE olm_sessions SET pickled_olm_session = ${updatedSession}, version = ${version} + 1 WHERE version = ${version} AND is_content = ${isContent} AND cookie_id = ${cookieID}; COMMIT; SELECT @currentVersion AS versionOnUpdateAttempt; `, { multipleStatements: true }, ); const selectResult = transactionResult.pop(); const [{ versionOnUpdateAttempt }] = selectResult; if (version === versionOnUpdateAttempt) { - return { encryptedMessages }; + return { encryptedMessages, encryptionOrder: version }; } await sleep(olmSessionUpdateRetryDelay); } throw new ServerError('max_olm_account_update_retry_exceeded'); } export { encryptAndUpdateOlmSession };