Use core decodeHex/rc4/toHex instead of local copies

Replace hand-rolled hex decoding, RC4 and hex encoding with the shared keiyoushi.utils helpers, following keiyoushi/extensions-source#15640. The output is identical for every valid input, so no version bumps.

- decodeHex: lib/m3u8server, lib/vidguardextractor, animepahe, uniquestream, gotaku, tioanimeh
- rc4: anikototheme, lib/vidsrcextractor, aniwaves
- toHex: lib/googledriveepisodes, googledrive, missav, serienstream, hanime, hexawatch, kayoanime, kickassanime, funanimetv, tranimeizle, cycity, iyf, xfani

commit a96c552e11b22ef6bedb34b097de4a101e9455cf from keiyoushi/extensions-source#15640
This commit is contained in:
Cuong-Tran
2026-10-15 13:36:10 +07:00
parent e4d4183727
commit 6bc0f4cbb0
23 changed files with 69 additions and 85 deletions

View File

@@ -26,6 +26,7 @@ import keiyoushi.utils.LazyMutable
import keiyoushi.utils.delegate
import keiyoushi.utils.getPreferencesLazy
import keiyoushi.utils.parseAs
import keiyoushi.utils.rc4
import keiyoushi.utils.tryParse
import keiyoushi.utils.useAsJsoup
import okhttp3.CacheControl
@@ -41,8 +42,6 @@ import java.math.RoundingMode
import java.text.SimpleDateFormat
import java.util.Locale
import java.util.concurrent.TimeUnit
import javax.crypto.Cipher
import javax.crypto.spec.SecretKeySpec
import kotlin.time.Duration.Companion.hours
abstract class AnikotoTheme(
@@ -1079,10 +1078,7 @@ private object AnikotoUtils {
}
private fun rc4Encrypt(key: String, input: String): String {
val rc4Key = SecretKeySpec(key.toByteArray(), "RC4")
val cipher = Cipher.getInstance("RC4")
cipher.init(Cipher.ENCRYPT_MODE, rc4Key, cipher.parameters)
val output = cipher.doFinal(input.toByteArray())
val output = rc4(key.toByteArray(), input.toByteArray())
return Base64.encode(output, Base64.URL_SAFE or Base64.NO_WRAP).toString(Charsets.UTF_8)
}

View File

@@ -6,6 +6,7 @@ import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.util.asJsoup
import keiyoushi.utils.formatBytes
import keiyoushi.utils.parseAs
import keiyoushi.utils.toHex
import kotlinx.serialization.Serializable
import okhttp3.Headers
import okhttp3.HttpUrl.Companion.toHttpUrl
@@ -118,7 +119,7 @@ class GoogleDriveEpisodes(private val client: OkHttpClient, private val headers:
val sapisidhash = MessageDigest
.getInstance("SHA-1")
.digest("$timeNow $SAPISID $origin".toByteArray())
.joinToString("") { "%02x".format(it) }
.toHex()
return "SAPISIDHASH ${timeNow}_$sapisidhash"
}

View File

@@ -1,6 +1,7 @@
package aniyomi.lib.m3u8server
import android.util.Log
import keiyoushi.utils.decodeHex
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
@@ -362,18 +363,21 @@ class M3u8HttpServer(
if (normalizedIv.length != 32) {
throw IOException("Invalid AES-128 IV length: ${normalizedIv.length}")
}
val ivBytes = try {
normalizedIv.decodeHex()
} catch (e: IllegalArgumentException) {
throw IOException("Invalid AES-128 IV", e)
}
return try {
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
cipher.init(
Cipher.DECRYPT_MODE,
SecretKeySpec(key, "AES"),
IvParameterSpec(normalizedIv.hexToByteArray()),
IvParameterSpec(ivBytes),
)
cipher.doFinal(data)
} catch (e: GeneralSecurityException) {
throw IOException("Failed to decrypt AES-128 segment", e)
} catch (e: NumberFormatException) {
throw IOException("Invalid AES-128 IV", e)
}
}
@@ -381,10 +385,6 @@ class M3u8HttpServer(
private fun String.normalizeHlsIv(): String = removePrefix("0x").removePrefix("0X").padStart(32, '0')
private fun String.hexToByteArray(): ByteArray = ByteArray(length / 2) { index ->
substring(index * 2, index * 2 + 2).toInt(16).toByte()
}
/**
* Health check
*/

View File

@@ -5,6 +5,7 @@ import android.util.Log
import aniyomi.lib.playlistutils.PlaylistUtils
import eu.kanade.tachiyomi.animesource.model.Video
import eu.kanade.tachiyomi.network.GET
import keiyoushi.utils.decodeHex
import keiyoushi.utils.parseAs
import keiyoushi.utils.useAsJsoup
import kotlinx.serialization.Serializable
@@ -37,8 +38,8 @@ class VidGuardExtractor(private val client: OkHttpClient) {
private fun sigDecode(url: String): String {
val sig = url.split("sig=")[1].split("&")[0]
val t = sig.chunked(2)
.joinToString("") { (Integer.parseInt(it, 16) xor 2).toChar().toString() }
val t = sig.decodeHex()
.joinToString("") { ((it.toInt() and 0xFF) xor 2).toChar().toString() }
.let {
val padding = when (it.length % 4) {
2 -> "=="

View File

@@ -7,6 +7,7 @@ import eu.kanade.tachiyomi.animesource.model.Track
import eu.kanade.tachiyomi.animesource.model.Video
import eu.kanade.tachiyomi.network.GET
import keiyoushi.utils.parseAs
import keiyoushi.utils.rc4
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
@@ -15,8 +16,6 @@ import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
import uy.kohesive.injekt.injectLazy
import java.net.URLDecoder
import javax.crypto.Cipher
import javax.crypto.spec.SecretKeySpec
@OptIn(ExperimentalSerializationApi::class)
class VidsrcExtractor(private val client: OkHttpClient, private val headers: Headers) {
@@ -76,10 +75,8 @@ class VidsrcExtractor(private val client: OkHttpClient, private val headers: Hea
}
private fun encodeID(videoID: String, key: String): String {
val rc4Key = SecretKeySpec(key.toByteArray(), "RC4")
val cipher = Cipher.getInstance("RC4")
cipher.init(Cipher.DECRYPT_MODE, rc4Key, cipher.parameters)
return Base64.encode(cipher.doFinal(videoID.toByteArray()), Base64.DEFAULT)
val encrypted = rc4(key.toByteArray(), videoID.toByteArray())
return Base64.encode(encrypted, Base64.DEFAULT)
.toString(Charsets.UTF_8)
.replace("+", "-")
.replace("/", "_")
@@ -98,11 +95,7 @@ class VidsrcExtractor(private val client: OkHttpClient, private val headers: Hea
}
private fun vrfDecrypt(input: String): String {
var vrf = Base64.decode(input.toByteArray(), Base64.URL_SAFE)
val rc4Key = SecretKeySpec(DECRYPTION_KEY.toByteArray(), "RC4")
val cipher = Cipher.getInstance("RC4")
cipher.init(Cipher.DECRYPT_MODE, rc4Key, cipher.parameters)
vrf = cipher.doFinal(vrf)
val vrf = rc4(DECRYPTION_KEY.toByteArray(), Base64.decode(input.toByteArray(), Base64.URL_SAFE))
return URLDecoder.decode(vrf.toString(Charsets.UTF_8), "utf-8")
}

View File

@@ -25,6 +25,7 @@ import keiyoushi.utils.commonEmptyRequestBody
import keiyoushi.utils.formatBytes
import keiyoushi.utils.getPreferencesLazy
import keiyoushi.utils.parseAs
import keiyoushi.utils.toHex
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import okhttp3.HttpUrl.Companion.toHttpUrl
@@ -510,7 +511,7 @@ class GoogleDrive :
val sapisidhash = MessageDigest
.getInstance("SHA-1")
.digest("$timeNow $SAPISID $origin".toByteArray())
.joinToString("") { "%02x".format(it) }
.toHex()
return "SAPISIDHASH ${timeNow}_$sapisidhash"
}

View File

@@ -1,6 +1,7 @@
package eu.kanade.tachiyomi.animeextension.all.missav
import android.util.Log
import keiyoushi.utils.toHex
import kotlinx.serialization.json.JsonObjectBuilder
import kotlinx.serialization.json.add
import kotlinx.serialization.json.put
@@ -94,7 +95,7 @@ object MissAvApi {
mac.init(secretKeySpec)
val hashBytes = mac.doFinal(data.toByteArray(StandardCharsets.UTF_8))
val hexString = hashBytes.joinToString("") { "%02x".format(it) }
val hexString = hashBytes.toHex()
"$data&frontend_sign=$hexString"
} catch (e: Exception) {

View File

@@ -21,6 +21,7 @@ import keiyoushi.utils.delegate
import keiyoushi.utils.getPreferencesLazy
import keiyoushi.utils.parallelCatchingFlatMapBlocking
import keiyoushi.utils.parallelMapNotNullBlocking
import keiyoushi.utils.toHex
import keiyoushi.utils.useAsJsoup
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
@@ -443,7 +444,7 @@ class Serienstream :
}
for (i in 0..maxnumber) {
val hash = md.digest((salt + i.toString()).toByteArray())
val hex = hash.joinToString("") { "%02x".format(it.toInt() and 0xFF) }
val hex = hash.toHex()
if (hex == challenge) return i
md.reset()
}

View File

@@ -1,6 +1,7 @@
package eu.kanade.tachiyomi.animeextension.en.animepahe
import eu.kanade.tachiyomi.animesource.model.Video
import keiyoushi.utils.decodeHex
import okhttp3.Headers
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
@@ -339,18 +340,22 @@ object AnimePaheHlsServer : NanoHTTPD(0) {
throw IOException("Invalid AES-128 IV length: ${normalizedIv.length}")
}
val ivBytes = try {
normalizedIv.decodeHex()
} catch (e: IllegalArgumentException) {
throw IOException("Invalid AES-128 IV", e)
}
return try {
val cipher = Cipher.getInstance("AES/CBC/NoPadding")
cipher.init(
Cipher.DECRYPT_MODE,
SecretKeySpec(key, "AES"),
IvParameterSpec(normalizedIv.hexToByteArray()),
IvParameterSpec(ivBytes),
)
cipher.doFinal(data)
} catch (e: GeneralSecurityException) {
throw IOException("Failed to decrypt AES-128 segment", e)
} catch (e: NumberFormatException) {
throw IOException("Invalid AES-128 IV", e)
}
}
@@ -359,8 +364,4 @@ object AnimePaheHlsServer : NanoHTTPD(0) {
private fun String.normalizeHlsIv(): String = removePrefix("0x")
.removePrefix("0X")
.padStart(32, '0')
private fun String.hexToByteArray(): ByteArray = ByteArray(length / 2) { index ->
substring(index * 2, index * 2 + 2).toInt(16).toByte()
}
}

View File

@@ -1,36 +1,18 @@
package eu.kanade.tachiyomi.animeextension.en.aniwaves
import android.util.Base64
import keiyoushi.utils.rc4
import java.net.URLEncoder
internal class VrfCodec {
fun encrypt(input: String): String {
val rc4Result = rc4(KEY, input)
val base64 = Base64.encodeToString(rc4Result.toByteArray(Charsets.ISO_8859_1), Base64.NO_WRAP)
val rc4Result = rc4(KEY, input.toByteArray(Charsets.ISO_8859_1))
val base64 = Base64.encodeToString(rc4Result, Base64.NO_WRAP)
return URLEncoder.encode(base64, "UTF-8")
}
private fun rc4(key: String, data: String): String {
val s = IntArray(256) { it }
var j = 0
for (i in 0..255) {
j = (j + s[i] + key[i % key.length].code) % 256
s[i] = s[j].also { s[j] = s[i] }
}
var i = 0
j = 0
val result = StringBuilder(data.length)
for (k in data.indices) {
i = (i + 1) % 256
j = (j + s[i]) % 256
s[i] = s[j].also { s[j] = s[i] }
result.append((data[k].code xor s[(s[i] + s[j]) % 256]).toChar())
}
return result.toString()
}
private companion object {
const val KEY = "simple-hash"
val KEY = "simple-hash".toByteArray()
}
}

View File

@@ -1,6 +1,7 @@
package eu.kanade.tachiyomi.animeextension.en.gotaku
import android.util.Base64
import keiyoushi.utils.decodeHex
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import okhttp3.HttpUrl.Companion.toHttpUrl
@@ -34,9 +35,7 @@ object Crypto {
167, 208, 189, 122, 150, 242, 60, 1, 70, 177, 253, 150, 158, 124, 253, 47,
).map { it.toByte() }.toByteArray()
private val MANIFEST_SEED = hexToBytes(
"458e5403c40a73332706df406193dd6b040a2ea97a6bb59b52b294a61006e6ef",
)
private val MANIFEST_SEED = "458e5403c40a73332706df406193dd6b040a2ea97a6bb59b52b294a61006e6ef".decodeHex()
/** HKDF-SHA256(ikm=API_SEED, salt=∅, info="api-seal|v1", L=32) — derived once. */
private val apiKeySpec by lazy {
@@ -266,6 +265,4 @@ object Crypto {
} catch (_: Exception) {
null
}
fun hexToBytes(hex: String): ByteArray = ByteArray(hex.length / 2) { i -> hex.substring(i * 2, i * 2 + 2).toInt(16).toByte() }
}

View File

@@ -1,5 +1,6 @@
package eu.kanade.tachiyomi.animeextension.en.hanime
import keiyoushi.utils.toHex
import java.security.MessageDigest
/**
@@ -68,7 +69,7 @@ open class NativeSignatureProvider : SignatureProvider {
val input = "$timestamp,$SALT_1,$ORIGIN,$SALT_2,$timestamp"
val digest = MessageDigest.getInstance("SHA-256")
val hashBytes = digest.digest(input.toByteArray(Charsets.UTF_8))
val hex = hashBytes.joinToString("") { "%02x".format(it) }
val hex = hashBytes.toHex()
return Signature(signature = hex, time = timestamp.toString())
}

View File

@@ -24,6 +24,7 @@ import keiyoushi.utils.getPreferencesLazy
import keiyoushi.utils.parallelFlatMap
import keiyoushi.utils.parallelMapNotNull
import keiyoushi.utils.parseAs
import keiyoushi.utils.toHex
import keiyoushi.utils.toJsonRequestBody
import keiyoushi.utils.toJsonString
import kotlinx.coroutines.CancellationException
@@ -516,7 +517,7 @@ class HexaWatch :
private class HosterData(val requestUrl: String, val server: String, val key: String, val url: String?)
private fun randomHex(bytes: Int) = ByteArray(bytes).also(SECURE_RANDOM::nextBytes)
.joinToString("") { "%02x".format(it) }
.toHex()
// Stands in for the site's hashed screen/UA/canvas fingerprint; only needs to be stable.
private val clientFingerprint by lazy { randomHex(4).toLong(16).toString(36) }

View File

@@ -18,6 +18,7 @@ import keiyoushi.utils.ParsedAnimeHttpLegacySource
import keiyoushi.utils.formatBytes
import keiyoushi.utils.getPreferencesLazy
import keiyoushi.utils.parseAs
import keiyoushi.utils.toHex
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import okhttp3.FormBody
@@ -455,7 +456,7 @@ class Kayoanime :
val sapisidhash = MessageDigest
.getInstance("SHA-1")
.digest("$timeNow $SAPISID $origin".toByteArray())
.joinToString("") { "%02x".format(it) }
.toHex()
return "SAPISIDHASH ${timeNow}_$sapisidhash"
}

View File

@@ -9,6 +9,7 @@ import eu.kanade.tachiyomi.network.awaitSuccess
import keiyoushi.lib.cryptoaes.CryptoAES
import keiyoushi.utils.bodyString
import keiyoushi.utils.decodeHex
import keiyoushi.utils.toHex
import kotlinx.serialization.json.Json
import okhttp3.Headers
import okhttp3.HttpUrl.Companion.toHttpUrl
@@ -254,7 +255,7 @@ class KickAssAnimeExtractor(
private fun sha1sum(value: String): String = try {
val md = MessageDigest.getInstance("SHA-1")
val bytes = md.digest(value.toByteArray())
bytes.joinToString("") { "%02x".format(it) }
bytes.toHex()
} catch (_: Exception) {
throw Exception("Attempt to create the signature failed.")
}

View File

@@ -1,6 +1,7 @@
package eu.kanade.tachiyomi.animeextension.en.uniquestream
import android.util.Base64
import keiyoushi.utils.decodeHex
import okhttp3.Dns
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
@@ -258,18 +259,22 @@ object UniqueStreamHlsServer : NanoHTTPD("127.0.0.1", 0) {
throw IOException("Invalid AES-128 key length: ${key.size}")
}
val ivBytes = try {
iv.decodeHex()
} catch (e: IllegalArgumentException) {
throw IOException("Invalid AES-128 IV", e)
}
return try {
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
cipher.init(
Cipher.DECRYPT_MODE,
SecretKeySpec(key, "AES"),
IvParameterSpec(iv.hexToByteArray()),
IvParameterSpec(ivBytes),
)
cipher.doFinal(data)
} catch (e: GeneralSecurityException) {
throw IOException("Failed to decrypt AES-128 segment", e)
} catch (e: IllegalArgumentException) {
throw IOException("Invalid AES-128 IV", e)
}
}
@@ -352,8 +357,4 @@ object UniqueStreamHlsServer : NanoHTTPD("127.0.0.1", 0) {
private fun String.normalizeHlsIv(): String = removePrefix("0x").removePrefix("0X").padStart(32, '0')
private fun encode(value: String): String = URLEncoder.encode(value, Charsets.UTF_8.name())
private fun String.hexToByteArray(): ByteArray = ByteArray(length / 2) { index ->
substring(index * 2, index * 2 + 2).toInt(16).toByte()
}
}

View File

@@ -11,6 +11,7 @@ import android.webkit.WebViewClient
import eu.kanade.tachiyomi.animesource.model.Video
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.util.asJsoup
import keiyoushi.utils.decodeHex
import okhttp3.Headers
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
@@ -105,8 +106,8 @@ class VidGuardExtractor(private val client: OkHttpClient) {
private fun fixUrl(url: String): String {
val httpUrl = url.toHttpUrl()
val originalSign = httpUrl.queryParameter("sig")!!
val newSign = originalSign.chunked(2).joinToString("") {
Char(it.toInt(16) xor 2).toString()
val newSign = originalSign.decodeHex().joinToString("") {
Char((it.toInt() and 0xFF) xor 2).toString()
}
.let { String(Base64.decode(it, Base64.DEFAULT)) }
.substring(5)

View File

@@ -19,6 +19,7 @@ import keiyoushi.network.rateLimit
import keiyoushi.utils.AnimeHttpLegacySource
import keiyoushi.utils.getPreferencesLazy
import keiyoushi.utils.parseAs
import keiyoushi.utils.toHex
import keiyoushi.utils.toJsonString
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.JsonObject
@@ -334,7 +335,7 @@ class FunAnimeTV :
fun md5(input: String): String {
val md = MessageDigest.getInstance("MD5")
val digest = md.digest(input.toByteArray(Charsets.UTF_8))
return digest.joinToString(separator = "") { "%02x".format(it) }
return digest.toHex()
}
private fun createRequestJson(

View File

@@ -1,6 +1,7 @@
package eu.kanade.tachiyomi.animeextension.pt.funanimetv.dto
import android.util.Base64
import keiyoushi.utils.toHex
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
@@ -24,9 +25,7 @@ class FunAnimeTVRequest(
val sign: String = run {
MessageDigest.getInstance("MD5")
.digest("$singsalt$salt".toByteArray(Charsets.UTF_8))
.joinToString("") {
"%02x".format(it)
}
.toHex()
}
fun toBase64(json: Json): String = Base64.encodeToString(json.encodeToString(this).toByteArray(), Base64.NO_PADDING)

View File

@@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.animeextension.tr.tranimeizle
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST
import keiyoushi.utils.toHex
import okhttp3.FormBody
import okhttp3.Headers
import okhttp3.Interceptor
@@ -65,5 +66,5 @@ class ShittyCaptchaInterceptor(private val baseUrl: String, private val headers:
private fun md5Hash(byteArray: ByteArray) = MessageDigest.getInstance("MD5")
.digest(byteArray)
.joinToString("") { "%02x".format(it) } // create hex
.toHex()
}

View File

@@ -17,6 +17,7 @@ import keiyoushi.lib.cryptoaes.CryptoAES
import keiyoushi.utils.AnimeHttpLegacySource
import keiyoushi.utils.getPreferencesLazy
import keiyoushi.utils.parseAs
import keiyoushi.utils.toHex
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Request
import okhttp3.Response
@@ -75,7 +76,7 @@ class Cycity :
val prefix = CharArray(k2.length)
k1.indices.forEach { prefix[k1[it] - '0'] = k2[it] }
val txt = "${prefix.joinToString("")}YLwJVbXw77pk2eOrAnFdBo2c3mWkLtodMni2wk81GCnP94ZltW"
val a = md5.digest(txt.toByteArray()).joinToString("") { "%02x".format(it) }
val a = md5.digest(txt.toByteArray()).toHex()
val ivBytes = a.take(16).toByteArray(Charsets.UTF_8)
val keyBytes = a.substring(16).toByteArray(Charsets.UTF_8)
return CryptoAES.decrypt(url, keyBytes, ivBytes)

View File

@@ -10,6 +10,7 @@ import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.util.asJsoup
import keiyoushi.utils.AnimeHttpLegacySource
import keiyoushi.utils.parseAs
import keiyoushi.utils.toHex
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.jsonArray
@@ -237,7 +238,7 @@ class Iyf : AnimeHttpLegacySource() {
private fun signature(query: String, pConfig: PConfig): String {
val s = "${pConfig.publicKey}&${query.lowercase()}&${pConfig.privateKey}"
return MessageDigest.getInstance("MD5").digest(s.toByteArray())
.joinToString(separator = "") { "%02x".format(it) }
.toHex()
}
override fun getFilterList(): AnimeFilterList = AnimeFilterList(

View File

@@ -1,5 +1,6 @@
package eu.kanade.tachiyomi.animeextension.zh.xfani
import keiyoushi.utils.toHex
import java.security.MessageDigest
private const val UID = "DCC147D11943AF75"
@@ -9,5 +10,5 @@ internal fun generateKey(time: Long): String = "DS${time}$UID".md5()
internal fun String.md5(): String {
val md = MessageDigest.getInstance("MD5")
val digest = md.digest(this.toByteArray())
return digest.joinToString(separator = "") { eachByte -> "%02x".format(eachByte) }
return digest.toHex()
}