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'
extClass = '.KickAssAnime'
libVersion = '13'
extVersionCode = 24
extVersionCode = 25
}
dependencies {

View File

@ -129,8 +129,7 @@ class KickAssAnime : ConfigurableAnimeSource, AnimeHttpSource() {
override fun videoListParse(response: Response): List<Video> {
val videos = response.parseAs<ServersDto>()
// Just to see the responses at mitmproxy
val extractor = KickAssAnimeExtractor(client, json)
val extractor = KickAssAnimeExtractor(client, json, headers)
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 kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import okhttp3.Headers
import okhttp3.OkHttpClient
import okhttp3.Request
import org.jsoup.Jsoup
import org.jsoup.nodes.Element
import java.security.MessageDigest
import java.text.CharacterIterator
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> {
val idQuery = url.substringAfterLast("?")
val query = url.substringAfterLast("?")
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()
.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(":\"")
.substringBefore('"')
.replace("\\", "")
.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 videoObject = try {
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)
} catch (e: Exception) {
e.printStackTrace()
@ -52,15 +75,12 @@ class KickAssAnimeExtractor(private val client: OkHttpClient, private val json:
val language = "${it.name} (${it.language})"
println("subUrl -> $subUrl")
Track(subUrl, language)
}
val masterPlaylist = client.newCall(GET(videoObject.playlistUrl)).execute()
.body.string()
val prefix = if ("pink" in url) "PinkBird" else "SapphireDuck"
return when {
videoObject.hls.isBlank() ->
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> {
val separator = "#EXT-X-STREAM-INF"
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:
val document = Jsoup.parse(playlist)
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 ->
Video(
videoSrc.text(),
"$prefix - ${videoSrc.attr("height")}p - ${formatBits(videoSrc.attr("bandwidth").toLongOrNull() ?: 0L)}",
"$prefix - ${videoSrc.attr("height")}p - ${videoSrc.formatBits()}",
videoSrc.text(),
audioTracks = audioList,
subtitleTracks = subs,
@ -102,8 +146,8 @@ class KickAssAnimeExtractor(private val client: OkHttpClient, private val json:
// ============================= Utilities ==============================
@SuppressLint("DefaultLocale")
fun formatBits(bits: Long): String? {
var bits = bits
private fun Element.formatBits(attribute: String = "bandwidth"): String? {
var bits = attr(attribute).toLongOrNull() ?: 0L
if (-1000 < bits && bits < 1000) {
return "${bits}b"
}