feat: end-to-end encryption

- message_logger: fix view binder bugs and deleted message color
- fix invalid message rule type
This commit is contained in:
rhunk
2023-09-28 02:12:03 +02:00
parent 061f5cc5a8
commit aaf8f3e43a
22 changed files with 869 additions and 220 deletions

View File

@ -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();

View File

@ -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);
}

View File

@ -0,0 +1,6 @@
package me.rhunk.snapenhance.bridge.e2ee;
parcelable EncryptionResult {
byte[] ciphertext;
byte[] iv;
}

View File

@ -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",

View File

@ -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()
}

View File

@ -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",

View File

@ -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()
}
}

View File

@ -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)
}
}

View File

@ -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 {

View File

@ -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())
}
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}
}
}

View File

@ -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,