mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-16 23:12:12 +00:00
feat(plugin-runtime): implement robust, future-proof native crypto engine (AES, PBKDF2, Web Crypto)
This commit is contained in:
parent
70d3eee9d2
commit
4c59ab90a3
12 changed files with 1692 additions and 18 deletions
|
|
@ -1,14 +1,139 @@
|
|||
package com.nuvio.app.features.plugins
|
||||
|
||||
import java.security.KeyFactory
|
||||
import java.security.MessageDigest
|
||||
import java.security.SecureRandom
|
||||
import java.security.Signature
|
||||
import java.security.spec.PKCS8EncodedKeySpec
|
||||
import java.security.spec.X509EncodedKeySpec
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.Mac
|
||||
import javax.crypto.SecretKeyFactory
|
||||
import javax.crypto.spec.GCMParameterSpec
|
||||
import javax.crypto.spec.IvParameterSpec
|
||||
import javax.crypto.spec.PBEKeySpec
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
import kotlin.io.encoding.Base64
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
import java.security.MessageDigest
|
||||
import javax.crypto.Mac
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
private val secureRandom = SecureRandom()
|
||||
|
||||
internal fun pluginGetRandomValues(length: Int): ByteArray {
|
||||
val bytes = ByteArray(length)
|
||||
secureRandom.nextBytes(bytes)
|
||||
return bytes
|
||||
}
|
||||
|
||||
internal fun pluginDigest(algorithm: String, data: ByteArray): ByteArray {
|
||||
return MessageDigest.getInstance(algorithm.uppercase()).digest(data)
|
||||
}
|
||||
|
||||
internal fun pluginPbkdf2(
|
||||
password: ByteArray,
|
||||
salt: ByteArray,
|
||||
iterations: Int,
|
||||
keySizeBits: Int,
|
||||
algorithm: String,
|
||||
): ByteArray {
|
||||
val normalizedAlgo = when (algorithm.uppercase()) {
|
||||
"SHA256" -> "PBKDF2WithHmacSHA256"
|
||||
"SHA1" -> "PBKDF2WithHmacSHA1"
|
||||
else -> "PBKDF2WithHmacSHA256"
|
||||
}
|
||||
val factory = SecretKeyFactory.getInstance(normalizedAlgo)
|
||||
val passChars = password.map { (it.toInt() and 0xFF).toChar() }.toCharArray()
|
||||
val spec = PBEKeySpec(passChars, salt, iterations, keySizeBits)
|
||||
return factory.generateSecret(spec).encoded
|
||||
}
|
||||
|
||||
internal fun pluginAesEncrypt(
|
||||
mode: String,
|
||||
key: ByteArray,
|
||||
iv: ByteArray,
|
||||
data: ByteArray,
|
||||
): ByteArray {
|
||||
val normalizedMode = when (mode.uppercase()) {
|
||||
"AES-CBC", "CBC" -> "AES/CBC/PKCS5Padding"
|
||||
"AES-GCM", "GCM" -> "AES/GCM/NoPadding"
|
||||
"AES-ECB", "ECB" -> "AES/ECB/PKCS5Padding"
|
||||
else -> "AES/CBC/PKCS5Padding"
|
||||
}
|
||||
|
||||
val cipher = Cipher.getInstance(normalizedMode)
|
||||
val keySpec = SecretKeySpec(key, "AES")
|
||||
|
||||
if (normalizedMode.contains("ECB")) {
|
||||
cipher.init(Cipher.ENCRYPT_MODE, keySpec)
|
||||
} else if (normalizedMode.contains("GCM")) {
|
||||
val gcmSpec = GCMParameterSpec(128, iv)
|
||||
cipher.init(Cipher.ENCRYPT_MODE, keySpec, gcmSpec)
|
||||
} else {
|
||||
val ivSpec = IvParameterSpec(iv)
|
||||
cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec)
|
||||
}
|
||||
|
||||
return cipher.doFinal(data)
|
||||
}
|
||||
|
||||
internal fun pluginAesDecrypt(
|
||||
mode: String,
|
||||
key: ByteArray,
|
||||
iv: ByteArray,
|
||||
data: ByteArray,
|
||||
): ByteArray {
|
||||
val normalizedMode = when (mode.uppercase()) {
|
||||
"AES-CBC", "CBC" -> "AES/CBC/PKCS5Padding"
|
||||
"AES-GCM", "GCM" -> "AES/GCM/NoPadding"
|
||||
"AES-ECB", "ECB" -> "AES/ECB/PKCS5Padding"
|
||||
else -> "AES/CBC/PKCS5Padding"
|
||||
}
|
||||
|
||||
val cipher = Cipher.getInstance(normalizedMode)
|
||||
val keySpec = SecretKeySpec(key, "AES")
|
||||
|
||||
if (normalizedMode.contains("ECB")) {
|
||||
cipher.init(Cipher.DECRYPT_MODE, keySpec)
|
||||
} else if (normalizedMode.contains("GCM")) {
|
||||
val gcmSpec = GCMParameterSpec(128, iv)
|
||||
cipher.init(Cipher.DECRYPT_MODE, keySpec, gcmSpec)
|
||||
} else {
|
||||
val ivSpec = IvParameterSpec(iv)
|
||||
cipher.init(Cipher.DECRYPT_MODE, keySpec, ivSpec)
|
||||
}
|
||||
|
||||
return cipher.doFinal(data)
|
||||
}
|
||||
|
||||
internal fun pluginSign(algorithm: String, privateKey: ByteArray, data: ByteArray): ByteArray {
|
||||
val (keyAlgo, sigAlgo) = when (algorithm.uppercase()) {
|
||||
"RSASSA-PKCS1-V1_5-SHA256", "RSASSA-PKCS1-V1_5" -> "RSA" to "SHA256withRSA"
|
||||
"ECDSA-SHA256", "ECDSA" -> "EC" to "SHA256withECDSA"
|
||||
else -> "RSA" to "SHA256withRSA"
|
||||
}
|
||||
val factory = KeyFactory.getInstance(keyAlgo)
|
||||
val privKey = factory.generatePrivate(PKCS8EncodedKeySpec(privateKey))
|
||||
val sig = Signature.getInstance(sigAlgo)
|
||||
sig.initSign(privKey)
|
||||
sig.update(data)
|
||||
return sig.sign()
|
||||
}
|
||||
|
||||
internal fun pluginVerify(algorithm: String, publicKey: ByteArray, signature: ByteArray, data: ByteArray): Boolean {
|
||||
val (keyAlgo, sigAlgo) = when (algorithm.uppercase()) {
|
||||
"RSASSA-PKCS1-V1_5-SHA256", "RSASSA-PKCS1-V1_5" -> "RSA" to "SHA256withRSA"
|
||||
"ECDSA-SHA256", "ECDSA" -> "EC" to "SHA256withECDSA"
|
||||
else -> "RSA" to "SHA256withRSA"
|
||||
}
|
||||
val factory = KeyFactory.getInstance(keyAlgo)
|
||||
val pubKey = factory.generatePublic(X509EncodedKeySpec(publicKey))
|
||||
val sig = Signature.getInstance(sigAlgo)
|
||||
sig.initVerify(pubKey)
|
||||
sig.update(data)
|
||||
return sig.verify(signature)
|
||||
}
|
||||
|
||||
internal fun pluginDigestHex(algorithm: String, data: String): String {
|
||||
val normalized = algorithm.uppercase()
|
||||
val digest = MessageDigest.getInstance(normalized).digest(data.encodeToByteArray())
|
||||
val digest = pluginDigest(algorithm, data.encodeToByteArray())
|
||||
return digest.joinToString(separator = "") { byte ->
|
||||
byte.toUByte().toString(16).padStart(2, '0')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,175 @@
|
|||
package com.nuvio.app.features.plugins.runtime
|
||||
|
||||
import com.nuvio.app.features.plugins.PluginRuntimeResult
|
||||
import com.nuvio.app.features.plugins.runtime.crypto.CryptoBridge
|
||||
import com.nuvio.app.features.plugins.runtime.dom.DomBridge
|
||||
import com.nuvio.app.features.plugins.runtime.host.HostApiRegistry
|
||||
import com.nuvio.app.features.plugins.runtime.host.HostFunctions
|
||||
import com.nuvio.app.features.plugins.runtime.js.JsBindings
|
||||
import com.nuvio.app.features.plugins.runtime.js.JsRuntime
|
||||
import com.nuvio.app.features.plugins.runtime.network.FetchBridge
|
||||
import com.nuvio.app.features.plugins.runtime.wasm.WasmBridge
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.intOrNull
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
|
||||
private const val PLUGIN_TIMEOUT_MS = 60_000L
|
||||
|
||||
internal object PluginRuntime {
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
suspend fun executePlugin(
|
||||
code: String,
|
||||
tmdbId: String,
|
||||
mediaType: String,
|
||||
season: Int?,
|
||||
episode: Int?,
|
||||
scraperId: String,
|
||||
scraperSettings: Map<String, Any> = emptyMap(),
|
||||
): List<PluginRuntimeResult> = withContext(Dispatchers.Default) {
|
||||
withTimeout(PLUGIN_TIMEOUT_MS) {
|
||||
executePluginInternal(
|
||||
code = code,
|
||||
tmdbId = tmdbId,
|
||||
mediaType = mediaType,
|
||||
season = season,
|
||||
episode = episode,
|
||||
scraperId = scraperId,
|
||||
scraperSettings = scraperSettings,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun executePluginInternal(
|
||||
code: String,
|
||||
tmdbId: String,
|
||||
mediaType: String,
|
||||
season: Int?,
|
||||
episode: Int?,
|
||||
scraperId: String,
|
||||
scraperSettings: Map<String, Any>,
|
||||
): List<PluginRuntimeResult> {
|
||||
val jsRuntime = JsRuntime()
|
||||
var resultJson = "[]"
|
||||
|
||||
val domBridge = DomBridge()
|
||||
val hostRegistry = HostApiRegistry().apply {
|
||||
addModule(HostFunctions(scraperId) { resultJson = it })
|
||||
addModule(FetchBridge())
|
||||
addModule(CryptoBridge())
|
||||
addModule(WasmBridge())
|
||||
addModule(domBridge)
|
||||
}
|
||||
|
||||
try {
|
||||
jsRuntime.use {
|
||||
hostRegistry.registerAll(this)
|
||||
|
||||
val settingsJson = toJsonElement(scraperSettings).toString()
|
||||
val polyfillCode = JsBindings.buildPolyfillCode(scraperId, settingsJson)
|
||||
evaluate<Any?>(polyfillCode)
|
||||
|
||||
val wrappedCode = """
|
||||
var module = { exports: {} };
|
||||
var exports = module.exports;
|
||||
(function() {
|
||||
$code
|
||||
})();
|
||||
""".trimIndent()
|
||||
evaluate<Any?>(wrappedCode)
|
||||
|
||||
val seasonArg = season?.toString() ?: "undefined"
|
||||
val episodeArg = episode?.toString() ?: "undefined"
|
||||
val callCode = """
|
||||
(async function() {
|
||||
try {
|
||||
var getStreams = module.exports.getStreams || globalThis.getStreams;
|
||||
if (!getStreams) {
|
||||
console.error("getStreams function not found on module.exports or globalThis");
|
||||
__capture_result(JSON.stringify([]));
|
||||
return;
|
||||
}
|
||||
var result = await getStreams("$tmdbId", "$mediaType", $seasonArg, $episodeArg);
|
||||
__capture_result(JSON.stringify(result || []));
|
||||
} catch (e) {
|
||||
console.error("getStreams error:", e && e.message ? e.message : e, e && e.stack ? e.stack : "");
|
||||
__capture_result(JSON.stringify([]));
|
||||
}
|
||||
})();
|
||||
""".trimIndent()
|
||||
evaluate<Any?>(callCode)
|
||||
}
|
||||
|
||||
return parseJsonResults(resultJson)
|
||||
} finally {
|
||||
domBridge.clear()
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseJsonResults(rawJson: String): List<PluginRuntimeResult> {
|
||||
return runCatching {
|
||||
val array = json.parseToJsonElement(rawJson) as? JsonArray ?: return emptyList()
|
||||
array.mapNotNull { element ->
|
||||
val item = element as? JsonObject ?: return@mapNotNull null
|
||||
val url = when (val urlValue = item["url"]) {
|
||||
is JsonPrimitive -> urlValue.contentOrNull?.takeIf { it.isNotBlank() }
|
||||
is JsonObject -> urlValue["url"]?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() }
|
||||
else -> null
|
||||
} ?: return@mapNotNull null
|
||||
|
||||
val headers = (item["headers"] as? JsonObject)
|
||||
?.mapNotNull { (key, value) ->
|
||||
value.jsonPrimitive.contentOrNull?.let { key to it }
|
||||
}
|
||||
?.toMap()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
|
||||
PluginRuntimeResult(
|
||||
title = item.stringOrNull("title") ?: item.stringOrNull("name") ?: "Unknown",
|
||||
name = item.stringOrNull("name"),
|
||||
url = url,
|
||||
quality = item.stringOrNull("quality"),
|
||||
size = item.stringOrNull("size"),
|
||||
language = item.stringOrNull("language"),
|
||||
provider = item.stringOrNull("provider"),
|
||||
type = item.stringOrNull("type"),
|
||||
seeders = item["seeders"]?.jsonPrimitive?.intOrNull,
|
||||
peers = item["peers"]?.jsonPrimitive?.intOrNull,
|
||||
infoHash = item.stringOrNull("infoHash"),
|
||||
headers = headers,
|
||||
)
|
||||
}.filter { it.url.isNotBlank() }
|
||||
}.getOrElse { emptyList() }
|
||||
}
|
||||
|
||||
private fun JsonObject.stringOrNull(key: String): String? =
|
||||
this[key]?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() && !it.contains("[object") }
|
||||
|
||||
private fun toJsonElement(value: Any?): JsonElement = when (value) {
|
||||
null -> JsonNull
|
||||
is JsonElement -> value
|
||||
is String -> JsonPrimitive(value)
|
||||
is Boolean -> JsonPrimitive(value)
|
||||
is Int -> JsonPrimitive(value)
|
||||
is Long -> JsonPrimitive(value)
|
||||
is Float -> JsonPrimitive(value)
|
||||
is Double -> JsonPrimitive(value)
|
||||
is Number -> JsonPrimitive(value.toDouble())
|
||||
is Map<*, *> -> JsonObject(
|
||||
value.entries
|
||||
.filter { it.key is String }
|
||||
.associate { (it.key as String) to toJsonElement(it.value) },
|
||||
)
|
||||
is Iterable<*> -> JsonArray(value.map(::toJsonElement))
|
||||
else -> JsonPrimitive(value.toString())
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,136 @@
|
|||
package com.nuvio.app.features.plugins.runtime.crypto
|
||||
|
||||
import com.dokar.quickjs.QuickJs
|
||||
import com.dokar.quickjs.binding.function
|
||||
import com.nuvio.app.features.plugins.runtime.host.HostModule
|
||||
import com.nuvio.app.features.plugins.pluginDigestHex
|
||||
import com.nuvio.app.features.plugins.pluginHmacHex
|
||||
import com.nuvio.app.features.plugins.pluginBase64Encode
|
||||
import com.nuvio.app.features.plugins.pluginBase64Decode
|
||||
import com.nuvio.app.features.plugins.pluginUtf8ToHex
|
||||
import com.nuvio.app.features.plugins.pluginHexToUtf8
|
||||
import com.nuvio.app.features.plugins.pluginGetRandomValues
|
||||
import com.nuvio.app.features.plugins.pluginDigest
|
||||
import com.nuvio.app.features.plugins.pluginPbkdf2
|
||||
import com.nuvio.app.features.plugins.pluginAesDecrypt
|
||||
import com.nuvio.app.features.plugins.pluginAesEncrypt
|
||||
import com.nuvio.app.features.plugins.pluginSign
|
||||
import com.nuvio.app.features.plugins.pluginVerify
|
||||
|
||||
internal class CryptoBridge : HostModule {
|
||||
override fun register(runtime: QuickJs) {
|
||||
// --- Binary-Safe Bridges (New) ---
|
||||
|
||||
runtime.function("__crypto_get_random_values") { args ->
|
||||
val length = (args.getOrNull(0) as? Number)?.toInt() ?: 0
|
||||
runCatching {
|
||||
pluginGetRandomValues(length)
|
||||
}.getOrElse { ByteArray(0) }
|
||||
}
|
||||
|
||||
runtime.function("__crypto_digest_raw") { args ->
|
||||
val algorithm = args.getOrNull(0)?.toString() ?: "SHA256"
|
||||
val data = args.getOrNull(1) as? ByteArray ?: ByteArray(0)
|
||||
runCatching {
|
||||
pluginDigest(algorithm, data)
|
||||
}.getOrElse { ByteArray(0) }
|
||||
}
|
||||
|
||||
runtime.function("__crypto_pbkdf2_raw") { args ->
|
||||
val password = args.getOrNull(0) as? ByteArray ?: ByteArray(0)
|
||||
val salt = args.getOrNull(1) as? ByteArray ?: ByteArray(0)
|
||||
val iterations = (args.getOrNull(2) as? Number)?.toInt() ?: 1000
|
||||
val keySizeBits = (args.getOrNull(3) as? Number)?.toInt() ?: 256
|
||||
val algorithm = args.getOrNull(4)?.toString() ?: "SHA256"
|
||||
runCatching {
|
||||
pluginPbkdf2(password, salt, iterations, keySizeBits, algorithm)
|
||||
}.getOrElse { ByteArray(0) }
|
||||
}
|
||||
|
||||
runtime.function("__crypto_aes_encrypt_raw") { args ->
|
||||
val mode = args.getOrNull(0)?.toString() ?: "AES-CBC"
|
||||
val key = args.getOrNull(1) as? ByteArray ?: ByteArray(0)
|
||||
val iv = args.getOrNull(2) as? ByteArray ?: ByteArray(0)
|
||||
val data = args.getOrNull(3) as? ByteArray ?: ByteArray(0)
|
||||
runCatching {
|
||||
pluginAesEncrypt(mode, key, iv, data)
|
||||
}.getOrElse { ByteArray(0) }
|
||||
}
|
||||
|
||||
runtime.function("__crypto_aes_decrypt_raw") { args ->
|
||||
val mode = args.getOrNull(0)?.toString() ?: "AES-CBC"
|
||||
val key = args.getOrNull(1) as? ByteArray ?: ByteArray(0)
|
||||
val iv = args.getOrNull(2) as? ByteArray ?: ByteArray(0)
|
||||
val data = args.getOrNull(3) as? ByteArray ?: ByteArray(0)
|
||||
runCatching {
|
||||
pluginAesDecrypt(mode, key, iv, data)
|
||||
}.getOrElse { ByteArray(0) }
|
||||
}
|
||||
|
||||
runtime.function("__crypto_sign_raw") { args ->
|
||||
val algorithm = args.getOrNull(0)?.toString() ?: ""
|
||||
val privateKey = args.getOrNull(1) as? ByteArray ?: ByteArray(0)
|
||||
val data = args.getOrNull(2) as? ByteArray ?: ByteArray(0)
|
||||
runCatching {
|
||||
pluginSign(algorithm, privateKey, data)
|
||||
}.getOrElse { ByteArray(0) }
|
||||
}
|
||||
|
||||
runtime.function("__crypto_verify_raw") { args ->
|
||||
val algorithm = args.getOrNull(0)?.toString() ?: ""
|
||||
val publicKey = args.getOrNull(1) as? ByteArray ?: ByteArray(0)
|
||||
val signature = args.getOrNull(2) as? ByteArray ?: ByteArray(0)
|
||||
val data = args.getOrNull(3) as? ByteArray ?: ByteArray(0)
|
||||
runCatching {
|
||||
pluginVerify(algorithm, publicKey, signature, data)
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
|
||||
// --- Legacy Hex/String Bridges (Backward Compatibility) ---
|
||||
|
||||
runtime.function("__crypto_digest_hex") { args ->
|
||||
val algorithm = args.getOrNull(0)?.toString() ?: "SHA256"
|
||||
val data = args.getOrNull(1)?.toString() ?: ""
|
||||
runCatching {
|
||||
pluginDigestHex(algorithm, data)
|
||||
}.getOrDefault("")
|
||||
}
|
||||
|
||||
runtime.function("__crypto_hmac_hex") { args ->
|
||||
val algorithm = args.getOrNull(0)?.toString() ?: "SHA256"
|
||||
val key = args.getOrNull(1)?.toString() ?: ""
|
||||
val data = args.getOrNull(2)?.toString() ?: ""
|
||||
runCatching {
|
||||
pluginHmacHex(algorithm, key, data)
|
||||
}.getOrDefault("")
|
||||
}
|
||||
|
||||
runtime.function("__crypto_base64_encode") { args ->
|
||||
val data = args.getOrNull(0)?.toString() ?: ""
|
||||
runCatching {
|
||||
pluginBase64Encode(data)
|
||||
}.getOrDefault("")
|
||||
}
|
||||
|
||||
runtime.function("__crypto_base64_decode") { args ->
|
||||
val data = args.getOrNull(0)?.toString() ?: ""
|
||||
runCatching {
|
||||
pluginBase64Decode(data)
|
||||
}.getOrDefault("")
|
||||
}
|
||||
|
||||
runtime.function("__crypto_utf8_to_hex") { args ->
|
||||
val data = args.getOrNull(0)?.toString() ?: ""
|
||||
runCatching {
|
||||
pluginUtf8ToHex(data)
|
||||
}.getOrDefault("")
|
||||
}
|
||||
|
||||
runtime.function("__crypto_hex_to_utf8") { args ->
|
||||
val data = args.getOrNull(0)?.toString() ?: ""
|
||||
runCatching {
|
||||
pluginHexToUtf8(data)
|
||||
}.getOrDefault("")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
package com.nuvio.app.features.plugins.runtime.dom
|
||||
|
||||
import com.dokar.quickjs.QuickJs
|
||||
import com.dokar.quickjs.binding.function
|
||||
import com.fleeksoft.ksoup.Ksoup
|
||||
import com.fleeksoft.ksoup.nodes.Document
|
||||
import com.fleeksoft.ksoup.nodes.Element
|
||||
import com.fleeksoft.ksoup.select.Elements
|
||||
import com.nuvio.app.features.plugins.runtime.host.HostModule
|
||||
import kotlin.random.Random
|
||||
|
||||
internal class DomBridge : HostModule {
|
||||
private val documentCache = mutableMapOf<String, Document>()
|
||||
private val elementCache = mutableMapOf<String, Element>()
|
||||
private var idCounter = 0
|
||||
private val containsRegex = Regex(""":contains\([\"']([^\"']+)[\"']\)""")
|
||||
|
||||
override fun register(runtime: QuickJs) {
|
||||
runtime.function("__cheerio_load") { args ->
|
||||
val html = args.getOrNull(0)?.toString() ?: ""
|
||||
val docId = "doc_${idCounter++}_${Random.nextInt(0, Int.MAX_VALUE)}"
|
||||
documentCache[docId] = Ksoup.parse(html)
|
||||
docId
|
||||
}
|
||||
|
||||
runtime.function("__cheerio_select") { args ->
|
||||
val docId = args.getOrNull(0)?.toString() ?: ""
|
||||
var selector = args.getOrNull(1)?.toString() ?: ""
|
||||
val doc = documentCache[docId] ?: return@function "[]"
|
||||
try {
|
||||
selector = selector.replace(containsRegex, ":contains($1)")
|
||||
val elements = if (selector.isEmpty()) Elements() else doc.select(selector)
|
||||
val ids = elements.mapIndexed { index, el ->
|
||||
val id = "$docId:$index:${el.hashCode()}"
|
||||
elementCache[id] = el
|
||||
id
|
||||
}
|
||||
"[" + ids.joinToString(",") { "\"${it.replace("\"", "\\\"")}\"" } + "]"
|
||||
} catch (_: Exception) {
|
||||
"[]"
|
||||
}
|
||||
}
|
||||
|
||||
runtime.function("__cheerio_find") { args ->
|
||||
val docId = args.getOrNull(0)?.toString() ?: ""
|
||||
val elementId = args.getOrNull(1)?.toString() ?: ""
|
||||
var selector = args.getOrNull(2)?.toString() ?: ""
|
||||
val element = elementCache[elementId] ?: return@function "[]"
|
||||
try {
|
||||
selector = selector.replace(containsRegex, ":contains($1)")
|
||||
val elements = element.select(selector)
|
||||
val ids = elements.mapIndexed { index, el ->
|
||||
val id = "$docId:find:$index:${el.hashCode()}"
|
||||
elementCache[id] = el
|
||||
id
|
||||
}
|
||||
"[" + ids.joinToString(",") { "\"${it.replace("\"", "\\\"")}\"" } + "]"
|
||||
} catch (_: Exception) {
|
||||
"[]"
|
||||
}
|
||||
}
|
||||
|
||||
runtime.function("__cheerio_text") { args ->
|
||||
val elementIds = args.getOrNull(1)?.toString() ?: ""
|
||||
elementIds.split(",")
|
||||
.filter { it.isNotEmpty() }
|
||||
.mapNotNull { elementCache[it]?.text() }
|
||||
.joinToString(" ")
|
||||
}
|
||||
|
||||
runtime.function("__cheerio_html") { args ->
|
||||
val docId = args.getOrNull(0)?.toString() ?: ""
|
||||
val elementId = args.getOrNull(1)?.toString() ?: ""
|
||||
if (elementId.isEmpty()) {
|
||||
documentCache[docId]?.html() ?: ""
|
||||
} else {
|
||||
elementCache[elementId]?.html() ?: ""
|
||||
}
|
||||
}
|
||||
|
||||
runtime.function("__cheerio_inner_html") { args ->
|
||||
val elementId = args.getOrNull(1)?.toString() ?: ""
|
||||
elementCache[elementId]?.html() ?: ""
|
||||
}
|
||||
|
||||
runtime.function("__cheerio_attr") { args ->
|
||||
val elementId = args.getOrNull(1)?.toString() ?: ""
|
||||
val attrName = args.getOrNull(2)?.toString() ?: ""
|
||||
val value = elementCache[elementId]?.attr(attrName)
|
||||
if (value.isNullOrEmpty()) "__UNDEFINED__" else value
|
||||
}
|
||||
|
||||
runtime.function("__cheerio_next") { args ->
|
||||
val docId = args.getOrNull(0)?.toString() ?: ""
|
||||
val elementId = args.getOrNull(1)?.toString() ?: ""
|
||||
val element = elementCache[elementId] ?: return@function "__NONE__"
|
||||
val next = element.nextElementSibling() ?: return@function "__NONE__"
|
||||
val nextId = "$docId:next:${next.hashCode()}"
|
||||
elementCache[nextId] = next
|
||||
nextId
|
||||
}
|
||||
|
||||
runtime.function("__cheerio_prev") { args ->
|
||||
val docId = args.getOrNull(0)?.toString() ?: ""
|
||||
val elementId = args.getOrNull(1)?.toString() ?: ""
|
||||
val element = elementCache[elementId] ?: return@function "__NONE__"
|
||||
val prev = element.previousElementSibling() ?: return@function "__NONE__"
|
||||
val prevId = "$docId:prev:${prev.hashCode()}"
|
||||
elementCache[prevId] = prev
|
||||
prevId
|
||||
}
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
documentCache.clear()
|
||||
elementCache.clear()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
package com.nuvio.app.features.plugins.runtime.host
|
||||
|
||||
import com.dokar.quickjs.QuickJs
|
||||
|
||||
internal interface HostModule {
|
||||
fun register(runtime: QuickJs)
|
||||
}
|
||||
|
||||
internal class HostApiRegistry {
|
||||
private val modules = mutableListOf<HostModule>()
|
||||
|
||||
fun addModule(module: HostModule) {
|
||||
modules.add(module)
|
||||
}
|
||||
|
||||
fun registerAll(runtime: QuickJs) {
|
||||
modules.forEach { it.register(runtime) }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
package com.nuvio.app.features.plugins.runtime.host
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import com.dokar.quickjs.QuickJs
|
||||
import com.dokar.quickjs.binding.define
|
||||
import com.dokar.quickjs.binding.function
|
||||
|
||||
internal class HostFunctions(
|
||||
private val scraperId: String,
|
||||
private val onResult: (String) -> Unit
|
||||
) : HostModule {
|
||||
private val log = Logger.withTag("PluginRuntime")
|
||||
|
||||
override fun register(runtime: QuickJs) {
|
||||
runtime.define("console") {
|
||||
function("log") { args ->
|
||||
log.d { "Plugin:$scraperId ${args.joinToString(" ") { it?.toString() ?: "null" }}" }
|
||||
null
|
||||
}
|
||||
function("error") { args ->
|
||||
log.e { "Plugin:$scraperId ${args.joinToString(" ") { it?.toString() ?: "null" }}" }
|
||||
null
|
||||
}
|
||||
function("warn") { args ->
|
||||
log.w { "Plugin:$scraperId ${args.joinToString(" ") { it?.toString() ?: "null" }}" }
|
||||
null
|
||||
}
|
||||
function("info") { args ->
|
||||
log.i { "Plugin:$scraperId ${args.joinToString(" ") { it?.toString() ?: "null" }}" }
|
||||
null
|
||||
}
|
||||
function("debug") { args ->
|
||||
log.d { "Plugin:$scraperId ${args.joinToString(" ") { it?.toString() ?: "null" }}" }
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
runtime.function("__capture_result") { args ->
|
||||
onResult(args.getOrNull(0)?.toString() ?: "[]")
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,595 @@
|
|||
package com.nuvio.app.features.plugins.runtime.js
|
||||
|
||||
internal object JsBindings {
|
||||
fun buildPolyfillCode(scraperId: String, settingsJson: String): String {
|
||||
return """
|
||||
globalThis.SCRAPER_ID = "$scraperId";
|
||||
globalThis.SCRAPER_SETTINGS = $settingsJson;
|
||||
if (typeof globalThis.global === 'undefined') globalThis.global = globalThis;
|
||||
if (typeof globalThis.window === 'undefined') globalThis.window = globalThis;
|
||||
if (typeof globalThis.self === 'undefined') globalThis.self = globalThis;
|
||||
|
||||
${fetchPolyfill()}
|
||||
${abortControllerPolyfill()}
|
||||
${base64Polyfill()}
|
||||
${urlPolyfill()}
|
||||
${cryptoPolyfill()}
|
||||
${cheerioPolyfill()}
|
||||
${requirePolyfill()}
|
||||
${arrayPolyfill()}
|
||||
${objectPolyfill()}
|
||||
${stringPolyfill()}
|
||||
""".trimIndent()
|
||||
}
|
||||
|
||||
private fun fetchPolyfill() = """
|
||||
var fetch = async function(url, options) {
|
||||
options = options || {};
|
||||
var method = (options.method || 'GET').toUpperCase();
|
||||
var headers = options.headers || {};
|
||||
var body = options.body || '';
|
||||
var followRedirects = options.redirect !== 'manual';
|
||||
var result = __native_fetch(url, method, JSON.stringify(headers), body, followRedirects);
|
||||
var parsed = JSON.parse(result);
|
||||
return {
|
||||
ok: parsed.ok,
|
||||
status: parsed.status,
|
||||
statusText: parsed.statusText,
|
||||
url: parsed.url,
|
||||
headers: {
|
||||
get: function(name) {
|
||||
return parsed.headers[name.toLowerCase()] || null;
|
||||
}
|
||||
},
|
||||
text: function() { return Promise.resolve(parsed.body); },
|
||||
json: function() {
|
||||
try {
|
||||
if (parsed.body === null || parsed.body === undefined || parsed.body === '') {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
return Promise.resolve(JSON.parse(parsed.body));
|
||||
} catch (e) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
""".trimIndent()
|
||||
|
||||
private fun abortControllerPolyfill() = """
|
||||
if (typeof AbortSignal === 'undefined') {
|
||||
var AbortSignal = function() { this.aborted = false; this.reason = undefined; this._listeners = []; };
|
||||
AbortSignal.prototype.addEventListener = function(type, listener) {
|
||||
if (type !== 'abort' || typeof listener !== 'function') return;
|
||||
this._listeners.push(listener);
|
||||
};
|
||||
AbortSignal.prototype.removeEventListener = function(type, listener) {
|
||||
if (type !== 'abort') return;
|
||||
this._listeners = this._listeners.filter(function(l) { return l !== listener; });
|
||||
};
|
||||
AbortSignal.prototype.dispatchEvent = function(event) {
|
||||
if (!event || event.type !== 'abort') return true;
|
||||
for (var i = 0; i < this._listeners.length; i++) {
|
||||
try { this._listeners[i].call(this, event); } catch (e) {}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
globalThis.AbortSignal = AbortSignal;
|
||||
}
|
||||
|
||||
if (typeof AbortController === 'undefined') {
|
||||
var AbortController = function() { this.signal = new AbortSignal(); };
|
||||
AbortController.prototype.abort = function(reason) {
|
||||
if (this.signal.aborted) return;
|
||||
this.signal.aborted = true;
|
||||
this.signal.reason = reason;
|
||||
this.signal.dispatchEvent({ type: 'abort' });
|
||||
};
|
||||
globalThis.AbortController = AbortController;
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
private fun base64Polyfill() = """
|
||||
if (typeof atob === 'undefined') {
|
||||
globalThis.atob = function(input) {
|
||||
var chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=';
|
||||
var str = String(input).replace(/=+$/, '');
|
||||
if (str.length % 4 === 1) throw new Error('InvalidCharacterError');
|
||||
var output = '';
|
||||
var bc = 0, bs, buffer, idx = 0;
|
||||
while ((buffer = str.charAt(idx++))) {
|
||||
buffer = chars.indexOf(buffer);
|
||||
if (buffer === -1) continue;
|
||||
bs = bc % 4 ? bs * 64 + buffer : buffer;
|
||||
if (bc++ % 4) output += String.fromCharCode(255 & (bs >> ((-2 * bc) & 6)));
|
||||
}
|
||||
return output;
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof btoa === 'undefined') {
|
||||
globalThis.btoa = function(input) {
|
||||
var chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=';
|
||||
var str = String(input);
|
||||
var output = '';
|
||||
for (var block, charCode, idx = 0, map = chars;
|
||||
str.charAt(idx | 0) || (map = '=', idx % 1);
|
||||
output += map.charAt(63 & (block >> (8 - (idx % 1) * 8)))) {
|
||||
charCode = str.charCodeAt(idx += 3 / 4);
|
||||
if (charCode > 0xFF) throw new Error('InvalidCharacterError');
|
||||
block = (block << 8) | charCode;
|
||||
}
|
||||
return output;
|
||||
};
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
private fun urlPolyfill() = """
|
||||
var __native_parse_url = typeof __parse_url !== 'undefined' ? __parse_url : function(u) { return JSON.stringify({ protocol: '', host: '', hostname: '', port: '', pathname: '/', search: '', hash: '' }); };
|
||||
var URL = function(urlString, base) {
|
||||
var fullUrl = urlString;
|
||||
if (base && !/^https?:\/\//i.test(urlString)) {
|
||||
var b = typeof base === 'string' ? base : base.href;
|
||||
if (urlString.charAt(0) === '/') {
|
||||
var m = b.match(/^(https?:\/\/[^\/]+)/);
|
||||
fullUrl = m ? m[1] + urlString : urlString;
|
||||
} else {
|
||||
fullUrl = b.replace(/\/[^\/]*$/, '/') + urlString;
|
||||
}
|
||||
}
|
||||
var parsed = __native_parse_url(fullUrl);
|
||||
var data = JSON.parse(parsed);
|
||||
this.href = fullUrl;
|
||||
this.protocol = data.protocol;
|
||||
this.host = data.host;
|
||||
this.hostname = data.hostname;
|
||||
this.port = data.port;
|
||||
this.pathname = data.pathname;
|
||||
this.search = data.search;
|
||||
this.hash = data.hash;
|
||||
this.origin = data.protocol + '//' + data.host;
|
||||
this.searchParams = new URLSearchParams(data.search || '');
|
||||
};
|
||||
URL.prototype.toString = function() { return this.href; };
|
||||
|
||||
var URLSearchParams = function(init) {
|
||||
this._params = {};
|
||||
var self = this;
|
||||
if (init && typeof init === 'object' && !Array.isArray(init)) {
|
||||
Object.keys(init).forEach(function(key) { self._params[key] = String(init[key]); });
|
||||
} else if (typeof init === 'string') {
|
||||
init.replace(/^\?/, '').split('&').forEach(function(pair) {
|
||||
var parts = pair.split('=');
|
||||
if (parts[0]) self._params[decodeURIComponent(parts[0])] = decodeURIComponent(parts[1] || '');
|
||||
});
|
||||
}
|
||||
};
|
||||
URLSearchParams.prototype.toString = function() {
|
||||
var self = this;
|
||||
return Object.keys(this._params).map(function(key) {
|
||||
return encodeURIComponent(key) + '=' + encodeURIComponent(self._params[key]);
|
||||
}).join('&');
|
||||
};
|
||||
URLSearchParams.prototype.get = function(key) { return this._params.hasOwnProperty(key) ? this._params[key] : null; };
|
||||
URLSearchParams.prototype.set = function(key, value) { this._params[key] = String(value); };
|
||||
URLSearchParams.prototype.append = function(key, value) { this._params[key] = String(value); };
|
||||
URLSearchParams.prototype.has = function(key) { return this._params.hasOwnProperty(key); };
|
||||
URLSearchParams.prototype.delete = function(key) { delete this._params[key]; };
|
||||
URLSearchParams.prototype.keys = function() { return Object.keys(this._params); };
|
||||
URLSearchParams.prototype.values = function() {
|
||||
var self = this;
|
||||
return Object.keys(this._params).map(function(k) { return self._params[k]; });
|
||||
};
|
||||
URLSearchParams.prototype.entries = function() {
|
||||
var self = this;
|
||||
return Object.keys(this._params).map(function(k) { return [k, self._params[k]]; });
|
||||
};
|
||||
URLSearchParams.prototype.forEach = function(callback) {
|
||||
var self = this;
|
||||
Object.keys(this._params).forEach(function(key) { callback(self._params[key], key, self); });
|
||||
};
|
||||
URLSearchParams.prototype.getAll = function(key) {
|
||||
return this._params.hasOwnProperty(key) ? [this._params[key]] : [];
|
||||
};
|
||||
URLSearchParams.prototype.sort = function() {
|
||||
var sorted = {};
|
||||
var self = this;
|
||||
Object.keys(this._params).sort().forEach(function(k) { sorted[k] = self._params[k]; });
|
||||
this._params = sorted;
|
||||
};
|
||||
""".trimIndent()
|
||||
|
||||
private fun cryptoPolyfill() = """
|
||||
function __hexToWords(hex) {
|
||||
var words = [];
|
||||
for (var i = 0; i < hex.length; i += 8) {
|
||||
var chunk = hex.substring(i, i + 8);
|
||||
while (chunk.length < 8) chunk += '0';
|
||||
words.push(parseInt(chunk, 16) | 0);
|
||||
}
|
||||
return words;
|
||||
}
|
||||
|
||||
function __wordsToHex(words, sigBytes) {
|
||||
var hex = '';
|
||||
for (var i = 0; i < sigBytes; i++) {
|
||||
var word = words[i >>> 2] || 0;
|
||||
var byte = (word >>> (24 - (i % 4) * 8)) & 0xff;
|
||||
var part = byte.toString(16);
|
||||
if (part.length < 2) part = '0' + part;
|
||||
hex += part;
|
||||
}
|
||||
return hex;
|
||||
}
|
||||
|
||||
function __wordArrayToHex(value) {
|
||||
if (!value) return '';
|
||||
if (typeof value.__hex === 'string') return value.__hex.toLowerCase();
|
||||
if (Array.isArray(value.words) && typeof value.sigBytes === 'number') {
|
||||
return __wordsToHex(value.words, value.sigBytes);
|
||||
}
|
||||
return typeof __crypto_utf8_to_hex !== 'undefined' ? __crypto_utf8_to_hex(String(value)) : '';
|
||||
}
|
||||
|
||||
function __buildWordArray(hex, utf8Override) {
|
||||
var normalizedHex = (hex || '').toLowerCase();
|
||||
if (normalizedHex.length % 2 !== 0) normalizedHex = '0' + normalizedHex;
|
||||
var wordArray = {
|
||||
__hex: normalizedHex,
|
||||
__utf8: utf8Override !== undefined ? utf8Override : (typeof __crypto_hex_to_utf8 !== 'undefined' ? __crypto_hex_to_utf8(normalizedHex) : ''),
|
||||
sigBytes: normalizedHex.length / 2,
|
||||
words: __hexToWords(normalizedHex),
|
||||
toString: function(encoder) {
|
||||
if (!encoder || encoder === CryptoJS.enc.Hex) return this.__hex;
|
||||
if (encoder === CryptoJS.enc.Utf8) return this.__utf8;
|
||||
if (encoder === CryptoJS.enc.Base64) return typeof __crypto_base64_encode !== 'undefined' ? __crypto_base64_encode(this.__utf8) : '';
|
||||
return this.__hex;
|
||||
},
|
||||
clamp: function() { return this; },
|
||||
concat: function(other) {
|
||||
var otherHex = __wordArrayToHex(other);
|
||||
this.__hex += otherHex;
|
||||
this.__utf8 = typeof __crypto_hex_to_utf8 !== 'undefined' ? __crypto_hex_to_utf8(this.__hex) : '';
|
||||
this.sigBytes = this.__hex.length / 2;
|
||||
this.words = __hexToWords(this.__hex);
|
||||
return this;
|
||||
}
|
||||
};
|
||||
return wordArray;
|
||||
}
|
||||
|
||||
function __wordArrayFromHex(hex) { return __buildWordArray(hex, undefined); }
|
||||
function __wordArrayFromUtf8(text) {
|
||||
var utf8 = text == null ? '' : String(text);
|
||||
var hex = typeof __crypto_utf8_to_hex !== 'undefined' ? __crypto_utf8_to_hex(utf8) : '';
|
||||
return __buildWordArray(hex, utf8);
|
||||
}
|
||||
function __wordArrayFromBase64(base64) {
|
||||
var utf8 = typeof __crypto_base64_decode !== 'undefined' ? __crypto_base64_decode(base64 || '') : '';
|
||||
return __wordArrayFromUtf8(utf8);
|
||||
}
|
||||
|
||||
function __normalizeWordArrayInput(value) {
|
||||
if (value && typeof value === 'object' && typeof value.__utf8 === 'string') return value.__utf8;
|
||||
if (value && typeof value === 'object' && typeof value.__hex === 'string') return typeof __crypto_hex_to_utf8 !== 'undefined' ? __crypto_hex_to_utf8(value.__hex) : '';
|
||||
if (value && typeof value === 'object' && Array.isArray(value.words) && typeof value.sigBytes === 'number') {
|
||||
return typeof __crypto_hex_to_utf8 !== 'undefined' ? __crypto_hex_to_utf8(__wordsToHex(value.words, value.sigBytes)) : '';
|
||||
}
|
||||
if (value == null) return '';
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function __bufferToUint8(data) {
|
||||
if (data instanceof Uint8Array) return data;
|
||||
if (data instanceof ArrayBuffer) return new Uint8Array(data);
|
||||
if (typeof data === 'string') return new TextEncoder().encode(data);
|
||||
return new Uint8Array(0);
|
||||
}
|
||||
|
||||
var CryptoJS = {
|
||||
enc: {
|
||||
Hex: { stringify: function(wa) { return __wordArrayToHex(wa); }, parse: function(s) { return __wordArrayFromHex(s); } },
|
||||
Utf8: { stringify: function(wa) { return wa.toString(CryptoJS.enc.Utf8); }, parse: function(s) { return __wordArrayFromUtf8(s); } },
|
||||
Base64: { stringify: function(wa) { return wa.toString(CryptoJS.enc.Base64); }, parse: function(s) { return __wordArrayFromBase64(s); } }
|
||||
},
|
||||
lib: { WordArray: { create: function(words, sigBytes) { return __buildWordArray(__wordsToHex(words, sigBytes), undefined); } } },
|
||||
mode: { CBC: 'AES-CBC', GCM: 'AES-GCM', ECB: 'AES-ECB' },
|
||||
pad: { Pkcs7: 'Pkcs7', NoPadding: 'NoPadding' },
|
||||
algo: { SHA256: 'SHA256' },
|
||||
MD5: function(m) { return __wordArrayFromHex(__crypto_digest_hex('MD5', __normalizeWordArrayInput(m))); },
|
||||
SHA1: function(m) { return __wordArrayFromHex(__crypto_digest_hex('SHA1', __normalizeWordArrayInput(m))); },
|
||||
SHA256: function(m) { return __wordArrayFromHex(__crypto_digest_hex('SHA256', __normalizeWordArrayInput(m))); },
|
||||
SHA512: function(m) { return __wordArrayFromHex(__crypto_digest_hex('SHA512', __normalizeWordArrayInput(m))); },
|
||||
PBKDF2: function(pass, salt, options) {
|
||||
var pBytes = __bufferToUint8(__normalizeWordArrayInput(pass));
|
||||
var sBytes = __bufferToUint8(__normalizeWordArrayInput(salt));
|
||||
var iter = options.iterations || 1000;
|
||||
var kSize = options.keySize || (256/32);
|
||||
var algo = options.hasher === CryptoJS.algo.SHA256 ? 'SHA256' : 'SHA1';
|
||||
var resBytes = typeof __crypto_pbkdf2_raw !== 'undefined' ? __crypto_pbkdf2_raw(pBytes, sBytes, iter, kSize * 32, algo) : new Uint8Array(0);
|
||||
return __wordArrayFromHex(__wordsToHex(Array.from(resBytes), resBytes.length));
|
||||
},
|
||||
AES: {
|
||||
encrypt: function(message, key, options) {
|
||||
var data = __bufferToUint8(__normalizeWordArrayInput(message));
|
||||
var kBytes = __bufferToUint8(__wordArrayToHex(key));
|
||||
var ivBytes = __bufferToUint8(__wordArrayToHex(options.iv || ''));
|
||||
var mode = options.mode || 'AES-CBC';
|
||||
var resBytes = typeof __crypto_aes_encrypt_raw !== 'undefined' ? __crypto_aes_encrypt_raw(mode, kBytes, ivBytes, data) : new Uint8Array(0);
|
||||
var wa = __wordArrayFromHex(__wordsToHex(Array.from(resBytes), resBytes.length));
|
||||
return {
|
||||
ciphertext: wa,
|
||||
toString: function() { return wa.toString(CryptoJS.enc.Base64); }
|
||||
};
|
||||
},
|
||||
decrypt: function(cipher, key, options) {
|
||||
var data = typeof cipher === 'string' ? __bufferToUint8(typeof __crypto_base64_decode !== 'undefined' ? __crypto_base64_decode(cipher) : '') : (cipher.ciphertext ? __bufferToUint8(typeof __crypto_base64_decode !== 'undefined' ? __crypto_base64_decode(cipher.ciphertext.toString(CryptoJS.enc.Base64)) : '') : __bufferToUint8(cipher));
|
||||
var kBytes = __bufferToUint8(__wordArrayToHex(key));
|
||||
var ivBytes = __bufferToUint8(__wordArrayToHex(options.iv || ''));
|
||||
var mode = options.mode || 'AES-CBC';
|
||||
var resBytes = typeof __crypto_aes_decrypt_raw !== 'undefined' ? __crypto_aes_decrypt_raw(mode, kBytes, ivBytes, data) : new Uint8Array(0);
|
||||
var plain = new TextDecoder().decode(resBytes);
|
||||
return { toString: function(enc) { return plain; } };
|
||||
}
|
||||
}
|
||||
};
|
||||
globalThis.CryptoJS = CryptoJS;
|
||||
|
||||
globalThis.crypto = {
|
||||
subtle: {
|
||||
digest: async function(algo, data) {
|
||||
var bytes = __bufferToUint8(data);
|
||||
var res = typeof __crypto_digest_raw !== 'undefined' ? __crypto_digest_raw(algo.name || algo, bytes) : new Uint8Array(0);
|
||||
return res.buffer;
|
||||
},
|
||||
importKey: async function(fmt, data, algo, ext, use) { return { _raw: data, _algo: algo }; },
|
||||
deriveBits: async function(params, key, len) {
|
||||
var pBytes = __bufferToUint8(key._raw);
|
||||
var sBytes = __bufferToUint8(params.salt);
|
||||
var res = typeof __crypto_pbkdf2_raw !== 'undefined' ? __crypto_pbkdf2_raw(pBytes, sBytes, params.iterations, len, params.hash) : new Uint8Array(0);
|
||||
return res.buffer;
|
||||
},
|
||||
encrypt: async function(params, key, data) {
|
||||
var kBytes = __bufferToUint8(key._raw);
|
||||
var ivBytes = __bufferToUint8(params.iv || '');
|
||||
var dBytes = __bufferToUint8(data);
|
||||
var res = typeof __crypto_aes_encrypt_raw !== 'undefined' ? __crypto_aes_encrypt_raw(params.name, kBytes, ivBytes, dBytes) : new Uint8Array(0);
|
||||
return res.buffer;
|
||||
},
|
||||
decrypt: async function(params, key, data) {
|
||||
var kBytes = __bufferToUint8(key._raw);
|
||||
var ivBytes = __bufferToUint8(params.iv || '');
|
||||
var dBytes = __bufferToUint8(data);
|
||||
var res = typeof __crypto_aes_decrypt_raw !== 'undefined' ? __crypto_aes_decrypt_raw(params.name, kBytes, ivBytes, dBytes) : new Uint8Array(0);
|
||||
return res.buffer;
|
||||
},
|
||||
sign: async function(algo, key, data) {
|
||||
var algoName = typeof algo === 'string' ? algo : (algo.name || '');
|
||||
var kBytes = __bufferToUint8(key._raw);
|
||||
var dBytes = __bufferToUint8(data);
|
||||
var res = typeof __crypto_sign_raw !== 'undefined' ? __crypto_sign_raw(algoName, kBytes, dBytes) : new Uint8Array(0);
|
||||
return res.buffer;
|
||||
},
|
||||
verify: async function(algo, key, sig, data) {
|
||||
var algoName = typeof algo === 'string' ? algo : (algo.name || '');
|
||||
var kBytes = __bufferToUint8(key._raw);
|
||||
var sBytes = __bufferToUint8(sig);
|
||||
var dBytes = __bufferToUint8(data);
|
||||
return typeof __crypto_verify_raw !== 'undefined' ? __crypto_verify_raw(algoName, kBytes, sBytes, dBytes) : false;
|
||||
}
|
||||
},
|
||||
getRandomValues: function(arr) {
|
||||
|
||||
var bytes = typeof __crypto_get_random_values !== 'undefined' ? __crypto_get_random_values(arr.length) : new Uint8Array(arr.length);
|
||||
for (var i = 0; i < arr.length; i++) arr[i] = bytes[i];
|
||||
return arr;
|
||||
}
|
||||
};
|
||||
|
||||
// WebAssembly placeholder
|
||||
globalThis.WebAssembly = {
|
||||
instantiate: async function(bufferSource, importObject) {
|
||||
console.warn("WebAssembly.instantiate called (placeholder)");
|
||||
return { instance: { exports: {} }, module: {} };
|
||||
}
|
||||
};
|
||||
""".trimIndent()
|
||||
|
||||
private fun cheerioPolyfill() = """
|
||||
var cheerio = {
|
||||
load: function(html) {
|
||||
var docId = __cheerio_load(html);
|
||||
var $ = function(selector, context) {
|
||||
if (selector && selector._elementIds) return selector;
|
||||
if (context && context._elementIds && context._elementIds.length > 0) {
|
||||
var allIds = [];
|
||||
for (var i = 0; i < context._elementIds.length; i++) {
|
||||
var childIdsJson = __cheerio_find(docId, context._elementIds[i], selector);
|
||||
var childIds = JSON.parse(childIdsJson);
|
||||
allIds = allIds.concat(childIds);
|
||||
}
|
||||
return createCheerioWrapperFromIds(docId, allIds);
|
||||
}
|
||||
return createCheerioWrapper(docId, selector);
|
||||
};
|
||||
$.html = function(el) {
|
||||
if (el && el._elementIds && el._elementIds.length > 0) {
|
||||
return __cheerio_html(docId, el._elementIds[0]);
|
||||
}
|
||||
return __cheerio_html(docId, '');
|
||||
};
|
||||
return $;
|
||||
}
|
||||
};
|
||||
|
||||
function createCheerioWrapper(docId, selector) {
|
||||
var elementIds;
|
||||
if (typeof selector === 'string') {
|
||||
var idsJson = __cheerio_select(docId, selector);
|
||||
elementIds = JSON.parse(idsJson);
|
||||
} else {
|
||||
elementIds = [];
|
||||
}
|
||||
return createCheerioWrapperFromIds(docId, elementIds);
|
||||
}
|
||||
|
||||
function createCheerioWrapperFromIds(docId, ids) {
|
||||
var wrapper = {
|
||||
_docId: docId,
|
||||
_elementIds: ids,
|
||||
length: ids.length,
|
||||
each: function(callback) {
|
||||
for (var i = 0; i < ids.length; i++) {
|
||||
var elWrapper = createCheerioWrapperFromIds(docId, [ids[i]]);
|
||||
callback.call(elWrapper, i, elWrapper);
|
||||
}
|
||||
return wrapper;
|
||||
},
|
||||
find: function(sel) {
|
||||
var allIds = [];
|
||||
for (var i = 0; i < ids.length; i++) {
|
||||
var childIdsJson = __cheerio_find(docId, ids[i], sel);
|
||||
var childIds = JSON.parse(childIdsJson);
|
||||
allIds = allIds.concat(childIds);
|
||||
}
|
||||
return createCheerioWrapperFromIds(docId, allIds);
|
||||
},
|
||||
text: function() {
|
||||
if (ids.length === 0) return '';
|
||||
return __cheerio_text(docId, ids.join(','));
|
||||
},
|
||||
html: function() {
|
||||
if (ids.length === 0) return '';
|
||||
return __cheerio_inner_html(docId, ids[0]);
|
||||
},
|
||||
attr: function(name) {
|
||||
if (ids.length === 0) return undefined;
|
||||
var val = __cheerio_attr(docId, ids[0], name);
|
||||
return val === '__UNDEFINED__' ? undefined : val;
|
||||
},
|
||||
first: function() { return createCheerioWrapperFromIds(docId, ids.length > 0 ? [ids[0]] : []); },
|
||||
last: function() { return createCheerioWrapperFromIds(docId, ids.length > 0 ? [ids[ids.length - 1]] : []); },
|
||||
next: function() {
|
||||
var nextIds = [];
|
||||
for (var i = 0; i < ids.length; i++) {
|
||||
var nextId = __cheerio_next(docId, ids[i]);
|
||||
if (nextId && nextId !== '__NONE__') nextIds.push(nextId);
|
||||
}
|
||||
return createCheerioWrapperFromIds(docId, nextIds);
|
||||
},
|
||||
prev: function() {
|
||||
var prevIds = [];
|
||||
for (var i = 0; i < ids.length; i++) {
|
||||
var prevId = __cheerio_prev(docId, ids[i]);
|
||||
if (prevId && prevId !== '__NONE__') prevIds.push(prevId);
|
||||
}
|
||||
return createCheerioWrapperFromIds(docId, prevIds);
|
||||
},
|
||||
eq: function(index) {
|
||||
if (index >= 0 && index < ids.length) return createCheerioWrapperFromIds(docId, [ids[index]]);
|
||||
return createCheerioWrapperFromIds(docId, []);
|
||||
},
|
||||
get: function(index) {
|
||||
if (typeof index === 'number') {
|
||||
if (index >= 0 && index < ids.length) return createCheerioWrapperFromIds(docId, [ids[index]]);
|
||||
return undefined;
|
||||
}
|
||||
return ids.map(function(id) { return createCheerioWrapperFromIds(docId, [id]); });
|
||||
},
|
||||
map: function(callback) {
|
||||
var results = [];
|
||||
for (var i = 0; i < ids.length; i++) {
|
||||
var elWrapper = createCheerioWrapperFromIds(docId, [ids[i]]);
|
||||
var result = callback.call(elWrapper, i, elWrapper);
|
||||
if (result !== undefined && result !== null) results.push(result);
|
||||
}
|
||||
return {
|
||||
length: results.length,
|
||||
get: function(index) { return typeof index === 'number' ? results[index] : results; },
|
||||
toArray: function() { return results; }
|
||||
};
|
||||
},
|
||||
filter: function(selectorOrCallback) {
|
||||
if (typeof selectorOrCallback === 'function') {
|
||||
var filteredIds = [];
|
||||
for (var i = 0; i < ids.length; i++) {
|
||||
var elWrapper = createCheerioWrapperFromIds(docId, [ids[i]]);
|
||||
var result = selectorOrCallback.call(elWrapper, i, elWrapper);
|
||||
if (result) filteredIds.push(ids[i]);
|
||||
}
|
||||
return createCheerioWrapperFromIds(docId, filteredIds);
|
||||
}
|
||||
return wrapper;
|
||||
},
|
||||
children: function(sel) { return this.find(sel || '*'); },
|
||||
parent: function() { return createCheerioWrapperFromIds(docId, []); },
|
||||
toArray: function() { return ids.map(function(id) { return createCheerioWrapperFromIds(docId, [id]); }); }
|
||||
};
|
||||
return wrapper;
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
private fun requirePolyfill() = """
|
||||
var require = function(moduleName) {
|
||||
if (moduleName === 'cheerio' || moduleName === 'cheerio-without-node-native' || moduleName === 'react-native-cheerio') {
|
||||
return cheerio;
|
||||
}
|
||||
if (moduleName === 'crypto-js') {
|
||||
return CryptoJS;
|
||||
}
|
||||
throw new Error("Module '" + moduleName + "' is not available");
|
||||
};
|
||||
""".trimIndent()
|
||||
|
||||
private fun arrayPolyfill() = """
|
||||
if (!Array.prototype.flat) {
|
||||
Array.prototype.flat = function(depth) {
|
||||
depth = depth === undefined ? 1 : Math.floor(depth);
|
||||
if (depth < 1) return Array.prototype.slice.call(this);
|
||||
return (function flatten(arr, d) {
|
||||
return d > 0
|
||||
? arr.reduce(function(acc, val) { return acc.concat(Array.isArray(val) ? flatten(val, d - 1) : val); }, [])
|
||||
: arr.slice();
|
||||
})(this, depth);
|
||||
};
|
||||
}
|
||||
|
||||
if (!Array.prototype.flatMap) {
|
||||
Array.prototype.flatMap = function(callback, thisArg) { return this.map(callback, thisArg).flat(); };
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
private fun objectPolyfill() = """
|
||||
if (!Object.entries) {
|
||||
Object.entries = function(obj) {
|
||||
var result = [];
|
||||
for (var key in obj) {
|
||||
if (obj.hasOwnProperty(key)) result.push([key, obj[key]]);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
}
|
||||
|
||||
if (!Object.fromEntries) {
|
||||
Object.fromEntries = function(entries) {
|
||||
var result = {};
|
||||
for (var i = 0; i < entries.length; i++) {
|
||||
result[entries[i][0]] = entries[i][1];
|
||||
}
|
||||
return result;
|
||||
};
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
private fun stringPolyfill() = """
|
||||
if (!String.prototype.replaceAll) {
|
||||
String.prototype.replaceAll = function(search, replace) {
|
||||
if (search instanceof RegExp) {
|
||||
if (!search.global) throw new TypeError('replaceAll must be called with a global RegExp');
|
||||
return this.replace(search, replace);
|
||||
}
|
||||
return this.split(search).join(replace);
|
||||
};
|
||||
}
|
||||
""".trimIndent()
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
package com.nuvio.app.features.plugins.runtime.js
|
||||
|
||||
import com.dokar.quickjs.QuickJs
|
||||
import com.dokar.quickjs.quickJs
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
|
||||
internal class JsRuntime(
|
||||
private val dispatcher: CoroutineDispatcher = Dispatchers.Default
|
||||
) {
|
||||
suspend fun <T> use(block: suspend QuickJs.() -> T): T {
|
||||
return quickJs(dispatcher) {
|
||||
block()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
package com.nuvio.app.features.plugins.runtime.network
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import com.dokar.quickjs.QuickJs
|
||||
import com.dokar.quickjs.binding.function
|
||||
import com.nuvio.app.features.addons.httpRequestRaw
|
||||
import com.nuvio.app.features.plugins.runtime.host.HostModule
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
|
||||
private const val MAX_FETCH_BODY_CHARS = 256 * 1024
|
||||
private const val MAX_FETCH_HEADER_VALUE_CHARS = 8 * 1024
|
||||
private const val FETCH_TRUNCATION_SUFFIX = "\n...[truncated]"
|
||||
|
||||
internal class FetchBridge : HostModule {
|
||||
private val log = Logger.withTag("PluginRuntime")
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
override fun register(runtime: QuickJs) {
|
||||
runtime.function("__native_fetch") { args ->
|
||||
val url = args.getOrNull(0)?.toString() ?: ""
|
||||
val method = args.getOrNull(1)?.toString() ?: "GET"
|
||||
val headersJson = args.getOrNull(2)?.toString() ?: "{}"
|
||||
val body = args.getOrNull(3)?.toString() ?: ""
|
||||
val followRedirects = args.getOrNull(4) as? Boolean ?: true
|
||||
try {
|
||||
performNativeFetch(url, method, headersJson, body, followRedirects)
|
||||
} catch (t: Throwable) {
|
||||
log.e(t) { "Fetch bridge error for $method $url" }
|
||||
JsonObject(
|
||||
mapOf(
|
||||
"ok" to JsonPrimitive(false),
|
||||
"status" to JsonPrimitive(0),
|
||||
"statusText" to JsonPrimitive(t.message ?: "Fetch failed"),
|
||||
"url" to JsonPrimitive(url),
|
||||
"body" to JsonPrimitive(""),
|
||||
"headers" to JsonObject(emptyMap()),
|
||||
),
|
||||
).toString()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun performNativeFetch(
|
||||
url: String,
|
||||
method: String,
|
||||
headersJson: String,
|
||||
body: String,
|
||||
followRedirects: Boolean,
|
||||
): String {
|
||||
val headers = parseHeaders(headersJson).toMutableMap()
|
||||
if (!headers.containsKey("User-Agent")) {
|
||||
headers["User-Agent"] = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
|
||||
}
|
||||
|
||||
val response = runBlocking {
|
||||
httpRequestRaw(
|
||||
method = method,
|
||||
url = url,
|
||||
headers = headers,
|
||||
body = body,
|
||||
followRedirects = followRedirects,
|
||||
)
|
||||
}
|
||||
|
||||
val responseHeaders = response.headers.mapValues { (_, value) ->
|
||||
truncateString(value, MAX_FETCH_HEADER_VALUE_CHARS)
|
||||
}
|
||||
val result = JsonObject(
|
||||
mapOf(
|
||||
"ok" to JsonPrimitive(response.status in 200..299),
|
||||
"status" to JsonPrimitive(response.status),
|
||||
"statusText" to JsonPrimitive(response.statusText),
|
||||
"url" to JsonPrimitive(response.url),
|
||||
"body" to JsonPrimitive(truncateString(response.body, MAX_FETCH_BODY_CHARS)),
|
||||
"headers" to JsonObject(responseHeaders.mapValues { JsonPrimitive(it.value) }),
|
||||
),
|
||||
)
|
||||
return result.toString()
|
||||
}
|
||||
|
||||
private fun parseHeaders(headersJson: String): Map<String, String> {
|
||||
return runCatching {
|
||||
val obj = json.parseToJsonElement(headersJson) as? JsonObject ?: JsonObject(emptyMap())
|
||||
obj.entries
|
||||
.mapNotNull { (key, value) ->
|
||||
value.jsonPrimitive.contentOrNull?.let { key to it }
|
||||
}
|
||||
.toMap()
|
||||
}.getOrDefault(emptyMap())
|
||||
}
|
||||
|
||||
private fun truncateString(value: String, maxChars: Int): String {
|
||||
if (value.length <= maxChars) return value
|
||||
val end = maxChars - FETCH_TRUNCATION_SUFFIX.length
|
||||
if (end <= 0) return FETCH_TRUNCATION_SUFFIX.take(maxChars)
|
||||
return value.substring(0, end) + FETCH_TRUNCATION_SUFFIX
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
package com.nuvio.app.features.plugins.runtime.wasm
|
||||
|
||||
import com.dokar.quickjs.QuickJs
|
||||
import com.nuvio.app.features.plugins.runtime.host.HostModule
|
||||
|
||||
/**
|
||||
* Lightweight WASM Helpers bridge.
|
||||
* For now, this is a placeholder for running small WASM modules.
|
||||
* In the future, this could integrate a lightweight WASM interpreter like Chasm or wasm-interp.js.
|
||||
*/
|
||||
internal class WasmBridge : HostModule {
|
||||
override fun register(runtime: QuickJs) {
|
||||
// Placeholder for WASM instantiation bridge
|
||||
// runtime.function("__native_wasm_instantiate") { ... }
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,15 @@ package com.nuvio.app.features.plugins
|
|||
|
||||
import kotlinx.cinterop.ExperimentalForeignApi
|
||||
import kotlinx.cinterop.refTo
|
||||
import kotlinx.cinterop.addressOf
|
||||
import kotlinx.cinterop.usePinned
|
||||
import kotlinx.cinterop.reinterpret
|
||||
import kotlinx.cinterop.UByteVar
|
||||
import kotlinx.cinterop.ByteVar
|
||||
import kotlinx.cinterop.alloc
|
||||
import kotlinx.cinterop.memScoped
|
||||
import kotlinx.cinterop.ptr
|
||||
import kotlinx.cinterop.value
|
||||
import kotlin.io.encoding.Base64
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
import com.nuvio.app.features.plugins.cryptointerop.CC_MD5
|
||||
|
|
@ -17,6 +26,226 @@ import com.nuvio.app.features.plugins.cryptointerop.kCCHmacAlgMD5
|
|||
import com.nuvio.app.features.plugins.cryptointerop.kCCHmacAlgSHA1
|
||||
import com.nuvio.app.features.plugins.cryptointerop.kCCHmacAlgSHA256
|
||||
import com.nuvio.app.features.plugins.cryptointerop.kCCHmacAlgSHA512
|
||||
import com.nuvio.app.features.plugins.cryptointerop.CCKeyDerivationPBKDF
|
||||
import com.nuvio.app.features.plugins.cryptointerop.kCCPBKDF2
|
||||
import com.nuvio.app.features.plugins.cryptointerop.kCCPRFHmacAlgSHA1
|
||||
import com.nuvio.app.features.plugins.cryptointerop.kCCPRFHmacAlgSHA256
|
||||
import com.nuvio.app.features.plugins.cryptointerop.CCCrypt
|
||||
import com.nuvio.app.features.plugins.cryptointerop.kCCDecrypt
|
||||
import com.nuvio.app.features.plugins.cryptointerop.kCCAlgorithmAES
|
||||
import com.nuvio.app.features.plugins.cryptointerop.kCCOptionECBMode
|
||||
import com.nuvio.app.features.plugins.cryptointerop.kCCEncrypt
|
||||
import com.nuvio.app.features.plugins.cryptointerop.kCCOptionPKCS7Padding
|
||||
import com.nuvio.app.features.plugins.cryptointerop.kCCSuccess
|
||||
import platform.Security.SecRandomCopyBytes
|
||||
import platform.Security.kSecRandomDefault
|
||||
|
||||
internal fun pluginGetRandomValues(length: Int): ByteArray {
|
||||
val bytes = ByteArray(length)
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
SecRandomCopyBytes(kSecRandomDefault, length.toULong(), bytes.refTo(0))
|
||||
return bytes
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
internal fun pluginDigest(algorithm: String, data: ByteArray): ByteArray {
|
||||
val normalized = algorithm.uppercase()
|
||||
val output = ByteArray(
|
||||
when (normalized) {
|
||||
"MD5" -> CC_MD5_DIGEST_LENGTH.toInt()
|
||||
"SHA1" -> CC_SHA1_DIGEST_LENGTH.toInt()
|
||||
"SHA256" -> CC_SHA256_DIGEST_LENGTH.toInt()
|
||||
"SHA512" -> CC_SHA512_DIGEST_LENGTH.toInt()
|
||||
else -> error("Unsupported digest algorithm: $algorithm")
|
||||
},
|
||||
)
|
||||
|
||||
data.usePinned { pinnedData ->
|
||||
output.usePinned { pinnedOutput ->
|
||||
val dataPtr = if (data.isNotEmpty()) pinnedData.addressOf(0) else null
|
||||
val outputPtr = pinnedOutput.addressOf(0).reinterpret<UByteVar>()
|
||||
|
||||
when (normalized) {
|
||||
"MD5" -> CC_MD5(dataPtr, data.size.toUInt(), outputPtr)
|
||||
"SHA1" -> CC_SHA1(dataPtr, data.size.toUInt(), outputPtr)
|
||||
"SHA256" -> CC_SHA256(dataPtr, data.size.toUInt(), outputPtr)
|
||||
"SHA512" -> CC_SHA512(dataPtr, data.size.toUInt(), outputPtr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
internal fun pluginPbkdf2(
|
||||
password: ByteArray,
|
||||
salt: ByteArray,
|
||||
iterations: Int,
|
||||
keySizeBits: Int,
|
||||
algorithm: String,
|
||||
): ByteArray {
|
||||
val prf = when (algorithm.uppercase()) {
|
||||
"SHA256" -> kCCPRFHmacAlgSHA256
|
||||
"SHA1" -> kCCPRFHmacAlgSHA1
|
||||
else -> kCCPRFHmacAlgSHA256
|
||||
}
|
||||
|
||||
val derivedKeyLen = keySizeBits / 8
|
||||
val derivedKey = ByteArray(derivedKeyLen)
|
||||
|
||||
password.usePinned { pinnedPassword ->
|
||||
salt.usePinned { pinnedSalt ->
|
||||
derivedKey.usePinned { pinnedDerivedKey ->
|
||||
val passwordPtr = if (password.isNotEmpty()) pinnedPassword.addressOf(0).reinterpret<ByteVar>() else null
|
||||
val saltPtr = if (salt.isNotEmpty()) pinnedSalt.addressOf(0).reinterpret<UByteVar>() else null
|
||||
val derivedKeyPtr = pinnedDerivedKey.addressOf(0).reinterpret<UByteVar>()
|
||||
|
||||
val status = CCKeyDerivationPBKDF(
|
||||
algorithm = kCCPBKDF2,
|
||||
password = passwordPtr,
|
||||
passwordLen = password.size.toULong(),
|
||||
salt = saltPtr,
|
||||
saltLen = salt.size.toULong(),
|
||||
prf = prf,
|
||||
rounds = iterations.toUInt(),
|
||||
derivedKey = derivedKeyPtr,
|
||||
derivedKeyLen = derivedKeyLen.toULong()
|
||||
)
|
||||
|
||||
require(status == kCCSuccess) { "PBKDF2 failed with status: $status" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return derivedKey
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
internal fun pluginAesEncrypt(
|
||||
mode: String,
|
||||
key: ByteArray,
|
||||
iv: ByteArray,
|
||||
data: ByteArray,
|
||||
): ByteArray {
|
||||
val isGcm = mode.uppercase().contains("GCM")
|
||||
if (isGcm) {
|
||||
throw UnsupportedOperationException("AES-GCM Encrypt is not yet implemented on iOS")
|
||||
}
|
||||
val isEcb = mode.uppercase().contains("ECB")
|
||||
|
||||
val dataOutAvailable = data.size + 16 // AES block size
|
||||
val dataOut = ByteArray(dataOutAvailable)
|
||||
|
||||
var finalData: ByteArray? = null
|
||||
|
||||
memScoped {
|
||||
val dataOutMoved = alloc<kotlinx.cinterop.size_tVar>()
|
||||
|
||||
val options = if (isEcb) {
|
||||
kCCOptionPKCS7Padding or kCCOptionECBMode
|
||||
} else {
|
||||
kCCOptionPKCS7Padding
|
||||
}
|
||||
|
||||
key.usePinned { pinnedKey ->
|
||||
iv.usePinned { pinnedIv ->
|
||||
data.usePinned { pinnedData ->
|
||||
dataOut.usePinned { pinnedDataOut ->
|
||||
val status = CCCrypt(
|
||||
op = kCCEncrypt,
|
||||
alg = kCCAlgorithmAES,
|
||||
options = options,
|
||||
key = if (key.isNotEmpty()) pinnedKey.addressOf(0) else null,
|
||||
keyLength = key.size.toULong(),
|
||||
iv = if (!isEcb && iv.isNotEmpty()) pinnedIv.addressOf(0) else null,
|
||||
dataIn = if (data.isNotEmpty()) pinnedData.addressOf(0) else null,
|
||||
dataInLength = data.size.toULong(),
|
||||
dataOut = pinnedDataOut.addressOf(0),
|
||||
dataOutAvailable = dataOutAvailable.toULong(),
|
||||
dataOutMoved = dataOutMoved.ptr
|
||||
)
|
||||
|
||||
if (status == kCCSuccess) {
|
||||
finalData = dataOut.copyOf(dataOutMoved.value.toInt())
|
||||
} else {
|
||||
error("CCCrypt Encrypt failed with status: $status")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return finalData ?: ByteArray(0)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
internal fun pluginAesDecrypt(
|
||||
mode: String,
|
||||
key: ByteArray,
|
||||
iv: ByteArray,
|
||||
data: ByteArray,
|
||||
): ByteArray {
|
||||
val isGcm = mode.uppercase().contains("GCM")
|
||||
if (isGcm) {
|
||||
throw UnsupportedOperationException("AES-GCM Decrypt is not yet implemented on iOS")
|
||||
}
|
||||
val isEcb = mode.uppercase().contains("ECB")
|
||||
|
||||
val dataOutAvailable = data.size + 16 // AES block size
|
||||
val dataOut = ByteArray(dataOutAvailable)
|
||||
|
||||
var finalData: ByteArray? = null
|
||||
|
||||
memScoped {
|
||||
val dataOutMoved = alloc<kotlinx.cinterop.size_tVar>()
|
||||
|
||||
val options = if (isEcb) {
|
||||
kCCOptionPKCS7Padding or kCCOptionECBMode
|
||||
} else {
|
||||
kCCOptionPKCS7Padding
|
||||
}
|
||||
|
||||
key.usePinned { pinnedKey ->
|
||||
iv.usePinned { pinnedIv ->
|
||||
data.usePinned { pinnedData ->
|
||||
dataOut.usePinned { pinnedDataOut ->
|
||||
val status = CCCrypt(
|
||||
op = kCCDecrypt,
|
||||
alg = kCCAlgorithmAES,
|
||||
options = options,
|
||||
key = if (key.isNotEmpty()) pinnedKey.addressOf(0) else null,
|
||||
keyLength = key.size.toULong(),
|
||||
iv = if (!isEcb && iv.isNotEmpty()) pinnedIv.addressOf(0) else null,
|
||||
dataIn = if (data.isNotEmpty()) pinnedData.addressOf(0) else null,
|
||||
dataInLength = data.size.toULong(),
|
||||
dataOut = pinnedDataOut.addressOf(0),
|
||||
dataOutAvailable = dataOutAvailable.toULong(),
|
||||
dataOutMoved = dataOutMoved.ptr
|
||||
)
|
||||
|
||||
if (status == kCCSuccess) {
|
||||
finalData = dataOut.copyOf(dataOutMoved.value.toInt())
|
||||
} else {
|
||||
error("CCCrypt failed with status: $status")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return finalData ?: ByteArray(0)
|
||||
}
|
||||
|
||||
internal fun pluginSign(algorithm: String, privateKey: ByteArray, data: ByteArray): ByteArray {
|
||||
throw UnsupportedOperationException("Asymmetric signing is currently implemented natively only on Android")
|
||||
}
|
||||
|
||||
internal fun pluginVerify(algorithm: String, publicKey: ByteArray, signature: ByteArray, data: ByteArray): Boolean {
|
||||
throw UnsupportedOperationException("Asymmetric verification is currently implemented natively only on Android")
|
||||
}
|
||||
|
||||
private fun UByteArray.toHex(): String = joinToString(separator = "") { byte ->
|
||||
byte.toString(16).padStart(2, '0')
|
||||
|
|
@ -36,11 +265,18 @@ internal fun pluginDigestHex(algorithm: String, data: String): String {
|
|||
},
|
||||
)
|
||||
|
||||
when (normalized) {
|
||||
"MD5" -> CC_MD5(input.refTo(0), input.size.toUInt(), output.refTo(0))
|
||||
"SHA1" -> CC_SHA1(input.refTo(0), input.size.toUInt(), output.refTo(0))
|
||||
"SHA256" -> CC_SHA256(input.refTo(0), input.size.toUInt(), output.refTo(0))
|
||||
"SHA512" -> CC_SHA512(input.refTo(0), input.size.toUInt(), output.refTo(0))
|
||||
input.usePinned { pinnedInput ->
|
||||
output.usePinned { pinnedOutput ->
|
||||
val dataPtr = if (input.isNotEmpty()) pinnedInput.addressOf(0) else null
|
||||
val outputPtr = pinnedOutput.addressOf(0)
|
||||
|
||||
when (normalized) {
|
||||
"MD5" -> CC_MD5(dataPtr, input.size.toUInt(), outputPtr)
|
||||
"SHA1" -> CC_SHA1(dataPtr, input.size.toUInt(), outputPtr)
|
||||
"SHA256" -> CC_SHA256(dataPtr, input.size.toUInt(), outputPtr)
|
||||
"SHA512" -> CC_SHA512(dataPtr, input.size.toUInt(), outputPtr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return output.toHex()
|
||||
|
|
@ -61,14 +297,25 @@ internal fun pluginHmacHex(algorithm: String, key: String, data: String): String
|
|||
}
|
||||
|
||||
val output = UByteArray(outputSize)
|
||||
CCHmac(
|
||||
alg,
|
||||
keyBytes.refTo(0),
|
||||
keyBytes.size.toULong(),
|
||||
input.refTo(0),
|
||||
input.size.toULong(),
|
||||
output.refTo(0),
|
||||
)
|
||||
|
||||
keyBytes.usePinned { pinnedKey ->
|
||||
input.usePinned { pinnedInput ->
|
||||
output.usePinned { pinnedOutput ->
|
||||
val keyPtr = if (keyBytes.isNotEmpty()) pinnedKey.addressOf(0) else null
|
||||
val inputPtr = if (input.isNotEmpty()) pinnedInput.addressOf(0) else null
|
||||
val outputPtr = pinnedOutput.addressOf(0)
|
||||
|
||||
CCHmac(
|
||||
alg,
|
||||
keyPtr,
|
||||
keyBytes.size.toULong(),
|
||||
inputPtr,
|
||||
input.size.toULong(),
|
||||
outputPtr,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return output.toHex()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,3 +31,84 @@ void CCHmac(
|
|||
size_t dataLength,
|
||||
void *macOut
|
||||
);
|
||||
|
||||
typedef uint32_t CCPBKDFAlgorithm;
|
||||
enum {
|
||||
kCCPBKDF2 = 2,
|
||||
};
|
||||
|
||||
typedef uint32_t CCPseudoRandomAlgorithm;
|
||||
enum {
|
||||
kCCPRFHmacAlgSHA1 = 1,
|
||||
kCCPRFHmacAlgSHA224 = 2,
|
||||
kCCPRFHmacAlgSHA256 = 3,
|
||||
kCCPRFHmacAlgSHA384 = 4,
|
||||
kCCPRFHmacAlgSHA512 = 5,
|
||||
};
|
||||
|
||||
int CCKeyDerivationPBKDF(
|
||||
CCPBKDFAlgorithm algorithm,
|
||||
const char *password,
|
||||
size_t passwordLen,
|
||||
const uint8_t *salt,
|
||||
size_t saltLen,
|
||||
CCPseudoRandomAlgorithm prf,
|
||||
uint32_t rounds,
|
||||
uint8_t *derivedKey,
|
||||
size_t derivedKeyLen
|
||||
);
|
||||
|
||||
typedef int32_t CCCryptorStatus;
|
||||
enum {
|
||||
kCCSuccess = 0,
|
||||
kCCParamError = -4300,
|
||||
kCCBufferTooSmall = -4301,
|
||||
kCCMemoryFailure = -4302,
|
||||
kCCAlignmentError = -4303,
|
||||
kCCDecodeError = -4304,
|
||||
kCCUnimplemented = -4305,
|
||||
kCCOverflow = -4306,
|
||||
kCCRNGFailure = -4307,
|
||||
kCCUnspecifiedError = -4308,
|
||||
kCCCallSequenceError = -4309,
|
||||
kCCKeySizeError = -4310,
|
||||
kCCInvalidKey = -4311,
|
||||
};
|
||||
|
||||
typedef uint32_t CCOperation;
|
||||
enum {
|
||||
kCCEncrypt = 0,
|
||||
kCCDecrypt = 1,
|
||||
};
|
||||
|
||||
typedef uint32_t CCAlgorithm;
|
||||
enum {
|
||||
kCCAlgorithmAES128 = 0,
|
||||
kCCAlgorithmAES = 0,
|
||||
kCCAlgorithmDES = 1,
|
||||
kCCAlgorithm3DES = 2,
|
||||
kCCAlgorithmCAST = 3,
|
||||
kCCAlgorithmRC4 = 4,
|
||||
kCCAlgorithmRC2 = 5,
|
||||
kCCAlgorithmBlowfish = 6,
|
||||
};
|
||||
|
||||
typedef uint32_t CCOptions;
|
||||
enum {
|
||||
kCCOptionPKCS7Padding = 1,
|
||||
kCCOptionECBMode = 2,
|
||||
};
|
||||
|
||||
CCCryptorStatus CCCrypt(
|
||||
CCOperation op,
|
||||
CCAlgorithm alg,
|
||||
CCOptions options,
|
||||
const void *key,
|
||||
size_t keyLength,
|
||||
const void *iv,
|
||||
const void *dataIn,
|
||||
size_t dataInLength,
|
||||
void *dataOut,
|
||||
size_t dataOutAvailable,
|
||||
size_t *dataOutMoved
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue