feat(KAA): Implement dynamic AES key extractor (#1525)

This commit is contained in:
Claudemirovsky
2023-04-21 09:22:42 -03:00
committed by GitHub
parent c8c3d7cad8
commit f6aa5eb28b
4 changed files with 168 additions and 18 deletions

View File

@ -9,7 +9,7 @@ ext {
pkgNameSuffix = 'en.kickassanime' pkgNameSuffix = 'en.kickassanime'
extClass = '.KickAssAnime' extClass = '.KickAssAnime'
libVersion = '13' libVersion = '13'
extVersionCode = 24 extVersionCode = 25
} }
dependencies { dependencies {

View File

@ -129,8 +129,7 @@ class KickAssAnime : ConfigurableAnimeSource, AnimeHttpSource() {
override fun videoListParse(response: Response): List<Video> { override fun videoListParse(response: Response): List<Video> {
val videos = response.parseAs<ServersDto>() val videos = response.parseAs<ServersDto>()
// Just to see the responses at mitmproxy val extractor = KickAssAnimeExtractor(client, json, headers)
val extractor = KickAssAnimeExtractor(client, json)
return videos.servers.flatMap(extractor::videosFromUrl) return videos.servers.flatMap(extractor::videosFromUrl)
} }

View File

@ -0,0 +1,107 @@
package eu.kanade.tachiyomi.animeextension.en.kickassanime.extractors
import android.app.Application
import android.os.Handler
import android.os.Looper
import android.webkit.JavascriptInterface
import android.webkit.WebSettings
import android.webkit.WebView
import eu.kanade.tachiyomi.network.GET
import okhttp3.OkHttpClient
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
class AESKeyExtractor(private val client: OkHttpClient) {
companion object {
val keyMap = mutableMapOf(
// Default key. if it changes, the extractor will update it.
"PinkBird" to "7191d608bd4deb4dc36f656c4bbca1b7".toByteArray(),
"SapphireDuck" to "f04274d54a9e01ed4a728c5c1889886e".toByteArray(), // i hate sapphire
)
private const val ERROR_MSG_GENERIC = "the AES key was not found."
private const val ERROR_MSG_VAR = "the AES key variable was not found"
private val keyVarRegex by lazy { Regex("\\.AES\\[.*?\\]\\((\\w+)\\),") }
}
private val context = Injekt.get<Application>()
private val handler by lazy { Handler(Looper.getMainLooper()) }
class ExtractorJSI(private val latch: CountDownLatch, private val prefix: String) {
@JavascriptInterface
fun setKey(key: String) {
AESKeyExtractor.keyMap.set(prefix, key.toByteArray())
latch.countDown()
}
}
fun getKeyFromHtml(url: String, html: String, prefix: String): ByteArray {
val patchedScript = patchScriptFromHtml(url, html)
return getKey(patchedScript, prefix)
}
fun getKeyFromUrl(url: String, prefix: String): ByteArray {
val patchedScript = patchScriptFromUrl(url)
return getKey(patchedScript, prefix)
}
private fun getKey(patchedScript: String, prefix: String): ByteArray {
val latch = CountDownLatch(1)
var webView: WebView? = null
val jsi = ExtractorJSI(latch, prefix)
handler.post {
val webview = WebView(context)
webView = webview
with(webview.settings) {
cacheMode = WebSettings.LOAD_NO_CACHE
databaseEnabled = false
domStorageEnabled = false
javaScriptEnabled = true
loadWithOverviewMode = false
useWideViewPort = false
webview.addJavascriptInterface(jsi, "AESKeyExtractor")
}
webView?.loadData("<html><body></body></html>", "text/html", "UTF-8")
webView?.evaluateJavascript(patchedScript) {}
}
latch.await(30, TimeUnit.SECONDS)
handler.post {
webView?.stopLoading()
webView?.destroy()
webView = null
}
return AESKeyExtractor.keyMap.get(prefix) ?: throw Exception(ERROR_MSG_GENERIC)
}
private fun patchScriptFromUrl(url: String): String {
return client.newCall(GET(url)).execute()
.body.string()
.let {
patchScriptFromHtml(url.substringBeforeLast("/"), it)
}
}
private fun patchScriptFromHtml(baseUrl: String, body: String): String {
val scriptPath = body.substringAfter("script src=\"").substringBefore('"')
val scriptUrl = "$baseUrl/$scriptPath"
val scriptBody = client.newCall(GET(scriptUrl)).execute().body.string()
val varWithKeyName = keyVarRegex.find(scriptBody)
?.groupValues
?.last()
?: Exception(ERROR_MSG_VAR)
val varWithKeyBody = scriptBody.substringAfter("var $varWithKeyName=")
.substringBefore(";")
return scriptBody.replace(varWithKeyBody, "AESKeyExtractor.setKey($varWithKeyBody)")
}
}

View File

@ -9,32 +9,55 @@ import eu.kanade.tachiyomi.lib.cryptoaes.CryptoAES.decodeHex
import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.GET
import kotlinx.serialization.decodeFromString import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import okhttp3.Headers
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request
import org.jsoup.Jsoup import org.jsoup.Jsoup
import org.jsoup.nodes.Element
import java.security.MessageDigest
import java.text.CharacterIterator import java.text.CharacterIterator
import java.text.StringCharacterIterator import java.text.StringCharacterIterator
class KickAssAnimeExtractor(private val client: OkHttpClient, private val json: Json) { class KickAssAnimeExtractor(
private val client: OkHttpClient,
private val json: Json,
private val headers: Headers,
) {
fun videosFromUrl(url: String): List<Video> { fun videosFromUrl(url: String): List<Video> {
val idQuery = url.substringAfterLast("?") val query = url.substringAfterLast("?")
val baseUrl = url.substringBeforeLast("/") // baseUrl + endpoint/player val baseUrl = url.substringBeforeLast("/") // baseUrl + endpoint/player
val response = client.newCall(GET("$baseUrl/source.php?$idQuery")).execute()
val html = client.newCall(GET(url, headers)).execute().body.string()
val prefix = if ("pink" in url) "PinkBird" else "SapphireDuck"
val key = AESKeyExtractor.keyMap.get(prefix)
?: AESKeyExtractor(client).getKeyFromHtml(baseUrl, html, prefix)
val request = sourcesRequest(baseUrl, url, html, query, key)
val response = client.newCall(request).execute()
.body.string() .body.string()
.ifEmpty { // Http 403 moment
val newkey = AESKeyExtractor(client).getKeyFromUrl(url, prefix)
sourcesRequest(baseUrl, url, html, query, newkey)
.let(client::newCall).execute()
.body.string()
}
val (encryptedData, ivhex) = response.substringAfter(":\"") val (encryptedData, ivhex) = response.substringAfter(":\"")
.substringBefore('"') .substringBefore('"')
.replace("\\", "") .replace("\\", "")
.split(":") .split(":")
// TODO: Create something to get the key dynamically.
// Maybe we can do something like what is being used at Dopebox, Sflix and Zoro:
// Leave the hard work to github actions and make the extension just fetch the key
// from the repository.
val key = "7191d608bd4deb4dc36f656c4bbca1b7".toByteArray()
val iv = ivhex.decodeHex() val iv = ivhex.decodeHex()
val videoObject = try { val videoObject = try {
val decrypted = CryptoAES.decrypt(encryptedData, key, iv) val decrypted = CryptoAES.decrypt(encryptedData, key, iv)
.ifEmpty { // Maybe the key did change.. AGAIN.
val newkey = AESKeyExtractor(client).getKeyFromUrl(url, prefix)
CryptoAES.decrypt(encryptedData, newkey, iv)
}
json.decodeFromString<VideoDto>(decrypted) json.decodeFromString<VideoDto>(decrypted)
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() e.printStackTrace()
@ -52,15 +75,12 @@ class KickAssAnimeExtractor(private val client: OkHttpClient, private val json:
val language = "${it.name} (${it.language})" val language = "${it.name} (${it.language})"
println("subUrl -> $subUrl")
Track(subUrl, language) Track(subUrl, language)
} }
val masterPlaylist = client.newCall(GET(videoObject.playlistUrl)).execute() val masterPlaylist = client.newCall(GET(videoObject.playlistUrl)).execute()
.body.string() .body.string()
val prefix = if ("pink" in url) "PinkBird" else "SapphireDuck"
return when { return when {
videoObject.hls.isBlank() -> videoObject.hls.isBlank() ->
extractVideosFromDash(masterPlaylist, prefix, subtitles) extractVideosFromDash(masterPlaylist, prefix, subtitles)
@ -68,6 +88,30 @@ class KickAssAnimeExtractor(private val client: OkHttpClient, private val json:
} }
} }
private fun sourcesRequest(baseUrl: String, url: String, html: String, query: String, key: ByteArray): Request {
val timestamp = ((System.currentTimeMillis() / 1000) + 60).toString()
val cid = html.substringAfter("cid: '").substringBefore("'").decodeHex()
val ip = String(cid).substringBefore("|")
val path = "/" + baseUrl.substringAfterLast("/") + "/source.php"
val userAgent = headers.get("User-Agent") ?: ""
val localHeaders = Headers.headersOf("User-Agent", userAgent, "referer", url)
val idQuery = query.substringAfter("=")
val items = listOf(timestamp, ip, userAgent, path.replace("player", "source"), idQuery, String(key))
val signature = sha1sum(items.joinToString(""))
return GET("$baseUrl/source.php?$query&e=$timestamp&s=$signature", localHeaders)
}
private fun sha1sum(value: String): String {
return try {
val md = MessageDigest.getInstance("SHA-1")
val bytes = md.digest(value.toByteArray())
bytes.joinToString("") { "%02x".format(it) }
} catch (e: Exception) {
throw Exception("Attempt to create the signature failed miserably.")
}
}
private fun extractVideosFromHLS(playlist: String, prefix: String, subs: List<Track>): List<Video> { private fun extractVideosFromHLS(playlist: String, prefix: String, subs: List<Track>): List<Video> {
val separator = "#EXT-X-STREAM-INF" val separator = "#EXT-X-STREAM-INF"
return playlist.substringAfter(separator).split(separator).map { return playlist.substringAfter(separator).split(separator).map {
@ -86,12 +130,12 @@ class KickAssAnimeExtractor(private val client: OkHttpClient, private val json:
// Parsing dash with Jsoup :YEP: // Parsing dash with Jsoup :YEP:
val document = Jsoup.parse(playlist) val document = Jsoup.parse(playlist)
val audioList = document.select("Representation[mimetype~=audio]").map { audioSrc -> val audioList = document.select("Representation[mimetype~=audio]").map { audioSrc ->
Track(audioSrc.text(), formatBits(audioSrc.attr("bandwidth").toLongOrNull() ?: 0L) ?: "audio") Track(audioSrc.text(), audioSrc.formatBits() ?: "audio")
} }
return document.select("Representation[mimetype~=video]").map { videoSrc -> return document.select("Representation[mimetype~=video]").map { videoSrc ->
Video( Video(
videoSrc.text(), videoSrc.text(),
"$prefix - ${videoSrc.attr("height")}p - ${formatBits(videoSrc.attr("bandwidth").toLongOrNull() ?: 0L)}", "$prefix - ${videoSrc.attr("height")}p - ${videoSrc.formatBits()}",
videoSrc.text(), videoSrc.text(),
audioTracks = audioList, audioTracks = audioList,
subtitleTracks = subs, subtitleTracks = subs,
@ -102,8 +146,8 @@ class KickAssAnimeExtractor(private val client: OkHttpClient, private val json:
// ============================= Utilities ============================== // ============================= Utilities ==============================
@SuppressLint("DefaultLocale") @SuppressLint("DefaultLocale")
fun formatBits(bits: Long): String? { private fun Element.formatBits(attribute: String = "bandwidth"): String? {
var bits = bits var bits = attr(attribute).toLongOrNull() ?: 0L
if (-1000 < bits && bits < 1000) { if (-1000 < bits && bits < 1000) {
return "${bits}b" return "${bits}b"
} }