Add kagane (#10599)

* Add kagane

* Small code cleanup

* Make sure nsfw cookie is always added

* Add interceptor to automatically refresh token

* Small code cleanup pt. 2
This commit is contained in:
Secozzi 2025-09-20 08:44:05 +00:00 committed by Draff
parent c47e2c1024
commit 2d450add12
Signed by: Draff
GPG Key ID: E8A89F3211677653
12 changed files with 997 additions and 0 deletions

View File

@ -0,0 +1,8 @@
ext {
extName = 'Kagane'
extClass = '.Kagane'
extVersionCode = 1
isNsfw = true
}
apply from: "$rootDir/common.gradle"

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@ -0,0 +1,103 @@
package eu.kanade.tachiyomi.extension.en.kagane
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import keiyoushi.utils.tryParse
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import java.text.SimpleDateFormat
import java.util.Locale
@Serializable
class BookDto(
val id: String,
val name: String,
val source: String,
val metadata: MetadataDto,
val booksMetadata: BooksMetadataDto,
) {
@Serializable
class MetadataDto(
val genres: List<String>,
val status: String,
val summary: String,
)
@Serializable
class BooksMetadataDto(
val authors: List<AuthorDto>,
) {
@Serializable
class AuthorDto(
val name: String,
val role: String,
)
}
fun toSManga(domain: String): SManga = SManga.create().apply {
title = name
url = "/series/$id"
description = buildString {
append(metadata.summary)
append("\n\n")
append("Source: ")
append(source)
}
thumbnail_url = "https://api.$domain/api/v1/series/$id/thumbnail"
author = getRoles(listOf("writer"))
artist = getRoles(listOf("inker", "colorist", "penciller"))
genre = metadata.genres.joinToString()
status = metadata.status.toStatus()
}
private fun String.toStatus(): Int {
return when (this) {
"ONGOING" -> SManga.ONGOING
"ENDED" -> SManga.COMPLETED
else -> SManga.COMPLETED
}
}
private fun getRoles(roles: List<String>): String {
return booksMetadata.authors
.filter { roles.contains(it.role) }
.joinToString { it.name }
}
}
@Serializable
class ChapterDto(
val id: String,
val metadata: MetadataDto,
) {
@Serializable
class MetadataDto(
val releaseDate: String? = null,
val title: String,
)
fun toSChapter(seriesId: String): SChapter = SChapter.create().apply {
url = "$seriesId;$id"
name = metadata.title
date_upload = dateFormat.tryParse(metadata.releaseDate)
}
companion object {
private val dateFormat by lazy {
SimpleDateFormat("yyyy-MM-dd", Locale.ENGLISH)
}
}
}
@Serializable
class ChallengeDto(
@SerialName("access_token")
val accessToken: String,
@SerialName("page_count")
val pageCount: Int,
)
@Serializable
class PaginationDto(
val hasNext: Boolean,
)

View File

@ -0,0 +1,199 @@
package eu.kanade.tachiyomi.extension.en.kagane
import okhttp3.Interceptor
import okhttp3.Protocol
import okhttp3.Response
import okhttp3.ResponseBody.Companion.toResponseBody
import okio.IOException
import java.math.BigInteger
import javax.crypto.Cipher
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
import javax.crypto.spec.SecretKeySpec
class ImageInterceptor : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val url = chain.request().url
return if (url.queryParameterNames.contains("token")) {
val seriesId = url.pathSegments[3]
val chapterId = url.pathSegments[5]
val index = url.pathSegments.last().toInt()
val imageResp = chain.proceed(chain.request())
val imageBytes = imageResp.body.bytes()
val decrypted = decryptImage(imageBytes, seriesId, chapterId)
?: throw IOException("Unable to decrypt data")
val unscrambled = processData(decrypted, index, seriesId, chapterId)
?: throw IOException("Unable to unscramble data")
Response.Builder().body(unscrambled.toResponseBody())
.request(chain.request())
.protocol(Protocol.HTTP_1_0)
.code(200)
.message("")
.build()
} else {
chain.proceed(chain.request())
}
}
data class WordArray(val words: IntArray, val sigBytes: Int)
private fun wordArrayToBytes(e: WordArray): ByteArray {
val result = ByteArray(e.sigBytes)
for (i in 0 until e.sigBytes) {
val word = e.words[i ushr 2]
val shift = 24 - (i % 4) * 8
result[i] = ((word ushr shift) and 0xFF).toByte()
}
return result
}
private fun aesGcmDecrypt(keyWordArray: WordArray, ivWordArray: WordArray, cipherWordArray: WordArray): ByteArray? {
return try {
val keyBytes = wordArrayToBytes(keyWordArray)
val iv = wordArrayToBytes(ivWordArray)
val cipherBytes = wordArrayToBytes(cipherWordArray)
val secretKey: SecretKey = SecretKeySpec(keyBytes, "AES")
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
val spec = GCMParameterSpec(128, iv)
cipher.init(Cipher.DECRYPT_MODE, secretKey, spec)
cipher.doFinal(cipherBytes)
} catch (_: Exception) {
null
}
}
private fun toWordArray(bytes: ByteArray): WordArray {
val words = IntArray((bytes.size + 3) / 4)
for (i in bytes.indices) {
val wordIndex = i / 4
val shift = 24 - (i % 4) * 8
words[wordIndex] = words[wordIndex] or ((bytes[i].toInt() and 0xFF) shl shift)
}
return WordArray(words, bytes.size)
}
private fun decryptImage(payload: ByteArray, keyPart1: String, keyPart2: String): ByteArray? {
return try {
if (payload.size < 140) return null
val iv = payload.sliceArray(128 until 140)
val ciphertext = payload.sliceArray(140 until payload.size)
val keyHash = "$keyPart1:$keyPart2".sha256()
val keyWA = toWordArray(keyHash)
val ivWA = toWordArray(iv)
val cipherWA = toWordArray(ciphertext)
aesGcmDecrypt(keyWA, ivWA, cipherWA)
} catch (_: Exception) {
null
}
}
private fun processData(input: ByteArray, index: Int, seriesId: String, chapterId: String): ByteArray? {
fun isValidImage(data: ByteArray): Boolean {
return when {
data.size >= 2 && data[0] == 0xFF.toByte() && data[1] == 0xD8.toByte() -> true
data.size >= 12 && data[0] == 'R'.code.toByte() && data[1] == 'I'.code.toByte() &&
data[2] == 'F'.code.toByte() && data[3] == 'F'.code.toByte() &&
data[8] == 'W'.code.toByte() && data[9] == 'E'.code.toByte() &&
data[10] == 'B'.code.toByte() && data[11] == 'P'.code.toByte() -> true
data.size >= 2 && data[0] == 0xFF.toByte() && data[1] == 0x0A.toByte() -> true
data.size >= 12 && data.copyOfRange(0, 12).contentEquals(
byteArrayOf(
0,
0,
0,
12,
'J'.code.toByte(),
'X'.code.toByte(),
'L'.code.toByte(),
' '.code.toByte(),
),
) -> true
else -> false
}
}
try {
var processed: ByteArray = input
if (!isValidImage(processed)) {
val seed = generateSeed(seriesId, chapterId, "%04d.jpg".format(index))
val scrambler = Scrambler(seed, 10)
val scrambleMapping = scrambler.getScrambleMapping()
processed = unscramble(processed, scrambleMapping, true)
if (!isValidImage(processed)) return null
}
return processed
} catch (_: Exception) {
return null
}
}
private fun generateSeed(t: String, n: String, e: String): BigInteger {
val sha256 = "$t:$n:$e".sha256()
var a = BigInteger.ZERO
for (i in 0 until 8) {
a = a.shiftLeft(8).or(BigInteger.valueOf((sha256[i].toInt() and 0xFF).toLong()))
}
return a
}
private fun unscramble(data: ByteArray, mapping: List<Pair<Int, Int>>, n: Boolean): ByteArray {
val s = mapping.size
val a = data.size
val l = a / s
val o = a % s
val (r, i) = if (n) {
if (o > 0) {
Pair(data.copyOfRange(0, o), data.copyOfRange(o, a))
} else {
Pair(ByteArray(0), data)
}
} else {
if (o > 0) {
Pair(data.copyOfRange(a - o, a), data.copyOfRange(0, a - o))
} else {
Pair(ByteArray(0), data)
}
}
val chunks = (0 until s).map { idx ->
val start = idx * l
val end = (idx + 1) * l
i.copyOfRange(start, end)
}.toMutableList()
val u = Array(s) { ByteArray(0) }
if (n) {
for ((e, m) in mapping) {
if (e < s && m < s) {
u[e] = chunks[m]
}
}
} else {
for ((e, m) in mapping) {
if (e < s && m < s) {
u[m] = chunks[e]
}
}
}
val h = u.fold(ByteArray(0)) { acc, chunk -> acc + chunk }
return if (n) {
h + r
} else {
r + h
}
}
}

View File

@ -0,0 +1,439 @@
package eu.kanade.tachiyomi.extension.en.kagane
import android.app.Application
import android.content.SharedPreferences
import android.os.Handler
import android.os.Looper
import android.util.Base64
import android.view.View
import android.webkit.CookieManager
import android.webkit.JavascriptInterface
import android.webkit.PermissionRequest
import android.webkit.WebChromeClient
import android.webkit.WebView
import androidx.preference.PreferenceScreen
import androidx.preference.SwitchPreferenceCompat
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.network.interceptor.rateLimit
import eu.kanade.tachiyomi.source.ConfigurableSource
import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.source.model.MangasPage
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.util.asJsoup
import keiyoushi.utils.getPreferencesLazy
import keiyoushi.utils.parseAs
import keiyoushi.utils.toJsonString
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import okhttp3.Cookie
import okhttp3.CookieJar
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Interceptor
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import okio.IOException
import rx.Observable
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.nio.charset.StandardCharsets
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import kotlin.collections.forEach
import kotlin.getValue
import kotlin.text.split
class Kagane : HttpSource(), ConfigurableSource {
override val name = "Kagane"
private val domain = "kagane.org"
private val apiUrl = "https://api.$domain"
override val baseUrl = "https://$domain"
override val lang = "en"
override val supportsLatest = false
private val preferences by getPreferencesLazy()
override val client = network.cloudflareClient.newBuilder()
.cookieJar(
object : CookieJar {
private val cookieManager by lazy { CookieManager.getInstance() }
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
val urlString = url.toString()
cookies.forEach { cookieManager.setCookie(urlString, it.toString()) }
}
override fun loadForRequest(url: HttpUrl): List<Cookie> {
val cookies = cookieManager.getCookie(url.toString()).orEmpty()
val cookieList = mutableListOf<Cookie>()
var hasNsfwCookie = false
cookies.split(";").mapNotNullTo(cookieList) { c ->
var cookieValue = c
if (url.host == domain && c.contains("kagane_mature_content")) {
hasNsfwCookie = true
val (key, _) = c.split("=")
cookieValue = "$key=${preferences.showNsfw}"
}
Cookie.parse(url, cookieValue)
}
if (!hasNsfwCookie && url.host == domain) {
Cookie.parse(url, "kagane_mature_content=${preferences.showNsfw}")?.let {
cookieList.add(it)
}
}
return cookieList
}
},
)
.addInterceptor(ImageInterceptor())
.addInterceptor(::refreshTokenInterceptor)
.rateLimit(2)
.build()
private fun refreshTokenInterceptor(chain: Interceptor.Chain): Response {
val request = chain.request()
val url = request.url
if (!url.queryParameterNames.contains("token")) {
return chain.proceed(request)
}
val seriesId = url.pathSegments[3]
val chapterId = url.pathSegments[5]
var response = chain.proceed(
request.newBuilder()
.url(url.newBuilder().setQueryParameter("token", accessToken).build())
.build(),
)
if (response.code == 401) {
response.close()
val challenge = try {
getChallengeResponse(seriesId, chapterId)
} catch (_: Exception) {
throw IOException("Failed to retrieve token")
}
accessToken = challenge.accessToken
response = chain.proceed(
request.newBuilder()
.url(url.newBuilder().setQueryParameter("token", accessToken).build())
.build(),
)
}
return response
}
// ============================== Popular ===============================
override fun popularMangaRequest(page: Int): Request {
return GET("$baseUrl/?page=$page", headers)
}
override fun popularMangaParse(response: Response): MangasPage {
return pageListParse(response, "initialSeriesData")
}
private fun pageListParse(response: Response, key: String): MangasPage {
val data = response.asJsoup().selectFirst("script:containsData($key)")!!.data()
val mangaList = data.getNextData(key)
.parseAs<List<BookDto>>()
.map { it.toSManga(domain) }
val pagination = data.getNextData("pagination", isList = false, selectFirst = false)
.parseAs<PaginationDto>()
return MangasPage(mangaList, pagination.hasNext)
}
// =============================== Latest ===============================
override fun latestUpdatesRequest(page: Int): Request {
throw UnsupportedOperationException()
}
override fun latestUpdatesParse(response: Response): MangasPage {
throw UnsupportedOperationException()
}
// =============================== Search ===============================
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
val url = "$baseUrl/search".toHttpUrl().newBuilder().apply {
addQueryParameter("name", query)
addQueryParameter("page", page.toString())
}.build()
return GET(url, headers)
}
override fun searchMangaParse(response: Response): MangasPage {
return pageListParse(response, "ssrData")
}
// =========================== Manga Details ============================
override fun mangaDetailsParse(response: Response): SManga {
val data = response.asJsoup().selectFirst("script:containsData(initialSeriesData)")!!.data()
.getNextData("initialSeriesData", isList = false)
return data.parseAs<BookDto>().toSManga(domain)
}
// ============================== Chapters ==============================
override fun chapterListParse(response: Response): List<SChapter> {
val seriesId = response.request.url.pathSegments.last()
val data = response.asJsoup().selectFirst("script:containsData(initialBooksData)")!!.data()
.getNextData("initialBooksData")
.parseAs<List<ChapterDto>>()
.reversed()
return data.map { it.toSChapter(seriesId) }
}
override fun getChapterUrl(chapter: SChapter): String {
val (seriesId, chapterId) = chapter.url.split(";")
return "$baseUrl/series/$seriesId/reader/$chapterId"
}
// =============================== Pages ================================
private val apiHeaders = headers.newBuilder().apply {
add("Origin", baseUrl)
add("Referer", "$baseUrl/")
}.build()
private fun getCertificate(): String {
return client.newCall(GET("$apiUrl/api/v1/static/bin.bin", apiHeaders)).execute()
.body.bytes()
.toBase64()
}
override fun fetchPageList(chapter: SChapter): Observable<List<Page>> {
val (seriesId, chapterId) = chapter.url.split(";")
val challengeResp = getChallengeResponse(seriesId, chapterId)
accessToken = challengeResp.accessToken
val pages = (0 until challengeResp.pageCount).map { page ->
val pageUrl = "$apiUrl/api/v1/books".toHttpUrl().newBuilder().apply {
addPathSegment(seriesId)
addPathSegment("file")
addPathSegment(chapterId)
addPathSegment((page + 1).toString())
addQueryParameter("token", accessToken)
}.build().toString()
Page(page, imageUrl = pageUrl)
}
return Observable.just(pages)
}
private var accessToken: String = ""
private fun getChallengeResponse(seriesId: String, chapterId: String): ChallengeDto {
val f = "$seriesId:$chapterId".sha256().sliceArray(0 until 16)
val interfaceName = "jsInterface"
val html = """
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<script>
function base64ToArrayBuffer(base64) {
var binaryString = atob(base64);
var bytes = new Uint8Array(binaryString.length);
for (var i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes.buffer;
}
async function getData() {
const g = base64ToArrayBuffer("${getCertificate()}");
let t = await navigator.requestMediaKeySystemAccess("com.widevine.alpha", [{
initDataTypes: ["cenc"],
audioCapabilities: [],
videoCapabilities: [{
contentType: 'video/mp4; codecs="avc1.42E01E"'
}]
}]);
let e = await t.createMediaKeys();
await e.setServerCertificate(g);
let n = e.createSession();
let i = new Promise((resolve, reject) => {
function onMessage(event) {
n.removeEventListener("message", onMessage);
resolve(event.message);
}
function onError() {
n.removeEventListener("error", onError);
reject(new Error("Failed to generate license challenge"));
}
n.addEventListener("message", onMessage);
n.addEventListener("error", onError);
});
await n.generateRequest("cenc", base64ToArrayBuffer("${getPssh(f).toBase64()}"));
let o = await i;
let m = new Uint8Array(o);
let v = btoa(String.fromCharCode(...m));
window.$interfaceName.passPayload(v);
}
getData();
</script>
</body>
</html>
""".trimIndent()
val handler = Handler(Looper.getMainLooper())
val latch = CountDownLatch(1)
val jsInterface = JsInterface(latch)
var webView: WebView? = null
handler.post {
val innerWv = WebView(Injekt.get<Application>())
webView = innerWv
innerWv.settings.domStorageEnabled = true
innerWv.settings.javaScriptEnabled = true
innerWv.settings.blockNetworkImage = true
innerWv.settings.userAgentString = headers["User-Agent"]
innerWv.setLayerType(View.LAYER_TYPE_SOFTWARE, null)
innerWv.addJavascriptInterface(jsInterface, interfaceName)
innerWv.webChromeClient = object : WebChromeClient() {
override fun onPermissionRequest(request: PermissionRequest?) {
if (request?.resources?.contains(PermissionRequest.RESOURCE_PROTECTED_MEDIA_ID) == true) {
request.grant(request.resources)
} else {
super.onPermissionRequest(request)
}
}
}
innerWv.loadDataWithBaseURL(baseUrl, html, "text/html", "UTF-8", null)
}
latch.await(10, TimeUnit.SECONDS)
handler.post { webView?.destroy() }
if (latch.count == 1L) {
throw Exception("Timed out getting drm challenge")
}
if (jsInterface.challenge.isEmpty()) {
throw Exception("Failed to get drm challenge")
}
val challengeUrl = "$apiUrl/api/v1/books/$seriesId/file/$chapterId"
val challengeBody = buildJsonObject {
put("challenge", jsInterface.challenge)
}.toJsonString().toRequestBody("application/json".toMediaType())
return client.newCall(POST(challengeUrl, apiHeaders, challengeBody)).execute()
.parseAs<ChallengeDto>()
}
private fun concat(vararg arrays: ByteArray): ByteArray =
arrays.reduce { acc, bytes -> acc + bytes }
private fun getPssh(t: ByteArray): ByteArray {
val e = Base64.decode("7e+LqXnWSs6jyCfc1R0h7Q==", Base64.DEFAULT)
val zeroes = ByteArray(4)
val i = byteArrayOf(18, t.size.toByte()) + t
val s = ByteBuffer.allocate(4).order(ByteOrder.BIG_ENDIAN).putInt(i.size).array()
val innerBox = concat(zeroes, e, s, i)
val outerSize = ByteBuffer.allocate(4).order(ByteOrder.BIG_ENDIAN).putInt(innerBox.size + 8).array()
val psshHeader = "pssh".toByteArray(StandardCharsets.UTF_8)
return concat(outerSize, psshHeader, innerBox)
}
internal class JsInterface(private val latch: CountDownLatch) {
var challenge: String = ""
@JavascriptInterface
@Suppress("UNUSED")
fun passPayload(rawData: String) {
try {
challenge = rawData
latch.countDown()
} catch (_: Exception) {
return
}
}
}
override fun pageListParse(response: Response): List<Page> {
throw UnsupportedOperationException()
}
override fun imageUrlParse(response: Response): String {
throw UnsupportedOperationException()
}
// ============================ Preferences =============================
private val SharedPreferences.showNsfw
get() = this.getBoolean(SHOW_NSFW_KEY, true)
override fun setupPreferenceScreen(screen: PreferenceScreen) {
SwitchPreferenceCompat(screen.context).apply {
key = SHOW_NSFW_KEY
title = "Show nsfw entries"
setDefaultValue(true)
}.let(screen::addPreference)
}
// ============================= Utilities ==============================
private fun String.getNextData(key: String, isList: Boolean = true, selectFirst: Boolean = true): String {
val (startDel, endDel) = if (isList) '[' to ']' else '{' to '}'
val keyIndex = if (selectFirst) this.indexOf(key) else this.lastIndexOf(key)
val start = this.indexOf(startDel, keyIndex)
var depth = 1
var i = start + 1
while (i < this.length && depth > 0) {
when (this[i]) {
startDel -> depth++
endDel -> depth--
}
i++
}
return "\"${this.substring(start, i)}\"".parseAs<String>()
}
companion object {
private const val SHOW_NSFW_KEY = "pref_show_nsfw"
}
}

