From e51ad2aadbff24b38bf6d7cefac125da4b09caa7 Mon Sep 17 00:00:00 2001 From: marcinwasowicz Date: Thu, 29 Jun 2023 16:41:31 +0200 Subject: [PATCH] Encrypt rescinds Summary: This differential encrypts rescinds before sending them. Test Plan: Ensure rescinds are working correctly and each time they are sent (messages are read on another device) olm session version in db is incremented Reviewers: tomek, bartek, kamil Reviewed By: tomek Subscribers: ashoat Differential Revision: https://phab.comm.dev/D8377 --- keyserver/src/push/rescind.js | 236 ++++++++++++------ .../CommNotificationsHandler.java | 12 +- 2 files changed, 171 insertions(+), 77 deletions(-) diff --git a/keyserver/src/push/rescind.js b/keyserver/src/push/rescind.js index 8860b19664..c13b264c53 100644 --- a/keyserver/src/push/rescind.js +++ b/keyserver/src/push/rescind.js @@ -3,16 +3,35 @@ import apn from '@parse/node-apn'; import invariant from 'invariant'; +import { NEXT_CODE_VERSION } from 'lib/shared/version-utils.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 { + prepareEncryptedAndroidNotificationRescinds, + prepareEncryptedIOSNotifications, +} 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'; +type ParsedDelivery = { + +platform: 'ios' | 'android', + +codeVersion: ?number, + +notificationID: string, + +deviceTokens: $ReadOnlyArray, +}; + +type ParsedDeliveries = { +[id: string]: $ReadOnlyArray }; + async function rescindPushNotifs( notifCondition: SQLStatementType, inputCountCondition?: SQLStatementType, @@ -37,85 +56,101 @@ async function rescindPushNotifs( 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') { + const deviceTokens = delivery.iosDeviceTokens ?? delivery.deviceTokens; + rowParsedDeliveries.push({ + notificationID: delivery.iosID, + codeVersion: delivery.codeVersion, + platform: '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, + 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 rawDelivery = JSON.parse(row.delivery); - const deliveries = Array.isArray(rawDelivery) ? rawDelivery : [rawDelivery]; 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 deliveries) { - if (delivery.iosID && delivery.iosDeviceTokens) { - // Old iOS - const notification = prepareIOSNotification( - delivery.iosID, - row.unread_count, - threadID, - ); - const targetedNotifications = delivery.iosDeviceTokens.map( - deviceToken => ({ deviceToken, notification }), - ); - deliveryPromises[id] = apnPush({ - targetedNotifications, - platformDetails: { platform: 'ios' }, - }); - } else if (delivery.androidID) { - // Old Android - const notification = prepareAndroidNotification( - row.collapse_key ? row.collapse_key : id, - row.unread_count, - threadID, - ); - deliveryPromises[id] = fcmPush({ - targetedNotifications: delivery.androidDeviceTokens.map( - deviceToken => ({ - deviceToken, - notification, - }), - ), - codeVersion: null, - }); - } else if (delivery.deviceType === 'ios') { - // New iOS - const { iosID, deviceTokens, codeVersion } = delivery; - const notification = prepareIOSNotification( - iosID, - row.unread_count, - threadID, - codeVersion, - ); - const targetedNotifications = deviceTokens.map(deviceToken => ({ + + for (const delivery of parsedDeliveries[id]) { + if (delivery.platform === 'ios') { + const devices = delivery.deviceTokens.map(deviceToken => ({ deviceToken, - notification, + cookieID: deviceTokenToCookieID[deviceToken], })); - deliveryPromises[id] = apnPush({ - targetedNotifications, - platformDetails: { platform: 'ios', codeVersion }, - }); - } else if (delivery.deviceType === 'android') { - // New Android - const { deviceTokens, codeVersion } = delivery; - const notification = prepareAndroidNotification( - row.collapse_key ? row.collapse_key : id, - row.unread_count, - threadID, - ); - deliveryPromises[id] = fcmPush({ - targetedNotifications: deviceTokens.map(deviceToken => ({ - deviceToken, - notification, - })), - codeVersion, - }); + const deliveryPromise = (async () => { + const targetedNotifications = await prepareIOSNotification( + delivery.notificationID, + row.unread_count, + threadID, + delivery.codeVersion, + 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, + delivery.codeVersion, + devices, + ); + return await fcmPush({ + targetedNotifications, + codeVersion: delivery.codeVersion, + }); + })(); + deliveryPromises[id] = deliveryPromise; } } - rescindedIDs.push(row.id); + rescindedIDs.push(id); } const numRescinds = Object.keys(deliveryPromises).length; @@ -165,18 +200,64 @@ async function rescindPushNotifs( } } -function prepareIOSNotification( +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: ( + cookieIDs: $ReadOnlyArray, + notification: T, + ) => Promise<$ReadOnlyArray>, +): Promise<$ReadOnlyArray<{ +deviceToken: string, +notification: T }>> { + const shouldBeEncrypted = codeVersion && codeVersion > NEXT_CODE_VERSION; + if (!shouldBeEncrypted) { + return devices.map(({ deviceToken }) => ({ + notification, + deviceToken, + })); + } + const notificationPromises = devices.map(({ cookieID, deviceToken }) => + (async () => { + const [encryptedNotif] = await encryptCallback([cookieID], notification); + return { + notification: encryptedNotif, + deviceToken, + }; + })(), + ); + return await Promise.all(notificationPromises); +} + +async function prepareIOSNotification( iosID: string, unreadCount: number, threadID: string, codeVersion: ?number, -): apn.Notification { + devices: $ReadOnlyArray, +): Promise<$ReadOnlyArray> { const notification = new apn.Notification(); notification.topic = getAPNsNotificationTopic({ platform: 'ios', codeVersion: codeVersion ?? undefined, }); - // It was agreed to temporarily make even releases staff-only. This way // we will be able to prevent shipping NSE functionality to public iOS // users until it is thoroughly tested among staff members. @@ -203,15 +284,22 @@ function prepareIOSNotification( notificationId: iosID, }, }; - return notification; + return await conditionallyEncryptNotification( + notification, + codeVersion, + devices, + prepareEncryptedIOSNotifications, + ); } -function prepareAndroidNotification( +async function prepareAndroidNotification( notifID: string, unreadCount: number, threadID: string, -): Object { - return { + codeVersion: ?number, + devices: $ReadOnlyArray, +): Promise<$ReadOnlyArray> { + const notification = { data: { badge: unreadCount.toString(), rescind: 'true', @@ -220,6 +308,12 @@ function prepareAndroidNotification( threadID, }, }; + return await conditionallyEncryptNotification( + notification, + codeVersion, + devices, + prepareEncryptedAndroidNotificationRescinds, + ); } export { rescindPushNotifs }; diff --git a/native/android/app/src/main/java/app/comm/android/notifications/CommNotificationsHandler.java b/native/android/app/src/main/java/app/comm/android/notifications/CommNotificationsHandler.java index 83816e629b..c0b158d98a 100644 --- a/native/android/app/src/main/java/app/comm/android/notifications/CommNotificationsHandler.java +++ b/native/android/app/src/main/java/app/comm/android/notifications/CommNotificationsHandler.java @@ -78,12 +78,6 @@ public void onNewToken(String token) { @Override public void onMessageReceived(RemoteMessage message) { - String rescind = message.getData().get(RESCIND_KEY); - if ("true".equals(rescind) && - android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) { - handleNotificationRescind(message); - } - if (message.getData().get(ENCRYPTED_PAYLOAD_KEY) != null) { try { message = this.decryptRemoteMessage(message); @@ -103,6 +97,12 @@ public void onMessageReceived(RemoteMessage message) { "Received unencrypted notification for client with existing olm session for notifications"); } + String rescind = message.getData().get(RESCIND_KEY); + if ("true".equals(rescind) && + android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) { + handleNotificationRescind(message); + } + String badge = message.getData().get(BADGE_KEY); if (badge != null) { try {