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 65314d76d4..d6b738d8c4 100644 --- a/app/src/play/kotlin/org/thoughtcrime/securesms/notifications/FirebasePushManager.kt +++ b/app/src/play/kotlin/org/thoughtcrime/securesms/notifications/FirebasePushManager.kt @@ -18,7 +18,7 @@ import nl.komponents.kovenant.functional.map import okhttp3.MediaType import okhttp3.Request import okhttp3.RequestBody -import org.session.libsession.messaging.sending_receiving.notifications.LegacyGroupsPushManager +import org.session.libsession.messaging.sending_receiving.notifications.PushManagerV1 import org.session.libsession.messaging.sending_receiving.notifications.PushNotificationMetadata import org.session.libsession.messaging.sending_receiving.notifications.Response import org.session.libsession.messaging.sending_receiving.notifications.SubscriptionRequest @@ -43,52 +43,18 @@ import org.thoughtcrime.securesms.crypto.KeyPairUtilities private const val TAG = "FirebasePushManager" -class FirebasePushManager (private val context: Context): PushManager { +class FirebasePushManager( + private val context: Context + ): PushManager { + + private val pushManagerV2 = PushManagerV2(context) companion object { - private const val maxRetryCount = 4 + const val maxRetryCount = 4 } private val tokenManager = FcmTokenManager(context, ExpiryManager(context)) private var firebaseInstanceIdJob: Job? = null - private val sodium = LazySodiumAndroid(SodiumAndroid()) - - private fun getOrCreateNotificationKey(): Key { - if (IdentityKeyUtil.retrieve(context, IdentityKeyUtil.NOTIFICATION_KEY) == null) { - // generate the key and store it - val key = sodium.keygen(AEAD.Method.XCHACHA20_POLY1305_IETF) - IdentityKeyUtil.save(context, IdentityKeyUtil.NOTIFICATION_KEY, key.asHexString) - } - return Key.fromHexString(IdentityKeyUtil.retrieve(context, IdentityKeyUtil.NOTIFICATION_KEY)) - } - - fun decrypt(encPayload: ByteArray): ByteArray? { - Log.d(TAG, "decrypt() called") - - val encKey = getOrCreateNotificationKey() - val nonce = encPayload.take(AEAD.XCHACHA20POLY1305_IETF_NPUBBYTES).toByteArray() - val payload = encPayload.drop(AEAD.XCHACHA20POLY1305_IETF_NPUBBYTES).toByteArray() - 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)?.values - ?: error("Failed to decode bencoded list from payload") - - val metadataJson = (expectedList[0] as? BencodeString)?.value ?: error("no metadata") - val metadata: PushNotificationMetadata = Json.decodeFromString(String(metadataJson)) - - 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(TAG, "Received push for ${metadata.account}/${metadata.namespace}, msg ${metadata.msg_hash}, ${metadata.data_len}B") - - return content - } @Synchronized override fun refresh(force: Boolean) { @@ -143,12 +109,12 @@ class FirebasePushManager (private val context: Context): PushManager { publicKey: String, userEd25519Key: KeyPair, namespaces: List = listOf(Namespace.DEFAULT) - ): Promise<*, Exception> = LegacyGroupsPushManager.register(token, publicKey) and getSubscription( + ): Promise<*, Exception> = PushManagerV1.register(token, publicKey) and pushManagerV2.register( token, publicKey, userEd25519Key, namespaces ) fail { Log.e(TAG, "Couldn't register for FCM due to error: $it.", it) } success { - Log.d(TAG, "register() success!!") + Log.d(TAG, "register() success... saving token!!") tokenManager.fcmToken = token } @@ -156,82 +122,13 @@ class FirebasePushManager (private val context: Context): PushManager { token: String, userPublicKey: String, userEdKey: KeyPair - ): Promise<*, Exception> = LegacyGroupsPushManager.unregister() and getUnsubscription( + ): Promise<*, Exception> = PushManagerV1.unregister() and pushManagerV2.unregister( token, userPublicKey, userEdKey ) fail { - Log.e(TAG, "Couldn't unregister for FCM due to error: ${it}.", it) + Log.e(TAG, "Couldn't unregister for FCM due to error: $it.", it) } success { tokenManager.fcmToken = null } - private fun getSubscription( - token: String, - publicKey: String, - userEd25519Key: KeyPair, - namespaces: List - ): Promise { - val pnKey = getOrCreateNotificationKey() - - val timestamp = SnodeAPI.nowWithOffset / 1000 // get timestamp in ms -> s - // if we want to support passing namespace list, here is the place to do it - val sigData = "MONITOR${publicKey}${timestamp}1${namespaces.joinToString(separator = ",")}".encodeToByteArray() - val signature = ByteArray(Sign.BYTES) - sodium.cryptoSignDetached(signature, sigData, sigData.size.toLong(), userEd25519Key.secretKey.asBytes) - val requestParameters = SubscriptionRequest( - pubkey = publicKey, - session_ed25519 = userEd25519Key.publicKey.asHexString, - namespaces = listOf(Namespace.DEFAULT), - data = true, // only permit data subscription for now (?) - service = "firebase", - sig_ts = timestamp, - signature = Base64.encodeBytes(signature), - service_info = mapOf("token" to token), - enc_key = pnKey.asHexString, - ).let(Json::encodeToString) - - return retryResponseBody("subscribe", requestParameters) - } - - private fun getUnsubscription( - token: String, - userPublicKey: String, - userEdKey: KeyPair - ): Promise { - val timestamp = SnodeAPI.nowWithOffset / 1000 // get timestamp in ms -> s - // if we want to support passing namespace list, here is the place to do it - val sigData = "UNSUBSCRIBE${userPublicKey}${timestamp}".encodeToByteArray() - val signature = ByteArray(Sign.BYTES) - sodium.cryptoSignDetached(signature, sigData, sigData.size.toLong(), userEdKey.secretKey.asBytes) - - val requestParameters = UnsubscriptionRequest( - pubkey = userPublicKey, - session_ed25519 = userEdKey.publicKey.asHexString, - service = "firebase", - sig_ts = timestamp, - signature = Base64.encodeBytes(signature), - service_info = mapOf("token" to token), - ).let(Json::encodeToString) - - return retryResponseBody("unsubscribe", requestParameters) - } - - private inline fun retryResponseBody(path: String, requestParameters: String): Promise = - retryIfNeeded(maxRetryCount) { getResponseBody(path, requestParameters) } - - private inline fun getResponseBody(path: String, requestParameters: String): Promise { - val url = "${LegacyGroupsPushManager.server}/$path" - val body = RequestBody.create(MediaType.get("application/json"), requestParameters) - val request = Request.Builder().url(url).post(body).build() - - return OnionRequestAPI.sendOnionRequest( - request, - LegacyGroupsPushManager.server, - LegacyGroupsPushManager.serverPublicKey, - Version.V4 - ).map { response -> - response.body!!.inputStream() - .let { Json.decodeFromStream(it) } - .also { if (it.isFailure()) throw Exception("error: ${it.message}.") } - } - } + fun decrypt(decode: ByteArray) = pushManagerV2.decrypt(decode) } diff --git a/app/src/play/kotlin/org/thoughtcrime/securesms/notifications/PushManagerV2.kt b/app/src/play/kotlin/org/thoughtcrime/securesms/notifications/PushManagerV2.kt new file mode 100644 index 0000000000..b63d6be5f0 --- /dev/null +++ b/app/src/play/kotlin/org/thoughtcrime/securesms/notifications/PushManagerV2.kt @@ -0,0 +1,155 @@ +package org.thoughtcrime.securesms.notifications + +import android.content.Context +import com.goterl.lazysodium.LazySodiumAndroid +import com.goterl.lazysodium.SodiumAndroid +import com.goterl.lazysodium.interfaces.AEAD +import com.goterl.lazysodium.interfaces.Sign +import com.goterl.lazysodium.utils.Key +import com.goterl.lazysodium.utils.KeyPair +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.decodeFromStream +import nl.komponents.kovenant.Promise +import nl.komponents.kovenant.functional.map +import okhttp3.MediaType +import okhttp3.Request +import okhttp3.RequestBody +import org.session.libsession.messaging.sending_receiving.notifications.PushManagerV1 +import org.session.libsession.messaging.sending_receiving.notifications.PushNotificationMetadata +import org.session.libsession.messaging.sending_receiving.notifications.Response +import org.session.libsession.messaging.sending_receiving.notifications.SubscriptionRequest +import org.session.libsession.messaging.sending_receiving.notifications.SubscriptionResponse +import org.session.libsession.messaging.sending_receiving.notifications.UnsubscribeResponse +import org.session.libsession.messaging.sending_receiving.notifications.UnsubscriptionRequest +import org.session.libsession.messaging.utilities.SodiumUtilities +import org.session.libsession.snode.OnionRequestAPI +import org.session.libsession.snode.SnodeAPI +import org.session.libsession.snode.Version +import org.session.libsession.utilities.bencode.Bencode +import org.session.libsession.utilities.bencode.BencodeList +import org.session.libsession.utilities.bencode.BencodeString +import org.session.libsignal.utilities.Base64 +import org.session.libsignal.utilities.Log +import org.session.libsignal.utilities.Namespace +import org.session.libsignal.utilities.retryIfNeeded +import org.thoughtcrime.securesms.crypto.IdentityKeyUtil + +private const val TAG = "PushManagerV2" + +class PushManagerV2(private val context: Context) { + private val sodium = LazySodiumAndroid(SodiumAndroid()) + + fun register( + token: String, + publicKey: String, + userEd25519Key: KeyPair, + namespaces: List + ): Promise { + val pnKey = getOrCreateNotificationKey() + + val timestamp = SnodeAPI.nowWithOffset / 1000 // get timestamp in ms -> s + // if we want to support passing namespace list, here is the place to do it + val sigData = "MONITOR${publicKey}${timestamp}1${namespaces.joinToString(separator = ",")}".encodeToByteArray() + val signature = ByteArray(Sign.BYTES) + sodium.cryptoSignDetached(signature, sigData, sigData.size.toLong(), userEd25519Key.secretKey.asBytes) + val requestParameters = SubscriptionRequest( + pubkey = publicKey, + session_ed25519 = userEd25519Key.publicKey.asHexString, + namespaces = listOf(Namespace.DEFAULT), + data = true, // only permit data subscription for now (?) + service = "firebase", + sig_ts = timestamp, + signature = Base64.encodeBytes(signature), + service_info = mapOf("token" to token), + enc_key = pnKey.asHexString, + ).let(Json::encodeToString) + + return retryResponseBody("subscribe", requestParameters) success { + Log.d(TAG, "register() success!!") + } + } + + fun unregister( + token: String, + userPublicKey: String, + userEdKey: KeyPair + ): Promise { + val timestamp = SnodeAPI.nowWithOffset / 1000 // get timestamp in ms -> s + // if we want to support passing namespace list, here is the place to do it + val sigData = "UNSUBSCRIBE${userPublicKey}${timestamp}".encodeToByteArray() + val signature = ByteArray(Sign.BYTES) + sodium.cryptoSignDetached(signature, sigData, sigData.size.toLong(), userEdKey.secretKey.asBytes) + + val requestParameters = UnsubscriptionRequest( + pubkey = userPublicKey, + session_ed25519 = userEdKey.publicKey.asHexString, + service = "firebase", + sig_ts = timestamp, + signature = Base64.encodeBytes(signature), + service_info = mapOf("token" to token), + ).let(Json::encodeToString) + + return retryResponseBody("unsubscribe", requestParameters) success { + Log.d(TAG, "unregister() success!!") + } + } + + private inline fun retryResponseBody(path: String, requestParameters: String): Promise = + retryIfNeeded(FirebasePushManager.maxRetryCount) { getResponseBody(path, requestParameters) } + + private inline fun getResponseBody(path: String, requestParameters: String): Promise { + val url = "${PushManagerV1.server}/$path" + val body = RequestBody.create(MediaType.get("application/json"), requestParameters) + val request = Request.Builder().url(url).post(body).build() + + return OnionRequestAPI.sendOnionRequest( + request, + PushManagerV1.server, + PushManagerV1.serverPublicKey, + Version.V4 + ).map { response -> + response.body!!.inputStream() + .let { Json.decodeFromStream(it) } + .also { if (it.isFailure()) throw Exception("error: ${it.message}.") } + } + } + + private fun getOrCreateNotificationKey(): Key { + if (IdentityKeyUtil.retrieve(context, IdentityKeyUtil.NOTIFICATION_KEY) == null) { + // generate the key and store it + val key = sodium.keygen(AEAD.Method.XCHACHA20_POLY1305_IETF) + IdentityKeyUtil.save(context, IdentityKeyUtil.NOTIFICATION_KEY, key.asHexString) + } + return Key.fromHexString(IdentityKeyUtil.retrieve(context, IdentityKeyUtil.NOTIFICATION_KEY)) + } + + fun decrypt(encPayload: ByteArray): ByteArray? { + Log.d(TAG, "decrypt() called") + + val encKey = getOrCreateNotificationKey() + val nonce = encPayload.take(AEAD.XCHACHA20POLY1305_IETF_NPUBBYTES).toByteArray() + val payload = encPayload.drop(AEAD.XCHACHA20POLY1305_IETF_NPUBBYTES).toByteArray() + 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)?.values + ?: error("Failed to decode bencoded list from payload") + + val metadataJson = (expectedList[0] as? BencodeString)?.value ?: error("no metadata") + val metadata: PushNotificationMetadata = Json.decodeFromString(String(metadataJson)) + + 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(TAG, "Received push for ${metadata.account}/${metadata.namespace}, msg ${metadata.msg_hash}, ${metadata.data_len}B") + + return content + } +} \ No newline at end of file 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 d09fd79913..0a73d0e428 100644 --- a/app/src/play/kotlin/org/thoughtcrime/securesms/notifications/PushNotificationService.kt +++ b/app/src/play/kotlin/org/thoughtcrime/securesms/notifications/PushNotificationService.kt @@ -8,7 +8,7 @@ import dagger.hilt.android.AndroidEntryPoint import org.session.libsession.messaging.jobs.BatchMessageReceiveJob import org.session.libsession.messaging.jobs.JobQueue import org.session.libsession.messaging.jobs.MessageReceiveParameters -import org.session.libsession.messaging.sending_receiving.notifications.LegacyGroupsPushManager +import org.session.libsession.messaging.sending_receiving.notifications.PushManagerV1 import org.session.libsession.messaging.utilities.MessageWrapper import org.session.libsession.utilities.TextSecurePreferences import org.session.libsignal.utilities.Base64 @@ -69,6 +69,6 @@ class PushNotificationService : FirebaseMessagingService() { override fun onDeletedMessages() { Log.d(TAG, "Called onDeletedMessages.") super.onDeletedMessages() - LegacyGroupsPushManager.register() + PushManagerV1.register() } } diff --git a/libsession/src/main/java/org/session/libsession/messaging/jobs/NotifyPNServerJob.kt b/libsession/src/main/java/org/session/libsession/messaging/jobs/NotifyPNServerJob.kt index 3ab92210d2..7ade248fa6 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/jobs/NotifyPNServerJob.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/jobs/NotifyPNServerJob.kt @@ -8,8 +8,8 @@ import okhttp3.Request import okhttp3.RequestBody import org.session.libsession.messaging.jobs.Job.Companion.MAX_BUFFER_SIZE -import org.session.libsession.messaging.sending_receiving.notifications.LegacyGroupsPushManager -import org.session.libsession.messaging.sending_receiving.notifications.LegacyGroupsPushManager.server +import org.session.libsession.messaging.sending_receiving.notifications.PushManagerV1 +import org.session.libsession.messaging.sending_receiving.notifications.PushManagerV1.server import org.session.libsession.messaging.utilities.Data import org.session.libsession.snode.SnodeMessage import org.session.libsession.snode.OnionRequestAPI @@ -41,7 +41,7 @@ class NotifyPNServerJob(val message: SnodeMessage) : Job { OnionRequestAPI.sendOnionRequest( request, server, - LegacyGroupsPushManager.serverPublicKey, + PushManagerV1.serverPublicKey, Version.V2 ) success { response -> when (response.info["code"]) { diff --git a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSenderClosedGroupHandler.kt b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSenderClosedGroupHandler.kt index 926554d3e2..ca4aa609d8 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSenderClosedGroupHandler.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSenderClosedGroupHandler.kt @@ -8,7 +8,7 @@ import nl.komponents.kovenant.deferred import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.messages.control.ClosedGroupControlMessage import org.session.libsession.messaging.sending_receiving.MessageSender.Error -import org.session.libsession.messaging.sending_receiving.notifications.LegacyGroupsPushManager +import org.session.libsession.messaging.sending_receiving.notifications.PushManagerV1 import org.session.libsession.messaging.sending_receiving.pollers.ClosedGroupPollerV2 import org.session.libsession.snode.SnodeAPI import org.session.libsession.utilities.Address @@ -83,7 +83,7 @@ fun MessageSender.create(name: String, members: Collection): Promise = MessagingModuleConfiguration.shared.storage.getAllClosedGroupPublicKeys() - ): Promise = + ): Promise<*, Exception> = retryIfNeeded(maxRetryCount) { - Log.d(TAG, "register() called") - - val parameters = mapOf( - "token" to token!!, - "pubKey" to publicKey!!, - "legacyGroupPublicKeys" to legacyGroupPublicKeys.takeIf { it.isNotEmpty() }!! - ) - val url = "$legacyServer/register_legacy_groups_only" - val body = RequestBody.create(MediaType.get("application/json"), JsonUtil.toJson(parameters)) - val request = Request.Builder().url(url).post(body).build() - - OnionRequestAPI.sendOnionRequest(request, legacyServer, legacyServerPublicKey, Version.V2).map { response -> - when (response.info["code"]) { - null, 0 -> throw Exception("error: ${response.info["message"]}.") - else -> Log.d(TAG, "register() success!!") - } - } + doRegister(token, publicKey, legacyGroupPublicKeys) } fail { exception -> Log.d(TAG, "Couldn't register for FCM due to error: ${exception}.") + } success { + Log.d(TAG, "register success!!") + } + + private fun doRegister(token: String?, publicKey: String?, legacyGroupPublicKeys: Collection): Promise<*, Exception> { + Log.d(TAG, "doRegister() called $token, $publicKey, $legacyGroupPublicKeys") + + token ?: return emptyPromise() + publicKey ?: return emptyPromise() + legacyGroupPublicKeys.takeIf { it.isNotEmpty() } ?: return emptyPromise() + + Log.d(TAG, "actually registering...") + + val parameters = mapOf( + "token" to token, + "pubKey" to publicKey, + "legacyGroupPublicKeys" to legacyGroupPublicKeys + ) + + val url = "$legacyServer/register_legacy_groups_only" + val body = RequestBody.create(MediaType.get("application/json"), JsonUtil.toJson(parameters)) + val request = Request.Builder().url(url).post(body).build() + + return OnionRequestAPI.sendOnionRequest(request, legacyServer, legacyServerPublicKey, Version.V2).map { response -> + when (response.info["code"]) { + null, 0 -> throw Exception("error: ${response.info["message"]}.") + else -> Log.d(TAG, "register() success!!") + } + } success { + Log.d(TAG, "onion request success") + } fail { + Log.d(TAG, "onion fail: $it") } + } /** * Unregister push notifications for 1-1 conversations as this is now done in FirebasePushManager. @@ -74,7 +90,7 @@ object LegacyGroupsPushManager { legacyServerPublicKey, Version.V2 ) success { - android.util.Log.d(TAG, "unregister() success!!") + Log.d(TAG, "unregister() success!!") when (it.info["code"]) { null, 0 -> throw Exception("error: ${it.info["message"]}.") diff --git a/libsignal/src/main/java/org/session/libsignal/utilities/Retrying.kt b/libsignal/src/main/java/org/session/libsignal/utilities/Retrying.kt index 28ed93b5a5..9f1b1a3e82 100644 --- a/libsignal/src/main/java/org/session/libsignal/utilities/Retrying.kt +++ b/libsignal/src/main/java/org/session/libsignal/utilities/Retrying.kt @@ -28,4 +28,3 @@ fun > retryIfNeeded(maxRetryCount: Int, retryInterv retryIfNeeded() return deferred.promise } -