View File

@ -0,0 +1,109 @@
package eu.kanade.tachiyomi.extension.en.kagane
import java.math.BigInteger
import java.nio.charset.StandardCharsets
import java.security.MessageDigest
class Randomizer(seedInput: BigInteger, t: Int) {
val size: Int = t * t
val seed: BigInteger
private var state: BigInteger
private val entropyPool: ByteArray
val order: MutableList<Int>
companion object {
private val MASK64 = BigInteger("FFFFFFFFFFFFFFFF", 16)
private val MASK32 = BigInteger("FFFFFFFF", 16)
private val MASK8 = BigInteger("FF", 16)
private val PRNG_MULT = BigInteger("27BB2EE687B0B0FD", 16)
private val RND_MULT_32 = BigInteger("45d9f3b", 16)
}
init {
val seedMask = BigInteger("FFFFFFFFFFFFFFFF", 16)
seed = seedInput.and(seedMask)
state = hashSeed(seed)
entropyPool = expandEntropy(seed)
order = MutableList(size) { it }
permute()
}
private fun hashSeed(e: BigInteger): BigInteger {
val md = e.toString().sha256()
return readBigUInt64BE(md, 0).xor(readBigUInt64BE(md, 8))
}
private fun readBigUInt64BE(bytes: ByteArray, offset: Int): BigInteger {
var n = BigInteger.ZERO
for (i in 0 until 8) {
n = n.shiftLeft(8).or(BigInteger.valueOf((bytes[offset + i].toInt() and 0xFF).toLong()))
}
return n
}
private fun expandEntropy(e: BigInteger): ByteArray =
MessageDigest.getInstance("SHA-512").digest(e.toString().toByteArray(StandardCharsets.UTF_8))
private fun sbox(e: Int): Int {
val t = intArrayOf(163, 95, 137, 13, 55, 193, 107, 228, 114, 185, 22, 243, 68, 218, 158, 40)
return t[e and 15] xor t[e shr 4 and 15]
}
fun prng(): BigInteger {
state = state.xor(state.shiftLeft(11).and(MASK64))
state = state.xor(state.shiftRight(19))
state = state.xor(state.shiftLeft(7).and(MASK64))
state = state.multiply(PRNG_MULT).and(MASK64)
return state
}
private fun roundFunc(e: BigInteger, t: Int): BigInteger {
var n = e.xor(prng()).xor(BigInteger.valueOf(t.toLong()))
val rot = n.shiftLeft(5).or(n.shiftRight(3)).and(MASK32)
n = rot.multiply(RND_MULT_32).and(MASK32)
val sboxVal = sbox(n.and(MASK8).toInt())
n = n.xor(BigInteger.valueOf(sboxVal.toLong()))
n = n.xor(n.shiftRight(13))
return n
}
private fun feistelMix(e: Int, t: Int, rounds: Int): Pair<BigInteger, BigInteger> {
var r = BigInteger.valueOf(e.toLong())
var i = BigInteger.valueOf(t.toLong())
for (round in 0 until rounds) {
val ent = entropyPool[round % entropyPool.size].toInt() and 0xFF
r = r.xor(roundFunc(i, ent))
val secondArg = ent xor (round * 31 and 255)
i = i.xor(roundFunc(r, secondArg))
}
return Pair(r, i)
}
private fun permute() {
val half = size / 2
val sizeBig = BigInteger.valueOf(size.toLong())
for (t in 0 until half) {
val n = t + half
val (rBig, iBig) = feistelMix(t, n, 4)
val s = rBig.mod(sizeBig).toInt()
val a = iBig.mod(sizeBig).toInt()
val tmp = order[s]
order[s] = order[a]
order[a] = tmp
}
for (e in size - 1 downTo 1) {
val ent = entropyPool[e % entropyPool.size].toInt() and 0xFF
val idxBig = prng().add(BigInteger.valueOf(ent.toLong())).mod(BigInteger.valueOf((e + 1).toLong()))
val n = idxBig.toInt()
val tmp = order[e]
order[e] = order[n]
order[n] = tmp
}
}
}

