diff --git a/keyserver/src/push/encrypted-notif-utils-api.js b/keyserver/src/push/encrypted-notif-utils-api.js --- a/keyserver/src/push/encrypted-notif-utils-api.js +++ b/keyserver/src/push/encrypted-notif-utils-api.js @@ -6,6 +6,7 @@ import { blobServiceUpload } from './utils.js'; import { encryptAndUpdateOlmSession } from '../updaters/olm-session-updater.js'; +import { getOlmUtility } from '../utils/olm-utils.js'; const encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI = { encryptSerializedNotifPayload: async ( @@ -44,6 +45,8 @@ uploadLargeNotifPayload: blobServiceUpload, getNotifByteSize: (serializedPayload: string) => Buffer.byteLength(serializedPayload), + getEncryptedNotifHash: (serializedNotification: string) => + getOlmUtility().sha256(serializedNotification), }; export default encryptedNotifUtilsAPI; diff --git a/keyserver/src/push/providers.js b/keyserver/src/push/providers.js --- a/keyserver/src/push/providers.js +++ b/keyserver/src/push/providers.js @@ -113,15 +113,6 @@ } } -function getAPNsNotificationTopic(platformDetails: PlatformDetails): string { - if (platformDetails.platform === 'macos') { - return 'app.comm.macos'; - } - return platformDetails.codeVersion && platformDetails.codeVersion >= 87 - ? 'app.comm' - : 'org.squadcal.app'; -} - type WebPushConfig = { +publicKey: string, +privateKey: string }; let cachedWebPushConfig: ?WebPushConfig = null; async function getWebPushConfig(): Promise { @@ -208,7 +199,6 @@ getFCMProvider, endFirebase, endAPNs, - getAPNsNotificationTopic, getWebPushConfig, ensureWebPushInitialized, getWNSToken, diff --git a/keyserver/src/push/rescind.js b/keyserver/src/push/rescind.js --- a/keyserver/src/push/rescind.js +++ b/keyserver/src/push/rescind.js @@ -6,6 +6,7 @@ import invariant from 'invariant'; import { createAndroidNotificationRescind } from 'lib/push/android-notif-creators.js'; +import { getAPNsNotificationTopic } from 'lib/shared/notif-utils.js'; import type { PlatformDetails } from 'lib/types/device-types.js'; import type { NotificationTargetDevice, @@ -20,7 +21,6 @@ import { prepareEncryptedIOSNotificationRescind } from './crypto.js'; import encryptedNotifUtilsAPI from './encrypted-notif-utils-api.js'; -import { getAPNsNotificationTopic } from './providers.js'; import type { TargetedAPNsNotification } from './types.js'; import { apnPush, diff --git a/keyserver/src/push/send.js b/keyserver/src/push/send.js --- a/keyserver/src/push/send.js +++ b/keyserver/src/push/send.js @@ -18,6 +18,7 @@ createAndroidVisualNotification, createAndroidBadgeOnlyNotification, } from 'lib/push/android-notif-creators.js'; +import { apnMaxNotificationPayloadByteSize } from 'lib/push/apns-notif-creators.js'; import { type WebNotifInputData, webNotifInputDataValidator, @@ -36,7 +37,10 @@ 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 { + notifTextsForMessageInfo, + getAPNsNotificationTopic, +} from 'lib/shared/notif-utils.js'; import { rawThreadInfoFromServerThreadInfo, threadInfoFromRawThreadInfo, @@ -66,11 +70,9 @@ import { prepareEncryptedAPNsNotifications } from './crypto.js'; import encryptedNotifUtilsAPI from './encrypted-notif-utils-api.js'; -import { getAPNsNotificationTopic } from './providers.js'; import { rescindPushNotifs } from './rescind.js'; import type { TargetedAPNsNotification } from './types.js'; import { - apnMaxNotificationPayloadByteSize, apnPush, fcmPush, getUnreadCounts, diff --git a/keyserver/src/push/utils.js b/keyserver/src/push/utils.js --- a/keyserver/src/push/utils.js +++ b/keyserver/src/push/utils.js @@ -8,8 +8,6 @@ import uuid from 'uuid'; import webpush from 'web-push'; -import { fcmMaxNotificationPayloadByteSize } from 'lib/push/android-notif-creators.js'; -import { wnsMaxNotificationPayloadByteSize } from 'lib/push/wns-notif-creators.js'; import type { PlatformDetails } from 'lib/types/device-types.js'; import type { TargetedAndroidNotification, @@ -40,7 +38,6 @@ const apnTokenInvalidationErrorCode = 410; const apnBadRequestErrorCode = 400; const apnBadTokenErrorString = 'BadDeviceToken'; -const apnMaxNotificationPayloadByteSize = 4096; const webInvalidTokenErrorCodes = [404, 410]; const wnsInvalidTokenErrorCodes = [404, 410]; @@ -446,7 +443,4 @@ webPush, wnsPush, getUnreadCounts, - apnMaxNotificationPayloadByteSize, - fcmMaxNotificationPayloadByteSize, - wnsMaxNotificationPayloadByteSize, }; diff --git a/lib/push/android-notif-creators.js b/lib/push/android-notif-creators.js --- a/lib/push/android-notif-creators.js +++ b/lib/push/android-notif-creators.js @@ -32,7 +32,7 @@ export const fcmMaxNotificationPayloadByteSize = 4000; -type CommonNativeNotifInputData = $ReadOnly<{ +export type CommonNativeNotifInputData = $ReadOnly<{ +senderDeviceDescriptor: SenderDeviceDescriptor, +notifTexts: ResolvedNotifTexts, +newRawMessageInfos: RawMessageInfo[], @@ -42,15 +42,16 @@ +platformDetails: PlatformDetails, }>; -const commonNativeNotifInputDataValidator = tShape({ - senderDeviceDescriptor: senderDeviceDescriptorValidator, - notifTexts: resolvedNotifTextsValidator, - newRawMessageInfos: t.list(rawMessageInfoValidator), - threadID: tID, - collapseKey: t.maybe(t.String), - unreadCount: t.maybe(t.Number), - platformDetails: tPlatformDetails, -}); +export const commonNativeNotifInputDataValidator: TInterface = + tShape({ + senderDeviceDescriptor: senderDeviceDescriptorValidator, + notifTexts: resolvedNotifTextsValidator, + newRawMessageInfos: t.list(rawMessageInfoValidator), + threadID: tID, + collapseKey: t.maybe(t.String), + unreadCount: t.maybe(t.Number), + platformDetails: tPlatformDetails, + }); export type AndroidNotifInputData = { ...CommonNativeNotifInputData, @@ -104,7 +105,7 @@ }, }; - if (unreadCount) { + if (unreadCount !== undefined && unreadCount !== null) { notification.data = { ...notification.data, badge: unreadCount.toString(), diff --git a/lib/push/apns-notif-creators.js b/lib/push/apns-notif-creators.js new file mode 100644 --- /dev/null +++ b/lib/push/apns-notif-creators.js @@ -0,0 +1,504 @@ +// @flow + +import invariant from 'invariant'; +import t, { type TInterface } from 'tcomb'; + +import { + type CommonNativeNotifInputData, + commonNativeNotifInputDataValidator, +} from './android-notif-creators.js'; +import { + prepareEncryptedAPNsVisualNotifications, + prepareEncryptedAPNsSilentNotifications, +} from './crypto.js'; +import { getAPNsNotificationTopic } from '../shared/notif-utils.js'; +import { + hasMinCodeVersion, + FUTURE_CODE_VERSION, +} from '../shared/version-utils.js'; +import type { PlatformDetails } from '../types/device-types.js'; +import { messageTypes } from '../types/message-types-enum.js'; +import { + type NotificationTargetDevice, + type EncryptedNotifUtilsAPI, + type TargetedAPNsNotification, + type APNsVisualNotification, + type APNsNotificationHeaders, + type SenderDeviceDescriptor, +} from '../types/notif-types.js'; +import { tShape } from '../utils/validation-utils.js'; + +export const apnMaxNotificationPayloadByteSize = 4096; + +export type APNsNotifInputData = { + ...CommonNativeNotifInputData, + +badgeOnly: boolean, + +uniqueID: string, +}; + +export const apnsNotifInputDataValidator: TInterface = + tShape({ + ...commonNativeNotifInputDataValidator.meta.props, + badgeOnly: t.Boolean, + uniqueID: t.String, + }); + +async function createAPNsVisualNotification( + encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI, + inputData: APNsNotifInputData, + devices: $ReadOnlyArray, +): Promise<$ReadOnlyArray> { + const { + senderDeviceDescriptor, + notifTexts, + newRawMessageInfos, + threadID, + collapseKey, + badgeOnly, + unreadCount, + platformDetails, + uniqueID, + } = inputData; + + const canDecryptNonCollapsibleTextIOSNotifs = hasMinCodeVersion( + platformDetails, + { native: 222 }, + ); + + const isNonCollapsibleTextNotification = + newRawMessageInfos.every( + newRawMessageInfo => newRawMessageInfo.type === messageTypes.TEXT, + ) && !collapseKey; + + const canDecryptAllIOSNotifs = hasMinCodeVersion(platformDetails, { + native: 267, + }); + + const canDecryptIOSNotif = + platformDetails.platform === 'ios' && + (canDecryptAllIOSNotifs || + (isNonCollapsibleTextNotification && + canDecryptNonCollapsibleTextIOSNotifs)); + + const canDecryptMacOSNotifs = + platformDetails.platform === 'macos' && + hasMinCodeVersion(platformDetails, { + web: 47, + majorDesktop: 9, + }); + + let apsDictionary = { + 'thread-id': threadID, + }; + if (unreadCount !== undefined && unreadCount !== null) { + apsDictionary = { + ...apsDictionary, + badge: unreadCount, + }; + } + + 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') { + apsDictionary = { + ...apsDictionary, + alert: merged, + sound: 'default', + }; + } + + if (hasMinCodeVersion(platformDetails, { native: 198 })) { + apsDictionary = { + ...apsDictionary, + 'mutable-content': 1, + }; + } + + let notificationPayload = { + ...rest, + id: uniqueID, + threadID, + }; + + let notificationHeaders: APNsNotificationHeaders = { + 'apns-topic': getAPNsNotificationTopic(platformDetails), + 'apns-id': uniqueID, + 'apns-push-type': 'alert', + }; + + if (collapseKey && (canDecryptAllIOSNotifs || canDecryptMacOSNotifs)) { + notificationPayload = { + ...notificationPayload, + collapseID: collapseKey, + }; + } else if (collapseKey) { + notificationHeaders = { + ...notificationHeaders, + 'apns-collapse-id': collapseKey, + }; + } + + const notification = { + ...notificationPayload, + headers: notificationHeaders, + aps: apsDictionary, + }; + + const messageInfos = JSON.stringify(newRawMessageInfos); + const copyWithMessageInfos = { + ...notification, + messageInfos, + }; + + const notificationSizeValidator = (notif: APNsVisualNotification) => { + const { headers, ...notifSansHeaders } = notif; + return ( + encryptedNotifUtilsAPI.getNotifByteSize( + JSON.stringify(notifSansHeaders), + ) <= apnMaxNotificationPayloadByteSize + ); + }; + + const serializeAPNsNotif = (notif: APNsVisualNotification) => { + const { headers, ...notifSansHeaders } = notif; + return JSON.stringify(notifSansHeaders); + }; + + const shouldBeEncrypted = canDecryptIOSNotif || canDecryptMacOSNotifs; + if (!shouldBeEncrypted) { + const notificationToSend = notificationSizeValidator(copyWithMessageInfos) + ? copyWithMessageInfos + : notification; + return devices.map(({ deliveryID }) => ({ + notification: notificationToSend, + deliveryID, + })); + } + + // The `messageInfos` field in notification payload is + // not used on MacOS so we can return early. + if (platformDetails.platform === 'macos') { + const macOSNotifsWithoutMessageInfos = + await prepareEncryptedAPNsVisualNotifications( + encryptedNotifUtilsAPI, + senderDeviceDescriptor, + devices, + notification, + platformDetails.codeVersion, + ); + return macOSNotifsWithoutMessageInfos.map( + ({ notification: notif, deliveryID }) => ({ + notification: notif, + deliveryID, + }), + ); + } + + const notifsWithMessageInfos = await prepareEncryptedAPNsVisualNotifications( + encryptedNotifUtilsAPI, + senderDeviceDescriptor, + devices, + copyWithMessageInfos, + platformDetails.codeVersion, + notificationSizeValidator, + ); + + const devicesWithExcessiveSizeNoHolders = notifsWithMessageInfos + .filter(({ payloadSizeExceeded }) => payloadSizeExceeded) + .map(({ cryptoID, deliveryID }) => ({ + cryptoID, + deliveryID, + })); + + if (devicesWithExcessiveSizeNoHolders.length === 0) { + return notifsWithMessageInfos.map( + ({ + notification: notif, + deliveryID, + encryptedPayloadHash, + encryptionOrder, + }) => ({ + notification: notif, + deliveryID, + encryptedPayloadHash, + encryptionOrder, + }), + ); + } + + const canQueryBlobService = hasMinCodeVersion(platformDetails, { + native: 331, + }); + + let blobHash, blobHolders, encryptionKey, blobUploadError; + if (canQueryBlobService) { + ({ blobHash, blobHolders, encryptionKey, blobUploadError } = + await encryptedNotifUtilsAPI.uploadLargeNotifPayload( + serializeAPNsNotif(copyWithMessageInfos), + devicesWithExcessiveSizeNoHolders.length, + )); + } + + if (blobUploadError) { + console.warn( + `Failed to upload payload of notification: ${uniqueID} ` + + `due to error: ${blobUploadError}`, + ); + } + + let devicesWithExcessiveSize = devicesWithExcessiveSizeNoHolders; + let notificationWithBlobMetadata = notification; + if ( + blobHash && + encryptionKey && + blobHolders && + blobHolders.length === devicesWithExcessiveSize.length + ) { + notificationWithBlobMetadata = { + ...notification, + blobHash, + encryptionKey, + }; + devicesWithExcessiveSize = blobHolders.map((holder, idx) => ({ + ...devicesWithExcessiveSize[idx], + blobHolder: holder, + })); + } + + const notifsWithoutMessageInfos = + await prepareEncryptedAPNsVisualNotifications( + encryptedNotifUtilsAPI, + senderDeviceDescriptor, + devicesWithExcessiveSize, + notificationWithBlobMetadata, + platformDetails.codeVersion, + ); + + const targetedNotifsWithMessageInfos = notifsWithMessageInfos + .filter(({ payloadSizeExceeded }) => !payloadSizeExceeded) + .map( + ({ + notification: notif, + deliveryID, + encryptedPayloadHash, + encryptionOrder, + }) => ({ + notification: notif, + deliveryID, + encryptedPayloadHash, + encryptionOrder, + }), + ); + + const targetedNotifsWithoutMessageInfos = notifsWithoutMessageInfos.map( + ({ + notification: notif, + deliveryID, + encryptedPayloadHash, + encryptionOrder, + }) => ({ + notification: notif, + deliveryID, + encryptedPayloadHash, + encryptionOrder, + }), + ); + + return [ + ...targetedNotifsWithMessageInfos, + ...targetedNotifsWithoutMessageInfos, + ]; +} + +type APNsNotificationRescindInputData = { + +senderDeviceDescriptor: SenderDeviceDescriptor, + +rescindID?: string, + +badge?: number, + +threadID: string, + +platformDetails: PlatformDetails, +}; + +async function createAPNsNotificationRescind( + encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI, + inputData: APNsNotificationRescindInputData, + devices: $ReadOnlyArray, +): Promise<$ReadOnlyArray> { + const { + badge, + rescindID, + threadID, + platformDetails, + senderDeviceDescriptor, + } = inputData; + + invariant( + (rescindID && badge !== null && badge !== undefined) || + hasMinCodeVersion(platformDetails, { native: FUTURE_CODE_VERSION }), + 'thick thread rescind not support for this client version', + ); + + const apnsTopic = getAPNsNotificationTopic(platformDetails); + let notification; + + if ( + rescindID && + badge !== null && + badge !== undefined && + hasMinCodeVersion(platformDetails, { native: 198 }) + ) { + notification = { + headers: { + 'apns-topic': apnsTopic, + 'apns-push-type': 'alert', + }, + aps: { + 'mutable-content': 1, + 'badge': badge, + }, + threadID, + notificationId: rescindID, + backgroundNotifType: 'CLEAR', + setUnreadStatus: true, + }; + } else if (rescindID && badge !== null && badge !== undefined) { + notification = { + headers: { + 'apns-topic': apnsTopic, + 'apns-push-type': 'background', + 'apns-priority': 5, + }, + aps: { + 'mutable-content': 1, + 'badge': badge, + }, + threadID, + notificationId: rescindID, + backgroundNotifType: 'CLEAR', + setUnreadStatus: true, + }; + } else { + notification = { + headers: { + 'apns-topic': apnsTopic, + 'apns-push-type': 'alert', + }, + aps: { + 'mutable-content': 1, + }, + threadID, + backgroundNotifType: 'CLEAR', + setUnreadStatus: true, + }; + } + + const shouldBeEncrypted = hasMinCodeVersion(platformDetails, { native: 233 }); + if (!shouldBeEncrypted) { + return devices.map(({ deliveryID }) => ({ + notification, + deliveryID, + })); + } + + const notifications = await prepareEncryptedAPNsSilentNotifications( + encryptedNotifUtilsAPI, + senderDeviceDescriptor, + devices, + notification, + platformDetails.codeVersion, + ); + + return notifications.map(({ deliveryID, notification: notif }) => ({ + deliveryID, + notification: notif, + })); +} + +type APNsBadgeOnlyNotificationInputData = { + +senderDeviceDescriptor: SenderDeviceDescriptor, + +badge?: number, + +threadID?: string, + +platformDetails: PlatformDetails, +}; + +async function createAPNsBadgeOnlyNotification( + encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI, + inputData: APNsBadgeOnlyNotificationInputData, + devices: $ReadOnlyArray, +): Promise<$ReadOnlyArray> { + const { senderDeviceDescriptor, platformDetails, threadID, badge } = + inputData; + invariant( + (!threadID && badge !== undefined && badge !== null) || + hasMinCodeVersion(platformDetails, { native: FUTURE_CODE_VERSION }), + 'thick thread badge updates not support for this client version', + ); + + const shouldBeEncrypted = hasMinCodeVersion(platformDetails, { + native: 222, + web: 47, + majorDesktop: 9, + }); + + const headers: APNsNotificationHeaders = { + 'apns-topic': getAPNsNotificationTopic(platformDetails), + 'apns-push-type': 'alert', + }; + + let notification; + if (shouldBeEncrypted && threadID) { + notification = { + headers, + threadID, + aps: { + 'mutable-content': 1, + }, + }; + } else if (shouldBeEncrypted && badge !== undefined && badge !== null) { + notification = { + headers, + aps: { + 'badge': badge, + 'mutable-content': 1, + }, + }; + } else { + invariant( + badge !== null && badge !== undefined, + 'badge update must contain either badge count or threadID', + ); + notification = { + headers, + aps: { + badge, + }, + }; + } + + if (!shouldBeEncrypted) { + return devices.map(({ deliveryID }) => ({ + deliveryID, + notification, + })); + } + + const notifications = await prepareEncryptedAPNsSilentNotifications( + encryptedNotifUtilsAPI, + senderDeviceDescriptor, + devices, + notification, + platformDetails.codeVersion, + ); + + return notifications.map(({ deliveryID, notification: notif }) => ({ + deliveryID, + notification: notif, + })); +} + +export { + createAPNsBadgeOnlyNotification, + createAPNsNotificationRescind, + createAPNsVisualNotification, +}; diff --git a/lib/push/crypto.js b/lib/push/crypto.js --- a/lib/push/crypto.js +++ b/lib/push/crypto.js @@ -1,5 +1,7 @@ // @flow +import invariant from 'invariant'; + import type { PlainTextWebNotification, PlainTextWebNotificationPayload, @@ -13,6 +15,9 @@ NotificationTargetDevice, SenderDeviceDescriptor, EncryptedNotifUtilsAPI, + APNsVisualNotification, + APNsNotificationRescind, + APNsBadgeOnlyNotification, } from '../types/notif-types.js'; async function encryptAndroidNotificationPayload( @@ -83,6 +88,170 @@ } } +async function encryptAPNsVisualNotification( + encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI, + cookieID: string, + senderDeviceDescriptor: SenderDeviceDescriptor, + notification: APNsVisualNotification, + notificationSizeValidator?: APNsVisualNotification => boolean, + codeVersion?: ?number, + blobHolder?: ?string, +): Promise<{ + +notification: APNsVisualNotification, + +payloadSizeExceeded: boolean, + +encryptedPayloadHash?: string, + +encryptionOrder?: number, +}> { + const { + id, + headers, + aps: { badge, alert, sound }, + ...rest + } = notification; + + invariant( + !headers['apns-collapse-id'], + `Collapse ID can't be directly stored in apn.Notification object due ` + + `to security reasons. Please put it in payload property`, + ); + + let unencryptedPayload = { + ...rest, + aps: { sound }, + merged: alert, + badge, + }; + + if (blobHolder) { + unencryptedPayload = { ...unencryptedPayload, blobHolder }; + } + + try { + const unencryptedSerializedPayload = JSON.stringify(unencryptedPayload); + + let encryptedNotifAps = { 'mutable-content': 1 }; + if (codeVersion && codeVersion >= 254 && codeVersion % 2 === 0) { + encryptedNotifAps = { + ...encryptedNotifAps, + alert: { body: 'ENCRYPTED' }, + }; + } + + let dbPersistCondition; + if (notificationSizeValidator) { + dbPersistCondition = (encryptedPayload: string) => + notificationSizeValidator({ + ...senderDeviceDescriptor, + id, + headers, + encryptedPayload, + aps: encryptedNotifAps, + }); + } + + const { + encryptedData: serializedPayload, + sizeLimitViolated: dbPersistConditionViolated, + encryptionOrder, + } = await encryptedNotifUtilsAPI.encryptSerializedNotifPayload( + cookieID, + unencryptedSerializedPayload, + dbPersistCondition, + ); + + const encryptedPayloadHash = encryptedNotifUtilsAPI.getEncryptedNotifHash( + serializedPayload.body, + ); + + return { + notification: { + ...senderDeviceDescriptor, + id, + headers, + encryptedPayload: serializedPayload.body, + aps: encryptedNotifAps, + }, + payloadSizeExceeded: !!dbPersistConditionViolated, + encryptedPayloadHash, + encryptionOrder, + }; + } catch (e) { + console.log('Notification encryption failed: ' + e); + const unencryptedNotification = { ...notification, encryptionFailed: '1' }; + return { + notification: unencryptedNotification, + payloadSizeExceeded: notificationSizeValidator + ? notificationSizeValidator(unencryptedNotification) + : false, + }; + } +} + +async function encryptAPNsSilentNotification( + encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI, + cookieID: string, + senderDeviceDescriptor: SenderDeviceDescriptor, + notification: APNsNotificationRescind | APNsBadgeOnlyNotification, + codeVersion?: ?number, +): Promise<{ + +notification: APNsNotificationRescind | APNsBadgeOnlyNotification, + +encryptedPayloadHash?: string, + +encryptionOrder?: number, +}> { + const { + headers, + aps: { badge }, + ...rest + } = notification; + + let unencryptedPayload = { + ...rest, + }; + + if (badge !== null && badge !== undefined) { + unencryptedPayload = { ...unencryptedPayload, badge, aps: {} }; + } + + try { + const unencryptedSerializedPayload = JSON.stringify(unencryptedPayload); + + let encryptedNotifAps = { 'mutable-content': 1 }; + if (codeVersion && codeVersion >= 254 && codeVersion % 2 === 0) { + encryptedNotifAps = { + ...encryptedNotifAps, + alert: { body: 'ENCRYPTED' }, + }; + } + + const { encryptedData: serializedPayload, encryptionOrder } = + await encryptedNotifUtilsAPI.encryptSerializedNotifPayload( + cookieID, + unencryptedSerializedPayload, + ); + + const encryptedPayloadHash = encryptedNotifUtilsAPI.getEncryptedNotifHash( + serializedPayload.body, + ); + + return { + notification: { + ...senderDeviceDescriptor, + headers, + encryptedPayload: serializedPayload.body, + aps: encryptedNotifAps, + }, + encryptedPayloadHash, + encryptionOrder, + }; + } catch (e) { + console.log('Notification encryption failed: ' + e); + const unencryptedNotification = { ...notification, encryptionFailed: '1' }; + return { + notification: unencryptedNotification, + }; + } +} + async function encryptAndroidVisualNotification( encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI, senderDeviceDescriptor: SenderDeviceDescriptor, @@ -254,6 +423,66 @@ }; } +function prepareEncryptedAPNsVisualNotifications( + encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI, + senderDeviceDescriptor: SenderDeviceDescriptor, + devices: $ReadOnlyArray, + notification: APNsVisualNotification, + codeVersion?: ?number, + notificationSizeValidator?: APNsVisualNotification => boolean, +): Promise< + $ReadOnlyArray<{ + +cryptoID: string, + +deliveryID: string, + +notification: APNsVisualNotification, + +payloadSizeExceeded: boolean, + +encryptedPayloadHash?: string, + +encryptionOrder?: number, + }>, +> { + const notificationPromises = devices.map( + async ({ cryptoID, deliveryID, blobHolder }) => { + const notif = await encryptAPNsVisualNotification( + encryptedNotifUtilsAPI, + cryptoID, + senderDeviceDescriptor, + notification, + notificationSizeValidator, + codeVersion, + blobHolder, + ); + return { cryptoID, deliveryID, ...notif }; + }, + ); + return Promise.all(notificationPromises); +} + +function prepareEncryptedAPNsSilentNotifications( + encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI, + senderDeviceDescriptor: SenderDeviceDescriptor, + devices: $ReadOnlyArray, + notification: APNsNotificationRescind | APNsBadgeOnlyNotification, + codeVersion?: ?number, +): Promise< + $ReadOnlyArray<{ + +cryptoID: string, + +deliveryID: string, + +notification: APNsNotificationRescind | APNsBadgeOnlyNotification, + }>, +> { + const notificationPromises = devices.map(async ({ deliveryID, cryptoID }) => { + const { notification: notif } = await encryptAPNsSilentNotification( + encryptedNotifUtilsAPI, + cryptoID, + senderDeviceDescriptor, + notification, + codeVersion, + ); + return { cryptoID, deliveryID, notification: notif }; + }); + return Promise.all(notificationPromises); +} + function prepareEncryptedAndroidVisualNotifications( encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI, senderDeviceDescriptor: SenderDeviceDescriptor, @@ -361,6 +590,8 @@ } export { + prepareEncryptedAPNsVisualNotifications, + prepareEncryptedAPNsSilentNotifications, prepareEncryptedAndroidVisualNotifications, prepareEncryptedAndroidSilentNotifications, prepareEncryptedWebNotifications, diff --git a/lib/shared/notif-utils.js b/lib/shared/notif-utils.js --- a/lib/shared/notif-utils.js +++ b/lib/shared/notif-utils.js @@ -7,6 +7,7 @@ import type { NotificationTextsParams } from './messages/message-spec.js'; import { messageSpecs } from './messages/message-specs.js'; import { threadNoun } from './thread-utils.js'; +import { type PlatformDetails } from '../types/device-types.js'; import { type MessageType, messageTypes } from '../types/message-types-enum.js'; import { type MessageData, @@ -18,7 +19,11 @@ import type { CreateSidebarMessageInfo } from '../types/messages/create-sidebar.js'; import type { TextMessageInfo } from '../types/messages/text.js'; import type { ThreadInfo } from '../types/minimally-encoded-thread-permissions-types.js'; -import type { NotifTexts, ResolvedNotifTexts } from '../types/notif-types.js'; +import type { + NotifTexts, + ResolvedNotifTexts, + APNsNotificationTopic, +} from '../types/notif-types.js'; import { type ThreadType, threadTypes } from '../types/thread-types-enum.js'; import type { RelativeUserInfo, UserInfo } from '../types/user-types.js'; import { prettyDate } from '../utils/date-utils.js'; @@ -321,6 +326,17 @@ return { body: merged, title }; } +function getAPNsNotificationTopic( + platformDetails: PlatformDetails, +): APNsNotificationTopic { + if (platformDetails.platform === 'macos') { + return 'app.comm.macos'; + } + return platformDetails.codeVersion && platformDetails.codeVersion >= 87 + ? 'app.comm' + : 'org.squadcal.app'; +} + export { notifRobotextForMessageInfo, notifTextsForMessageInfo, @@ -329,4 +345,5 @@ notifTextsForSidebarCreation, getNotifCollapseKey, mergePrefixIntoBody, + getAPNsNotificationTopic, }; diff --git a/lib/types/notif-types.js b/lib/types/notif-types.js --- a/lib/types/notif-types.js +++ b/lib/types/notif-types.js @@ -37,6 +37,7 @@ tShape({ senderDeviceID: t.String }), ]); +// Web notifs types export type PlainTextWebNotificationPayload = { +body: string, +prefix?: string, @@ -61,6 +62,7 @@ | PlainTextWebNotification | EncryptedWebNotification; +// WNS notifs types export type PlainTextWNSNotification = { +body: string, +prefix?: string, @@ -79,13 +81,14 @@ | PlainTextWNSNotification | EncryptedWNSNotification; +// Android notifs types export type AndroidVisualNotificationPayloadBase = $ReadOnly<{ +badge?: string, +body: string, +title: string, +prefix?: string, +threadID: string, - +collapseKey?: string, + +collapseID?: string, +badgeOnly?: '0', +encryptionFailed?: '1', }>; @@ -184,6 +187,155 @@ +priority: 'normal', }; +// APNs notifs types +export type APNsNotificationTopic = + | 'app.comm.macos' + | 'app.comm' + | 'org.squadcal.app'; + +export type APNsNotificationHeaders = { + +'apns-priority'?: 1 | 5 | 10, + +'apns-id'?: string, + +'apns-expiration'?: number, + +'apns-topic': APNsNotificationTopic, + +'apns-collapse-id'?: string, + +'apns-push-type': 'background' | 'alert' | 'voip', +}; + +export type EncryptedAPNsSilentNotification = $ReadOnly<{ + ...SenderDeviceDescriptor, + +headers: APNsNotificationHeaders, + +encryptedPayload: string, + +aps: { +'mutable-content': number, +'alert'?: { body: 'ENCRYPTED' } }, +}>; + +export type EncryptedAPNsVisualNotification = $ReadOnly<{ + ...EncryptedAPNsSilentNotification, + +id: string, +}>; + +type APNsVisualNotificationPayloadBase = { + +aps: { + +'badge'?: string | number, + +'alert'?: string | { +body?: string, ... }, + +'thread-id': string, + +'mutable-content'?: number, + +'sound'?: string, + }, + +body: string, + +title: string, + +prefix?: string, + +threadID: string, + +collapseID?: string, + +encryptionFailed?: '1', +}; + +type APNsSmallVisualNotificationPayload = $ReadOnly<{ + ...APNsVisualNotificationPayloadBase, + +messageInfos?: string, +}>; + +type APNsLargeVisualNotificationPayload = $ReadOnly<{ + ...APNsVisualNotificationPayloadBase, + +blobHash: string, + +encryptionKey: string, +}>; + +export type APNsVisualNotification = + | $ReadOnly<{ + +headers: APNsNotificationHeaders, + +id: string, + ... + | APNsSmallVisualNotificationPayload + | APNsLargeVisualNotificationPayload, + }> + | EncryptedAPNsVisualNotification; + +type APNsLegacyRescindPayload = { + +backgroundNotifType: 'CLEAR', + +notificationId: string, + +setUnreadStatus: true, + +threadID: string, + +aps: { + +'badge': string | number, + +'content-available': number, + }, +}; + +type APNsKeyserverRescindPayload = { + +backgroundNotifType: 'CLEAR', + +notificationId: string, + +setUnreadStatus: true, + +threadID: string, + +aps: { + +'badge': string | number, + +'mutable-content': number, + }, +}; + +type APNsThickThreadRescindPayload = { + +backgroundNotifType: 'CLEAR', + +setUnreadStatus: true, + +threadID: string, + +aps: { + +'mutable-content': number, + }, +}; + +export type APNsNotificationRescind = + | $ReadOnly<{ + +headers: APNsNotificationHeaders, + +encryptionFailed?: '1', + ... + | APNsLegacyRescindPayload + | APNsKeyserverRescindPayload + | APNsThickThreadRescindPayload, + }> + | EncryptedAPNsSilentNotification; + +type APNsLegacyBadgeOnlyNotification = { + +aps: { + +badge: string | number, + }, +}; + +type APNsKeyserverBadgeOnlyNotification = { + +aps: { + +'badge': string | number, + +'mutable-content': number, + }, +}; + +type APNsThickThreadBadgeOnlyNotification = { + +aps: { + +'mutable-content': number, + }, + +threadID: string, +}; + +export type APNsBadgeOnlyNotification = + | $ReadOnly<{ + +headers: APNsNotificationHeaders, + +encryptionFailed?: '1', + ... + | APNsLegacyBadgeOnlyNotification + | APNsKeyserverBadgeOnlyNotification + | APNsThickThreadBadgeOnlyNotification, + }> + | EncryptedAPNsSilentNotification; + +export type APNsNotification = + | APNsVisualNotification + | APNsNotificationRescind + | APNsBadgeOnlyNotification; + +export type TargetedAPNsNotification = { + +notification: APNsNotification, + +deliveryID: string, + +encryptedPayloadHash?: string, + +encryptionOrder?: number, +}; + export type TargetedAndroidNotification = $ReadOnly<{ ...AndroidNotificationWithPriority, +deliveryID: string, @@ -230,4 +382,5 @@ | { +blobUploadError: string }, >, +getNotifByteSize: (serializedNotification: string) => number, + +getEncryptedNotifHash: (serializedNotification: string) => string, };