From 46acd7878d57f58f1a5c3ba19a5f1902afccbacd Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Thu, 18 May 2023 18:03:00 -0300 Subject: [PATCH] New SPNS subscription and notifications Finishes the WIP for subscribing to push notifications and handling the new-style pushes we get. --- .../notifications/FirebasePushManager.kt | 46 +++++++++---------- .../notifications/PushNotificationService.kt | 20 ++++---- .../sending_receiving/notifications/Models.kt | 39 +++++++++++++--- .../notifications/PushNotificationAPI.kt | 2 +- .../messaging/utilities/SodiumUtilities.kt | 2 +- 5 files changed, 69 insertions(+), 40 deletions(-) diff --git a/app/src/play/kotlin/org/thoughtcrime/securesms/notifications/FirebasePushManager.kt b/app/src/play/kotlin/org/thoughtcrime/securesms/notifications/FirebasePushManager.kt index 39705c75d..12e62cee4 100644 --- a/app/src/play/kotlin/org/thoughtcrime/securesms/notifications/FirebasePushManager.kt +++ b/app/src/play/kotlin/org/thoughtcrime/securesms/notifications/FirebasePushManager.kt @@ -8,6 +8,7 @@ import com.goterl.lazysodium.interfaces.Sign import com.goterl.lazysodium.utils.Key import com.goterl.lazysodium.utils.KeyPair import kotlinx.coroutines.Job +import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import kotlinx.serialization.json.decodeFromStream @@ -17,6 +18,7 @@ import okhttp3.MediaType import okhttp3.Request import okhttp3.RequestBody import org.session.libsession.messaging.sending_receiving.notifications.PushNotificationAPI +import org.session.libsession.messaging.sending_receiving.notifications.PushNotificationMetadata import org.session.libsession.messaging.sending_receiving.notifications.SubscriptionRequest import org.session.libsession.messaging.sending_receiving.notifications.SubscriptionResponse import org.session.libsession.messaging.utilities.SodiumUtilities @@ -26,7 +28,6 @@ import org.session.libsession.snode.Version import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.TextSecurePreferences.Companion.getLocalNumber import org.session.libsession.utilities.bencode.Bencode -import org.session.libsession.utilities.bencode.BencodeDict import org.session.libsession.utilities.bencode.BencodeList import org.session.libsession.utilities.bencode.BencodeString import org.session.libsignal.utilities.Base64 @@ -60,35 +61,32 @@ class FirebasePushManager(private val context: Context, private val prefs: TextS ) } - fun decrypt(encPayload: ByteArray) { + fun decrypt(encPayload: ByteArray): ByteArray? { val encKey = getOrCreateNotificationKey() val nonce = encPayload.take(AEAD.XCHACHA20POLY1305_IETF_NPUBBYTES).toByteArray() val payload = encPayload.drop(AEAD.XCHACHA20POLY1305_IETF_NPUBBYTES).toByteArray() - val decrypted = SodiumUtilities.decrypt(payload, encKey.asBytes, nonce) - ?: return Log.e("Loki", "Failed to decrypt push notification") + val padded = SodiumUtilities.decrypt(payload, encKey.asBytes, nonce) + ?: error("Failed to decrypt push notification") + val decrypted = padded.dropLastWhile { it.toInt() == 0 }.toByteArray() val bencoded = Bencode.Decoder(decrypted) - val expectedList = (bencoded.decode() as? BencodeList) - ?: return Log.e("Loki", "Failed to decode bencoded list from payload") + val expectedList = (bencoded.decode() as? BencodeList)?.values + ?: error("Failed to decode bencoded list from payload") - val (metadata, content) = expectedList.values - val metadataDict = (metadata as? BencodeDict)?.values - ?: return Log.e("Loki", "Failed to decode metadata dict") + val metadataJson = (expectedList[0] as? BencodeString)?.value + ?: error("no metadata") + val metadata:PushNotificationMetadata = Json.decodeFromString(String(metadataJson)) - val push = """ - Push metadata received was: - @: ${metadataDict["@"]} - #: ${metadataDict["#"]} - n: ${metadataDict["n"]} - l: ${metadataDict["l"]} - B: ${metadataDict["B"]} - """.trimIndent() + val content: ByteArray? = if (expectedList.size >= 2) (expectedList[1] as? BencodeString)?.value else null + // null content is valid only if we got a "data_too_long" flag + if (content == null) + check(metadata.data_too_long) { "missing message data, but no too-long flag" } + else + check(metadata.data_len == content.size) { "wrong message data size" } - Log.d("Loki", "push") + Log.d("Loki", + "Received push for ${metadata.account}/${metadata.namespace}, msg ${metadata.msg_hash}, ${metadata.data_len}B") - val contentBytes = (content as? BencodeString)?.value - ?: return Log.e("Loki", "Failed to decode content string") - - // TODO: something with contentBytes + return content } override fun register(force: Boolean) { @@ -158,10 +156,10 @@ class FirebasePushManager(private val context: Context, private val prefs: TextS TextSecurePreferences.setLastFCMUploadTime(context, System.currentTimeMillis()) } else { val (_, message) = response.errorInfo() - Log.d("Loki", "Couldn't register for FCM due to error: $message.") + Log.e("Loki", "Couldn't register for FCM due to error: $message.") } }.fail { exception -> - Log.d("Loki", "Couldn't register for FCM due to error: ${exception}.") + Log.e("Loki", "Couldn't register for FCM due to error: ${exception}.") } } } diff --git a/app/src/play/kotlin/org/thoughtcrime/securesms/notifications/PushNotificationService.kt b/app/src/play/kotlin/org/thoughtcrime/securesms/notifications/PushNotificationService.kt index 4c268ce18..bb625bc54 100644 --- a/app/src/play/kotlin/org/thoughtcrime/securesms/notifications/PushNotificationService.kt +++ b/app/src/play/kotlin/org/thoughtcrime/securesms/notifications/PushNotificationService.kt @@ -28,15 +28,19 @@ class PushNotificationService : FirebaseMessagingService() { override fun onMessageReceived(message: RemoteMessage) { Log.d("Loki", "Received a push notification.") - if (message.data.containsKey("spns")) { - // assume this is the new push notification content - // deal with the enc payload (probably decrypting through the PushManager? - Log.d("Loki", "TODO: deal with the enc_payload\n${message.data["enc_payload"]}") - pushManager.decrypt(Base64.decode(message.data["enc_payload"])) - return + val data: ByteArray? = if (message.data.containsKey("spns")) { + // this is a v2 push notification + try { + pushManager.decrypt(Base64.decode(message.data["enc_payload"])) + } catch(e: Exception) { + Log.e("Loki", "Invalid push notification: ${e.message}") + return + } + } else { + // old v1 push notification; we still need this for receiving legacy closed group notifications + val base64EncodedData = message.data?.get("ENCRYPTED_DATA") + base64EncodedData?.let { Base64.decode(it) } } - val base64EncodedData = message.data?.get("ENCRYPTED_DATA") - val data = base64EncodedData?.let { Base64.decode(it) } if (data != null) { try { val envelopeAsData = MessageWrapper.unwrap(data).toByteArray() diff --git a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/notifications/Models.kt b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/notifications/Models.kt index 78b6cd4a1..e7a0ad79a 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/notifications/Models.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/notifications/Models.kt @@ -7,7 +7,8 @@ import kotlinx.serialization.Serializable /** * N.B. all of these variable names will be named the same as the actual JSON utf-8 request/responses expected from the server. * Changing the variable names will break how data is serialized/deserialized. - * If it's less than ideally named we can use [SerialName] + * If it's less than ideally named we can use [SerialName], such as for the push metadata which uses + * single-letter keys to be as compact as possible. */ @Serializable @@ -37,11 +38,11 @@ data class SubscriptionRequest( @Serializable data class SubscriptionResponse( - val error: Int?, - val message: String?, - val success: Boolean?, - val added: Boolean?, - val updated: Boolean?, + val error: Int? = null, + val message: String? = null, + val success: Boolean? = null, + val added: Boolean? = null, + val updated: Boolean? = null, ) { companion object { /** invalid values, missing reuqired arguments etc, details in message */ @@ -59,6 +60,32 @@ data class SubscriptionResponse( } else null to null } +@Serializable +data class PushNotificationMetadata( + /** Account ID (such as Session ID or closed group ID) where the message arrived **/ + @SerialName("@") + val account: String, + + /** The hash of the message in the swarm. */ + @SerialName("#") + val msg_hash: String, + + /** The swarm namespace in which this message arrived. */ + @SerialName("n") + val namespace: Int, + + /** The length of the message data. This is always included, even if the message content + * itself was too large to fit into the push notification. */ + @SerialName("l") + val data_len: Int, + + /** This will be true if the data was omitted because it was too long to fit in a push + * notification (around 2.5kB of raw data), in which case the push notification includes + * only this metadata but not the message content itself. */ + @SerialName("B") + val data_too_long : Boolean = false +) + @Serializable data class PushNotificationServerObject( val enc_payload: String, diff --git a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/notifications/PushNotificationAPI.kt b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/notifications/PushNotificationAPI.kt index cf037b019..9016f30eb 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/notifications/PushNotificationAPI.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/notifications/PushNotificationAPI.kt @@ -17,7 +17,7 @@ import org.session.libsignal.utilities.retryIfNeeded object PushNotificationAPI { val context = MessagingModuleConfiguration.shared.context val server = "https://push.getsession.org" - val serverPublicKey: String = TODO("get the new server pubkey here") + val serverPublicKey: String = "d7557fe563e2610de876c0ac7341b62f3c82d5eea4b62c702392ea4368f51b3b" private val legacyServer = "https://live.apns.getsession.org" private val legacyServerPublicKey = "642a6585919742e5a2d4dc51244964fbcd8bcab2b75612407de58b810740d049" private val maxRetryCount = 4 diff --git a/libsession/src/main/java/org/session/libsession/messaging/utilities/SodiumUtilities.kt b/libsession/src/main/java/org/session/libsession/messaging/utilities/SodiumUtilities.kt index 9a1de4f2d..079caee23 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/utilities/SodiumUtilities.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/utilities/SodiumUtilities.kt @@ -205,7 +205,7 @@ object SodiumUtilities { } fun decrypt(ciphertext: ByteArray, decryptionKey: ByteArray, nonce: ByteArray): ByteArray? { - val plaintextSize = ciphertext.size - AEAD.CHACHA20POLY1305_ABYTES + val plaintextSize = ciphertext.size - AEAD.XCHACHA20POLY1305_IETF_ABYTES val plaintext = ByteArray(plaintextSize) return if (sodium.cryptoAeadXChaCha20Poly1305IetfDecrypt( plaintext,