fix(PR): story decryption by hazelnut27
Fix story decryption
This commit is contained in:
@@ -46,18 +46,19 @@ import me.eternal.purrfectsnap.core.wrapper.impl.media.dash.SnapPlaylistItem
|
||||
import me.eternal.purrfectsnap.core.wrapper.impl.media.opera.Layer
|
||||
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.EncryptionWrapper
|
||||
import me.eternal.purrfectsnap.mapper.impl.OperaPageViewControllerMapper
|
||||
import java.nio.file.Paths
|
||||
import java.util.UUID
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
import kotlin.math.absoluteValue
|
||||
import android.util.Base64
|
||||
|
||||
class SnapChapterInfo(
|
||||
val offset: Long,
|
||||
val duration: Long?
|
||||
)
|
||||
|
||||
|
||||
class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleType.AUTO_DOWNLOAD) {
|
||||
private var lastSeenMediaInfoMap: MutableMap<SplitMediaAssetType, MediaInfo>? = null
|
||||
var lastSeenMapParams: ParamMap? = null
|
||||
@@ -204,49 +205,107 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
|
||||
}
|
||||
}
|
||||
|
||||
private fun downloadOperaMedia(downloadManagerClient: DownloadManagerClient, mediaInfoMap: Map<SplitMediaAssetType, MediaInfo>, paramMap: ParamMap) {
|
||||
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(
|
||||
downloadManagerClient: DownloadManagerClient,
|
||||
mediaInfoMap: Map<SplitMediaAssetType, MediaInfo>,
|
||||
paramMap: ParamMap
|
||||
) {
|
||||
if (mediaInfoMap.isEmpty()) return
|
||||
|
||||
paramMap["SNAP_ID"]?.toString()?.let { snapId ->
|
||||
context.database.getStorySnapEntry(snapId)?.let { storySnapEntry ->
|
||||
downloadManagerClient.downloadSingleMedia(
|
||||
storySnapEntry.mediaUrl ?: throw Exception("Media URL not found"),
|
||||
DownloadMediaType.fromUri(Uri.parse(storySnapEntry.mediaUrl)),
|
||||
(storySnapEntry.mediaKey to storySnapEntry.mediaIv).takeIf { it.first != null && it.second != null }?.let { (key, iv) ->
|
||||
MediaEncryptionKeyPair(key!!, iv!!, urlSafe = false)
|
||||
}
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
val storyKeyPair = extractStoryEncryption(paramMap)
|
||||
|
||||
val originalMediaInfo = mediaInfoMap[SplitMediaAssetType.ORIGINAL]!!
|
||||
val originalMediaInfoReference = handleLocalReferences(originalMediaInfo.uri)
|
||||
|
||||
paramMap["SNAP_ID"]?.toString()?.let { snapId ->
|
||||
context.database.getStorySnapEntry(snapId)?.let { storySnapEntry ->
|
||||
|
||||
val urlToDownload = storySnapEntry?.mediaUrl ?: originalMediaInfo.uri
|
||||
val encryptionPair =
|
||||
safeGetEncryptionPair(originalMediaInfo)
|
||||
?: extractStoryEncryption(paramMap)
|
||||
|
||||
downloadManagerClient.downloadSingleMedia(
|
||||
originalMediaInfoReference,
|
||||
DownloadMediaType.fromUri(Uri.parse(originalMediaInfoReference)),
|
||||
encryptionPair
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
mediaInfoMap[SplitMediaAssetType.OVERLAY]?.let { overlay ->
|
||||
val overlayReference = handleLocalReferences(overlay.uri)
|
||||
|
||||
val originalEncryption = safeGetEncryptionPair(originalMediaInfo)
|
||||
val overlayEncryption = overlay.encryption?.toKeyPair()
|
||||
|
||||
downloadManagerClient.downloadMediaWithOverlay(
|
||||
original = InputMedia(
|
||||
originalMediaInfoReference,
|
||||
DownloadMediaType.fromUri(Uri.parse(originalMediaInfoReference)),
|
||||
originalMediaInfo.encryption?.toKeyPair()
|
||||
encryption = originalEncryption
|
||||
),
|
||||
overlay = InputMedia(
|
||||
overlayReference,
|
||||
DownloadMediaType.fromUri(Uri.parse(overlayReference)),
|
||||
overlay.encryption?.toKeyPair(),
|
||||
encryption = overlayEncryption,
|
||||
isOverlay = true
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// fallback to single media download
|
||||
val encryptionPair =
|
||||
storyKeyPair ?: safeGetEncryptionPair(originalMediaInfo)
|
||||
|
||||
downloadManagerClient.downloadSingleMedia(
|
||||
originalMediaInfoReference,
|
||||
DownloadMediaType.fromUri(Uri.parse(originalMediaInfoReference)),
|
||||
originalMediaInfo.encryption?.toKeyPair()
|
||||
encryptionPair
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -254,6 +313,32 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
|
||||
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
|
||||
*
|
||||
@@ -262,11 +347,13 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
|
||||
* @param forceDownload if the media should be downloaded
|
||||
*/
|
||||
private fun handleOperaMedia(
|
||||
paramMap: ParamMap,
|
||||
mediaInfoMap: Map<SplitMediaAssetType, MediaInfo>,
|
||||
forceDownload: Boolean,
|
||||
paramMap: ParamMap,
|
||||
mediaInfoMap: Map<SplitMediaAssetType,
|
||||
MediaInfo>,
|
||||
forceDownload: Boolean,
|
||||
forceAllowDuplicate: Boolean = false
|
||||
) {
|
||||
|
||||
//messages
|
||||
paramMap["MESSAGE_ID"]?.toString()?.takeIf { forceDownload || shouldAutoDownload("friend_snaps") }?.let { id ->
|
||||
val messageId = id.substring(id.lastIndexOf(":") + 1).toLong()
|
||||
@@ -307,28 +394,40 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
|
||||
}?.let { playlistGroup ->
|
||||
val playlistGroupString = playlistGroup.toString()
|
||||
|
||||
val storyUserId = paramMap["TOPIC_SNAP_CREATOR_USER_ID"]?.toString() ?: paramMap["PLAYABLE_STORY_SNAP_RECORD"]?.toString()?.let {
|
||||
if (it.contains("userId=")) it.substringAfter("userId=").substringBefore(",") else null
|
||||
} ?: if (playlistGroupString.contains("storyUserId=")) {
|
||||
playlistGroupString.substringAfter("storyUserId=").substringBefore(",")
|
||||
} else {
|
||||
//story replies
|
||||
val arroyoMessageId = playlistGroup::class.java.methods.firstOrNull { it.name == "getId" }
|
||||
?.invoke(playlistGroup)?.toString()
|
||||
?.split(":")?.getOrNull(2) ?: return@let
|
||||
// Try multiple possible keys/paths in order of likelihood
|
||||
val storyUserId = sequenceOf(
|
||||
// Most common new locations
|
||||
{ paramMap["STORY_USER_ID"]?.toString() },
|
||||
{ paramMap["CREATOR_USER_ID"]?.toString() },
|
||||
{ paramMap["USER_ID"]?.toString() },
|
||||
{ paramMap["TOPIC_SNAP_CREATOR_USER_ID"]?.toString() },
|
||||
// Old ones as fallback
|
||||
{ paramMap["PLAYABLE_STORY_SNAP_RECORD"]
|
||||
?.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
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
conversationParticipants.firstOrNull { it != conversationMessage.senderId }
|
||||
val authorUserId = storyUserId ?: run {
|
||||
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(
|
||||
if (storyUserId == null || storyUserId == "null")
|
||||
context.database.myUserId
|
||||
else storyUserId
|
||||
) ?: throw Exception("Friend not found in database")
|
||||
val authorName = author.usernameForSorting!!
|
||||
val author = context.database.getFriendInfo(authorUserId)
|
||||
?: context.database.getFriendInfoByUsername(authorUserId) // sometimes it's username
|
||||
?: throw Exception("No friend info for ID: $authorUserId")
|
||||
|
||||
val authorName = author.usernameForSorting ?: author.displayName ?: "UnknownFriend"
|
||||
|
||||
if (!forceDownload) {
|
||||
if (context.config.downloader.preventSelfAutoDownload.get() && author.userId == context.database.myUserId) return
|
||||
@@ -381,13 +480,7 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
|
||||
return "${(hours % 24).toString().padStart(2, '0')}:${(minutes % 60).toString().padStart(2, '0')}:${(seconds % 60).toString().padStart(2, '0')}"
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
val playlistUrl = resolveMediaUrl(paramMap["MEDIA_ID"].toString())
|
||||
|
||||
context.runOnUiThread {
|
||||
val selectedChapters = mutableListOf<Int>()
|
||||
|
||||
@@ -10,10 +10,19 @@ import javax.crypto.CipherInputStream
|
||||
import javax.crypto.CipherOutputStream
|
||||
import javax.crypto.spec.IvParameterSpec
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
import kotlin.io.encoding.Base64
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
import android.util.Base64
|
||||
|
||||
enum class SnapCipherMode {
|
||||
CBC,
|
||||
CTR
|
||||
}
|
||||
|
||||
class EncryptionWrapper(
|
||||
instance: Any?,
|
||||
private val mode: SnapCipherMode = SnapCipherMode.CBC
|
||||
) : AbstractWrapper(instance) {
|
||||
|
||||
class EncryptionWrapper(instance: Any?) : AbstractWrapper(instance) {
|
||||
fun decrypt(data: ByteArray?): ByteArray {
|
||||
return newCipher(Cipher.DECRYPT_MODE).doFinal(data)
|
||||
}
|
||||
@@ -26,56 +35,58 @@ class EncryptionWrapper(instance: Any?) : AbstractWrapper(instance) {
|
||||
return CipherOutputStream(outputStream, newCipher(Cipher.DECRYPT_MODE))
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for a byte[] field with the specified length
|
||||
*
|
||||
* @param arrayLength the length of the byte[] field
|
||||
* @return the field
|
||||
*/
|
||||
private fun searchByteArrayField(arrayLength: Int): Field {
|
||||
return instanceNonNull()::class.java.fields.first { f ->
|
||||
try {
|
||||
if (!f.type.isArray || f.type
|
||||
.componentType != Byte::class.javaPrimitiveType
|
||||
) return@first false
|
||||
return@first (f.get(instanceNonNull()) as ByteArray).size == arrayLength
|
||||
} catch (e: Exception) {
|
||||
return@first false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new cipher with the specified mode
|
||||
*/
|
||||
fun newCipher(mode: Int): Cipher {
|
||||
fun newCipher(modeInt: Int): Cipher {
|
||||
val cipher = cipher
|
||||
cipher.init(mode, SecretKeySpec(keySpec, "AES"), IvParameterSpec(ivKeyParameterSpec))
|
||||
cipher.init(
|
||||
modeInt,
|
||||
SecretKeySpec(keySpec, "AES"),
|
||||
IvParameterSpec(ivKeyParameterSpec)
|
||||
)
|
||||
return cipher
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the cipher from the encryption wrapper
|
||||
* Dynamic cipher selection
|
||||
*/
|
||||
private val cipher: Cipher
|
||||
get() = Cipher.getInstance("AES/CBC/PKCS5Padding")
|
||||
get() = when (mode) {
|
||||
SnapCipherMode.CBC ->
|
||||
Cipher.getInstance("AES/CBC/PKCS5Padding")
|
||||
|
||||
SnapCipherMode.CTR ->
|
||||
Cipher.getInstance("AES/CTR/NoPadding")
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the key spec from the encryption wrapper
|
||||
*/
|
||||
val keySpec: ByteArray by lazy {
|
||||
searchByteArrayField(32)[instance] as ByteArray
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the iv key parameter spec from the encryption wrapper
|
||||
*/
|
||||
val ivKeyParameterSpec: ByteArray by lazy {
|
||||
searchByteArrayField(16)[instance] as ByteArray
|
||||
}
|
||||
|
||||
private fun searchByteArrayField(arrayLength: Int): Field {
|
||||
return instanceNonNull()::class.java.fields.first { f ->
|
||||
try {
|
||||
if (!f.type.isArray ||
|
||||
f.type.componentType != Byte::class.javaPrimitiveType
|
||||
) return@first false
|
||||
|
||||
(f.get(instanceNonNull()) as ByteArray).size == arrayLength
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalEncodingApi::class)
|
||||
fun EncryptionWrapper.toKeyPair(): MediaEncryptionKeyPair {
|
||||
return MediaEncryptionKeyPair(
|
||||
key = android.util.Base64.encodeToString(this.keySpec, android.util.Base64.NO_WRAP),
|
||||
iv = android.util.Base64.encodeToString(this.ivKeyParameterSpec, android.util.Base64.NO_WRAP),
|
||||
urlSafe = true
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@OptIn(ExperimentalEncodingApi::class)
|
||||
fun EncryptionWrapper.toKeyPair()
|
||||
= MediaEncryptionKeyPair(Base64.UrlSafe.encode(this.keySpec), Base64.UrlSafe.encode(this.ivKeyParameterSpec))
|
||||
|
||||
Reference in New Issue
Block a user