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.ExperimentalEncodingApi
|
||||
|
||||
// key and iv are base64 encoded into url safe strings
|
||||
data class MediaEncryptionKeyPair(
|
||||
val key: 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.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,
|
||||
@@ -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(
|
||||
downloadManagerClient: DownloadManagerClient,
|
||||
mediaInfoMap: Map<SplitMediaAssetType, MediaInfo>,
|
||||
@@ -243,69 +219,50 @@ 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()
|
||||
}
|
||||
|
||||
fun canAutoDownloadMessage(databaseMessage: ConversationMessage): Boolean {
|
||||
@@ -313,32 +270,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 +278,94 @@ 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
|
||||
|
||||
// ─── 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)
|
||||
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 +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')}"
|
||||
}
|
||||
|
||||
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<Int>()
|
||||
@@ -810,7 +736,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()),
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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