fix(PR): story decryption by hazelnut27
Enhance media encryption handling with hybrid resolver and improved decryption methods
This commit is contained in:
@@ -9,7 +9,6 @@ import javax.crypto.spec.SecretKeySpec
|
|||||||
import kotlin.io.encoding.Base64
|
import kotlin.io.encoding.Base64
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
// key and iv are base64 encoded into url safe strings
|
|
||||||
data class MediaEncryptionKeyPair(
|
data class MediaEncryptionKeyPair(
|
||||||
val key: String,
|
val key: String,
|
||||||
val iv: String,
|
val iv: String,
|
||||||
|
|||||||
@@ -48,11 +48,18 @@ import me.eternal.purrfectsnap.core.wrapper.impl.media.opera.ParamMap
|
|||||||
import me.eternal.purrfectsnap.core.wrapper.impl.media.toKeyPair
|
import me.eternal.purrfectsnap.core.wrapper.impl.media.toKeyPair
|
||||||
import me.eternal.purrfectsnap.core.wrapper.impl.media.EncryptionWrapper
|
import me.eternal.purrfectsnap.core.wrapper.impl.media.EncryptionWrapper
|
||||||
import me.eternal.purrfectsnap.mapper.impl.OperaPageViewControllerMapper
|
import me.eternal.purrfectsnap.mapper.impl.OperaPageViewControllerMapper
|
||||||
|
import me.eternal.purrfectsnap.core.wrapper.impl.media.SnapCipherMode
|
||||||
|
import me.eternal.purrfectsnap.core.wrapper.impl.media.toKeyPairUrlSafe
|
||||||
|
import me.eternal.purrfectsnap.core.wrapper.impl.media.HybridEncryptionResolver
|
||||||
|
import me.eternal.purrfectsnap.core.wrapper.AbstractWrapper
|
||||||
import java.nio.file.Paths
|
import java.nio.file.Paths
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import kotlin.coroutines.suspendCoroutine
|
import kotlin.coroutines.suspendCoroutine
|
||||||
import kotlin.math.absoluteValue
|
import kotlin.math.absoluteValue
|
||||||
import android.util.Base64
|
import android.util.Base64
|
||||||
|
import javax.crypto.Cipher
|
||||||
|
import javax.crypto.spec.IvParameterSpec
|
||||||
|
import javax.crypto.spec.SecretKeySpec
|
||||||
|
|
||||||
class SnapChapterInfo(
|
class SnapChapterInfo(
|
||||||
val offset: Long,
|
val offset: Long,
|
||||||
@@ -205,37 +212,6 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun extractStoryEncryption(paramMap: ParamMap): MediaEncryptionKeyPair? {
|
|
||||||
|
|
||||||
val keyRaw = paramMap["CONTEXT_REPLY_MEDIA_KEY"] as? String
|
|
||||||
?: paramMap["REPLY_MEDIA_KEY"] as? String
|
|
||||||
?: return null
|
|
||||||
|
|
||||||
val ivRaw = paramMap["CONTEXT_REPLY_MEDIA_IV"] as? String
|
|
||||||
?: paramMap["REPLY_MEDIA_IV"] as? String
|
|
||||||
?: return null
|
|
||||||
|
|
||||||
return try {
|
|
||||||
|
|
||||||
val keyBytes = android.util.Base64.decode(keyRaw, android.util.Base64.DEFAULT)
|
|
||||||
val ivBytes = android.util.Base64.decode(ivRaw, android.util.Base64.DEFAULT)
|
|
||||||
|
|
||||||
if (keyBytes.size != 32 || ivBytes.size != 16) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
MediaEncryptionKeyPair(
|
|
||||||
key = android.util.Base64.encodeToString(keyBytes, android.util.Base64.NO_WRAP),
|
|
||||||
iv = android.util.Base64.encodeToString(ivBytes, android.util.Base64.NO_WRAP),
|
|
||||||
urlSafe = false
|
|
||||||
)
|
|
||||||
|
|
||||||
} catch (e: Exception) {
|
|
||||||
context.log.error("Story AES decode failed", e)
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun downloadOperaMedia(
|
private fun downloadOperaMedia(
|
||||||
downloadManagerClient: DownloadManagerClient,
|
downloadManagerClient: DownloadManagerClient,
|
||||||
mediaInfoMap: Map<SplitMediaAssetType, MediaInfo>,
|
mediaInfoMap: Map<SplitMediaAssetType, MediaInfo>,
|
||||||
@@ -243,69 +219,50 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
|
|||||||
) {
|
) {
|
||||||
if (mediaInfoMap.isEmpty()) return
|
if (mediaInfoMap.isEmpty()) return
|
||||||
|
|
||||||
val storyKeyPair = extractStoryEncryption(paramMap)
|
// Story Snap Entry (images)
|
||||||
|
|
||||||
val originalMediaInfo = mediaInfoMap[SplitMediaAssetType.ORIGINAL]!!
|
|
||||||
val originalMediaInfoReference = handleLocalReferences(originalMediaInfo.uri)
|
|
||||||
|
|
||||||
paramMap["SNAP_ID"]?.toString()?.let { snapId ->
|
paramMap["SNAP_ID"]?.toString()?.let { snapId ->
|
||||||
context.database.getStorySnapEntry(snapId)?.let { storySnapEntry ->
|
context.database.getStorySnapEntry(snapId)?.let { storySnapEntry ->
|
||||||
|
|
||||||
val urlToDownload = storySnapEntry?.mediaUrl ?: originalMediaInfo.uri
|
|
||||||
val encryptionPair =
|
|
||||||
safeGetEncryptionPair(originalMediaInfo)
|
|
||||||
?: extractStoryEncryption(paramMap)
|
|
||||||
|
|
||||||
downloadManagerClient.downloadSingleMedia(
|
downloadManagerClient.downloadSingleMedia(
|
||||||
originalMediaInfoReference,
|
storySnapEntry.mediaUrl ?: throw Exception("Media URL not found"),
|
||||||
DownloadMediaType.fromUri(Uri.parse(originalMediaInfoReference)),
|
DownloadMediaType.fromUri(Uri.parse(storySnapEntry.mediaUrl)),
|
||||||
encryptionPair
|
(storySnapEntry.mediaKey to storySnapEntry.mediaIv)
|
||||||
|
.takeIf { it.first != null && it.second != null }
|
||||||
|
?.let { (key, iv) -> MediaEncryptionKeyPair(key!!, iv!!, urlSafe = false) }
|
||||||
)
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val originalMediaInfo = mediaInfoMap[SplitMediaAssetType.ORIGINAL]!!
|
||||||
|
val originalMediaInfoReference = handleLocalReferences(originalMediaInfo.uri)
|
||||||
|
|
||||||
|
// Overlay (if present)
|
||||||
mediaInfoMap[SplitMediaAssetType.OVERLAY]?.let { overlay ->
|
mediaInfoMap[SplitMediaAssetType.OVERLAY]?.let { overlay ->
|
||||||
val overlayReference = handleLocalReferences(overlay.uri)
|
val overlayReference = handleLocalReferences(overlay.uri)
|
||||||
|
|
||||||
val originalEncryption = safeGetEncryptionPair(originalMediaInfo)
|
|
||||||
val overlayEncryption = overlay.encryption?.toKeyPair()
|
|
||||||
|
|
||||||
downloadManagerClient.downloadMediaWithOverlay(
|
downloadManagerClient.downloadMediaWithOverlay(
|
||||||
original = InputMedia(
|
original = InputMedia(
|
||||||
originalMediaInfoReference,
|
originalMediaInfoReference,
|
||||||
DownloadMediaType.fromUri(Uri.parse(originalMediaInfoReference)),
|
DownloadMediaType.fromUri(Uri.parse(originalMediaInfoReference)),
|
||||||
encryption = originalEncryption
|
originalMediaInfo.encryption?.toKeyPair()
|
||||||
),
|
),
|
||||||
overlay = InputMedia(
|
overlay = InputMedia(
|
||||||
overlayReference,
|
overlayReference,
|
||||||
DownloadMediaType.fromUri(Uri.parse(overlayReference)),
|
DownloadMediaType.fromUri(Uri.parse(overlayReference)),
|
||||||
encryption = overlayEncryption,
|
overlay.encryption?.toKeyPair(),
|
||||||
isOverlay = true
|
isOverlay = true
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// fallback to single media download
|
// Single media (video/DASH)
|
||||||
val encryptionPair =
|
|
||||||
storyKeyPair ?: safeGetEncryptionPair(originalMediaInfo)
|
|
||||||
|
|
||||||
downloadManagerClient.downloadSingleMedia(
|
downloadManagerClient.downloadSingleMedia(
|
||||||
originalMediaInfoReference,
|
originalMediaInfoReference,
|
||||||
DownloadMediaType.fromUri(Uri.parse(originalMediaInfoReference)),
|
DownloadMediaType.fromUri(Uri.parse(originalMediaInfoReference)),
|
||||||
encryptionPair
|
originalMediaInfo.encryption?.toKeyPair()
|
||||||
)
|
)
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun safeGetEncryptionPair(info: MediaInfo?): MediaEncryptionKeyPair? {
|
|
||||||
val enc = info?.encryption ?: return null
|
|
||||||
return runCatching {
|
|
||||||
enc.toKeyPair()
|
|
||||||
}.onFailure { context.log.verbose("Failed to parse encryption key pair → ${info.uri}")
|
|
||||||
}.getOrNull()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun canAutoDownloadMessage(databaseMessage: ConversationMessage): Boolean {
|
fun canAutoDownloadMessage(databaseMessage: ConversationMessage): Boolean {
|
||||||
@@ -313,32 +270,6 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
|
|||||||
return canUseRule(databaseMessage.clientConversationId!!)
|
return canUseRule(databaseMessage.clientConversationId!!)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun resolveMediaUrl(raw: String): String {
|
|
||||||
var url = raw.trim()
|
|
||||||
|
|
||||||
// Remove garbage prefix/suffix sometimes present
|
|
||||||
if (url.contains("https://")) {
|
|
||||||
url = url.substringAfter("https://")
|
|
||||||
url = "https://$url"
|
|
||||||
}
|
|
||||||
|
|
||||||
val knownGoodPrefixes = listOf(
|
|
||||||
"https://cf-st.sc-cdn.net",
|
|
||||||
"https://bolt-gcdn.sc-cdn.net",
|
|
||||||
"https://app.snapchat.com",
|
|
||||||
"https://cf-st-nl1.sc-cdn.net" // sometimes region specific
|
|
||||||
)
|
|
||||||
|
|
||||||
return when {
|
|
||||||
url.startsWith("http") -> url
|
|
||||||
knownGoodPrefixes.any { url.contains(it) } -> url.substringAfterLast("http")
|
|
||||||
else -> "${RemoteMediaResolver.CF_ST_CDN_D}/$url"
|
|
||||||
}.also { resolved ->
|
|
||||||
if (resolved != raw) {
|
|
||||||
context.log.debug("URL rewritten: $raw → $resolved")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/**
|
/**
|
||||||
* Handles the media from the opera viewer
|
* Handles the media from the opera viewer
|
||||||
*
|
*
|
||||||
@@ -347,105 +278,94 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
|
|||||||
* @param forceDownload if the media should be downloaded
|
* @param forceDownload if the media should be downloaded
|
||||||
*/
|
*/
|
||||||
private fun handleOperaMedia(
|
private fun handleOperaMedia(
|
||||||
paramMap: ParamMap,
|
paramMap: ParamMap,
|
||||||
mediaInfoMap: Map<SplitMediaAssetType,
|
mediaInfoMap: Map<SplitMediaAssetType, MediaInfo>,
|
||||||
MediaInfo>,
|
forceDownload: Boolean,
|
||||||
forceDownload: Boolean,
|
|
||||||
forceAllowDuplicate: Boolean = false
|
forceAllowDuplicate: Boolean = false
|
||||||
) {
|
) {
|
||||||
|
|
||||||
//messages
|
// ─── Messages ─────────────────────────
|
||||||
paramMap["MESSAGE_ID"]?.toString()?.takeIf { forceDownload || shouldAutoDownload("friend_snaps") }?.let { id ->
|
paramMap["MESSAGE_ID"]?.toString()?.takeIf { forceDownload || shouldAutoDownload("friend_snaps") }?.let { id ->
|
||||||
val messageId = id.substring(id.lastIndexOf(":") + 1).toLong()
|
val messageId = id.substringAfterLast(":").toLong()
|
||||||
val conversationMessage = context.database.getConversationMessageFromId(messageId)!!
|
val conversationMessage = context.database.getConversationMessageFromId(messageId) ?: return@let
|
||||||
|
|
||||||
val conversationId = conversationMessage.clientConversationId!!
|
val conversationId = conversationMessage.clientConversationId!!
|
||||||
|
|
||||||
if (!forceDownload && !canUseRule(conversationId)) {
|
if (!forceDownload && !canUseRule(conversationId)) return@let
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val senderId = conversationMessage.senderId!!
|
val senderId = conversationMessage.senderId!!
|
||||||
|
if (!forceDownload && context.config.downloader.preventSelfAutoDownload.get() &&
|
||||||
|
senderId == context.database.myUserId
|
||||||
|
) return@let
|
||||||
|
|
||||||
if (!forceDownload && context.config.downloader.preventSelfAutoDownload.get() && senderId == context.database.myUserId) return
|
val author = context.database.getFriendInfo(senderId) ?: return@let
|
||||||
|
|
||||||
val author = context.database.getFriendInfo(senderId) ?: return
|
|
||||||
val authorUsername = author.usernameForSorting!!
|
val authorUsername = author.usernameForSorting!!
|
||||||
val mediaId = paramMap["MEDIA_ID"]?.toString()?.let {
|
val mediaId = paramMap["MEDIA_ID"]?.toString()?.substringAfter("-")?.substringBefore(".") ?: ""
|
||||||
if (it.contains("-")) it.substringAfter("-")
|
|
||||||
else it
|
|
||||||
}?.substringBefore(".")
|
|
||||||
|
|
||||||
downloadOperaMedia(provideDownloadManagerClient(
|
|
||||||
mediaIdentifier = "$conversationId$senderId${conversationMessage.serverMessageId}$mediaId",
|
|
||||||
mediaAuthor = authorUsername,
|
|
||||||
creationTimestamp = conversationMessage.creationTimestamp,
|
|
||||||
downloadSource = MediaDownloadSource.CHAT_MEDIA,
|
|
||||||
friendInfo = author,
|
|
||||||
forceAllowDuplicate = forceAllowDuplicate
|
|
||||||
), mediaInfoMap, paramMap)
|
|
||||||
|
|
||||||
|
downloadOperaMedia(
|
||||||
|
provideDownloadManagerClient(
|
||||||
|
mediaIdentifier = "$conversationId$senderId${conversationMessage.serverMessageId}$mediaId",
|
||||||
|
mediaAuthor = authorUsername,
|
||||||
|
creationTimestamp = conversationMessage.creationTimestamp,
|
||||||
|
downloadSource = MediaDownloadSource.CHAT_MEDIA,
|
||||||
|
friendInfo = author,
|
||||||
|
forceAllowDuplicate = forceAllowDuplicate
|
||||||
|
),
|
||||||
|
mediaInfoMap,
|
||||||
|
paramMap
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
//private stories
|
// ─── Private Friend Story ─────────────────────────
|
||||||
paramMap["PLAYLIST_V2_GROUP"]?.takeIf {
|
paramMap["PLAYLIST_V2_GROUP"]?.takeIf {
|
||||||
forceDownload || shouldAutoDownload("friend_stories")
|
forceDownload || shouldAutoDownload("friend_stories")
|
||||||
}?.let { playlistGroup ->
|
}?.let { playlistGroup ->
|
||||||
val playlistGroupString = playlistGroup.toString()
|
val playlistGroupString = playlistGroup.toString()
|
||||||
|
|
||||||
// Try multiple possible keys/paths in order of likelihood
|
val storyUserId = paramMap["TOPIC_SNAP_CREATOR_USER_ID"]?.toString() ?: paramMap["PLAYABLE_STORY_SNAP_RECORD"]?.toString()?.let {
|
||||||
val storyUserId = sequenceOf(
|
if (it.contains("userId=")) it.substringAfter("userId=").substringBefore(",") else null
|
||||||
// Most common new locations
|
} ?: if (playlistGroupString.contains("storyUserId=")) {
|
||||||
{ paramMap["STORY_USER_ID"]?.toString() },
|
playlistGroupString.substringAfter("storyUserId=").substringBefore(",")
|
||||||
{ paramMap["CREATOR_USER_ID"]?.toString() },
|
} else {
|
||||||
{ paramMap["USER_ID"]?.toString() },
|
//story replies
|
||||||
{ paramMap["TOPIC_SNAP_CREATOR_USER_ID"]?.toString() },
|
val arroyoMessageId = playlistGroup::class.java.methods.firstOrNull { it.name == "getId" }
|
||||||
// Old ones as fallback
|
?.invoke(playlistGroup)?.toString()
|
||||||
{ paramMap["PLAYABLE_STORY_SNAP_RECORD"]
|
?.split(":")?.getOrNull(2) ?: return@let
|
||||||
?.toString()
|
|
||||||
?.substringAfter("userId=")
|
|
||||||
?.substringBefore(",") },
|
|
||||||
// Parse playlistGroup string more carefully
|
|
||||||
{ playlistGroupString.substringAfter("userId=", missingDelimiterValue = "").substringBefore(",") },
|
|
||||||
{ playlistGroupString.substringAfter("storyUserId=", missingDelimiterValue = "").substringBefore(",") },
|
|
||||||
// Last desperate fallback — sometimes it's in nested snap record
|
|
||||||
{ paramMap["SNAP_PLAYLIST_ITEM"]
|
|
||||||
?.toString()
|
|
||||||
?.substringAfter("userId=")
|
|
||||||
?.substringBefore(",") }
|
|
||||||
).mapNotNull { it() }.firstOrNull { it.isNotBlank() && it != "null" }
|
|
||||||
|
|
||||||
// ──────────────────────────────────────────────
|
val conversationMessage = context.database.getConversationMessageFromId(arroyoMessageId.toLong()) ?: return@let
|
||||||
|
val conversationParticipants = context.database.getConversationParticipants(conversationMessage.clientConversationId.toString()) ?: return@let
|
||||||
val authorUserId = storyUserId ?: run {
|
conversationParticipants.firstOrNull { it != conversationMessage.senderId }
|
||||||
context.log.warn("[FriendStories] Could not extract user ID — falling back to current user")
|
|
||||||
context.database.myUserId // prevents crash, but will tag as self
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val author = context.database.getFriendInfo(authorUserId)
|
val author = context.database.getFriendInfo(
|
||||||
?: context.database.getFriendInfoByUsername(authorUserId) // sometimes it's username
|
if (storyUserId == null || storyUserId == "null")
|
||||||
?: throw Exception("No friend info for ID: $authorUserId")
|
context.database.myUserId
|
||||||
|
else storyUserId
|
||||||
val authorName = author.usernameForSorting ?: author.displayName ?: "UnknownFriend"
|
) ?: throw Exception("Friend not found in database")
|
||||||
|
val authorName = author.usernameForSorting!!
|
||||||
|
|
||||||
if (!forceDownload) {
|
if (!forceDownload) {
|
||||||
if (context.config.downloader.preventSelfAutoDownload.get() && author.userId == context.database.myUserId) return
|
if (context.config.downloader.preventSelfAutoDownload.get() && author.userId == context.database.myUserId) return
|
||||||
if (!canUseRule(author.userId!!)) return
|
if (!canUseRule(author.userId!!)) return
|
||||||
}
|
}
|
||||||
|
|
||||||
downloadOperaMedia(provideDownloadManagerClient(
|
downloadOperaMedia(
|
||||||
mediaIdentifier = paramMap["MEDIA_ID"].toString(),
|
provideDownloadManagerClient(
|
||||||
mediaAuthor = authorName,
|
mediaIdentifier = paramMap["MEDIA_ID"].toString(),
|
||||||
creationTimestamp = paramMap["PLAYABLE_STORY_SNAP_RECORD"]?.toString()?.substringAfter("timestamp=")
|
mediaAuthor = authorName,
|
||||||
?.substringBefore(",")?.toLongOrNull(),
|
creationTimestamp = paramMap["PLAYABLE_STORY_SNAP_RECORD"]?.toString()?.substringAfter("timestamp=")
|
||||||
downloadSource = MediaDownloadSource.STORY,
|
?.substringBefore(",")?.toLongOrNull(),
|
||||||
friendInfo = author,
|
downloadSource = MediaDownloadSource.STORY,
|
||||||
forceAllowDuplicate = forceAllowDuplicate,
|
friendInfo = author,
|
||||||
), mediaInfoMap, paramMap)
|
forceAllowDuplicate = forceAllowDuplicate
|
||||||
|
),
|
||||||
|
mediaInfoMap,
|
||||||
|
paramMap
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Public Stories / Spotlight ───────────────────
|
||||||
val snapSource = paramMap["SNAP_SOURCE"].toString()
|
val snapSource = paramMap["SNAP_SOURCE"].toString()
|
||||||
|
|
||||||
//spotlight
|
//spotlight
|
||||||
@@ -480,7 +400,13 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
|
|||||||
return "${(hours % 24).toString().padStart(2, '0')}:${(minutes % 60).toString().padStart(2, '0')}:${(seconds % 60).toString().padStart(2, '0')}"
|
return "${(hours % 24).toString().padStart(2, '0')}:${(minutes % 60).toString().padStart(2, '0')}:${(seconds % 60).toString().padStart(2, '0')}"
|
||||||
}
|
}
|
||||||
|
|
||||||
val playlistUrl = resolveMediaUrl(paramMap["MEDIA_ID"].toString())
|
val playlistUrl = paramMap["MEDIA_ID"].toString().let {
|
||||||
|
val urlIndexes = arrayOf(it.indexOf("https://cf-st.sc-cdn.net"), it.indexOf("https://bolt-gcdn.sc-cdn.net"))
|
||||||
|
|
||||||
|
urlIndexes.firstOrNull { index -> index != -1 }?.let { validIndex ->
|
||||||
|
it.substring(validIndex)
|
||||||
|
} ?: "${RemoteMediaResolver.CF_ST_CDN_D}$it"
|
||||||
|
}
|
||||||
|
|
||||||
context.runOnUiThread {
|
context.runOnUiThread {
|
||||||
val selectedChapters = mutableListOf<Int>()
|
val selectedChapters = mutableListOf<Int>()
|
||||||
@@ -810,7 +736,7 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
|
|||||||
if (!isPreview) {
|
if (!isPreview) {
|
||||||
if (forceDownloadFirst ||
|
if (forceDownloadFirst ||
|
||||||
decodedAttachments.size == 1 ||
|
decodedAttachments.size == 1 ||
|
||||||
context.isMainActivityPaused // we can't show alert dialogs when it downloads from a notification, so it downloads the first one
|
context.isMainActivityPaused
|
||||||
) {
|
) {
|
||||||
downloadMessageAttachments(friendInfo, message, authorName,
|
downloadMessageAttachments(friendInfo, message, authorName,
|
||||||
listOf(decodedAttachments.first()),
|
listOf(decodedAttachments.first()),
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package me.eternal.purrfectsnap.core.wrapper.impl.media
|
package me.eternal.purrfectsnap.core.wrapper.impl.media
|
||||||
|
|
||||||
|
import android.util.Base64
|
||||||
|
import android.util.Log
|
||||||
import me.eternal.purrfectsnap.common.data.download.MediaEncryptionKeyPair
|
import me.eternal.purrfectsnap.common.data.download.MediaEncryptionKeyPair
|
||||||
import me.eternal.purrfectsnap.core.wrapper.AbstractWrapper
|
import me.eternal.purrfectsnap.core.wrapper.AbstractWrapper
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
@@ -10,83 +12,206 @@ import javax.crypto.CipherInputStream
|
|||||||
import javax.crypto.CipherOutputStream
|
import javax.crypto.CipherOutputStream
|
||||||
import javax.crypto.spec.IvParameterSpec
|
import javax.crypto.spec.IvParameterSpec
|
||||||
import javax.crypto.spec.SecretKeySpec
|
import javax.crypto.spec.SecretKeySpec
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
|
||||||
import android.util.Base64
|
// Cipher mode enum
|
||||||
|
|
||||||
enum class SnapCipherMode {
|
enum class SnapCipherMode {
|
||||||
CBC,
|
CBC,
|
||||||
CTR
|
CTR
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Encryption Wrapper
|
||||||
|
|
||||||
class EncryptionWrapper(
|
class EncryptionWrapper(
|
||||||
instance: Any?,
|
instance: Any? = null,
|
||||||
private val mode: SnapCipherMode = SnapCipherMode.CBC
|
private val mode: SnapCipherMode = SnapCipherMode.CBC
|
||||||
) : AbstractWrapper(instance) {
|
) : AbstractWrapper(instance) {
|
||||||
|
|
||||||
fun decrypt(data: ByteArray?): ByteArray {
|
// Manual key injection (story fallback)
|
||||||
return newCipher(Cipher.DECRYPT_MODE).doFinal(data)
|
|
||||||
|
private var manualKey: ByteArray? = null
|
||||||
|
private var manualIv: ByteArray? = null
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
key: ByteArray,
|
||||||
|
iv: ByteArray,
|
||||||
|
mode: SnapCipherMode = SnapCipherMode.CBC
|
||||||
|
) : this(null, mode) {
|
||||||
|
manualKey = key
|
||||||
|
manualIv = iv
|
||||||
}
|
}
|
||||||
|
|
||||||
fun decrypt(inputStream: InputStream?): InputStream {
|
// Key + IV extraction
|
||||||
return CipherInputStream(inputStream, newCipher(Cipher.DECRYPT_MODE))
|
|
||||||
}
|
|
||||||
|
|
||||||
fun decrypt(outputStream: OutputStream?): OutputStream {
|
|
||||||
return CipherOutputStream(outputStream, newCipher(Cipher.DECRYPT_MODE))
|
|
||||||
}
|
|
||||||
|
|
||||||
fun newCipher(modeInt: Int): Cipher {
|
|
||||||
val cipher = cipher
|
|
||||||
cipher.init(
|
|
||||||
modeInt,
|
|
||||||
SecretKeySpec(keySpec, "AES"),
|
|
||||||
IvParameterSpec(ivKeyParameterSpec)
|
|
||||||
)
|
|
||||||
return cipher
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Dynamic cipher selection
|
|
||||||
*/
|
|
||||||
private val cipher: Cipher
|
|
||||||
get() = when (mode) {
|
|
||||||
SnapCipherMode.CBC ->
|
|
||||||
Cipher.getInstance("AES/CBC/PKCS5Padding")
|
|
||||||
|
|
||||||
SnapCipherMode.CTR ->
|
|
||||||
Cipher.getInstance("AES/CTR/NoPadding")
|
|
||||||
}
|
|
||||||
|
|
||||||
val keySpec: ByteArray by lazy {
|
val keySpec: ByteArray by lazy {
|
||||||
searchByteArrayField(32)[instance] as ByteArray
|
manualKey ?: searchByteArrayField(32)[instance] as ByteArray
|
||||||
}
|
}
|
||||||
|
|
||||||
val ivKeyParameterSpec: ByteArray by lazy {
|
val ivKeyParameterSpec: ByteArray by lazy {
|
||||||
searchByteArrayField(16)[instance] as ByteArray
|
manualIv ?: searchByteArrayField(16)[instance] as ByteArray
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun searchByteArrayField(arrayLength: Int): Field {
|
private fun searchByteArrayField(length: Int): Field {
|
||||||
return instanceNonNull()::class.java.fields.first { f ->
|
return instanceNonNull()::class.java.declaredFields.first { field ->
|
||||||
try {
|
try {
|
||||||
if (!f.type.isArray ||
|
field.isAccessible = true
|
||||||
f.type.componentType != Byte::class.javaPrimitiveType
|
|
||||||
|
if (!field.type.isArray ||
|
||||||
|
field.type.componentType != Byte::class.javaPrimitiveType
|
||||||
) return@first false
|
) return@first false
|
||||||
|
|
||||||
(f.get(instanceNonNull()) as ByteArray).size == arrayLength
|
val value = field.get(instanceNonNull()) as? ByteArray
|
||||||
|
value?.size == length
|
||||||
|
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cipher builders
|
||||||
|
|
||||||
|
/** Chat media */
|
||||||
|
private fun buildCBCCipherPKCS5(mode: Int): Cipher {
|
||||||
|
return Cipher.getInstance("AES/CBC/PKCS5Padding").apply {
|
||||||
|
init(
|
||||||
|
mode,
|
||||||
|
SecretKeySpec(keySpec, "AES"),
|
||||||
|
IvParameterSpec(ivKeyParameterSpec)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Story images */
|
||||||
|
private fun buildCBCCipherNoPadding(mode: Int): Cipher {
|
||||||
|
return Cipher.getInstance("AES/CBC/NoPadding").apply {
|
||||||
|
init(
|
||||||
|
mode,
|
||||||
|
SecretKeySpec(keySpec, "AES"),
|
||||||
|
IvParameterSpec(ivKeyParameterSpec)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Videos + DASH */
|
||||||
|
private fun buildCTRCipher(mode: Int): Cipher {
|
||||||
|
return Cipher.getInstance("AES/CTR/NoPadding").apply {
|
||||||
|
init(
|
||||||
|
mode,
|
||||||
|
SecretKeySpec(keySpec, "AES"),
|
||||||
|
IvParameterSpec(ivKeyParameterSpec)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generic decrypt (auto detect)
|
||||||
|
|
||||||
|
fun decrypt(data: ByteArray): ByteArray {
|
||||||
|
|
||||||
|
Log.d(
|
||||||
|
"PurrfectSnap",
|
||||||
|
"Decrypt → keySize=${keySpec.size} ivSize=${ivKeyParameterSpec.size}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return try {
|
||||||
|
buildCBCCipherPKCS5(Cipher.DECRYPT_MODE).doFinal(data)
|
||||||
|
|
||||||
|
} catch (cbcError: Exception) {
|
||||||
|
|
||||||
|
Log.d("PurrfectSnap", "PKCS5 failed → trying CTR")
|
||||||
|
|
||||||
|
buildCTRCipher(Cipher.DECRYPT_MODE).doFinal(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Image decrypt (story + chat safe)
|
||||||
|
|
||||||
|
fun decryptImage(data: ByteArray): ByteArray {
|
||||||
|
|
||||||
|
Log.d(
|
||||||
|
"PurrfectSnap",
|
||||||
|
"Image Decrypt → keySize=${keySpec.size} ivSize=${ivKeyParameterSpec.size}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return try {
|
||||||
|
// Story images
|
||||||
|
buildCBCCipherNoPadding(Cipher.DECRYPT_MODE).doFinal(data)
|
||||||
|
|
||||||
|
} catch (noPadError: Exception) {
|
||||||
|
|
||||||
|
Log.d("PurrfectSnap", "CBC NoPadding failed → trying PKCS5")
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Chat images
|
||||||
|
buildCBCCipherPKCS5(Cipher.DECRYPT_MODE).doFinal(data)
|
||||||
|
|
||||||
|
} catch (pkcsError: Exception) {
|
||||||
|
|
||||||
|
Log.d("PurrfectSnap", "PKCS5 failed → trying CTR")
|
||||||
|
|
||||||
|
// Edge fallback
|
||||||
|
buildCTRCipher(Cipher.DECRYPT_MODE).doFinal(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stream decrypt
|
||||||
|
|
||||||
|
fun decrypt(input: InputStream): InputStream {
|
||||||
|
val cipher = try {
|
||||||
|
buildCBCCipherPKCS5(Cipher.DECRYPT_MODE)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
buildCTRCipher(Cipher.DECRYPT_MODE)
|
||||||
|
}
|
||||||
|
|
||||||
|
return CipherInputStream(input, cipher)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun decryptImage(input: InputStream): InputStream {
|
||||||
|
|
||||||
|
val cipher = try {
|
||||||
|
buildCBCCipherNoPadding(Cipher.DECRYPT_MODE)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
buildCBCCipherPKCS5(Cipher.DECRYPT_MODE)
|
||||||
|
}
|
||||||
|
|
||||||
|
return CipherInputStream(input, cipher)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun decrypt(output: OutputStream): OutputStream {
|
||||||
|
|
||||||
|
val cipher = try {
|
||||||
|
buildCBCCipherPKCS5(Cipher.DECRYPT_MODE)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
buildCTRCipher(Cipher.DECRYPT_MODE)
|
||||||
|
}
|
||||||
|
|
||||||
|
return CipherOutputStream(output, cipher)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalEncodingApi::class)
|
// KeyPair Extensions
|
||||||
|
|
||||||
|
/** Standard Base64 */
|
||||||
fun EncryptionWrapper.toKeyPair(): MediaEncryptionKeyPair {
|
fun EncryptionWrapper.toKeyPair(): MediaEncryptionKeyPair {
|
||||||
return MediaEncryptionKeyPair(
|
return MediaEncryptionKeyPair(
|
||||||
key = android.util.Base64.encodeToString(this.keySpec, android.util.Base64.NO_WRAP),
|
key = Base64.encodeToString(this.keySpec, Base64.NO_WRAP),
|
||||||
iv = android.util.Base64.encodeToString(this.ivKeyParameterSpec, android.util.Base64.NO_WRAP),
|
iv = Base64.encodeToString(this.ivKeyParameterSpec, Base64.NO_WRAP),
|
||||||
urlSafe = true
|
urlSafe = false
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** URL Safe (story media) */
|
||||||
|
fun EncryptionWrapper.toKeyPairUrlSafe(): MediaEncryptionKeyPair {
|
||||||
|
return MediaEncryptionKeyPair(
|
||||||
|
key = Base64.encodeToString(
|
||||||
|
this.keySpec,
|
||||||
|
Base64.URL_SAFE or Base64.NO_WRAP
|
||||||
|
),
|
||||||
|
iv = Base64.encodeToString(
|
||||||
|
this.ivKeyParameterSpec,
|
||||||
|
Base64.URL_SAFE or Base64.NO_WRAP
|
||||||
|
),
|
||||||
|
urlSafe = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package me.eternal.purrfectsnap.core.wrapper.impl.media
|
||||||
|
|
||||||
|
import android.util.Base64
|
||||||
|
import me.eternal.purrfectsnap.common.data.download.MediaEncryptionKeyPair
|
||||||
|
import javax.crypto.Cipher
|
||||||
|
import javax.crypto.spec.IvParameterSpec
|
||||||
|
import javax.crypto.spec.SecretKeySpec
|
||||||
|
|
||||||
|
object HybridEncryptionResolver {
|
||||||
|
|
||||||
|
fun resolve(
|
||||||
|
mediaInfo: MediaInfo,
|
||||||
|
storyKeyPair: MediaEncryptionKeyPair?
|
||||||
|
): MediaEncryptionKeyPair? {
|
||||||
|
|
||||||
|
// ────OLD WRAPPER (images/snaps)───────────────────
|
||||||
|
val wrapperPair = runCatching {
|
||||||
|
mediaInfo.encryption?.toKeyPair()
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
|
if (wrapperPair != null) {
|
||||||
|
return wrapperPair
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────DIRECT MEDIAINFO KEYPAIR────────────────
|
||||||
|
val directPair = runCatching {
|
||||||
|
mediaInfo.encryption?.toKeyPairUrlSafe()
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
|
if (directPair != null) {
|
||||||
|
return directPair
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────STORY PARAMMAP AES─────────────────
|
||||||
|
if (storyKeyPair != null) {
|
||||||
|
return storyKeyPair
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────NONE FOUND─────────────────
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional cipher builder if needed elsewhere
|
||||||
|
fun buildCipher(pair: MediaEncryptionKeyPair): Cipher {
|
||||||
|
val key = Base64.decode(pair.key, Base64.DEFAULT)
|
||||||
|
val iv = Base64.decode(pair.iv, Base64.DEFAULT)
|
||||||
|
|
||||||
|
return Cipher.getInstance("AES/CBC/PKCS5Padding").apply {
|
||||||
|
init(
|
||||||
|
Cipher.DECRYPT_MODE,
|
||||||
|
SecretKeySpec(key, "AES"),
|
||||||
|
IvParameterSpec(iv)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user