diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 6ec0d8638..965af61b8 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -92,10 +92,21 @@ kotlin { } } - listOf( + val iosTargets = listOf( iosArm64(), iosSimulatorArm64() - ).forEach { iosTarget -> + ) + + iosTargets.forEach { iosTarget -> + iosTarget.compilations.getByName("main") { + cinterops { + create("commoncrypto") { + defFile(project.file("src/nativeInterop/cinterop/commoncrypto.def")) + compilerOpts("-I${project.projectDir}/src/nativeInterop/cinterop") + } + } + } + iosTarget.binaries.framework { baseName = "ComposeApp" isStatic = true diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.android.kt index 0f90e8bc5..3bdd5dc6e 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.android.kt @@ -2,21 +2,19 @@ package com.nuvio.app.features.addons import android.content.Context import android.content.SharedPreferences -import io.ktor.client.HttpClient -import io.ktor.client.engine.android.Android -import io.ktor.client.plugins.HttpTimeout -import io.ktor.client.request.accept -import io.ktor.client.request.get -import io.ktor.client.request.header -import io.ktor.client.request.post -import io.ktor.client.request.request -import io.ktor.client.request.setBody -import io.ktor.client.request.url -import io.ktor.client.statement.bodyAsText -import io.ktor.http.ContentType -import io.ktor.http.HttpHeaders -import io.ktor.http.HttpMethod -import io.ktor.http.isSuccess +import com.nuvio.app.core.network.IPv4FirstDns +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.ResponseBody +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import java.net.Proxy +import java.io.ByteArrayOutputStream +import java.io.InputStream +import kotlin.text.Charsets +import java.util.concurrent.TimeUnit actual object AddonStorage { private const val preferencesName = "nuvio_addons" @@ -45,95 +43,156 @@ actual object AddonStorage { } } -private val addonHttpClient = HttpClient(Android) { - install(HttpTimeout) { - requestTimeoutMillis = 10_000 - connectTimeoutMillis = 10_000 - socketTimeoutMillis = 10_000 +private val addonHttpClient = OkHttpClient.Builder() + .dns(IPv4FirstDns()) + .connectTimeout(10, TimeUnit.SECONDS) + .readTimeout(10, TimeUnit.SECONDS) + .writeTimeout(10, TimeUnit.SECONDS) + .followRedirects(true) + .followSslRedirects(true) + .proxy(Proxy.NO_PROXY) + .build() + +private val jsonMediaType = "application/json; charset=utf-8".toMediaType() +private const val maxResponseBodyBytes = 256 * 1024 +private const val truncationSuffix = "\n...[truncated]" + +private fun requestAllowsBody(method: String): Boolean = + when (method.uppercase()) { + "POST", "PUT", "PATCH", "DELETE" -> true + else -> false + } + +private fun Map.withoutAcceptEncoding(): Map = + entries + .filterNot { (key, _) -> key.equals("Accept-Encoding", ignoreCase = true) } + .associate { (key, value) -> key to value } + +private fun Map.getHeaderIgnoreCase(name: String): String? = + entries.firstOrNull { (key, _) -> key.equals(name, ignoreCase = true) }?.value + +private data class LimitedReadResult( + val bytes: ByteArray, + val truncated: Boolean, +) + +private fun readAtMostBytes(stream: InputStream, maxBytes: Int): LimitedReadResult { + val out = ByteArrayOutputStream(minOf(maxBytes, 16 * 1024)) + val buffer = ByteArray(8 * 1024) + var remaining = maxBytes + var truncated = false + + while (remaining > 0) { + val read = stream.read(buffer, 0, minOf(buffer.size, remaining)) + if (read <= 0) break + out.write(buffer, 0, read) + remaining -= read + } + + if (remaining == 0) { + truncated = stream.read() != -1 + } + + return LimitedReadResult(out.toByteArray(), truncated) +} + +private fun readResponseBodyLimited(body: ResponseBody?): String { + if (body == null) return "" + val charset = body.contentType()?.charset(Charsets.UTF_8) ?: Charsets.UTF_8 + val readResult = body.byteStream().use { stream -> + readAtMostBytes(stream, maxResponseBodyBytes) + } + + val decoded = try { + String(readResult.bytes, charset) + } catch (_: Exception) { + String(readResult.bytes, Charsets.UTF_8) + } + + return if (readResult.truncated) { + decoded + truncationSuffix + } else { + decoded + } +} + +private suspend fun executeTextRequest( + method: String, + url: String, + headers: Map = emptyMap(), + body: String = "", +): String = withContext(Dispatchers.IO) { + val normalizedMethod = method.uppercase() + val sanitizedHeaders = headers.withoutAcceptEncoding() + val builder = Request.Builder().url(url) + sanitizedHeaders.forEach { (key, value) -> + builder.header(key, value) + } + + val request = if (requestAllowsBody(normalizedMethod)) { + val contentType = sanitizedHeaders.getHeaderIgnoreCase("Content-Type") + ?: if (normalizedMethod == "POST") "application/x-www-form-urlencoded" else "application/json" + // Preserve exact media type and avoid implicit charset rewriting used in signed APIs like MovieBox. + val requestBody = body.toByteArray(Charsets.UTF_8).toRequestBody(contentType.toMediaType()) + builder.method(normalizedMethod, requestBody) + } else { + builder.method(normalizedMethod, null) + }.build() + + addonHttpClient.newCall(request).execute().use { response -> + val payload = readResponseBodyLimited(response.body) + if (!response.isSuccessful) { + error("Request failed with HTTP ${response.code}") + } + if (payload.isBlank()) { + throw IllegalStateException("Empty response body") + } + payload } - expectSuccess = false } actual suspend fun httpGetText(url: String): String = - addonHttpClient - .get(url) { - accept(ContentType.Application.Json) - } - .let { response -> - val payload = response.bodyAsText() - if (!response.status.isSuccess()) { - error("Request failed with HTTP ${response.status.value}") - } - if (payload.isBlank()) { - throw IllegalStateException("Empty response body") - } - payload - } + executeTextRequest( + method = "GET", + url = url, + headers = mapOf("Accept" to "application/json"), + ) actual suspend fun httpPostJson(url: String, body: String): String = - addonHttpClient - .post(url) { - accept(ContentType.Application.Json) - header(HttpHeaders.ContentType, ContentType.Application.Json.toString()) - setBody(body) - } - .let { response -> - val payload = response.bodyAsText() - if (!response.status.isSuccess()) { - error("Request failed with HTTP ${response.status.value}") - } - if (payload.isBlank()) { - throw IllegalStateException("Empty response body") - } - payload - } + executeTextRequest( + method = "POST", + url = url, + headers = mapOf( + "Accept" to "application/json", + "Content-Type" to "application/json", + ), + body = body, + ) actual suspend fun httpGetTextWithHeaders( url: String, headers: Map, ): String = - addonHttpClient - .get(url) { - accept(ContentType.Application.Json) - headers.forEach { (key, value) -> - header(key, value) - } - } - .let { response -> - val payload = response.bodyAsText() - if (!response.status.isSuccess()) { - error("Request failed with HTTP ${response.status.value}") - } - if (payload.isBlank()) { - throw IllegalStateException("Empty response body") - } - payload - } + executeTextRequest( + method = "GET", + url = url, + headers = mapOf("Accept" to "application/json") + headers, + ) actual suspend fun httpPostJsonWithHeaders( url: String, body: String, headers: Map, ): String = - addonHttpClient - .post(url) { - accept(ContentType.Application.Json) - header(HttpHeaders.ContentType, ContentType.Application.Json.toString()) - headers.forEach { (key, value) -> - header(key, value) - } - setBody(body) - } - .let { response -> - val payload = response.bodyAsText() - if (!response.status.isSuccess()) { - error("Request failed with HTTP ${response.status.value}") - } - if (payload.isBlank()) { - throw IllegalStateException("Empty response body") - } - payload - } + executeTextRequest( + method = "POST", + url = url, + headers = mapOf( + "Accept" to "application/json", + "Content-Type" to "application/json", + ) + headers, + body = body, + ) actual suspend fun httpRequestRaw( method: String, @@ -141,25 +200,34 @@ actual suspend fun httpRequestRaw( headers: Map, body: String, ): RawHttpResponse = - addonHttpClient - .request { - url(url) - this.method = HttpMethod.parse(method.uppercase()) - headers.forEach { (key, value) -> - header(key, value) - } - if (this.method == HttpMethod.Post || this.method == HttpMethod.Put || this.method == HttpMethod.Patch) { - setBody(body) - } + withContext(Dispatchers.IO) { + val normalizedMethod = method.uppercase() + val sanitizedHeaders = headers.withoutAcceptEncoding() + val builder = Request.Builder().url(url) + sanitizedHeaders.forEach { (key, value) -> + builder.header(key, value) } - .let { response -> + + val request = if (requestAllowsBody(normalizedMethod)) { + val contentType = sanitizedHeaders.getHeaderIgnoreCase("Content-Type") + ?: if (normalizedMethod == "POST") "application/x-www-form-urlencoded" else "application/json" + val requestBody = body.toByteArray(Charsets.UTF_8).toRequestBody(contentType.toMediaType()) + builder.method(normalizedMethod, requestBody) + } else { + builder.method(normalizedMethod, null) + }.build() + + addonHttpClient.newCall(request).execute().use { response -> RawHttpResponse( - status = response.status.value, - statusText = response.status.description, - url = response.call.request.url.toString(), - body = response.bodyAsText(), - headers = response.headers.entries().associate { (name, values) -> - name.lowercase() to values.joinToString(",") + status = response.code, + statusText = response.message, + url = response.request.url.toString(), + body = readResponseBodyLimited(response.body), + headers = response.headers.toMultimap().mapValues { (_, values) -> + values.joinToString(",") + }.mapKeys { (name, _) -> + name.lowercase() }, ) } + } diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/plugins/PluginCrypto.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/plugins/PluginCrypto.android.kt new file mode 100644 index 000000000..d2cf117d3 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/plugins/PluginCrypto.android.kt @@ -0,0 +1,29 @@ +package com.nuvio.app.features.plugins + +import java.security.MessageDigest +import javax.crypto.Mac +import javax.crypto.spec.SecretKeySpec + +internal actual fun pluginDigestHex(algorithm: String, data: String): String { + val normalized = algorithm.uppercase() + val digest = MessageDigest.getInstance(normalized).digest(data.encodeToByteArray()) + return digest.joinToString(separator = "") { byte -> + byte.toUByte().toString(16).padStart(2, '0') + } +} + +internal actual fun pluginHmacHex(algorithm: String, key: String, data: String): String { + val normalized = when (algorithm.uppercase()) { + "SHA1" -> "HmacSHA1" + "SHA256" -> "HmacSHA256" + "SHA512" -> "HmacSHA512" + "MD5" -> "HmacMD5" + else -> error("Unsupported HMAC algorithm: $algorithm") + } + val mac = Mac.getInstance(normalized) + mac.init(SecretKeySpec(key.encodeToByteArray(), normalized)) + val digest = mac.doFinal(data.encodeToByteArray()) + return digest.joinToString(separator = "") { byte -> + byte.toUByte().toString(16).padStart(2, '0') + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/plugins/PluginCrypto.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/plugins/PluginCrypto.kt new file mode 100644 index 000000000..71e985be1 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/plugins/PluginCrypto.kt @@ -0,0 +1,39 @@ +package com.nuvio.app.features.plugins + +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi + +internal expect fun pluginDigestHex(algorithm: String, data: String): String + +internal expect fun pluginHmacHex(algorithm: String, key: String, data: String): String + +@OptIn(ExperimentalEncodingApi::class) +internal fun pluginBase64Encode(data: String): String = + Base64.encode(data.encodeToByteArray()) + +@OptIn(ExperimentalEncodingApi::class) +internal fun pluginBase64Decode(data: String): String { + val normalized = data.trim().replace("\n", "").replace("\r", "").replace(" ", "") + val decoded = Base64.decode(normalized) + return decoded.decodeToString() +} + +internal fun pluginUtf8ToHex(value: String): String = + value.encodeToByteArray().joinToString(separator = "") { byte -> + byte.toUByte().toString(16).padStart(2, '0') + } + +internal fun pluginHexToUtf8(hex: String): String { + val normalized = hex.trim().lowercase() + .replace(" ", "") + .removePrefix("0x") + if (normalized.isEmpty()) return "" + + val evenHex = if (normalized.length % 2 == 0) normalized else "0$normalized" + val out = ByteArray(evenHex.length / 2) + for (index in out.indices) { + val part = evenHex.substring(index * 2, index * 2 + 2) + out[index] = part.toInt(16).toByte() + } + return out.decodeToString() +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/plugins/PluginRuntime.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/plugins/PluginRuntime.kt index 97ea1eff6..641d56ba1 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/plugins/PluginRuntime.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/plugins/PluginRuntime.kt @@ -120,6 +120,51 @@ internal object PluginRuntime { } } + function("__crypto_digest_hex") { args -> + val algorithm = args.getOrNull(0)?.toString() ?: "SHA256" + val data = args.getOrNull(1)?.toString() ?: "" + runCatching { + pluginDigestHex(algorithm, data) + }.getOrDefault("") + } + + 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("") + } + + function("__crypto_base64_encode") { args -> + val data = args.getOrNull(0)?.toString() ?: "" + runCatching { + pluginBase64Encode(data) + }.getOrDefault("") + } + + function("__crypto_base64_decode") { args -> + val data = args.getOrNull(0)?.toString() ?: "" + runCatching { + pluginBase64Decode(data) + }.getOrDefault("") + } + + function("__crypto_utf8_to_hex") { args -> + val data = args.getOrNull(0)?.toString() ?: "" + runCatching { + pluginUtf8ToHex(data) + }.getOrDefault("") + } + + function("__crypto_hex_to_utf8") { args -> + val data = args.getOrNull(0)?.toString() ?: "" + runCatching { + pluginHexToUtf8(data) + }.getOrDefault("") + } + function("__parse_url") { args -> parseUrl(args.getOrNull(0)?.toString() ?: "") } @@ -607,6 +652,149 @@ internal object PluginRuntime { this._params = sorted; }; + 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 __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 : __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 __crypto_base64_encode(this.__utf8); + return this.__hex; + }, + clamp: function() { + return this; + }, + concat: function(other) { + var otherHex = __wordArrayToHex(other); + this.__hex += otherHex; + this.__utf8 = __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); + return __buildWordArray(__crypto_utf8_to_hex(utf8), utf8); + } + + function __wordArrayFromBase64(base64) { + return __wordArrayFromUtf8(__crypto_base64_decode(base64 || '')); + } + + 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 __crypto_hex_to_utf8(value.__hex); + } + if (value && typeof value === 'object' && Array.isArray(value.words) && typeof value.sigBytes === 'number') { + return __crypto_hex_to_utf8(__wordsToHex(value.words, value.sigBytes)); + } + if (value == null) return ''; + return String(value); + } + + function __cryptoHashWordArray(algorithm, message) { + var utf8 = __normalizeWordArrayInput(message); + var hex = __crypto_digest_hex(algorithm, utf8); + return __wordArrayFromHex(hex); + } + + function __cryptoHmacWordArray(algorithm, message, key) { + var utf8Message = __normalizeWordArrayInput(message); + var utf8Key = __normalizeWordArrayInput(key); + var hex = __crypto_hmac_hex(algorithm, utf8Key, utf8Message); + return __wordArrayFromHex(hex); + } + + var CryptoJS = { + enc: { + Hex: { + stringify: function(wordArray) { + return __wordArrayToHex(wordArray); + }, + parse: function(hexStr) { + return __wordArrayFromHex(hexStr || ''); + } + }, + Utf8: { + stringify: function(wordArray) { + if (wordArray && typeof wordArray.__utf8 === 'string') return wordArray.__utf8; + if (wordArray && typeof wordArray.__hex === 'string') return __crypto_hex_to_utf8(wordArray.__hex); + return __normalizeWordArrayInput(wordArray); + }, + parse: function(text) { + return __wordArrayFromUtf8(text); + } + }, + Base64: { + stringify: function(wordArray) { + if (wordArray && typeof wordArray.__utf8 === 'string') { + return __crypto_base64_encode(wordArray.__utf8); + } + return __crypto_base64_encode(__normalizeWordArrayInput(wordArray)); + }, + parse: function(base64) { + return __wordArrayFromBase64(base64); + } + } + }, + MD5: function(message) { return __cryptoHashWordArray('MD5', message); }, + SHA1: function(message) { return __cryptoHashWordArray('SHA1', message); }, + SHA256: function(message) { return __cryptoHashWordArray('SHA256', message); }, + SHA512: function(message) { return __cryptoHashWordArray('SHA512', message); }, + HmacMD5: function(message, key) { return __cryptoHmacWordArray('MD5', message, key); }, + HmacSHA1: function(message, key) { return __cryptoHmacWordArray('SHA1', message, key); }, + HmacSHA256: function(message, key) { return __cryptoHmacWordArray('SHA256', message, key); }, + HmacSHA512: function(message, key) { return __cryptoHmacWordArray('SHA512', message, key); } + }; + globalThis.CryptoJS = CryptoJS; + var cheerio = { load: function(html) { var docId = __cheerio_load(html); @@ -743,6 +931,9 @@ internal object PluginRuntime { 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"); }; diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/plugins/PluginCrypto.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/plugins/PluginCrypto.ios.kt new file mode 100644 index 000000000..fa15ec007 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/plugins/PluginCrypto.ios.kt @@ -0,0 +1,72 @@ +package com.nuvio.app.features.plugins + +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.refTo +import com.nuvio.app.features.plugins.cryptointerop.CC_MD5 +import com.nuvio.app.features.plugins.cryptointerop.CC_MD5_DIGEST_LENGTH +import com.nuvio.app.features.plugins.cryptointerop.CC_SHA1 +import com.nuvio.app.features.plugins.cryptointerop.CC_SHA1_DIGEST_LENGTH +import com.nuvio.app.features.plugins.cryptointerop.CC_SHA256 +import com.nuvio.app.features.plugins.cryptointerop.CC_SHA256_DIGEST_LENGTH +import com.nuvio.app.features.plugins.cryptointerop.CC_SHA512 +import com.nuvio.app.features.plugins.cryptointerop.CC_SHA512_DIGEST_LENGTH +import com.nuvio.app.features.plugins.cryptointerop.CCHmac +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 + +private fun UByteArray.toHex(): String = joinToString(separator = "") { byte -> + byte.toString(16).padStart(2, '0') +} + +@OptIn(ExperimentalForeignApi::class) +internal actual fun pluginDigestHex(algorithm: String, data: String): String { + val normalized = algorithm.uppercase() + val input = data.encodeToByteArray() + val output = UByteArray( + 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") + }, + ) + + 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)) + } + + return output.toHex() +} + +@OptIn(ExperimentalForeignApi::class) +internal actual fun pluginHmacHex(algorithm: String, key: String, data: String): String { + val normalized = algorithm.uppercase() + val keyBytes = key.encodeToByteArray() + val input = data.encodeToByteArray() + + val (alg, outputSize) = when (normalized) { + "MD5" -> kCCHmacAlgMD5 to CC_MD5_DIGEST_LENGTH.toInt() + "SHA1" -> kCCHmacAlgSHA1 to CC_SHA1_DIGEST_LENGTH.toInt() + "SHA256" -> kCCHmacAlgSHA256 to CC_SHA256_DIGEST_LENGTH.toInt() + "SHA512" -> kCCHmacAlgSHA512 to CC_SHA512_DIGEST_LENGTH.toInt() + else -> error("Unsupported HMAC algorithm: $algorithm") + } + + val output = UByteArray(outputSize) + CCHmac( + alg, + keyBytes.refTo(0), + keyBytes.size.toULong(), + input.refTo(0), + input.size.toULong(), + output.refTo(0), + ) + + return output.toHex() +} diff --git a/composeApp/src/nativeInterop/cinterop/commoncrypto.def b/composeApp/src/nativeInterop/cinterop/commoncrypto.def new file mode 100644 index 000000000..bd8d596cb --- /dev/null +++ b/composeApp/src/nativeInterop/cinterop/commoncrypto.def @@ -0,0 +1,3 @@ +language = C +headers = commoncrypto_shim.h +package = com.nuvio.app.features.plugins.cryptointerop diff --git a/composeApp/src/nativeInterop/cinterop/commoncrypto_shim.h b/composeApp/src/nativeInterop/cinterop/commoncrypto_shim.h new file mode 100644 index 000000000..cc394555f --- /dev/null +++ b/composeApp/src/nativeInterop/cinterop/commoncrypto_shim.h @@ -0,0 +1,33 @@ +#include +#include + +typedef uint32_t CC_LONG; + +enum { + CC_MD5_DIGEST_LENGTH = 16, + CC_SHA1_DIGEST_LENGTH = 20, + CC_SHA256_DIGEST_LENGTH = 32, + CC_SHA512_DIGEST_LENGTH = 64, +}; + +typedef enum { + kCCHmacAlgSHA1 = 0, + kCCHmacAlgMD5 = 1, + kCCHmacAlgSHA256 = 2, + kCCHmacAlgSHA384 = 3, + kCCHmacAlgSHA512 = 4, +} CCHmacAlgorithm; + +unsigned char *CC_MD5(const void *data, CC_LONG len, unsigned char *md); +unsigned char *CC_SHA1(const void *data, CC_LONG len, unsigned char *md); +unsigned char *CC_SHA256(const void *data, CC_LONG len, unsigned char *md); +unsigned char *CC_SHA512(const void *data, CC_LONG len, unsigned char *md); + +void CCHmac( + CCHmacAlgorithm algorithm, + const void *key, + size_t keyLength, + const void *data, + size_t dataLength, + void *macOut +); diff --git a/gradle.properties b/gradle.properties index 6f8e6ea61..7b9cbeee0 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,6 +1,7 @@ #Kotlin kotlin.code.style=official kotlin.daemon.jvmargs=-Xmx3072M +kotlin.mpp.enableCInteropCommonization=true #Gradle org.gradle.jvmargs=-Xmx4096M -Dfile.encoding=UTF-8