Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion compose/api/compose.klib.api
Original file line number Diff line number Diff line change
Expand Up @@ -322,14 +322,16 @@ final class app.tinyui/HostException : kotlin/Exception { // app.tinyui/HostExce
}

final class app.tinyui/HttpRequest { // app.tinyui/HttpRequest|null[0]
constructor <init>(kotlin/String, kotlin/String, kotlin.collections/Map<kotlin/String, kotlin/String>, kotlin/String?, kotlin/Long?) // app.tinyui/HttpRequest.<init>|<init>(kotlin.String;kotlin.String;kotlin.collections.Map<kotlin.String,kotlin.String>;kotlin.String?;kotlin.Long?){}[0]
constructor <init>(kotlin/String, kotlin/String, kotlin.collections/Map<kotlin/String, kotlin/String>, kotlin/String?, kotlin/Long?, kotlin.collections/Map<kotlin/String, kotlin/String> = ...) // app.tinyui/HttpRequest.<init>|<init>(kotlin.String;kotlin.String;kotlin.collections.Map<kotlin.String,kotlin.String>;kotlin.String?;kotlin.Long?;kotlin.collections.Map<kotlin.String,kotlin.String>){}[0]

final val body // app.tinyui/HttpRequest.body|{}body[0]
final fun <get-body>(): kotlin/String? // app.tinyui/HttpRequest.body.<get-body>|<get-body>(){}[0]
final val headers // app.tinyui/HttpRequest.headers|{}headers[0]
final fun <get-headers>(): kotlin.collections/Map<kotlin/String, kotlin/String> // app.tinyui/HttpRequest.headers.<get-headers>|<get-headers>(){}[0]
final val method // app.tinyui/HttpRequest.method|{}method[0]
final fun <get-method>(): kotlin/String // app.tinyui/HttpRequest.method.<get-method>|<get-method>(){}[0]
final val tags // app.tinyui/HttpRequest.tags|{}tags[0]
final fun <get-tags>(): kotlin.collections/Map<kotlin/String, kotlin/String> // app.tinyui/HttpRequest.tags.<get-tags>|<get-tags>(){}[0]
final val timeoutMs // app.tinyui/HttpRequest.timeoutMs|{}timeoutMs[0]
final fun <get-timeoutMs>(): kotlin/Long? // app.tinyui/HttpRequest.timeoutMs.<get-timeoutMs>|<get-timeoutMs>(){}[0]
final val url // app.tinyui/HttpRequest.url|{}url[0]
Expand Down
20 changes: 20 additions & 0 deletions compose/src/androidHostTest/kotlin/app/tinyui/RsaRoundTripTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package app.tinyui

import java.security.KeyFactory
import java.security.spec.PKCS8EncodedKeySpec
import javax.crypto.Cipher
import kotlin.io.encoding.Base64
import kotlin.test.Test
import kotlin.test.assertEquals

/** The padding is random, so the only check that means anything is decrypting with the private key. */
class RsaRoundTripTest {
@Test
fun thePrivateKeyGetsTheTextBack() {
val text = "Passw0rd!中文"
val cipher = Base64.decode(rsaEncrypt(text, RsaTestKeys.PUBLIC))
val privateKey = KeyFactory.getInstance("RSA").generatePrivate(PKCS8EncodedKeySpec(Base64.decode(RsaTestKeys.PRIVATE_PKCS8)))
val plain = Cipher.getInstance("RSA/ECB/PKCS1Padding").run { init(Cipher.DECRYPT_MODE, privateKey); doFinal(cipher) }
assertEquals(text, plain.decodeToString())
}
}
19 changes: 19 additions & 0 deletions compose/src/androidMain/kotlin/app/tinyui/Crypto.android.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package app.tinyui

import java.security.KeyFactory
import java.security.spec.InvalidKeySpecException
import java.security.spec.X509EncodedKeySpec
import javax.crypto.Cipher

internal actual fun platformRsaEncrypt(plain: ByteArray, key: RsaPublicKey): ByteArray {
// the common parse should have caught it already; a key the platform still refuses is the page's input, not the network
val publicKey = try {
KeyFactory.getInstance("RSA").generatePublic(X509EncodedKeySpec(key.spki))
} catch (e: InvalidKeySpecException) {
throw HostException("E_INVALID", "the platform rejected the public key: ${e.message}")
}
return Cipher.getInstance("RSA/ECB/PKCS1Padding").run {
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
Resolved
init(Cipher.ENCRYPT_MODE, publicKey)
doFinal(plain)
}
}
88 changes: 88 additions & 0 deletions compose/src/commonMain/kotlin/app/tinyui/Crypto.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package app.tinyui

import kotlin.io.encoding.Base64

/**
* An RSA public key read from X.509 SubjectPublicKeyInfo DER (docs/native-api.md §16). Both forms travel on: Android's
* KeyFactory wants [spki], iOS's SecKeyCreateWithData wants the PKCS#1 RSAPublicKey inside it ([pkcs1]).
*/
internal class RsaPublicKey private constructor(val spki: ByteArray, val pkcs1: ByteArray, val modulusBytes: Int) {
companion object {
private const val MIN_BITS = 1024

// 1.2.840.113549.1.1.1
private val RSA_ENCRYPTION = byteArrayOf(0x2A, 0x86.toByte(), 0x48, 0x86.toByte(), 0xF7.toByte(), 0x0D, 0x01, 0x01, 0x01)

fun parse(base64: String): RsaPublicKey {
val der = try { Base64.decode(base64.filterNot { it.isWhitespace() }) } catch (e: IllegalArgumentException) { invalid("the public key is not base64") }
// every level read whole, so what passes here is what both platforms accept and their own errors never show
val outer = Der(der)
val spki = outer.sequence().also { outer.done() }
val algorithm = spki.sequence()
if (!algorithm.next(0x06).contentEquals(RSA_ENCRYPTION)) invalid("the public key is not an RSA key, or not X.509 SubjectPublicKeyInfo")
// RFC 3279: the parameters are NULL, though some encoders leave them out
if (!algorithm.atEnd()) { if (algorithm.next(0x05).isNotEmpty()) invalid("the public key's algorithm parameters are not NULL") }
algorithm.done()
val bits = spki.next(0x03)
spki.done()
if (bits.isEmpty() || bits[0] != 0.toByte()) invalid("the public key's BIT STRING is malformed")
val pkcs1 = bits.copyOfRange(1, bits.size)
val wrapper = Der(pkcs1)
val rsa = wrapper.sequence().also { wrapper.done() }
val modulus = rsa.next(0x02).dropWhile { it == 0.toByte() }
val exponent = rsa.next(0x02).dropWhile { it == 0.toByte() }
rsa.done()
if (exponent.isEmpty()) invalid("the public key's exponent is zero")
// 512 and 768 bits have been factored; 1024 stays for servers that still use it
if (modulus.size * 8 < MIN_BITS) invalid("the public key has ${modulus.size * 8} bits; at least $MIN_BITS are required")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[建议] modulus.size * 8 只是编码占用的整字节位数,不是模数的实际位数。例如首字节为 0x7f 的 1023 位模数在去掉符号用的前导零后仍有 128 字节,会通过这里的 1024 位下限并交给平台。应依据首个非零字节的有效位数计算实际 bit length 后再与 MIN_BITS 比较;modulusBytes 可继续用于计算 PKCS#1 的明文上限。

return RsaPublicKey(der, pkcs1, modulus.size)
}
}
}

/** PKCS#1 v1.5 encryption of [text] as UTF-8; base64 out. Length and key are checked here, so both platforms fail alike. */
internal fun rsaEncrypt(text: String, publicKeyBase64: String): String {
val key = RsaPublicKey.parse(publicKeyBase64)
val plain = text.encodeToByteArray()
// PKCS#1 v1.5 needs 11 bytes of padding inside the modulus
if (plain.size > key.modulusBytes - 11) invalid("the text is ${plain.size} bytes; a ${key.modulusBytes * 8}-bit key takes at most ${key.modulusBytes - 11}")
return Base64.encode(platformRsaEncrypt(plain, key))
}

internal expect fun platformRsaEncrypt(plain: ByteArray, key: RsaPublicKey): ByteArray

private fun invalid(message: String): Nothing = throw HostException("E_INVALID", message)

/** Just enough DER to walk SubjectPublicKeyInfo: definite lengths, one element at a time. */
private class Der(private val bytes: ByteArray, private var at: Int = 0, private val end: Int = bytes.size) {
fun sequence(): Der {
val (start, stop) = element(0x30)
return Der(bytes, start, stop)
}

fun next(tag: Int): ByteArray {
val (start, stop) = element(tag)
return bytes.copyOfRange(start, stop)
}

fun atEnd() = at == end

/** Nothing may follow the last element read. */
fun done() { if (at != end) invalid("the public key has trailing data") }

private fun element(tag: Int): Pair<Int, Int> {
if (at + 2 > end || bytes[at].toInt() and 0xFF != tag) invalid("the public key is not valid DER")
var length = bytes[at + 1].toInt() and 0xFF
var start = at + 2
if (length >= 0x80) {
val count = length and 0x7F
if (count == 0 || count > 3 || start + count > end) invalid("the public key is not valid DER")
length = 0
repeat(count) { length = (length shl 8) or (bytes[start + it].toInt() and 0xFF) }
start += count
}
if (start + length > end) invalid("the public key is not valid DER")
at = start + length
return start to start + length
}
}
12 changes: 10 additions & 2 deletions compose/src/commonMain/kotlin/app/tinyui/HostInterfaces.kt
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,15 @@ internal class InMemoryStore : Store {
}

/** One request of J3 `http.request`; [body] is the text to send, its `Content-Type` among [headers]. */
class HttpRequest(val method: String, val url: String, val headers: Map<String, String>, val body: String?, val timeoutMs: Long?)
/** [tags]: the page's notes for this channel (`captchaScene`), never sent; the built-in channels ignore them (docs/native-api.md §6). */
class HttpRequest(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[建议] 移除 @JvmOverloads 后,JVM 上不再生成原有的五参数构造函数;已有 Java 宿主若调用 new HttpRequest(method, url, headers, body, timeoutMs) 会直接编译失败。tags 已有默认值,保留 @JvmOverloads 即可继续提供该重载,并与 PR 声明的源码兼容保持一致。

val method: String,
val url: String,
val headers: Map<String, String>,
val body: String?,
val timeoutMs: Long?,
val tags: Map<String, String> = emptyMap(),
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
Resolved
)

class HttpResponse(val status: Int, val headers: Map<String, String>, val body: String)

Expand Down Expand Up @@ -114,7 +122,7 @@ internal val FrameworkNames = setOf(
"device.info", "store.get", "store.set", "store.subscribe", "storage.get", "storage.set", "storage.remove", "storage.clear",
"i18n.t", "i18n.locale", "i18n.timeZone", "i18n.zoneOffset", "i18n.subscribe", "session.get", "session.subscribe", "session.signIn",
"navigation.push", "navigation.pop", "events.emit", "events.subscribe", "http.request", "timer.schedule", "timer.cancel",
"ui.toast", "ui.alert", "ui.confirm", "linking.openUrl", "clipboard.setText", "analytics.track",
"ui.toast", "ui.alert", "ui.confirm", "linking.openUrl", "clipboard.setText", "analytics.track", "crypto.rsaEncrypt",
)

/** What every host offers, listed in the mount manifest ahead of the host's own; `session.signIn` only comes with a session. */
Expand Down
12 changes: 12 additions & 0 deletions compose/src/commonMain/kotlin/app/tinyui/PageHost.kt
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,13 @@ class PageHost internal constructor(
)
if (name == "ui.confirm") confirmed.toString() else ""
}
"crypto.rsaEncrypt" -> settle(cbId) {
// strings only: a number would be encrypted as its decimal text, which is not what the page holds
val text = (a["text"] as? JsonPrimitive)?.takeIf { it.isString }?.content ?: throw HostException("E_INVALID", "crypto.rsaEncrypt needs text as a string")
val key = (a["publicKey"] as? JsonPrimitive)?.takeIf { it.isString }?.content ?: throw HostException("E_INVALID", "crypto.rsaEncrypt needs a publicKey string")
// off the engine's lane: RSA is cheap but not free on a slow phone
JsonPrimitive(withContext(Dispatchers.Default) { rsaEncrypt(text, key) }).toString()
}
"clipboard.setText" -> settle(cbId) {
context.copy(a.str("text") ?: throw HostException("E_INVALID", "clipboard.setText needs text"))
""
Expand Down Expand Up @@ -393,6 +400,11 @@ class PageHost internal constructor(
headers = headers,
body = body,
timeoutMs = a["timeout"]?.jsonPrimitive?.longOrNull,
tags = when (val t = a["tags"]) {
null, JsonNull -> emptyMap()
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
Resolved
is JsonObject -> t.mapValues { (k, v) -> (v as? JsonPrimitive)?.takeIf { it.isString }?.content ?: throw HostException("E_INVALID", "http tag \"$k\" must be a string") }
else -> throw HostException("E_INVALID", "http tags must be an object of strings")
},
)
val response = request.timeoutMs?.let { ms ->
withTimeoutOrNull(ms) { channel.request(request) } ?: throw HostException("E_TIMEOUT", "${request.method} ${request.url} took longer than $ms ms")
Expand Down
Loading
Loading