commit aaf8f3e43a7a692988f323f245cf28338a722949
parent 061f5cc5a87364a55afd1f0f6b31bb061cd8e213
Author: rhunk <101876869+rhunk@users.noreply.github.com>
Date:   Thu, 28 Sep 2023 02:12:03 +0200

feat: end-to-end encryption
- message_logger: fix view binder bugs and deleted message color
- fix invalid message rule type

Diffstat:
Mapp/build.gradle.kts | 1+
Mapp/src/main/kotlin/me/rhunk/snapenhance/RemoteSideContext.kt | 2++
Mapp/src/main/kotlin/me/rhunk/snapenhance/bridge/BridgeService.kt | 3+++
Aapp/src/main/kotlin/me/rhunk/snapenhance/e2ee/E2EEImplementation.kt | 156+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mapp/src/main/kotlin/me/rhunk/snapenhance/messaging/ModDatabase.kt | 8+++++++-
Mapp/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/social/ScopeContent.kt | 82++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Mapp/src/main/kotlin/me/rhunk/snapenhance/ui/util/AlertDialogs.kt | 40++++++++++++++++++++++++++++++++++++++++
Mcore/src/main/aidl/me/rhunk/snapenhance/bridge/BridgeInterface.aidl | 3+++
Acore/src/main/aidl/me/rhunk/snapenhance/bridge/e2ee/E2eeInterface.aidl | 35+++++++++++++++++++++++++++++++++++
Acore/src/main/aidl/me/rhunk/snapenhance/bridge/e2ee/EncryptionResult.aidl | 7+++++++
Mcore/src/main/assets/lang/en_US.json | 13+++++++------
Mcore/src/main/kotlin/me/rhunk/snapenhance/core/bridge/BridgeClient.kt | 3+++
Mcore/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/Experimental.kt | 2+-
Mcore/src/main/kotlin/me/rhunk/snapenhance/core/event/EventDispatcher.kt | 45++++++++++++++++++++++++++++++++++++++++-----
Acore/src/main/kotlin/me/rhunk/snapenhance/core/event/events/impl/BindViewEvent.kt | 27+++++++++++++++++++++++++++
Mcore/src/main/kotlin/me/rhunk/snapenhance/core/messaging/MessagingCoreObjects.kt | 2+-
Mcore/src/main/kotlin/me/rhunk/snapenhance/data/MessageSender.kt | 9+++++++++
Dcore/src/main/kotlin/me/rhunk/snapenhance/features/impl/experiments/AESMessageEncryption.kt | 162-------------------------------------------------------------------------------
Acore/src/main/kotlin/me/rhunk/snapenhance/features/impl/experiments/EndToEndEncryption.kt | 443+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcore/src/main/kotlin/me/rhunk/snapenhance/features/impl/spying/MessageLogger.kt | 47+++++++++++------------------------------------
Mcore/src/main/kotlin/me/rhunk/snapenhance/manager/impl/FeatureManager.kt | 3+--
Mgradle/libs.versions.toml | 2++
22 files changed, 874 insertions(+), 221 deletions(-)

