mirror of
https://github.com/rhunk/SnapEnhance.git
synced 2025-06-12 05:07:46 +02:00
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:
@ -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();
|
||||
|
@ -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);
|
||||
}
|
@ -0,0 +1,6 @@
|
||||
package me.rhunk.snapenhance.bridge.e2ee;
|
||||
|
||||
parcelable EncryptionResult {
|
||||
byte[] ciphertext;
|
||||
byte[] iv;
|
||||
}
|
@ -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",
|
||||
|
@ -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()
|
||||
}
|
||||
|
@ -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",
|
||||
|
@ -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()
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
@ -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 {
|
||||
|
@ -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())
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
@ -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
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
|
Reference in New Issue
Block a user