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 @@ -375,7 +375,6 @@ newRawMessageInfos: shimmedNewRawMessageInfos, threadID: threadInfo.id, collapseKey: notifInfo.collapseKey, - badgeOnly, unreadCount, platformDetails, dbID, @@ -900,26 +899,36 @@ return byPlatform; } -type APNsNotifInputData = { +type CommonNativeNotifInputData = { +keyserverID: string, +notifTexts: ResolvedNotifTexts, +newRawMessageInfos: RawMessageInfo[], +threadID: string, +collapseKey: ?string, - +badgeOnly: boolean, +unreadCount: number, +platformDetails: PlatformDetails, }; -const apnsNotifInputDataValidator = tShape({ + +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, }); + +type APNsNotifInputData = { + ...CommonNativeNotifInputData, + +badgeOnly: boolean, +}; + +const apnsNotifInputDataValidator = tShape({ + ...commonNativeNotifInputDataValidator.meta.props, + badgeOnly: t.Boolean, +}); + async function prepareAPNsNotification( inputData: APNsNotifInputData, devices: $ReadOnlyArray, @@ -1152,11 +1161,11 @@ } type AndroidNotifInputData = { - ...APNsNotifInputData, + ...CommonNativeNotifInputData, +dbID: string, }; const androidNotifInputDataValidator = tShape({ - ...apnsNotifInputDataValidator.meta.props, + ...commonNativeNotifInputDataValidator.meta.props, dbID: t.String, }); async function prepareAndroidNotification( @@ -1174,19 +1183,23 @@ newRawMessageInfos, threadID, collapseKey, - badgeOnly, unreadCount, - platformDetails: { codeVersion }, + platformDetails, dbID, } = convertedData; - const canDecryptNonCollapsibleTextNotifs = codeVersion && codeVersion > 228; + const canDecryptNonCollapsibleTextNotifs = hasMinCodeVersion( + platformDetails, + { native: 228 }, + ); const isNonCollapsibleTextNotif = newRawMessageInfos.every( newRawMessageInfo => newRawMessageInfo.type === messageTypes.TEXT, ) && !collapseKey; - const canDecryptAllNotifTypes = codeVersion && codeVersion >= 267; + const canDecryptAllNotifTypes = hasMinCodeVersion(platformDetails, { + native: 267, + }); const shouldBeEncrypted = canDecryptAllNotifTypes || @@ -1215,19 +1228,11 @@ notifID = dbID; } - // The reason we only include `badgeOnly` for newer clients is because older - // clients don't know how to parse it. The reason we only include `id` for - // newer clients is that if the older clients see that field, they assume - // the notif has a full payload, and then crash when trying to parse it. - // By skipping `id` we allow old clients to still handle in-app notifs and - // badge updating. - if (!badgeOnly || (codeVersion && codeVersion >= 69)) { - notification.data = { - ...notification.data, - id: notifID, - badgeOnly: badgeOnly ? '1' : '0', - }; - } + notification.data = { + ...notification.data, + id: notifID, + badgeOnly: '0', + }; const messageInfos = JSON.stringify(newRawMessageInfos); const copyWithMessageInfos = { @@ -1276,6 +1281,33 @@ ); } + const canQueryBlobService = hasMinCodeVersion(platformDetails, { + native: NEXT_CODE_VERSION, + }); + + let blobHash, encryptionKey, blobUploadError; + if (canQueryBlobService) { + ({ blobHash, encryptionKey, blobUploadError } = await blobServiceUpload( + JSON.stringify(copyWithMessageInfos.data), + 1, + )); + } + + if (blobUploadError) { + console.warn( + `Failed to upload payload of notification: ${notifID} ` + + `due to error: ${blobUploadError}`, + ); + } + + if (blobHash && encryptionKey) { + notification.data = { + ...notification.data, + blobHash, + encryptionKey, + }; + } + const notifsWithoutMessageInfos = await prepareEncryptedAndroidNotifications( devicesWithExcessiveSize, notification, diff --git a/native/android/app/src/main/java/app/comm/android/commservices/CommAndroidBlobClient.java b/native/android/app/src/main/java/app/comm/android/commservices/CommAndroidBlobClient.java new file mode 100644 --- /dev/null +++ b/native/android/app/src/main/java/app/comm/android/commservices/CommAndroidBlobClient.java @@ -0,0 +1,75 @@ +package app.comm.android.commservices; + +import app.comm.android.BuildConfig; +import app.comm.android.fbjni.CommSecureStore; +import java.io.IOException; +import java.lang.OutOfMemoryError; +import java.util.Base64; +import java.util.concurrent.TimeUnit; +import okhttp3.Call; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; +import org.json.JSONException; +import org.json.JSONObject; + +public class CommAndroidBlobClient { + private static final String BLOB_SERVICE_URL = BuildConfig.DEBUG + ? "https://blob.staging.commtechnologies.org" + : "https://blob.commtechnologies.org"; + // The FirebaseMessagingService docs state that message + // processing should complete within at most 20 seconds + // window. Therefore we limit http time call to 15 seconds + // https://firebase.google.com/docs/cloud-messaging/android/receive#handling_messages + private static final int NOTIF_PROCESSING_TIME_LIMIT_SECONDS = 15; + // OkHttp docs advise to share OkHttpClient instances + // https://square.github.io/okhttp/4.x/okhttp/okhttp3/-ok-http-client/#okhttpclients-should-be-shared + private static final OkHttpClient httpClient = + new OkHttpClient.Builder() + .callTimeout(NOTIF_PROCESSING_TIME_LIMIT_SECONDS, TimeUnit.SECONDS) + .build(); + + public byte[] getBlobSync(String blobHash) throws IOException, JSONException { + String authToken = getAuthToken(); + Request request = new Request.Builder() + .get() + .url(BLOB_SERVICE_URL + "/blob/" + blobHash) + .header("Authorization", authToken) + .build(); + + Response response = httpClient.newCall(request).execute(); + if (!response.isSuccessful()) { + throw new RuntimeException( + "Failed to download blob from blob service. Response error code: " + + response); + } + return response.body().bytes(); + } + + private String getAuthToken() throws JSONException { + // Authentication data are retrieved on every request + // since they might change while CommNotificationsHandler + // thread is running so we should not rely on caching + // them in memory. + + String userID = CommSecureStore.get("userID"); + String accessToken = CommSecureStore.get("accessToken"); + String deviceID = CommSecureStore.get("deviceID"); + + userID = userID == null ? "" : userID; + accessToken = accessToken == null ? "" : accessToken; + deviceID = deviceID == null ? "" : deviceID; + + String authObjectJsonBody = new JSONObject() + .put("userID", userID) + .put("accessToken", accessToken) + .put("deviceID", deviceID) + .toString(); + + String encodedAuthObjectJsonBody = + Base64.getEncoder().encodeToString(authObjectJsonBody.getBytes()); + + return "Bearer " + encodedAuthObjectJsonBody; + } +} 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 --- 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 @@ -18,6 +18,8 @@ import app.comm.android.ExpoUtils; import app.comm.android.MainActivity; import app.comm.android.R; +import app.comm.android.aescrypto.AESCryptoModuleCompat; +import app.comm.android.commservices.CommAndroidBlobClient; import app.comm.android.fbjni.CommMMKV; import app.comm.android.fbjni.CommSecureStore; import app.comm.android.fbjni.GlobalDBSingleton; @@ -29,9 +31,13 @@ import com.google.firebase.messaging.FirebaseMessagingService; import com.google.firebase.messaging.RemoteMessage; import java.io.File; +import java.io.IOException; +import java.lang.OutOfMemoryError; import java.lang.StringBuilder; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Arrays; +import java.util.Base64; import me.leolin.shortcutbadger.ShortcutBadger; import org.json.JSONException; import org.json.JSONObject; @@ -39,11 +45,12 @@ public class CommNotificationsHandler extends FirebaseMessagingService { private static final String BADGE_KEY = "badge"; private static final String BADGE_ONLY_KEY = "badgeOnly"; - private static final String BACKGROUND_NOTIF_TYPE_KEY = "backgroundNotifType"; private static final String SET_UNREAD_STATUS_KEY = "setUnreadStatus"; private static final String NOTIF_ID_KEY = "id"; private static final String ENCRYPTED_PAYLOAD_KEY = "encryptedPayload"; private static final String ENCRYPTION_FAILED_KEY = "encryptionFailed"; + private static final String BLOB_HASH_KEY = "blobHash"; + private static final String AES_ENCRYPTION_KEY_LABEL = "encryptionKey"; private static final String GROUP_NOTIF_IDS_KEY = "groupNotifIDs"; private static final String COLLAPSE_ID_KEY = "collapseKey"; private static final String KEYSERVER_ID_KEY = "keyserverID"; @@ -58,6 +65,8 @@ private Bitmap displayableNotificationLargeIcon; private NotificationManager notificationManager; private LocalBroadcastManager localBroadcastManager; + private CommAndroidBlobClient blobClient; + private AESCryptoModuleCompat aesCryptoModule; public static final String RESCIND_KEY = "rescind"; public static final String RESCIND_ID_KEY = "rescindID"; @@ -80,6 +89,8 @@ localBroadcastManager = LocalBroadcastManager.getInstance(this); displayableNotificationLargeIcon = BitmapFactory.decodeResource( this.getApplicationContext().getResources(), R.mipmap.ic_launcher); + blobClient = new CommAndroidBlobClient(); + aesCryptoModule = new AESCryptoModuleCompat(); } @Override @@ -103,7 +114,7 @@ if (message.getData().get(ENCRYPTED_PAYLOAD_KEY) != null) { try { - message = this.decryptRemoteMessage(message, senderKeyserverID); + message = this.olmDecryptRemoteMessage(message, senderKeyserverID); } catch (JSONException e) { Log.w("COMM", "Malformed notification JSON payload.", e); return; @@ -137,19 +148,13 @@ return; } - String backgroundNotifType = - message.getData().get(BACKGROUND_NOTIF_TYPE_KEY); + if (message.getData().get(MESSAGE_INFOS_KEY) != null) { + handleMessageInfosPersistence(message); + } - String rawMessageInfosString = message.getData().get(MESSAGE_INFOS_KEY); - File sqliteFile = - this.getApplicationContext().getDatabasePath("comm.sqlite"); - if (rawMessageInfosString != null && sqliteFile.exists()) { - GlobalDBSingleton.scheduleOrRun(() -> { - MessageOperationsUtilities.storeMessageInfos( - sqliteFile.getPath(), rawMessageInfosString); - }); - } else if (rawMessageInfosString != null) { - Log.w("COMM", "Database not existing yet. Skipping notification"); + if (message.getData().get(BLOB_HASH_KEY) != null && + message.getData().get(AES_ENCRYPTION_KEY_LABEL) != null) { + handleLargeNotification(message); } Intent intent = new Intent(MESSAGE_EVENT); @@ -273,6 +278,31 @@ } } + private void handleMessageInfosPersistence(RemoteMessage message) { + String rawMessageInfosString = message.getData().get(MESSAGE_INFOS_KEY); + File sqliteFile = + this.getApplicationContext().getDatabasePath("comm.sqlite"); + if (rawMessageInfosString != null && sqliteFile.exists()) { + GlobalDBSingleton.scheduleOrRun(() -> { + MessageOperationsUtilities.storeMessageInfos( + sqliteFile.getPath(), rawMessageInfosString); + }); + } else if (rawMessageInfosString != null) { + Log.w("COMM", "Database not existing yet. Skipping notification"); + } + } + + private void handleLargeNotification(RemoteMessage message) { + String blobHash = message.getData().get(BLOB_HASH_KEY); + try { + byte[] largePayload = blobClient.getBlobSync(blobHash); + message = aesDecryptRemoteMessage(message, largePayload); + handleMessageInfosPersistence(message); + } catch (Exception e) { + Log.w("COMM", "Failure when handling large notification.", e); + } + } + private void addToThreadGroupAndDisplay( String notificationID, NotificationCompat.Builder notificationBuilder, @@ -387,16 +417,10 @@ PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_MUTABLE); } - private RemoteMessage - decryptRemoteMessage(RemoteMessage message, String senderKeyserverID) + private RemoteMessage updateRemoteMessageWithDecryptedPayload( + RemoteMessage message, + String decryptedSerializedPayload) throws JSONException, IllegalStateException { - String encryptedSerializedPayload = - message.getData().get(ENCRYPTED_PAYLOAD_KEY); - String decryptedSerializedPayload = NotificationsCryptoModule.decrypt( - senderKeyserverID, - encryptedSerializedPayload, - NotificationsCryptoModule.olmEncryptedTypeMessage()); - JSONObject decryptedPayload = new JSONObject(decryptedSerializedPayload); ((Iterable)() -> decryptedPayload.keys()) @@ -413,6 +437,38 @@ return message; } + private RemoteMessage + olmDecryptRemoteMessage(RemoteMessage message, String senderKeyserverID) + throws JSONException, IllegalStateException { + String encryptedSerializedPayload = + message.getData().get(ENCRYPTED_PAYLOAD_KEY); + String decryptedSerializedPayload = NotificationsCryptoModule.decrypt( + senderKeyserverID, + encryptedSerializedPayload, + NotificationsCryptoModule.olmEncryptedTypeMessage()); + + return updateRemoteMessageWithDecryptedPayload( + message, decryptedSerializedPayload); + } + + private RemoteMessage + aesDecryptRemoteMessage(RemoteMessage message, byte[] blob) + throws JSONException, IllegalStateException { + String aesEncryptionKey = message.getData().get(AES_ENCRYPTION_KEY_LABEL); + // On the keyserver AES key is generated as raw bytes + // so to send it in JSON it is encoded to Base64 string. + byte[] aesEncryptionKeyBytes = Base64.getDecoder().decode(aesEncryptionKey); + // On the keyserver notification is a string so it is + // first encoded into UTF8 bytes. Therefore bytes + // obtained from blob decryption are correct UTF8 bytes. + String decryptedSerializedPayload = new String( + aesCryptoModule.decrypt(aesEncryptionKeyBytes, blob), + StandardCharsets.UTF_8); + + return updateRemoteMessageWithDecryptedPayload( + message, decryptedSerializedPayload); + } + private Bundle serializeMessageDataForIntentAttachment(RemoteMessage message) { Bundle bundle = new Bundle();