Enhance media encryption handling with hybrid resolver and improved decryption methods

This commit is contained in:
hazelnut27
2026-02-07 03:08:40 -06:00
parent c20c157c2b
commit e53a7db9ed
4 changed files with 398 additions and 183 deletions

View File

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

View File

@@ -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,
@@ -206,7 +213,6 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
} }
private fun extractStoryEncryption(paramMap: ParamMap): MediaEncryptionKeyPair? { private fun extractStoryEncryption(paramMap: ParamMap): MediaEncryptionKeyPair? {
val keyRaw = paramMap["CONTEXT_REPLY_MEDIA_KEY"] as? String val keyRaw = paramMap["CONTEXT_REPLY_MEDIA_KEY"] as? String
?: paramMap["REPLY_MEDIA_KEY"] as? String ?: paramMap["REPLY_MEDIA_KEY"] as? String
?: return null ?: return null
@@ -216,19 +222,18 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
?: return null ?: return null
return try { return try {
val keyBytes = android.util.Base64.decode(keyRaw, android.util.Base64.DEFAULT) val keyBytes = android.util.Base64.decode(keyRaw, android.util.Base64.DEFAULT)
val ivBytes = android.util.Base64.decode(ivRaw, android.util.Base64.DEFAULT) val ivBytes = android.util.Base64.decode(ivRaw, android.util.Base64.DEFAULT)
if (keyBytes.size != 32 || ivBytes.size != 16) { if (keyBytes.size != 32 || ivBytes.size != 16) {
context.log.verbose("Fallback encryption → invalid key/iv length")
return null return null
} }
MediaEncryptionKeyPair( val encryptionWrapper = EncryptionWrapper(keyBytes, ivBytes, SnapCipherMode.CTR)
key = android.util.Base64.encodeToString(keyBytes, android.util.Base64.NO_WRAP), val keyPair = encryptionWrapper.toKeyPairUrlSafe()
iv = android.util.Base64.encodeToString(ivBytes, android.util.Base64.NO_WRAP),
urlSafe = false keyPair
)
} catch (e: Exception) { } catch (e: Exception) {
context.log.error("Story AES decode failed", e) context.log.error("Story AES decode failed", e)
@@ -243,69 +248,103 @@ 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? { private fun getHybridEncryption(
val enc = info?.encryption ?: return null mediaInfo: MediaInfo?,
return runCatching { paramMap: ParamMap?
enc.toKeyPair() ): MediaEncryptionKeyPair? {
}.onFailure { context.log.verbose("Failed to parse encryption key pair → ${info.uri}") // Try MediaInfo encryption first (works for videos, DASH, Opera)
}.getOrNull() mediaInfo?.encryption?.toKeyPair()?.let {
context.log.verbose(
"Encryption autodetect -> WRAPPER(${if (mediaInfo.uri.endsWith(".mp4")) "video" else "image"}/snap)"
)
return it
}
// Try story ParamMap keys (works for story images)
paramMap?.let { pm ->
val keyRaw = pm["CONTEXT_REPLY_MEDIA_KEY"] as? String
?: pm["REPLY_MEDIA_KEY"] as? String
val ivRaw = pm["CONTEXT_REPLY_MEDIA_IV"] as? String
?: pm["REPLY_MEDIA_IV"] as? String
if (keyRaw != null && ivRaw != null) {
runCatching {
val keyBytes = try {
android.util.Base64.decode(keyRaw, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)
} catch (_: Exception) {
android.util.Base64.decode(keyRaw, Base64.DEFAULT)
}
val ivBytes = try {
android.util.Base64.decode(ivRaw, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)
} catch (_: Exception) {
android.util.Base64.decode(ivRaw, Base64.DEFAULT)
}
if (keyBytes.size == 32 && ivBytes.size == 16) {
return MediaEncryptionKeyPair(
android.util.Base64.encodeToString(keyBytes, android.util.Base64.URL_SAFE or android.util.Base64.NO_WRAP),
android.util.Base64.encodeToString(ivBytes, android.util.Base64.URL_SAFE or android.util.Base64.NO_WRAP)
)
}
}.onFailure {
context.log.verbose("Failed story key/IV decode: ${it.message}")
}
}
context.log.verbose("Encryption autodetect -> STORY PARAMMAP, HasEncryption=false")
}
return null
}
private fun safeGetEncryptionPair(info: MediaInfo?, paramMap: ParamMap? = null): MediaEncryptionKeyPair? {
return getHybridEncryption(info, paramMap)
} }
fun canAutoDownloadMessage(databaseMessage: ConversationMessage): Boolean { fun canAutoDownloadMessage(databaseMessage: ConversationMessage): Boolean {
@@ -313,32 +352,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 +360,120 @@ 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( // ─── Prepare encryption if needed ─────────────
mediaIdentifier = paramMap["MEDIA_ID"].toString(), val storyKeyPair: MediaEncryptionKeyPair? = try {
mediaAuthor = authorName, val keyRaw = paramMap["CONTEXT_REPLY_MEDIA_KEY"] as? String
creationTimestamp = paramMap["PLAYABLE_STORY_SNAP_RECORD"]?.toString()?.substringAfter("timestamp=") ?: paramMap["REPLY_MEDIA_KEY"] as? String
?.substringBefore(",")?.toLongOrNull(), val ivRaw = paramMap["CONTEXT_REPLY_MEDIA_IV"] as? String
downloadSource = MediaDownloadSource.STORY, ?: paramMap["REPLY_MEDIA_IV"] as? String
friendInfo = author,
forceAllowDuplicate = forceAllowDuplicate, if (keyRaw != null && ivRaw != null) {
), mediaInfoMap, paramMap) 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) {
EncryptionWrapper(keyBytes, ivBytes, SnapCipherMode.CTR).toKeyPairUrlSafe()
} else null
} else null
} catch (e: Exception) {
context.log.error("Story AES decode failed", e)
null
}
if (storyKeyPair != null) {
context.log.verbose("Story encryption detected")
} else {
context.log.verbose("No story encryption detected")
}
downloadOperaMedia(
provideDownloadManagerClient(
mediaIdentifier = paramMap["MEDIA_ID"].toString(),
mediaAuthor = authorName,
creationTimestamp = paramMap["PLAYABLE_STORY_SNAP_RECORD"]?.toString()?.substringAfter("timestamp=")
?.substringBefore(",")?.toLongOrNull(),
downloadSource = MediaDownloadSource.STORY,
friendInfo = author,
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 +508,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 +844,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()),

View File

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

View File

@@ -0,0 +1,57 @@
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?,
logger: (String) -> Unit
): 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)
)
}
}
}