View File

@ -0,0 +1,125 @@
package eu.kanade.tachiyomi.extension.en.kagane
import java.math.BigInteger
class Scrambler(private val seed: BigInteger, private val gridSize: Int) {
private val totalPieces: Int = gridSize * gridSize
private val randomizer: Randomizer = Randomizer(seed, gridSize)
private val dependencyGraph: DependencyGraph
private val scramblePath: List<Int>
init {
dependencyGraph = buildDependencyGraph()
scramblePath = generateScramblePath()
}
private data class DependencyGraph(
val graph: MutableMap<Int, MutableList<Int>>,
val inDegree: MutableMap<Int, Int>,
)
private fun buildDependencyGraph(): DependencyGraph {
val graph = mutableMapOf<Int, MutableList<Int>>()
val inDegree = mutableMapOf<Int, Int>()
for (n in 0 until totalPieces) {
inDegree[n] = 0
graph[n] = mutableListOf()
}
val rng = Randomizer(seed, gridSize)
for (r in 0 until totalPieces) {
val i = (rng.prng() % BigInteger.valueOf(3) + BigInteger.valueOf(2)).toInt()
repeat(i) {
val j = (rng.prng() % BigInteger.valueOf(totalPieces.toLong())).toInt()
if (j != r && !wouldCreateCycle(graph, j, r)) {
graph[j]!!.add(r)
inDegree[r] = inDegree[r]!! + 1
}
}
}
for (r in 0 until totalPieces) {
if (inDegree[r] == 0) {
var tries = 0
while (tries < 10) {
val s = (rng.prng() % BigInteger.valueOf(totalPieces.toLong())).toInt()
if (s != r && !wouldCreateCycle(graph, s, r)) {
graph[s]!!.add(r)
inDegree[r] = inDegree[r]!! + 1
break
}
tries++
}
}
}
return DependencyGraph(graph, inDegree)
}
private fun wouldCreateCycle(graph: Map<Int, List<Int>>, target: Int, start: Int): Boolean {
val visited = mutableSetOf<Int>()
val stack = ArrayDeque<Int>()
stack.add(start)
while (stack.isNotEmpty()) {
val n = stack.removeLast()
if (n == target) return true
if (!visited.add(n)) continue
graph[n]?.let { stack.addAll(it) }
}
return false
}
private fun generateScramblePath(): List<Int> {
val graphCopy = dependencyGraph.graph.mapValues { it.value.toMutableList() }.toMutableMap()
val inDegreeCopy = dependencyGraph.inDegree.toMutableMap()
val queue = ArrayDeque<Int>()
for (n in 0 until totalPieces) {
if (inDegreeCopy[n] == 0) {
queue.add(n)
}
}
val order = mutableListOf<Int>()
while (queue.isNotEmpty()) {
val i = queue.removeFirst()
order.add(i)
val neighbors = graphCopy[i]
if (neighbors != null) {
for (e in neighbors) {
inDegreeCopy[e] = inDegreeCopy[e]!! - 1
if (inDegreeCopy[e] == 0) {
queue.add(e)
}
}
}
}
return order
}
fun getScrambleMapping(): List<Pair<Int, Int>> {
var e = randomizer.order.toMutableList()
if (scramblePath.size == totalPieces) {
val t = Array(totalPieces) { 0 }
for (i in scramblePath.indices) {
t[i] = scramblePath[i]
}
val n = Array(totalPieces) { 0 }
for (r in 0 until totalPieces) {
n[r] = e[t[r]]
}
e = n.toMutableList()
}
val result = mutableListOf<Pair<Int, Int>>()
for (n in 0 until totalPieces) {
result.add(n to e[n])
}
return result
}
}

View File

@ -0,0 +1,14 @@
package eu.kanade.tachiyomi.extension.en.kagane
import android.util.Base64
import java.security.MessageDigest
fun ByteArray.toBase64(): String {
return Base64.encodeToString(this, Base64.NO_WRAP)
}
fun String.sha256(): ByteArray {
return MessageDigest
.getInstance("SHA-256")
.digest(toByteArray())
}