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 @@ -1163,17 +1163,22 @@ 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 || @@ -1202,17 +1207,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)) { + if (!badgeOnly) { notification.data = { ...notification.data, id: notifID, - badgeOnly: badgeOnly ? '1' : '0', + badgeOnly: '0', }; } @@ -1263,6 +1262,32 @@ ); } + const canQueryBlobService = hasMinCodeVersion(platformDetails, { + native: NEXT_CODE_VERSION, + }); + + let blobHash, encryptionKey, blobUploadError; + if (canQueryBlobService) { + ({ blobHash, encryptionKey, blobUploadError } = await blobServiceUpload( + JSON.stringify(copyWithMessageInfos.data), + )); + } + + 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,76 @@ +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 NOTIFICATION_PROCESSING_TIME_LIMIT = 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(NOTIFICATION_PROCESSING_TIME_LIMIT, TimeUnit.SECONDS) + .build(); + + public byte[] getBlobSync(String blobHash) + throws OutOfMemoryError, IOException, Exception { + 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; + } +} \ No newline at end of file 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 @@ -93,7 +104,7 @@ public void onMessageReceived(RemoteMessage message) { if (message.getData().get(ENCRYPTED_PAYLOAD_KEY) != null) { try { - message = this.decryptRemoteMessage(message); + message = this.olmDecryptRemoteMessage(message); } catch (JSONException e) { Log.w("COMM", "Malformed notification JSON payload.", e); return; @@ -127,19 +138,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); @@ -263,6 +268,39 @@ } } + 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 (JSONException e) { + Log.w("COMM", "Malformed notification JSON payload", e); + } catch (IOException e) { + Log.w("COMM", "I/O exception during large notification handling.", e); + } catch (OutOfMemoryError e) { + Log.w("COMM", "Notification payload to large to fit into memory.", e); + } catch (IllegalStateException e) { + Log.w("COMM", "Android notification type violation.", e); + } catch (Exception e) { + Log.w("COMM", "Failure when handling large notification.", e); + } + } + private void addToThreadGroupAndDisplay( String notificationID, NotificationCompat.Builder notificationBuilder, @@ -377,15 +415,10 @@ PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_MUTABLE); } - private RemoteMessage decryptRemoteMessage(RemoteMessage message) + private RemoteMessage updateRemoteMessageWithDecryptedPayload( + RemoteMessage message, + String decryptedSerializedPayload) throws JSONException, IllegalStateException { - String encryptedSerializedPayload = - message.getData().get(ENCRYPTED_PAYLOAD_KEY); - String decryptedSerializedPayload = NotificationsCryptoModule.decrypt( - encryptedSerializedPayload, - NotificationsCryptoModule.olmEncryptedTypeMessage(), - "CommNotificationsHandler"); - JSONObject decryptedPayload = new JSONObject(decryptedSerializedPayload); ((Iterable)() -> decryptedPayload.keys()) @@ -402,6 +435,37 @@ return message; } + private RemoteMessage olmDecryptRemoteMessage(RemoteMessage message) + throws JSONException, IllegalStateException { + String encryptedSerializedPayload = + message.getData().get(ENCRYPTED_PAYLOAD_KEY); + String decryptedSerializedPayload = NotificationsCryptoModule.decrypt( + encryptedSerializedPayload, + NotificationsCryptoModule.olmEncryptedTypeMessage(), + "CommNotificationsHandler"); + + 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();