diff --git a/native/android/app/src/main/java/app/comm/android/notifications/CommAndroidNotificationsBlobServiceClient.java b/native/android/app/src/main/java/app/comm/android/notifications/CommAndroidNotificationsBlobServiceClient.java new file mode 100644 --- /dev/null +++ b/native/android/app/src/main/java/app/comm/android/notifications/CommAndroidNotificationsBlobServiceClient.java @@ -0,0 +1,103 @@ +package app.comm.android.notifications; + +import android.util.Log; +import com.google.firebase.messaging.RemoteMessage; +import java.io.IOException; +import java.lang.OutOfMemoryError; +import java.util.Base64; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; +import okhttp3.Call; +import okhttp3.Callback; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; +import org.json.JSONException; +import org.json.JSONObject; + +public class CommAndroidNotificationsBlobServiceClient { + private static final String BLOB_SERVICE_URL = + "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(); + + @FunctionalInterface + public interface BlobServiceMessageConsumer { + void accept(RemoteMessage message, byte[] blob); + } + + public void getAndConsumeAsync( + String blobHash, + RemoteMessage message, + BlobServiceMessageConsumer successConsumer, + Consumer fallbackConsumer) { + String authToken = getAuthToken(); + Request request = new Request.Builder() + .get() + .url(BLOB_SERVICE_URL + "/blob/" + blobHash) + .header("Authorization", authToken) + .build(); + + httpClient.newCall(request).enqueue(new Callback() { + @Override + public void onFailure(Call call, IOException e) { + fallbackConsumer.accept(message); + Log.w("COMM", "Failed to download blob from blob service.", e); + call.cancel(); + } + + @Override + public void onResponse(Call call, Response response) { + try { + successConsumer.accept(message, response.body().bytes()); + } catch (OutOfMemoryError e) { + fallbackConsumer.accept(message); + Log.w("COMM", "Notification payload exceeds available memory.", e); + } catch (IOException e) { + fallbackConsumer.accept(message); + Log.w("COMM", "Unable to get payload from HTTP response.", e); + } + } + }); + } + + private String getAuthToken() { + // 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. + + // TODO: retrieve those values from CommSecureStore + + String userID = "placeholder"; + String accessToken = "placeholder"; + String deviceID = "placeholder"; + + String authObjectJsonBody; + try { + authObjectJsonBody = new JSONObject() + .put("userID", userID) + .put("accessToken", accessToken) + .put("deviceID", deviceID) + .toString(); + } catch (JSONException e) { + Log.w("COMM", "Failed to build authentication token as JSON object.", e); + return null; + } + + 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 @@ -9,7 +9,6 @@ import android.graphics.BitmapFactory; import android.os.Bundle; import android.service.notification.StatusBarNotification; -import android.util.JsonReader; import android.util.Log; import androidx.core.app.NotificationCompat; import androidx.lifecycle.Lifecycle; @@ -18,6 +17,7 @@ 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.fbjni.CommSecureStore; import app.comm.android.fbjni.GlobalDBSingleton; import app.comm.android.fbjni.MessageOperationsUtilities; @@ -27,6 +27,10 @@ import com.google.firebase.messaging.FirebaseMessagingService; import com.google.firebase.messaging.RemoteMessage; import java.io.File; +import java.io.IOException; +import java.lang.Thread; +import java.nio.charset.StandardCharsets; +import java.util.Base64; import me.leolin.shortcutbadger.ShortcutBadger; import org.json.JSONException; import org.json.JSONObject; @@ -39,12 +43,16 @@ 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_KEY = "encryptionKey"; private static final String CHANNEL_ID = "default"; private static final long[] VIBRATION_SPEC = {500, 500}; private Bitmap displayableNotificationLargeIcon; private NotificationManager notificationManager; private LocalBroadcastManager localBroadcastManager; + private CommAndroidNotificationsBlobServiceClient blobServiceClient; + private AESCryptoModuleCompat aesCryptoModule; public static final String RESCIND_KEY = "rescind"; public static final String RESCIND_ID_KEY = "rescindID"; @@ -67,6 +75,8 @@ localBroadcastManager = LocalBroadcastManager.getInstance(this); displayableNotificationLargeIcon = BitmapFactory.decodeResource( this.getApplicationContext().getResources(), R.mipmap.ic_launcher); + blobServiceClient = new CommAndroidNotificationsBlobServiceClient(); + aesCryptoModule = new AESCryptoModuleCompat(); } @Override @@ -80,7 +90,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; @@ -88,7 +98,7 @@ Log.w("COMM", "Android notification type violation.", e); return; } catch (Exception e) { - Log.w("COMM", "Notification decryption failure.", e); + Log.w("COMM", "Notification olm decryption failure.", e); return; } } else if ("1".equals(message.getData().get(ENCRYPTION_FAILED_KEY))) { @@ -97,6 +107,34 @@ "Received unencrypted notification for client with existing olm session for notifications"); } + String blobHash = message.getData().get(BLOB_HASH_KEY); + if (blobHash != null) { + blobServiceClient.getAndConsumeAsync( + blobHash, + message, + (remoteMessage, blob) + -> { + try { + remoteMessage = aesDecryptRemoteMessage(remoteMessage, blob); + persistAndDisplayUnencryptedMessage(remoteMessage); + } catch (JSONException e) { + Log.w("COMM", "Malformed notification JSON payload.", e); + } catch (IllegalStateException e) { + Log.w("COMM", "Android notification type violation.", e); + } catch (Exception e) { + Log.w("COMM", "Notification aes decryption failure.", e); + } + }, + (remoteMessage) -> { + persistAndDisplayUnencryptedMessage(remoteMessage); + }); + return; + } + + persistAndDisplayUnencryptedMessage(message); + } + + private void persistAndDisplayUnencryptedMessage(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) { @@ -288,15 +326,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()) @@ -313,6 +346,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_KEY); + // 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();