From e53a7db9ed96dd1bc1f576d93dc29a4aebbe8ef8 Mon Sep 17 00:00:00 2001 From: hazelnut27 Date: Sat, 7 Feb 2026 03:08:40 -0600 Subject: [PATCH] Enhance media encryption handling with hybrid resolver and improved decryption methods --- .../data/download/MediaEncryptionKeyPair.kt | 1 - .../impl/downloader/MediaDownloader.kt | 306 ++++++++++-------- .../wrapper/impl/media/EncryptionWrapper.kt | 217 ++++++++++--- .../impl/media/HybridEncryptionResolver.kt | 57 ++++ 4 files changed, 398 insertions(+), 183 deletions(-) create mode 100644 core/src/main/kotlin/me/eternal/purrfectsnap/core/wrapper/impl/media/HybridEncryptionResolver.kt diff --git a/common/src/main/kotlin/me/eternal/purrfectsnap/common/data/download/MediaEncryptionKeyPair.kt b/common/src/main/kotlin/me/eternal/purrfectsnap/common/data/download/MediaEncryptionKeyPair.kt index 96059373..6025efc0 100644 --- a/common/src/main/kotlin/me/eternal/purrfectsnap/common/data/download/MediaEncryptionKeyPair.kt +++ b/common/src/main/kotlin/me/eternal/purrfectsnap/common/data/download/MediaEncryptionKeyPair.kt @@ -9,7 +9,6 @@ import javax.crypto.spec.SecretKeySpec import kotlin.io.encoding.Base64 import kotlin.io.encoding.ExperimentalEncodingApi -// key and iv are base64 encoded into url safe strings data class MediaEncryptionKeyPair( val key: String, val iv: String, diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/downloader/MediaDownloader.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/downloader/MediaDownloader.kt index 82a2e99d..2422ed70 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/downloader/MediaDownloader.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/downloader/MediaDownloader.kt @@ -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.EncryptionWrapper 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.util.UUID import kotlin.coroutines.suspendCoroutine import kotlin.math.absoluteValue import android.util.Base64 +import javax.crypto.Cipher +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.SecretKeySpec class SnapChapterInfo( val offset: Long, @@ -206,7 +213,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 @@ -216,19 +222,18 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp ?: 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) + val ivBytes = android.util.Base64.decode(ivRaw, android.util.Base64.DEFAULT) if (keyBytes.size != 32 || ivBytes.size != 16) { + context.log.verbose("Fallback encryption → invalid key/iv length") 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 - ) + val encryptionWrapper = EncryptionWrapper(keyBytes, ivBytes, SnapCipherMode.CTR) + val keyPair = encryptionWrapper.toKeyPairUrlSafe() + + keyPair } catch (e: Exception) { context.log.error("Story AES decode failed", e) @@ -243,69 +248,103 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp ) { if (mediaInfoMap.isEmpty()) return - val storyKeyPair = extractStoryEncryption(paramMap) - - val originalMediaInfo = mediaInfoMap[SplitMediaAssetType.ORIGINAL]!! - val originalMediaInfoReference = handleLocalReferences(originalMediaInfo.uri) - + // Story Snap Entry (images) 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 + 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 originalMediaInfo = mediaInfoMap[SplitMediaAssetType.ORIGINAL]!! + val originalMediaInfoReference = handleLocalReferences(originalMediaInfo.uri) + + // Overlay (if present) 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)), - encryption = originalEncryption + originalMediaInfo.encryption?.toKeyPair() ), overlay = InputMedia( overlayReference, DownloadMediaType.fromUri(Uri.parse(overlayReference)), - encryption = overlayEncryption, + overlay.encryption?.toKeyPair(), isOverlay = true ) ) return } - // fallback to single media download - val encryptionPair = - storyKeyPair ?: safeGetEncryptionPair(originalMediaInfo) - + // Single media (video/DASH) downloadManagerClient.downloadSingleMedia( 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() + private fun getHybridEncryption( + mediaInfo: MediaInfo?, + paramMap: ParamMap? + ): MediaEncryptionKeyPair? { + // Try MediaInfo encryption first (works for videos, DASH, Opera) + 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 { @@ -313,32 +352,6 @@ 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 * @@ -347,105 +360,120 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp * @param forceDownload if the media should be downloaded */ private fun handleOperaMedia( - paramMap: ParamMap, - mediaInfoMap: Map, - forceDownload: Boolean, + paramMap: ParamMap, + mediaInfoMap: Map, + forceDownload: Boolean, forceAllowDuplicate: Boolean = false ) { - - //messages + + // ─── Messages ───────────────────────── paramMap["MESSAGE_ID"]?.toString()?.takeIf { forceDownload || shouldAutoDownload("friend_snaps") }?.let { id -> - val messageId = id.substring(id.lastIndexOf(":") + 1).toLong() - val conversationMessage = context.database.getConversationMessageFromId(messageId)!! - + val messageId = id.substringAfterLast(":").toLong() + val conversationMessage = context.database.getConversationMessageFromId(messageId) ?: return@let val conversationId = conversationMessage.clientConversationId!! - if (!forceDownload && !canUseRule(conversationId)) { - return - } + if (!forceDownload && !canUseRule(conversationId)) return@let 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 + val author = context.database.getFriendInfo(senderId) ?: return@let val authorUsername = author.usernameForSorting!! - val mediaId = paramMap["MEDIA_ID"]?.toString()?.let { - 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) + val mediaId = paramMap["MEDIA_ID"]?.toString()?.substringAfter("-")?.substringBefore(".") ?: "" + downloadOperaMedia( + provideDownloadManagerClient( + mediaIdentifier = "$conversationId$senderId${conversationMessage.serverMessageId}$mediaId", + mediaAuthor = authorUsername, + creationTimestamp = conversationMessage.creationTimestamp, + downloadSource = MediaDownloadSource.CHAT_MEDIA, + friendInfo = author, + forceAllowDuplicate = forceAllowDuplicate + ), + mediaInfoMap, + paramMap + ) return } - //private stories + // ─── Private Friend Story ───────────────────────── paramMap["PLAYLIST_V2_GROUP"]?.takeIf { forceDownload || shouldAutoDownload("friend_stories") }?.let { playlistGroup -> val playlistGroupString = playlistGroup.toString() - // 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 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 - // ────────────────────────────────────────────── - - 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 conversationMessage = context.database.getConversationMessageFromId(arroyoMessageId.toLong()) ?: return@let + val conversationParticipants = context.database.getConversationParticipants(conversationMessage.clientConversationId.toString()) ?: return@let + conversationParticipants.firstOrNull { it != conversationMessage.senderId } } - 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" + 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!! if (!forceDownload) { if (context.config.downloader.preventSelfAutoDownload.get() && author.userId == context.database.myUserId) return if (!canUseRule(author.userId!!)) return } - 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) + // ─── Prepare encryption if needed ───────────── + val storyKeyPair: MediaEncryptionKeyPair? = try { + val keyRaw = paramMap["CONTEXT_REPLY_MEDIA_KEY"] as? String + ?: paramMap["REPLY_MEDIA_KEY"] as? String + val ivRaw = paramMap["CONTEXT_REPLY_MEDIA_IV"] as? String + ?: paramMap["REPLY_MEDIA_IV"] as? String + + if (keyRaw != null && ivRaw != null) { + 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 } + // ─── Public Stories / Spotlight ─────────────────── val snapSource = paramMap["SNAP_SOURCE"].toString() //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')}" } - 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 { val selectedChapters = mutableListOf() @@ -810,7 +844,7 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp if (!isPreview) { if (forceDownloadFirst || 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, listOf(decodedAttachments.first()), diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/wrapper/impl/media/EncryptionWrapper.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/wrapper/impl/media/EncryptionWrapper.kt index af79620f..e8db5e19 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/wrapper/impl/media/EncryptionWrapper.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/wrapper/impl/media/EncryptionWrapper.kt @@ -1,5 +1,7 @@ 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.core.wrapper.AbstractWrapper import java.io.InputStream @@ -10,83 +12,206 @@ import javax.crypto.CipherInputStream import javax.crypto.CipherOutputStream import javax.crypto.spec.IvParameterSpec import javax.crypto.spec.SecretKeySpec -import kotlin.io.encoding.ExperimentalEncodingApi -import android.util.Base64 + +// Cipher mode enum enum class SnapCipherMode { CBC, CTR } +// Encryption Wrapper + class EncryptionWrapper( - instance: Any?, + instance: Any? = null, private val mode: SnapCipherMode = SnapCipherMode.CBC ) : AbstractWrapper(instance) { - fun decrypt(data: ByteArray?): ByteArray { - return newCipher(Cipher.DECRYPT_MODE).doFinal(data) + // Manual key injection (story fallback) + + 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 { - 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") - } + // Key + IV extraction val keySpec: ByteArray by lazy { - searchByteArrayField(32)[instance] as ByteArray + manualKey ?: searchByteArrayField(32)[instance] as ByteArray } val ivKeyParameterSpec: ByteArray by lazy { - searchByteArrayField(16)[instance] as ByteArray + manualIv ?: searchByteArrayField(16)[instance] as ByteArray } - private fun searchByteArrayField(arrayLength: Int): Field { - return instanceNonNull()::class.java.fields.first { f -> + private fun searchByteArrayField(length: Int): Field { + return instanceNonNull()::class.java.declaredFields.first { field -> try { - if (!f.type.isArray || - f.type.componentType != Byte::class.javaPrimitiveType + field.isAccessible = true + + if (!field.type.isArray || + field.type.componentType != Byte::class.javaPrimitiveType ) return@first false - (f.get(instanceNonNull()) as ByteArray).size == arrayLength + val value = field.get(instanceNonNull()) as? ByteArray + value?.size == length + } catch (_: Exception) { 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 { 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 + key = Base64.encodeToString(this.keySpec, Base64.NO_WRAP), + iv = Base64.encodeToString(this.ivKeyParameterSpec, Base64.NO_WRAP), + 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 + ) +} diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/wrapper/impl/media/HybridEncryptionResolver.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/wrapper/impl/media/HybridEncryptionResolver.kt new file mode 100644 index 00000000..866a1e7a --- /dev/null +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/wrapper/impl/media/HybridEncryptionResolver.kt @@ -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) + ) + } + } +}