diff --git a/app/build.gradle.kts b/app/build.gradle.kts @@ -103,6 +103,7 @@ dependencies { implementation(libs.ffmpeg.kit) implementation(libs.osmdroid.android) implementation(libs.rhino) + implementation(libs.bcprov.jdk18on) debugImplementation("androidx.compose.ui:ui-tooling:1.4.3") implementation("androidx.compose.ui:ui-tooling-preview:1.4.3") diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/RemoteSideContext.kt b/app/src/main/kotlin/me/rhunk/snapenhance/RemoteSideContext.kt @@ -22,6 +22,7 @@ import me.rhunk.snapenhance.core.bridge.wrapper.LocaleWrapper import me.rhunk.snapenhance.core.bridge.wrapper.MappingsWrapper import me.rhunk.snapenhance.core.config.ModConfig import me.rhunk.snapenhance.download.DownloadTaskManager +import me.rhunk.snapenhance.e2ee.E2EEImplementation import me.rhunk.snapenhance.messaging.ModDatabase import me.rhunk.snapenhance.messaging.StreaksReminder import me.rhunk.snapenhance.scripting.RemoteScriptManager @@ -58,6 +59,7 @@ class RemoteSideContext( val log = LogManager(this) val scriptManager = RemoteScriptManager(this) val settingsOverlay = SettingsOverlay(this) + val e2eeImplementation = E2EEImplementation(this) //used to load bitmoji selfies and download previews val imageLoader by lazy { diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/bridge/BridgeService.kt b/app/src/main/kotlin/me/rhunk/snapenhance/bridge/BridgeService.kt @@ -187,6 +187,9 @@ class BridgeService : Service() { } override fun getScriptingInterface() = remoteSideContext.scriptManager + + override fun getE2eeInterface() = remoteSideContext.e2eeImplementation + override fun openSettingsOverlay() { runCatching { remoteSideContext.settingsOverlay.show() diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/e2ee/E2EEImplementation.kt b/app/src/main/kotlin/me/rhunk/snapenhance/e2ee/E2EEImplementation.kt @@ -0,0 +1,155 @@ +package me.rhunk.snapenhance.e2ee + +import me.rhunk.snapenhance.RemoteSideContext +import me.rhunk.snapenhance.bridge.e2ee.E2eeInterface +import me.rhunk.snapenhance.bridge.e2ee.EncryptionResult +import org.bouncycastle.pqc.crypto.crystals.kyber.* +import java.io.File +import java.security.SecureRandom +import javax.crypto.Cipher +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.SecretKeySpec + + +class E2EEImplementation ( + private val context: RemoteSideContext +) : E2eeInterface.Stub() { + private val kyberDefaultParameters = KyberParameters.kyber1024_aes + private val secureRandom = SecureRandom() + + private val e2eeFolder by lazy { File(context.androidContext.filesDir, "e2ee").also { + if (!it.exists()) it.mkdirs() + }} + private val pairingFolder by lazy { File(context.androidContext.cacheDir, "e2ee-pairing").also { + if (!it.exists()) it.mkdirs() + } } + + fun storeSharedSecretKey(friendId: String, key: ByteArray) { + File(e2eeFolder, "$friendId.key").writeBytes(key) + } + + fun getSharedSecretKey(friendId: String): ByteArray? { + return runCatching { + File(e2eeFolder, "$friendId.key").readBytes() + }.onFailure { + context.log.error("Failed to read shared secret key", it) + }.getOrNull() + } + + fun deleteSharedSecretKey(friendId: String) { + File(e2eeFolder, "$friendId.key").delete() + } + + + override fun createKeyExchange(friendId: String): ByteArray? { + val keyPairGenerator = KyberKeyPairGenerator() + keyPairGenerator.init( + KyberKeyGenerationParameters(secureRandom, kyberDefaultParameters) + ) + val keyPair = keyPairGenerator.generateKeyPair() + val publicKey = keyPair.public as KyberPublicKeyParameters + val privateKey = keyPair.private as KyberPrivateKeyParameters + runCatching { + File(pairingFolder, "$friendId.private").writeBytes(privateKey.encoded) + File(pairingFolder, "$friendId.public").writeBytes(publicKey.encoded) + }.onFailure { + context.log.error("Failed to write private key to file", it) + return null + } + return publicKey.encoded + } + + override fun acceptPairingRequest(friendId: String, publicKey: ByteArray): ByteArray? { + val kemGen = KyberKEMGenerator(secureRandom) + val encapsulatedSecret = runCatching { + kemGen.generateEncapsulated( + KyberPublicKeyParameters( + kyberDefaultParameters, + publicKey + ) + ) + }.onFailure { + context.log.error("Failed to generate encapsulated secret", it) + return null + }.getOrThrow() + + runCatching { + storeSharedSecretKey(friendId, encapsulatedSecret.secret) + }.onFailure { + context.log.error("Failed to store shared secret key", it) + return null + } + return encapsulatedSecret.encapsulation + } + + override fun acceptPairingResponse(friendId: String, encapsulatedSecret: ByteArray): Boolean { + val privateKey = runCatching { + val secretKey = File(pairingFolder, "$friendId.private").readBytes() + object: KyberPrivateKeyParameters(kyberDefaultParameters, null, null, null, null, null) { + override fun getEncoded() = secretKey + } + }.onFailure { + context.log.error("Failed to read private key from file", it) + return false + }.getOrThrow() + + val kemExtractor = KyberKEMExtractor(privateKey) + val sharedSecret = runCatching { + kemExtractor.extractSecret(encapsulatedSecret) + }.onFailure { + context.log.error("Failed to extract shared secret", it) + return false + }.getOrThrow() + + runCatching { + storeSharedSecretKey(friendId, sharedSecret) + }.onFailure { + context.log.error("Failed to store shared secret key", it) + return false + } + + return true + } + + override fun friendKeyExists(friendId: String): Boolean { + return File(e2eeFolder, "$friendId.key").exists() + } + + override fun encryptMessage(friendId: String, message: ByteArray): EncryptionResult? { + val encryptionKey = runCatching { + File(e2eeFolder, "$friendId.key").readBytes() + }.onFailure { + context.log.error("Failed to read shared secret key", it) + }.getOrNull() + + return runCatching { + val iv = ByteArray(16).apply { secureRandom.nextBytes(this) } + val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") + cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(encryptionKey, "AES"), IvParameterSpec(iv)) + EncryptionResult().apply { + this.iv = iv + this.ciphertext = cipher.doFinal(message) + } + }.onFailure { + context.log.error("Failed to encrypt message for $friendId", it) + }.getOrNull() + } + + override fun decryptMessage(friendId: String, message: ByteArray, iv: ByteArray): ByteArray? { + val encryptionKey = runCatching { + File(e2eeFolder, "$friendId.key").readBytes() + }.onFailure { + context.log.error("Failed to read shared secret key", it) + return null + }.getOrNull() + + return runCatching { + val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") + cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(encryptionKey, "AES"), IvParameterSpec(iv)) + cipher.doFinal(message) + }.onFailure { + context.log.error("Failed to decrypt message from $friendId", it) + return null + }.getOrNull() + } +}+ \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/messaging/ModDatabase.kt b/app/src/main/kotlin/me/rhunk/snapenhance/messaging/ModDatabase.kt @@ -158,7 +158,11 @@ class ModDatabase( )).use { cursor -> val rules = mutableListOf<MessagingRuleType>() while (cursor.moveToNext()) { - rules.add(MessagingRuleType.getByName(cursor.getStringOrNull("type")!!)) + runCatching { + rules.add(MessagingRuleType.getByName(cursor.getStringOrNull("type")!!)) + }.onFailure { + context.log.error("Failed to parse rule", it) + } } rules } @@ -197,12 +201,14 @@ class ModDatabase( executeAsync { database.execSQL("DELETE FROM friends WHERE userId = ?", arrayOf(userId)) database.execSQL("DELETE FROM streaks WHERE userId = ?", arrayOf(userId)) + database.execSQL("DELETE FROM rules WHERE targetUuid = ?", arrayOf(userId)) } } fun deleteGroup(conversationId: String) { executeAsync { database.execSQL("DELETE FROM groups WHERE conversationId = ?", arrayOf(conversationId)) + database.execSQL("DELETE FROM rules WHERE targetUuid = ?", arrayOf(conversationId)) } } diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/social/ScopeContent.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/social/ScopeContent.kt @@ -1,12 +1,7 @@ package me.rhunk.snapenhance.ui.manager.sections.social -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.padding +import android.content.Intent +import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Card @@ -32,6 +27,10 @@ import me.rhunk.snapenhance.core.messaging.MessagingRuleType import me.rhunk.snapenhance.core.messaging.SocialScope import me.rhunk.snapenhance.ui.util.BitmojiImage import me.rhunk.snapenhance.core.util.snap.BitmojiSelfie +import me.rhunk.snapenhance.ui.util.AlertDialogs +import me.rhunk.snapenhance.ui.util.Dialog +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi class ScopeContent( private val context: RemoteSideContext, @@ -40,6 +39,7 @@ class ScopeContent( val scope: SocialScope, private val id: String ) { + private val dialogs by lazy { AlertDialogs(context.translation) } private val translation by lazy { context.translation.getCategory("manager.sections.social") } fun deleteScope(coroutineScope: CoroutineScope) { @@ -162,6 +162,7 @@ class ScopeContent( return "Expired" } + @OptIn(ExperimentalEncodingApi::class) @Composable private fun Friend() { //fetch the friend from the database @@ -241,6 +242,73 @@ class ScopeContent( } } } + // e2ee section + + SectionTitle(translation["e2ee_title"]) + var hasSecretKey by remember { mutableStateOf(context.e2eeImplementation.friendKeyExists(friend.userId))} + var importDialog by remember { mutableStateOf(false) } + + if (importDialog) { + Dialog( + onDismissRequest = { importDialog = false } + ) { + dialogs.RawInputDialog(onDismiss = { importDialog = false }, onConfirm = { newKey -> + importDialog = false + runCatching { + val key = Base64.decode(newKey) + if (key.size != 32) { + context.longToast("Invalid key size (must be 32 bytes)") + return@runCatching + } + + context.e2eeImplementation.storeSharedSecretKey(friend.userId, key) + context.longToast("Successfully imported key") + hasSecretKey = true + }.onFailure { + context.longToast("Failed to import key: ${it.message}") + context.log.error("Failed to import key", it) + } + }) + } + } + + ContentCard { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + if (hasSecretKey) { + OutlinedButton(onClick = { + val secretKey = Base64.encode(context.e2eeImplementation.getSharedSecretKey(friend.userId) ?: return@OutlinedButton) + //TODO: fingerprint auth + context.activity!!.startActivity(Intent.createChooser(Intent().apply { + action = Intent.ACTION_SEND + putExtra(Intent.EXTRA_TEXT, secretKey) + type = "text/plain" + }, "").apply { + putExtra(Intent.EXTRA_INITIAL_INTENTS, arrayOf( + Intent().apply { + putExtra(Intent.EXTRA_TEXT, secretKey) + putExtra(Intent.EXTRA_SUBJECT, secretKey) + }) + ) + }) + }) { + Text( + text = "Export Base64", + maxLines = 1 + ) + } + } + + OutlinedButton(onClick = { importDialog = true }) { + Text( + text = "Import Base64", + maxLines = 1 + ) + } + } + } } } diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/AlertDialogs.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/AlertDialogs.kt @@ -239,6 +239,46 @@ class AlertDialogs( } @Composable + fun RawInputDialog(onDismiss: () -> Unit, onConfirm: (value: String) -> Unit) { + val focusRequester = remember { FocusRequester() } + + DefaultDialogCard { + val fieldValue = remember { + mutableStateOf(TextFieldValue()) + } + + TextField( + modifier = Modifier + .fillMaxWidth() + .padding(all = 10.dp) + .onGloballyPositioned { + focusRequester.requestFocus() + } + .focusRequester(focusRequester), + value = fieldValue.value, + onValueChange = { + fieldValue.value = it + }, + singleLine = true + ) + + Row( + modifier = Modifier.padding(top = 10.dp).fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + Button(onClick = { onDismiss() }) { + Text(text = translation["button.cancel"]) + } + Button(onClick = { + onConfirm(fieldValue.value.text) + }) { + Text(text = translation["button.ok"]) + } + } + } + } + + @Composable @Suppress("UNCHECKED_CAST") fun MultipleSelectionDialog(property: PropertyPair<*>) { val defaultItems = property.value.defaultValues as List<String> diff --git a/core/src/main/aidl/me/rhunk/snapenhance/bridge/BridgeInterface.aidl b/core/src/main/aidl/me/rhunk/snapenhance/bridge/BridgeInterface.aidl @@ -4,6 +4,7 @@ import java.util.List; import me.rhunk.snapenhance.bridge.DownloadCallback; import me.rhunk.snapenhance.bridge.SyncCallback; import me.rhunk.snapenhance.bridge.scripting.IScripting; +import me.rhunk.snapenhance.bridge.e2ee.E2eeInterface; interface BridgeInterface { /** @@ -94,6 +95,8 @@ interface BridgeInterface { IScripting getScriptingInterface(); + E2eeInterface getE2eeInterface(); + void openSettingsOverlay(); void closeSettingsOverlay(); diff --git a/core/src/main/aidl/me/rhunk/snapenhance/bridge/e2ee/E2eeInterface.aidl b/core/src/main/aidl/me/rhunk/snapenhance/bridge/e2ee/E2eeInterface.aidl @@ -0,0 +1,34 @@ +package me.rhunk.snapenhance.bridge.e2ee; + +import me.rhunk.snapenhance.bridge.e2ee.EncryptionResult; + +interface E2eeInterface { + /** + * Start a new pairing process with a friend + * @param friendId + * @return the pairing public key + */ + @nullable byte[] createKeyExchange(String friendId); + + /** + * Accept a pairing request from a friend + * @param friendId + * @param publicKey the public key received from the friend + * @return the encapsulated secret to send to the friend + */ + @nullable byte[] acceptPairingRequest(String friendId, in byte[] publicKey); + + /** + * Accept a pairing response from a friend + * @param friendId + * @param encapsulatedSecret the encapsulated secret received from the friend + * @return true if the pairing was successful + */ + boolean acceptPairingResponse(String friendId, in byte[] encapsulatedSecret); + + boolean friendKeyExists(String friendId); + + @nullable EncryptionResult encryptMessage(String friendId, in byte[] message); + + @nullable byte[] decryptMessage(String friendId, in byte[] message, in byte[] iv); +}+ \ No newline at end of file diff --git a/core/src/main/aidl/me/rhunk/snapenhance/bridge/e2ee/EncryptionResult.aidl b/core/src/main/aidl/me/rhunk/snapenhance/bridge/e2ee/EncryptionResult.aidl @@ -0,0 +1,6 @@ +package me.rhunk.snapenhance.bridge.e2ee; + +parcelable EncryptionResult { + byte[] ciphertext; + byte[] iv; +}+ \ No newline at end of file diff --git a/core/src/main/assets/lang/en_US.json b/core/src/main/assets/lang/en_US.json @@ -44,6 +44,7 @@ "disabled": "Disabled" }, "social": { + "e2ee_title": "End-to-End Encryption", "rules_title": "Rules", "participants_text": "{count} participants", "not_found": "Not found", @@ -98,8 +99,8 @@ "hide_chat_feed": { "name": "Hide from Chat feed" }, - "aes_encryption": { - "name": "Use AES Encryption" + "e2e_encryption": { + "name": "Use E2E Encryption" }, "pin_conversation": { "name": "Pin Conversation" @@ -519,9 +520,9 @@ "name": "No Friend Score Delay", "description": "Removes the delay when viewing a Friends Score" }, - "use_message_aes_encryption": { - "name": "Use AES Encryption", - "description": "Encrypts your messages using AES\nMessages are only readable by other SnapEnhance users" + "e2e_encryption": { + "name": "End-To-End Encryption", + "description": "Encrypts your messages with AES using a shared secret key\nMake sure to save your key somewhere safe!" }, "add_friend_source_spoof": { "name": "Add Friend Source Spoof", @@ -565,7 +566,7 @@ "auto_save": "\uD83D\uDCAC Auto Save Messages", "stealth": "\uD83D\uDC7B Stealth Mode", "conversation_info": "\uD83D\uDC64 Conversation Info", - "aes_encryption": "\uD83D\uDD12 Use AES Encryption" + "e2e_encryption": "\uD83D\uDD12 Use E2E Encryption" }, "path_format": { "create_author_folder": "Create folder for each author", diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/bridge/BridgeClient.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/bridge/BridgeClient.kt @@ -14,6 +14,7 @@ import me.rhunk.snapenhance.ModContext import me.rhunk.snapenhance.bridge.BridgeInterface import me.rhunk.snapenhance.bridge.DownloadCallback import me.rhunk.snapenhance.bridge.SyncCallback +import me.rhunk.snapenhance.bridge.e2ee.E2eeInterface import me.rhunk.snapenhance.bridge.scripting.IScripting import me.rhunk.snapenhance.core.BuildConfig import me.rhunk.snapenhance.core.bridge.types.BridgeFileType @@ -145,6 +146,8 @@ class BridgeClient( fun getScriptingInterface(): IScripting = service.getScriptingInterface() + fun getE2eeInterface(): E2eeInterface = service.getE2eeInterface() + fun openSettingsOverlay() = service.openSettingsOverlay() fun closeSettingsOverlay() = service.closeSettingsOverlay() } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/Experimental.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/Experimental.kt @@ -12,7 +12,7 @@ class Experimental : ConfigContainer() { val meoPasscodeBypass = boolean("meo_passcode_bypass") val unlimitedMultiSnap = boolean("unlimited_multi_snap") { addNotices(FeatureNotice.BAN_RISK)} val noFriendScoreDelay = boolean("no_friend_score_delay") - val useMessageAESEncryption = boolean("use_message_aes_encryption") + val useE2EEncryption = boolean("e2e_encryption") val hiddenSnapchatPlusFeatures = boolean("hidden_snapchat_plus_features") { addNotices(FeatureNotice.BAN_RISK, FeatureNotice.UNSTABLE) } val addFriendSourceSpoof = unique("add_friend_source_spoof", "added_by_username", diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/event/EventDispatcher.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/event/EventDispatcher.kt @@ -5,11 +5,7 @@ import android.view.View import android.view.ViewGroup import android.view.ViewGroup.LayoutParams import me.rhunk.snapenhance.ModContext -import me.rhunk.snapenhance.core.event.events.impl.AddViewEvent -import me.rhunk.snapenhance.core.event.events.impl.NetworkApiRequestEvent -import me.rhunk.snapenhance.core.event.events.impl.OnSnapInteractionEvent -import me.rhunk.snapenhance.core.event.events.impl.SendMessageWithContentEvent -import me.rhunk.snapenhance.core.event.events.impl.SnapWidgetBroadcastReceiveEvent +import me.rhunk.snapenhance.core.event.events.impl.* import me.rhunk.snapenhance.core.util.ktx.getObjectField import me.rhunk.snapenhance.core.util.ktx.setObjectField import me.rhunk.snapenhance.core.util.snap.SnapWidgetBroadcastReceiverHelper @@ -18,11 +14,48 @@ import me.rhunk.snapenhance.data.wrapper.impl.MessageDestinations import me.rhunk.snapenhance.data.wrapper.impl.SnapUUID import me.rhunk.snapenhance.hook.HookStage import me.rhunk.snapenhance.hook.hook +import me.rhunk.snapenhance.hook.hookConstructor import me.rhunk.snapenhance.manager.Manager class EventDispatcher( private val context: ModContext ) : Manager { + private fun findClass(name: String) = context.androidContext.classLoader.loadClass(name) + + private fun hookViewBinder() { + val cachedHooks = mutableListOf<String>() + val viewBinderMappings = runCatching { context.mappings.getMappedMap("ViewBinder") }.getOrNull() ?: return + + fun cacheHook(clazz: Class<*>, block: Class<*>.() -> Unit) { + if (!cachedHooks.contains(clazz.name)) { + clazz.block() + cachedHooks.add(clazz.name) + } + } + + findClass(viewBinderMappings["class"].toString()).hookConstructor(HookStage.AFTER) { methodParam -> + cacheHook( + methodParam.thisObject<Any>()::class.java + ) { + hook(viewBinderMappings["bindMethod"].toString(), HookStage.AFTER) bindViewMethod@{ param -> + val instance = param.thisObject<Any>() + val view = instance::class.java.methods.first { + it.name == viewBinderMappings["getViewMethod"].toString() + }.invoke(instance) as? View ?: return@bindViewMethod + + context.event.post( + BindViewEvent( + prevModel = param.arg(0), + nextModel = param.argNullable(1), + view = view + ) + ) + } + } + } + } + + override fun init() { context.classCache.conversationManager.hook("sendMessageWithContent", HookStage.BEFORE) { param -> context.event.post(SendMessageWithContentEvent( @@ -113,5 +146,7 @@ class EventDispatcher( if (event.canceled) param.setResult(null) } } + + hookViewBinder() } } \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/event/events/impl/BindViewEvent.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/event/events/impl/BindViewEvent.kt @@ -0,0 +1,26 @@ +package me.rhunk.snapenhance.core.event.events.impl + +import android.view.View +import me.rhunk.snapenhance.core.event.Event + +class BindViewEvent( + val prevModel: Any, + val nextModel: Any?, + val view: View +): Event() { + fun chatMessage(block: (conversationId: String, messageId: String) -> Unit) { + val prevModelToString = prevModel.toString() + if (!prevModelToString.startsWith("ChatViewModel")) return + prevModelToString.substringAfter("messageId=").substringBefore(",").split(":").apply { + if (size != 3) return + block(this[0], this[2]) + } + } + + fun friendFeedItem(block: (conversationId: String) -> Unit) { + val prevModelToString = nextModel.toString() + if (!prevModelToString.startsWith("FriendFeedItemViewModel")) return + val conversationId = prevModelToString.substringAfter("conversationId: ").substringBefore("\n") + block(conversationId) + } +}+ \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/messaging/MessagingCoreObjects.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/messaging/MessagingCoreObjects.kt @@ -35,7 +35,7 @@ enum class MessagingRuleType( STEALTH("stealth", true), AUTO_SAVE("auto_save", true), HIDE_CHAT_FEED("hide_chat_feed", false, showInFriendMenu = false), - AES_ENCRYPTION("aes_encryption", false), + E2E_ENCRYPTION("e2e_encryption", false), PIN_CONVERSATION("pin_conversation", false, showInFriendMenu = false); fun translateOptionKey(optionKey: String): String { diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/data/MessageSender.kt b/core/src/main/kotlin/me/rhunk/snapenhance/data/MessageSender.kt @@ -166,4 +166,13 @@ class MessageSender( .override("onError", callback = { onError(it.arg(0)) }) .build()) } + + fun sendCustomChatMessage(conversations: List<SnapUUID>, contentType: ContentType, message: ProtoWriter.() -> Unit, onError: (Any) -> Unit = {}, onSuccess: () -> Unit = {}) { + internalSendMessage(conversations, createLocalMessageContentTemplate(contentType, ProtoWriter().apply { + message() + }.toByteArray(), savePolicy = "LIFETIME"), CallbackBuilder(sendMessageCallback) + .override("onSuccess", callback = { onSuccess() }) + .override("onError", callback = { onError(it.arg(0)) }) + .build()) + } } \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/experiments/AESMessageEncryption.kt b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/experiments/AESMessageEncryption.kt @@ -1,161 +0,0 @@ -package me.rhunk.snapenhance.features.impl.experiments - -import me.rhunk.snapenhance.core.event.events.impl.SendMessageWithContentEvent -import me.rhunk.snapenhance.core.event.events.impl.UnaryCallEvent -import me.rhunk.snapenhance.core.messaging.MessagingRuleType -import me.rhunk.snapenhance.core.messaging.RuleState -import me.rhunk.snapenhance.core.util.protobuf.ProtoEditor -import me.rhunk.snapenhance.core.util.protobuf.ProtoReader -import me.rhunk.snapenhance.core.util.protobuf.ProtoWriter -import me.rhunk.snapenhance.data.ContentType -import me.rhunk.snapenhance.data.wrapper.impl.Message -import me.rhunk.snapenhance.data.wrapper.impl.SnapUUID -import me.rhunk.snapenhance.features.FeatureLoadParams -import me.rhunk.snapenhance.features.MessagingRuleFeature -import me.rhunk.snapenhance.hook.HookStage -import me.rhunk.snapenhance.hook.hookConstructor -import javax.crypto.Cipher -import javax.crypto.spec.IvParameterSpec -import javax.crypto.spec.SecretKeySpec -import kotlin.random.Random - -/* - To prevent snapchat from using fidelius, snaps are spoofed to external media and chats into status before it's sent to the native. - When the CreateContentMessage request is sent to the server, the content is encrypted - */ - -//TODO: RSA encryption -class AESMessageEncryption : MessagingRuleFeature( - "AESMessageEncryption", - MessagingRuleType.AES_ENCRYPTION, - loadParams = FeatureLoadParams.INIT_ASYNC or FeatureLoadParams.INIT_SYNC -) { - private val key = intArrayOf( - 0x6f, 0x6f, 0x6f, 0x6f, 0x6f, 0x6f, 0x6f, 0x6f, - 0x6f, 0x6f, 0x6f, 0x6f, 0x6f, 0x6f, 0x6f, 0x6f, - 0x6f, 0x6f, 0x6f, 0x6f, 0x6f, 0x6f, 0x6f, 0x6f, - 0x6f, 0x6f, 0x6f, 0x6f, 0x6f, 0x6f, 0x6f, 0x6f - ).map { it.toByte() }.toByteArray() - - private val isEnabled get() = context.config.experimental.useMessageAESEncryption.get() - - private fun useCipher(input: ByteArray, iv: ByteArray, decrypt: Boolean = false): ByteArray { - val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") - cipher.init(if (decrypt) Cipher.DECRYPT_MODE else Cipher.ENCRYPT_MODE, SecretKeySpec(key, "AES"), IvParameterSpec(iv)) - return cipher.doFinal(input) - } - - private fun fixContentType(contentType: ContentType, message: ProtoReader): ContentType { - return when { - contentType == ContentType.EXTERNAL_MEDIA && message.containsPath(11) -> { - ContentType.SNAP - } - contentType == ContentType.SHARE && message.containsPath(2) -> { - ContentType.CHAT - } - else -> contentType - } - } - - override fun asyncInit() { - // trick to disable fidelius encryption - context.event.subscribe(SendMessageWithContentEvent::class, { isEnabled }) { param -> - val messageContent = param.messageContent - val destinations = param.destinations - if (destinations.conversations.size != 1 || destinations.stories.isNotEmpty()) return@subscribe - - if (!getState(destinations.conversations.first().toString())) return@subscribe - - if (messageContent.contentType == ContentType.SNAP) { - messageContent.contentType = ContentType.EXTERNAL_MEDIA - } - - if (messageContent.contentType == ContentType.CHAT) { - messageContent.contentType = ContentType.SHARE - } - } - } - - override fun init() { - context.event.subscribe(UnaryCallEvent::class, { isEnabled }) { event -> - if (event.uri != "/messagingcoreservice.MessagingCoreService/CreateContentMessage") return@subscribe - val protoReader = ProtoReader(event.buffer) - - val conversationIds = mutableListOf<SnapUUID>() - protoReader.eachBuffer(3) { - conversationIds.add(SnapUUID.fromBytes(getByteArray(1, 1, 1) ?: return@eachBuffer)) - } - - if (conversationIds.size != 1) return@subscribe - - if (!getState(conversationIds.first().toString())) return@subscribe - - val generatedIv = ByteArray(16).also { Random.nextBytes(it) } - - event.buffer = ProtoEditor(event.buffer).apply { - protoReader.followPath(4) { - val contentType = fixContentType(ContentType.fromId(getVarInt(2)?.toInt() ?: -1), followPath(4) ?: return@followPath) - - runCatching { - val encryptedMessage = useCipher(getByteArray(4) ?: return@followPath, generatedIv, false) - edit(4) { - //set message content type - remove(2) - addVarInt(2, contentType.id) - - //set encrypted content - remove(4) - add(4) { - from(2) { - from(1) { - addBuffer(1, encryptedMessage) - addBuffer(2, generatedIv) - } - addVarInt(2, 1) - } - } - } - }.onFailure { - event.canceled = true - context.log.error("Failed to encrypt message", it) - context.longToast("Failed to encrypt message! Check logcat for more details.") - } - } - }.toByteArray() - } - - context.classCache.message.hookConstructor(HookStage.AFTER, { isEnabled }) { param -> - val message = Message(param.thisObject()) - val reader = ProtoReader(message.messageContent.content) - - // fix content type - message.messageContent.contentType?.also { - message.messageContent.contentType = fixContentType(it, reader) - } - - reader.followPath(2) { - if (getVarInt(2) != 1L) return@followPath - - runCatching { - followPath(1) path@{ - val encryptedMessage = getByteArray(1) ?: return@path - val iv = getByteArray(2) ?: return@path - - val decryptedMessage = useCipher(encryptedMessage, iv, decrypt = true) - message.messageContent.content = decryptedMessage - } - }.onFailure { - context.log.error("Failed to decrypt message id: ${message.messageDescriptor.messageId}", it) - message.messageContent.contentType = ContentType.CHAT - message.messageContent.content = ProtoWriter().apply { - from(2) { - addString(1, "Failed to decrypt message, id=${message.messageDescriptor.messageId}. Check logcat for more details.") - } - }.toByteArray() - } - } - } - } - - override fun getRuleState() = RuleState.WHITELIST -}- \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/experiments/EndToEndEncryption.kt b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/experiments/EndToEndEncryption.kt @@ -0,0 +1,442 @@ +package me.rhunk.snapenhance.features.impl.experiments + +import android.annotation.SuppressLint +import android.view.View +import android.view.ViewGroup +import android.view.ViewGroup.LayoutParams +import android.view.ViewGroup.MarginLayoutParams +import android.widget.Button +import android.widget.TextView +import me.rhunk.snapenhance.core.event.events.impl.AddViewEvent +import me.rhunk.snapenhance.core.event.events.impl.BindViewEvent +import me.rhunk.snapenhance.core.event.events.impl.SendMessageWithContentEvent +import me.rhunk.snapenhance.core.event.events.impl.UnaryCallEvent +import me.rhunk.snapenhance.core.messaging.MessagingRuleType +import me.rhunk.snapenhance.core.messaging.RuleState +import me.rhunk.snapenhance.core.util.ktx.getObjectField +import me.rhunk.snapenhance.core.util.protobuf.ProtoEditor +import me.rhunk.snapenhance.core.util.protobuf.ProtoReader +import me.rhunk.snapenhance.core.util.protobuf.ProtoWriter +import me.rhunk.snapenhance.data.ContentType +import me.rhunk.snapenhance.data.wrapper.impl.Message +import me.rhunk.snapenhance.data.wrapper.impl.MessageContent +import me.rhunk.snapenhance.data.wrapper.impl.SnapUUID +import me.rhunk.snapenhance.features.FeatureLoadParams +import me.rhunk.snapenhance.features.MessagingRuleFeature +import me.rhunk.snapenhance.features.impl.Messaging +import me.rhunk.snapenhance.hook.HookStage +import me.rhunk.snapenhance.hook.hookConstructor +import me.rhunk.snapenhance.ui.ViewAppearanceHelper +import java.security.MessageDigest +import kotlin.random.Random + +class EndToEndEncryption : MessagingRuleFeature( + "EndToEndEncryption", + MessagingRuleType.E2E_ENCRYPTION, + loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC or FeatureLoadParams.INIT_SYNC or FeatureLoadParams.INIT_ASYNC +) { + private val isEnabled get() = context.config.experimental.useE2EEncryption.get() + private val e2eeInterface by lazy { context.bridgeClient.getE2eeInterface() } + + companion object { + const val REQUEST_PK_MESSAGE_ID = 1 + const val RESPONSE_SK_MESSAGE_ID = 2 + const val ENCRYPTED_MESSAGE_ID = 3 + } + + private val pkRequests = mutableMapOf<Long, ByteArray>() + private val secretResponses = mutableMapOf<Long, ByteArray>() + + private fun getE2EParticipants(conversationId: String): List<String> { + return context.database.getConversationParticipants(conversationId)?.filter { friendId -> e2eeInterface.friendKeyExists(friendId) } ?: emptyList() + } + + private fun askForKeys(conversationId: String) { + val friendId = context.database.getDMOtherParticipant(conversationId) ?: run { + context.longToast("Can't find friendId for conversationId $conversationId") + return + } + + val publicKey = e2eeInterface.createKeyExchange(friendId) ?: run { + context.longToast("Can't create key exchange for friendId $friendId") + return + } + + context.log.verbose("created publicKey: ${publicKey.contentToString()}") + + sendCustomMessage(conversationId, REQUEST_PK_MESSAGE_ID) { + addBuffer(2, publicKey) + } + } + + private fun sendCustomMessage(conversationId: String, messageId: Int, message: ProtoWriter.() -> Unit) { + context.messageSender.sendCustomChatMessage( + listOf(SnapUUID.fromString(conversationId)), + ContentType.CHAT, + message = { + from(2) { + from(1) { + addVarInt(1, messageId) + addBuffer(2, ProtoWriter().apply(message).toByteArray()) + } + } + } + ) + } + + private fun warnKeyOverwrite(friendId: String, block: () -> Unit) { + if (!e2eeInterface.friendKeyExists(friendId)) { + block() + return + } + + context.mainActivity?.runOnUiThread { + val mainActivity = context.mainActivity ?: return@runOnUiThread + ViewAppearanceHelper.newAlertDialogBuilder(mainActivity).apply { + setTitle("End-to-end encryption") + setMessage("WARNING: This will overwrite your existing key. You will loose access to all encrypted messages from this friend. Are you sure you want to continue?") + setPositiveButton("Yes") { _, _ -> + ViewAppearanceHelper.newAlertDialogBuilder(mainActivity).apply { + setTitle("End-to-end encryption") + setMessage("Are you REALLY sure you want to continue? This is your last chance to back out.") + setNeutralButton("Yes") { _, _ -> block() } + setPositiveButton("No") { _, _ -> } + }.show() + } + setNegativeButton("No") { _, _ -> } + }.show() + } + } + + private fun handlePublicKeyRequest(conversationId: String, publicKey: ByteArray) { + val friendId = context.database.getDMOtherParticipant(conversationId) ?: run { + context.longToast("Can't find friendId for conversationId $conversationId") + return + } + warnKeyOverwrite(friendId) { + val encapsulatedSecret = e2eeInterface.acceptPairingRequest(friendId, publicKey) + if (encapsulatedSecret == null) { + context.longToast("Failed to accept public key") + return@warnKeyOverwrite + } + context.longToast("Public key successfully accepted") + + sendCustomMessage(conversationId, RESPONSE_SK_MESSAGE_ID) { + addBuffer(2, encapsulatedSecret) + } + } + } + + private fun handleSecretResponse(conversationId: String, secret: ByteArray) { + val friendId = context.database.getDMOtherParticipant(conversationId) ?: run { + context.longToast("Can't find friendId for conversationId $conversationId") + return + } + warnKeyOverwrite(friendId) { + context.log.verbose("handleSecretResponse, secret = $secret") + val result = e2eeInterface.acceptPairingResponse(friendId, secret) + if (!result) { + context.longToast("Failed to accept secret") + return@warnKeyOverwrite + } + context.longToast("Done! You can now exchange encrypted messages with this friend.") + } + } + + private fun openManagementPopup() { + val conversationId = context.feature(Messaging::class).openedConversationUUID?.toString() ?: return + + if (context.database.getDMOtherParticipant(conversationId) == null) { + context.shortToast("This menu is only available in direct messages.") + return + } + + val actions = listOf( + "Initiate a new shared secret" + ) + + ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity!!).apply { + setTitle("End-to-end encryption") + setItems(actions.toTypedArray()) { _, which -> + when (which) { + 0 -> askForKeys(conversationId) + } + } + setPositiveButton("OK") { _, _ -> } + }.show() + } + + @SuppressLint("SetTextI18n") + override fun onActivityCreate() { + if (!isEnabled) return + // add button to input bar + context.event.subscribe(AddViewEvent::class) { param -> + if (param.view.toString().contains("default_input_bar")) { + (param.view as ViewGroup).addView(TextView(param.view.context).apply { + layoutParams = MarginLayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.MATCH_PARENT) + setOnClickListener { openManagementPopup() } + setPadding(20, 20, 20, 20) + textSize = 23f + text = "\uD83D\uDD12" + }) + } + } + + // hook view binder to add special buttons + val receivePublicKeyTag = Random.nextLong().toString(16) + val receiveSecretTag = Random.nextLong().toString(16) + + context.event.subscribe(BindViewEvent::class) { event -> + event.chatMessage { conversationId, messageId -> + val viewGroup = event.view as ViewGroup + + viewGroup.findViewWithTag<View>(receiveSecretTag)?.also { + viewGroup.removeView(it) + } + + viewGroup.findViewWithTag<View>(receivePublicKeyTag)?.also { + viewGroup.removeView(it) + } + + secretResponses[messageId.toLong()]?.also { secret -> + viewGroup.addView(Button(context.mainActivity!!).apply { + text = "Accept secret" + tag = receiveSecretTag + setOnClickListener { + handleSecretResponse(conversationId, secret) + } + }) + } + + pkRequests[messageId.toLong()]?.also { publicKey -> + viewGroup.addView(Button(context.mainActivity!!).apply { + text = "Receive public key" + tag = receivePublicKeyTag + setOnClickListener { + handlePublicKeyRequest(conversationId, publicKey) + } + }) + } + } + } + } + + private fun fixContentType(contentType: ContentType, message: ProtoReader): ContentType { + return when { + contentType == ContentType.EXTERNAL_MEDIA && message.containsPath(11) -> { + ContentType.SNAP + } + contentType == ContentType.SHARE && message.containsPath(2) -> { + ContentType.CHAT + } + else -> contentType + } + } + + private fun hashParticipantId(participantId: String, salt: ByteArray): ByteArray { + return MessageDigest.getInstance("SHA-256").apply { + update(participantId.toByteArray()) + update(salt) + }.digest() + } + + private fun messageHook(conversationId: String, messageId: Long, senderId: String, messageContent: MessageContent) { + val reader = ProtoReader(messageContent.content) + + fun replaceMessageText(text: String) { + messageContent.content = ProtoWriter().apply { + from(2) { + addString(1, text) + } + }.toByteArray() + } + + // decrypt messages + reader.followPath(2, 1) { + val messageTypeId = getVarInt(1)?.toInt() ?: return@followPath + val isMe = context.database.myUserId == senderId + val conversationParticipants by lazy { + getE2EParticipants(conversationId) + } + + if (messageTypeId == ENCRYPTED_MESSAGE_ID) { + runCatching { + replaceMessageText("Cannot find a key to decrypt this message.") + eachBuffer(2) { + val participantIdHash = getByteArray(1) ?: return@eachBuffer + val iv = getByteArray(2) ?: return@eachBuffer + val ciphertext = getByteArray(3) ?: return@eachBuffer + + if (isMe) { + if (conversationParticipants.isEmpty()) return@eachBuffer + val participantId = conversationParticipants.firstOrNull { participantIdHash.contentEquals(hashParticipantId(it, iv)) } ?: return@eachBuffer + messageContent.content = e2eeInterface.decryptMessage(participantId, ciphertext, iv) + return@eachBuffer + } + + if (!participantIdHash.contentEquals(hashParticipantId(context.database.myUserId, iv))) return@eachBuffer + + messageContent.content = e2eeInterface.decryptMessage(senderId, ciphertext, iv) + } + + // fix content type + messageContent.contentType?.also { + messageContent.contentType = fixContentType(it, reader) + } + }.onFailure { + context.log.error("Failed to decrypt message id: $messageId", it) + messageContent.contentType = ContentType.CHAT + messageContent.content = ProtoWriter().apply { + from(2) { + addString(1, "Failed to decrypt message, id=$messageId. Check logcat for more details.") + } + }.toByteArray() + } + + return@followPath + } + + val payload = getByteArray(2, 2) ?: return@followPath + + if (senderId == context.database.myUserId) { + when (messageTypeId) { + REQUEST_PK_MESSAGE_ID -> { + replaceMessageText("[Key exchange request]") + } + RESPONSE_SK_MESSAGE_ID -> { + replaceMessageText("[Key exchange response]") + } + } + return@followPath + } + + when (messageTypeId) { + REQUEST_PK_MESSAGE_ID -> { + pkRequests[messageId] = payload + replaceMessageText("You just received a public key request. Click below to accept it.") + } + RESPONSE_SK_MESSAGE_ID -> { + secretResponses[messageId] = payload + replaceMessageText("Your friend just accepted your public key. Click below to accept the secret.") + } + } + } + } + + override fun asyncInit() { + if (!isEnabled) return + // trick to disable fidelius encryption + context.event.subscribe(SendMessageWithContentEvent::class) { param -> + val messageContent = param.messageContent + val destinations = param.destinations + if (destinations.conversations.size != 1 || destinations.stories.isNotEmpty()) return@subscribe + + if (!getState(destinations.conversations.first().toString())) return@subscribe + + if (messageContent.contentType == ContentType.SNAP) { + messageContent.contentType = ContentType.EXTERNAL_MEDIA + } + + if (messageContent.contentType == ContentType.CHAT) { + messageContent.contentType = ContentType.SHARE + } + } + } + + override fun init() { + if (!isEnabled) return + context.event.subscribe(UnaryCallEvent::class) { event -> + if (event.uri != "/messagingcoreservice.MessagingCoreService/CreateContentMessage") return@subscribe + val protoReader = ProtoReader(event.buffer) + + val conversationIds = mutableListOf<SnapUUID>() + protoReader.eachBuffer(3) { + conversationIds.add(SnapUUID.fromBytes(getByteArray(1, 1, 1) ?: return@eachBuffer)) + } + + if (conversationIds.any { !getState(it.toString()) }) { + context.log.debug("Skipping encryption for conversation ids: ${conversationIds.joinToString(", ")}") + return@subscribe + } + + val participantsIds = conversationIds.map { getE2EParticipants(it.toString()) }.flatten().distinct() + + if (participantsIds.isEmpty()) { + context.longToast("You don't have any friends in this conversation to encrypt messages with!") + event.canceled = true + return@subscribe + } + val messageReader = protoReader.followPath(4) ?: return@subscribe + + if (messageReader.getVarInt(4, 2, 1, 1) != null) { + return@subscribe + } + + event.buffer = ProtoEditor(event.buffer).apply { + val contentType = fixContentType(ContentType.fromId(messageReader.getVarInt(2)?.toInt() ?: -1), messageReader.followPath(4) ?: return@apply) + val messageContent = messageReader.getByteArray(4) ?: return@apply + + runCatching { + edit(4) { + //set message content type + remove(2) + addVarInt(2, contentType.id) + + //set encrypted content + remove(4) + add(4) { + from(2) { + from(1) { + addVarInt(1, ENCRYPTED_MESSAGE_ID) + participantsIds.forEach { participantId -> + val encryptedMessage = e2eeInterface.encryptMessage(participantId, + messageContent + ) ?: run { + context.log.error("Failed to encrypt message for $participantId") + return@forEach + } + context.log.debug("encrypted message size = ${encryptedMessage.ciphertext.size} for $participantId") + from(2) { + // participantId is hashed with iv to prevent leaking it when sending to multiple conversations + addBuffer(1, hashParticipantId(participantId, encryptedMessage.iv)) + addBuffer(2, encryptedMessage.iv) + addBuffer(3, encryptedMessage.ciphertext) + } + } + } + } + } + } + }.onFailure { + event.canceled = true + context.log.error("Failed to encrypt message", it) + context.longToast("Failed to encrypt message! Check logcat for more details.") + } + }.toByteArray() + } + + context.classCache.message.hookConstructor(HookStage.AFTER) { param -> + val message = Message(param.thisObject()) + val conversationId = message.messageDescriptor.conversationId.toString() + messageHook( + conversationId = conversationId, + messageId = message.messageDescriptor.messageId, + senderId = message.senderId.toString(), + messageContent = message.messageContent + ) + message.messageContent.instanceNonNull() + .getObjectField("mQuotedMessage") + ?.getObjectField("mContent") + ?.also { quotedMessage -> + messageHook( + conversationId = conversationId, + messageId = quotedMessage.getObjectField("mMessageId")?.toString()?.toLong() ?: return@also, + senderId = SnapUUID(quotedMessage.getObjectField("mSenderId")).toString(), + messageContent = MessageContent(quotedMessage) + ) + } + } + } + + override fun getRuleState() = RuleState.WHITELIST +}+ \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/spying/MessageLogger.kt b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/spying/MessageLogger.kt @@ -2,9 +2,9 @@ package me.rhunk.snapenhance.features.impl.spying import android.graphics.drawable.ColorDrawable import android.os.DeadObjectException -import android.view.View import com.google.gson.JsonObject import com.google.gson.JsonParser +import me.rhunk.snapenhance.core.event.events.impl.BindViewEvent import me.rhunk.snapenhance.data.ContentType import me.rhunk.snapenhance.data.MessageState import me.rhunk.snapenhance.data.wrapper.impl.Message @@ -12,8 +12,6 @@ import me.rhunk.snapenhance.features.Feature import me.rhunk.snapenhance.features.FeatureLoadParams import me.rhunk.snapenhance.hook.HookStage import me.rhunk.snapenhance.hook.Hooker -import me.rhunk.snapenhance.hook.hook -import me.rhunk.snapenhance.hook.hookConstructor import java.util.concurrent.Executors import kotlin.time.ExperimentalTime import kotlin.time.measureTime @@ -33,6 +31,7 @@ class MessageLogger : Feature("MessageLogger", companion object { const val PREFETCH_MESSAGE_COUNT = 20 const val PREFETCH_FEED_COUNT = 20 + const val DELETED_MESSAGE_COLOR = 0x2Eb71c1c } private val isEnabled get() = context.config.messaging.messageLogger.get() @@ -154,40 +153,16 @@ class MessageLogger : Feature("MessageLogger", override fun onActivityCreate() { if (!isEnabled) return - val viewBinderMappings = context.mappings.getMappedMap("ViewBinder") - val cachedHooks = mutableListOf<String>() - - fun cacheHook(clazz: Class<*>, block: Class<*>.() -> Unit) { - if (!cachedHooks.contains(clazz.name)) { - clazz.block() - cachedHooks.add(clazz.name) - } - } - - findClass(viewBinderMappings["class"].toString()).hookConstructor(HookStage.AFTER) { methodParam -> - cacheHook( - methodParam.thisObject<Any>()::class.java - ) { - hook(viewBinderMappings["bindMethod"].toString(), HookStage.BEFORE) bindViewMethod@{ param -> - val instance = param.thisObject<Any>() - val model1 = param.arg<Any>(0).toString().also { - if (!it.startsWith("ChatViewModel")) return@bindViewMethod - } - - val messageId = model1.substringAfter("messageId=").substringBefore(",").split(":").let { - it[0] to it[2] - } - - getServerMessageIdentifier(messageId.first, messageId.second.toLong())?.let { serverMessageId -> - if (!deletedMessageCache.contains(serverMessageId)) return@bindViewMethod - } ?: return@bindViewMethod - - val view = instance::class.java.methods.first { - it.name == viewBinderMappings["getViewMethod"].toString() - }.invoke(instance) as? View ?: return@bindViewMethod - - view.foreground = ColorDrawable(0x1E90313e) // red with alpha + context.event.subscribe(BindViewEvent::class) { event -> + event.chatMessage { conversationId, messageId -> + val foreground = event.view.foreground + if (foreground is ColorDrawable && foreground.color == DELETED_MESSAGE_COLOR) { + event.view.foreground = null } + getServerMessageIdentifier(conversationId, messageId.toLong())?.let { serverMessageId -> + if (!deletedMessageCache.contains(serverMessageId)) return@chatMessage + } ?: return@chatMessage + event.view.foreground = ColorDrawable(DELETED_MESSAGE_COLOR) // red with alpha } } } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/manager/impl/FeatureManager.kt b/core/src/main/kotlin/me/rhunk/snapenhance/manager/impl/FeatureManager.kt @@ -2,7 +2,6 @@ package me.rhunk.snapenhance.manager.impl import me.rhunk.snapenhance.ModContext import me.rhunk.snapenhance.core.Logger -import me.rhunk.snapenhance.features.impl.experiments.AESMessageEncryption import me.rhunk.snapenhance.features.Feature import me.rhunk.snapenhance.features.FeatureLoadParams import me.rhunk.snapenhance.features.MessagingRuleFeature @@ -54,7 +53,7 @@ class FeatureManager(private val context: ModContext) : Manager { override fun init() { register( - AESMessageEncryption::class, + EndToEndEncryption::class, ScopeSync::class, Messaging::class, MediaDownloader::class, diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml @@ -1,5 +1,6 @@ [versions] agp = "8.1.1" +bcprov-jdk18on = "1.76" coil-compose = "2.4.0" junit = "4.13.2" kotlin = "1.8.22" @@ -26,6 +27,7 @@ androidx-material-icons-core = { module = "androidx.compose.material:material-ic androidx-material-icons-extended = { module = "androidx.compose.material:material-icons-extended", version.ref = "material-icons-extended" } androidx-material-ripple = { module = "androidx.compose.material:material-ripple", version.ref = "material-icons-core" } androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigation-compose" } +bcprov-jdk18on = { module = "org.bouncycastle:bcprov-jdk18on", version.ref = "bcprov-jdk18on" } coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coil-compose" } coil-video = { module = "io.coil-kt:coil-video", version.ref = "coil-compose" } coroutines = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinx-coroutines-android" }