feat(KAA): Implement dynamic AES key extractor (#1525)
This commit is contained in:
@ -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 {
|
||||||
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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)")
|
||||||
|
}
|
||||||
|
}
|
@ -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"
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user