diff --git a/compose/api/compose.klib.api b/compose/api/compose.klib.api index df66b19..73cd7ba 100644 --- a/compose/api/compose.klib.api +++ b/compose/api/compose.klib.api @@ -322,7 +322,7 @@ final class app.tinyui/HostException : kotlin/Exception { // app.tinyui/HostExce } final class app.tinyui/HttpRequest { // app.tinyui/HttpRequest|null[0] - constructor (kotlin/String, kotlin/String, kotlin.collections/Map, kotlin/String?, kotlin/Long?) // app.tinyui/HttpRequest.|(kotlin.String;kotlin.String;kotlin.collections.Map;kotlin.String?;kotlin.Long?){}[0] + constructor (kotlin/String, kotlin/String, kotlin.collections/Map, kotlin/String?, kotlin/Long?, kotlin.collections/Map = ...) // app.tinyui/HttpRequest.|(kotlin.String;kotlin.String;kotlin.collections.Map;kotlin.String?;kotlin.Long?;kotlin.collections.Map){}[0] final val body // app.tinyui/HttpRequest.body|{}body[0] final fun (): kotlin/String? // app.tinyui/HttpRequest.body.|(){}[0] @@ -330,6 +330,8 @@ final class app.tinyui/HttpRequest { // app.tinyui/HttpRequest|null[0] final fun (): kotlin.collections/Map // app.tinyui/HttpRequest.headers.|(){}[0] final val method // app.tinyui/HttpRequest.method|{}method[0] final fun (): kotlin/String // app.tinyui/HttpRequest.method.|(){}[0] + final val tags // app.tinyui/HttpRequest.tags|{}tags[0] + final fun (): kotlin.collections/Map // app.tinyui/HttpRequest.tags.|(){}[0] final val timeoutMs // app.tinyui/HttpRequest.timeoutMs|{}timeoutMs[0] final fun (): kotlin/Long? // app.tinyui/HttpRequest.timeoutMs.|(){}[0] final val url // app.tinyui/HttpRequest.url|{}url[0] diff --git a/compose/src/androidHostTest/kotlin/app/tinyui/RsaRoundTripTest.kt b/compose/src/androidHostTest/kotlin/app/tinyui/RsaRoundTripTest.kt new file mode 100644 index 0000000..f698aed --- /dev/null +++ b/compose/src/androidHostTest/kotlin/app/tinyui/RsaRoundTripTest.kt @@ -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()) + } +} diff --git a/compose/src/androidMain/kotlin/app/tinyui/Crypto.android.kt b/compose/src/androidMain/kotlin/app/tinyui/Crypto.android.kt new file mode 100644 index 0000000..e4454a0 --- /dev/null +++ b/compose/src/androidMain/kotlin/app/tinyui/Crypto.android.kt @@ -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 { + init(Cipher.ENCRYPT_MODE, publicKey) + doFinal(plain) + } +} diff --git a/compose/src/commonMain/kotlin/app/tinyui/Crypto.kt b/compose/src/commonMain/kotlin/app/tinyui/Crypto.kt new file mode 100644 index 0000000..709983a --- /dev/null +++ b/compose/src/commonMain/kotlin/app/tinyui/Crypto.kt @@ -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") + 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 { + 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 + } +} diff --git a/compose/src/commonMain/kotlin/app/tinyui/HostInterfaces.kt b/compose/src/commonMain/kotlin/app/tinyui/HostInterfaces.kt index de02316..d6eaa98 100644 --- a/compose/src/commonMain/kotlin/app/tinyui/HostInterfaces.kt +++ b/compose/src/commonMain/kotlin/app/tinyui/HostInterfaces.kt @@ -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, 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( + val method: String, + val url: String, + val headers: Map, + val body: String?, + val timeoutMs: Long?, + val tags: Map = emptyMap(), +) class HttpResponse(val status: Int, val headers: Map, val body: String) @@ -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. */ diff --git a/compose/src/commonMain/kotlin/app/tinyui/PageHost.kt b/compose/src/commonMain/kotlin/app/tinyui/PageHost.kt index b33a8fa..4856e44 100644 --- a/compose/src/commonMain/kotlin/app/tinyui/PageHost.kt +++ b/compose/src/commonMain/kotlin/app/tinyui/PageHost.kt @@ -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")) "" @@ -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() + 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") diff --git a/compose/src/commonMain/kotlin/app/tinyui/generated/RuntimeBytecode.kt b/compose/src/commonMain/kotlin/app/tinyui/generated/RuntimeBytecode.kt index b1a00ea..664d713 100644 --- a/compose/src/commonMain/kotlin/app/tinyui/generated/RuntimeBytecode.kt +++ b/compose/src/commonMain/kotlin/app/tinyui/generated/RuntimeBytecode.kt @@ -8,6 +8,6 @@ internal object RuntimeBytecode { ) val native: Array = arrayOf( - "UUpLQjQAAAABAAAAMDRiZTI0NjAwMTU5OWY1OTk1ZmEyZjJkOGM5MWEwZjE5OGQzZjM0YwXYARp0aW55dWktbmF0aXZlFnRpbnl1aS1jb3JlEkhvc3RFcnJvcg5WRVJTSU9OEmFuYWx5dGljcwxjYWNoZWQSY2xpcGJvYXJkDGV2ZW50cwhob3N0CGh0dHAIaTE4bg5saW5raW5nFG5hdmlnYXRpb24Oc2Vzc2lvbg5zdG9yYWdlCnN0b3JlBHVpEGludGVybmFsDHNpZ25hbBJvbkNsZWFudXASaW50ZXJuYWwyEmludGVybmFsMxJpbnRlcm5hbDQSaW50ZXJuYWw1EmludGVybmFsNg5zaWduYWwyFEhvc3RFcnJvcjISaW50ZXJuYWw3DnNpZ25hbDMSaW50ZXJuYWw4DnNpZ25hbDQSaW50ZXJuYWw5FGludGVybmFsMTAUaW50ZXJuYWwxMRRIb3N0RXJyb3IzFGludGVybmFsMTIUSG9zdEVycm9yNBJfX2RlZlByb3AQX19leHBvcnQkbmF2aWdhdGlvbl9leHBvcnRzCHB1c2gGcG9wEG9uUmVzdWx0GnN0b3JlX2V4cG9ydHMKd2F0Y2gcZXZlbnRzX2V4cG9ydHMIZW1pdARvbhhodHRwX2V4cG9ydHMOY2hhbm5lbBBmYWxsYmFjawxjbGllbnQOcmVxdWVzdAhnZXQyCHBvc3QGcHV0BmRlbBhob3N0X2V4cG9ydHMIY2FsbB5zdG9yYWdlX2V4cG9ydHMIZ2V0MwhzZXQyDHJlbW92ZQpjbGVhchhpMThuX2V4cG9ydHMOY3VycmVudBRzZXRDdXJyZW50CHpvbmUOc2V0Wm9uZRRzdWJzY3JpYmVkDGZvbGxvdwxsb2NhbGUCdBB0aW1lWm9uZQpUT0tFTgZwYWQUZm9ybWF0RGF0ZR5zZXNzaW9uX2V4cG9ydHMQY3VycmVudDIWc2V0Q3VycmVudDIWc3Vic2NyaWJlZDIKc3RhdGUMc2lnbkluFHVpX2V4cG9ydHMKdG9hc3QKYWxlcnQOY29uZmlybR5saW5raW5nX2V4cG9ydHMOb3BlblVybCJjbGlwYm9hcmRfZXhwb3J0cw5zZXRUZXh0ImFuYWx5dGljc19leHBvcnRzCnRyYWNrEGxvZ2dlZEluDHVzZXJJZAwwLjEzLjAGYWxsCHBhZ2UMcGFyYW1zCHNlbmQebmF2aWdhdGlvbi5wdXNoDHJlc3VsdBxuYXZpZ2F0aW9uLnBvcARmbgxvbkVtaXQibmF2aWdhdGlvbi5yZXN1bHQCcAJyBmtleQJ2CnF1ZXJ5EnN0b3JlLmdldBJzdG9yZS5zZXQSc3RyaW5naWZ5EHNldFZhbHVlHnN0b3JlLnN1YnNjcmliZQxzdG9yZToKdG9waWMOcGF5bG9hZBZldmVudHMuZW1pdCBldmVudHMuc3Vic2NyaWJlEHJlcXVlc3QyDG1ldGhvZAZ1cmwIYm9keQ5vcHRpb25zGGh0dHAucmVxdWVzdAZHRVQIUE9TVAZQVVQMREVMRVRFCGFyZ3MWc3RvcmFnZS5nZXQIY29kZRZzdG9yYWdlLnNldBpzdG9yYWdlLnNldCgiFiIpIGZhaWxlZDogHHN0b3JhZ2UucmVtb3ZlGnN0b3JhZ2UuY2xlYXIOZmV0Y2hlcghkYXRhDnNldERhdGEObG9hZGluZxRzZXRMb2FkaW5nCmVycm9yEHNldEVycm9yEGRpc3Bvc2VkDGxhdGVzdAZydW4OcmVmZXRjaAhydW4yEGN1cnJlbnQzDnBlbmRpbmcCZRZpMThuLmxvY2FsZRppMThuLnRpbWVab25lHGkxOG4uc3Vic2NyaWJlDGxpc3RlbghyZXN0CHRleHQMaTE4bi50DnJlcGxhY2UCbQJuCndpZHRoEHBhZFN0YXJ0DmVwb2NoTXMOcGF0dGVybg5pbnZhbGlkAmQQaXNGaW5pdGUKaXNOYU4OZ2V0VGltZR5pMThuLnpvbmVPZmZzZXQEbXMSRV9JTlZBTElEImkxOG4uZm9ybWF0RGF0ZTogOCBpcyBub3QgYSB0aW1lIERhdGUgY2FuIGhvbGQMcXVvdGVkAicUcmVwbGFjZUFsbAQnJwh5eXl5HGdldFVUQ0Z1bGxZZWFyBE1NFmdldFVUQ01vbnRoAk0EZGQUZ2V0VVRDRGF0ZQRISBZnZXRVVENIb3VycwJIBG1tGmdldFVUQ01pbnV0ZXMEc3MaZ2V0VVRDU2Vjb25kcwZTU1MkZ2V0VVRDTWlsbGlzZWNvbmRzJGkxOG4uZm9ybWF0RGF0ZTogIgwiIGluICI0IiBpcyBub3QgYSBzdXBwb3J0ZWQgZmllbGQWc2Vzc2lvbi5nZXQi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AAIAlgVLQgHbKOYDBaQCCwIMAAxDBgGWBQEAAQUAAQAXAdwFAQAApgQQGgBkAAA+LQEAAATCAQAAC89JbgEAACUCAOYDCacCAAMSERRXIQAMAgYBAAAAAAEAAQACAJoFTUIB2yjmAwWuAgsCDAAMQwYBmgUBAAEFAAEAFwGkBgEAAKgEERoAZAAAPi0BAAAEwwEAAAvPSZIBAAAlAgDmAwmxAgADEhEUVyEADAIGAQAAAAABAAEAAgCeBU9CAdso5gMFuAIJAgwADEEGAZ4FAgQBDAADAKYBBnQBAACIBwEAAHT/////DwCgiAcBAKCKBwAAsMAFAwCwygJWAwCqBBIaAKwEExoAXgEAXgAAz8fQEfDoBA4L2MheAwBeAgA4AAA+xgEAANAkAQB76l17fgAOyX4ADsqCXwMA8elNXwMAlQRMAAAAquhBXwMAlQRKAAAAqug1XwMAlQRLAAAAqugpZAEAEQSiAQAABMcBAAA+YAAAAM8EyAEAAF8CAATJAQAAJAQAIQIAMH4A6KEOgmQCAD5WAQAABMoBAAALz0k6AAAA0EnEAQAAJAIAKeYDLLsCAHYuEQwbEgcBUzkRBBEmEQQvLhEELy4RBDCBAWFYIBY5WREnKAcRFHUKAA==", + "UUpLQjQAAAABAAAAMDRiZTI0NjAwMTU5OWY1OTk1ZmEyZjJkOGM5MWEwZjE5OGQzZjM0YwXeARp0aW55dWktbmF0aXZlFnRpbnl1aS1jb3JlEkhvc3RFcnJvcg5WRVJTSU9OEmFuYWx5dGljcwxjYWNoZWQSY2xpcGJvYXJkDGNyeXB0bwxldmVudHMIaG9zdAhodHRwCGkxOG4ObGlua2luZxRuYXZpZ2F0aW9uDnNlc3Npb24Oc3RvcmFnZQpzdG9yZQR1aRBpbnRlcm5hbAxzaWduYWwSb25DbGVhbnVwEmludGVybmFsMhJpbnRlcm5hbDMSaW50ZXJuYWw0EmludGVybmFsNRJpbnRlcm5hbDYOc2lnbmFsMhRIb3N0RXJyb3IyEmludGVybmFsNw5zaWduYWwzEmludGVybmFsOA5zaWduYWw0EmludGVybmFsORRpbnRlcm5hbDEwFGludGVybmFsMTEUaW50ZXJuYWwxMhRIb3N0RXJyb3IzFGludGVybmFsMTMUSG9zdEVycm9yNBJfX2RlZlByb3AQX19leHBvcnQkbmF2aWdhdGlvbl9leHBvcnRzCHB1c2gGcG9wEG9uUmVzdWx0GnN0b3JlX2V4cG9ydHMKd2F0Y2gcZXZlbnRzX2V4cG9ydHMIZW1pdARvbhhodHRwX2V4cG9ydHMOY2hhbm5lbBBmYWxsYmFjawxjbGllbnQOcmVxdWVzdAhnZXQyCHBvc3QGcHV0BmRlbBhob3N0X2V4cG9ydHMIY2FsbB5zdG9yYWdlX2V4cG9ydHMIZ2V0MwhzZXQyDHJlbW92ZQpjbGVhchhpMThuX2V4cG9ydHMOY3VycmVudBRzZXRDdXJyZW50CHpvbmUOc2V0Wm9uZRRzdWJzY3JpYmVkDGZvbGxvdwxsb2NhbGUCdBB0aW1lWm9uZQpUT0tFTgZwYWQUZm9ybWF0RGF0ZR5zZXNzaW9uX2V4cG9ydHMQY3VycmVudDIWc2V0Q3VycmVudDIWc3Vic2NyaWJlZDIKc3RhdGUMc2lnbkluFHVpX2V4cG9ydHMKdG9hc3QKYWxlcnQOY29uZmlybR5saW5raW5nX2V4cG9ydHMOb3BlblVybCJjbGlwYm9hcmRfZXhwb3J0cw5zZXRUZXh0HGNyeXB0b19leHBvcnRzFHJzYUVuY3J5cHQiYW5hbHl0aWNzX2V4cG9ydHMKdHJhY2sQbG9nZ2VkSW4MdXNlcklkDDAuMTMuMAZhbGwIcGFnZQxwYXJhbXMIc2VuZB5uYXZpZ2F0aW9uLnB1c2gMcmVzdWx0HG5hdmlnYXRpb24ucG9wBGZuDG9uRW1pdCJuYXZpZ2F0aW9uLnJlc3VsdAJwAnIGa2V5AnYKcXVlcnkSc3RvcmUuZ2V0EnN0b3JlLnNldBJzdHJpbmdpZnkQc2V0VmFsdWUec3RvcmUuc3Vic2NyaWJlDHN0b3JlOgp0b3BpYw5wYXlsb2FkFmV2ZW50cy5lbWl0IGV2ZW50cy5zdWJzY3JpYmUQcmVxdWVzdDIMbWV0aG9kBnVybAhib2R5Dm9wdGlvbnMYaHR0cC5yZXF1ZXN0BkdFVAhQT1NUBlBVVAxERUxFVEUIYXJncxZzdG9yYWdlLmdldAhjb2RlFnN0b3JhZ2Uuc2V0GnN0b3JhZ2Uuc2V0KCIWIikgZmFpbGVkOiAcc3RvcmFnZS5yZW1vdmUac3RvcmFnZS5jbGVhcg5mZXRjaGVyCGRhdGEOc2V0RGF0YQ5sb2FkaW5nFHNldExvYWRpbmcKZXJyb3IQc2V0RXJyb3IQZGlzcG9zZWQMbGF0ZXN0BnJ1bg5yZWZldGNoCHJ1bjIQY3VycmVudDMOcGVuZGluZwJlFmkxOG4ubG9jYWxlGmkxOG4udGltZVpvbmUcaTE4bi5zdWJzY3JpYmUMbGlzdGVuCHJlc3QIdGV4dAxpMThuLnQOcmVwbGFjZQJtAm4Kd2lkdGgQcGFkU3RhcnQOZXBvY2hNcw5wYXR0ZXJuDmludmFsaWQCZBBpc0Zpbml0ZQppc05hTg5nZXRUaW1lHmkxOG4uem9uZU9mZnNldARtcxJFX0lOVkFMSUQiaTE4bi5mb3JtYXREYXRlOiA4IGlzIG5vdCBhIHRpbWUgRGF0ZSBjYW4gaG9sZAxxdW90ZWQCJxRyZXBsYWNlQWxsBCcnCHl5eXkcZ2V0VVRDRnVsbFllYXIETU0WZ2V0VVRDTW9udGgCTQRkZBRnZXRVVENEYXRlBEhIFmdldFVUQ0hvdXJzAkgEbW0aZ2V0VVRDTWludXRlcwRzcxpnZXRVVENTZWNvbmRzBlNTUyRnZXRVVENNaWxsaXNlY29uZHMkaTE4bi5mb3JtYXREYXRlOiAiDCIg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", ) } diff --git a/compose/src/commonTest/kotlin/app/tinyui/CryptoTest.kt b/compose/src/commonTest/kotlin/app/tinyui/CryptoTest.kt new file mode 100644 index 0000000..8ab2d23 --- /dev/null +++ b/compose/src/commonTest/kotlin/app/tinyui/CryptoTest.kt @@ -0,0 +1,55 @@ +package app.tinyui + +import kotlin.io.encoding.Base64 +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotEquals + +class CryptoTest { + // a 512-bit X.509 key: well-formed, too weak + private val SMALL_512 = "MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBAN5UihI8MN9+9ZkgS8SQlYI+EM59bKPwPlygh9x+0pl9zXneileSgZ5SxjxwdOjynS0xTsHvgpB0w1y0Tv3cqDkCAwEAAQ==" + + private fun invalid(block: () -> Unit) = assertEquals("E_INVALID", assertFailsWith { block() }.code) + + @Test + fun readsTheModulusSizeOutOfX509() = assertEquals(256, RsaPublicKey.parse(RsaTestKeys.PUBLIC).modulusBytes) + + @Test + fun takesThePemBodyWithItsLineBreaks() = + assertEquals(256, RsaPublicKey.parse(RsaTestKeys.PUBLIC.chunked(64).joinToString("\n")).modulusBytes) + + @Test + fun rejectsWhatIsNotAnX509RsaKey() { + invalid { RsaPublicKey.parse("not base64!") } + invalid { RsaPublicKey.parse(Base64.encode(byteArrayOf(0x30, 0x03, 0x02, 0x01, 0x01))) } + // the bare PKCS#1 RSAPublicKey: the contract is X.509, say so instead of guessing + invalid { RsaPublicKey.parse(Base64.encode(RsaPublicKey.parse(RsaTestKeys.PUBLIC).pkcs1)) } + } + + @Test + fun encryptsUpToTheKeySizeMinusElevenBytes() { + val cipher = rsaEncrypt("a".repeat(245), RsaTestKeys.PUBLIC) + assertEquals(256, Base64.decode(cipher).size) + invalid { rsaEncrypt("a".repeat(246), RsaTestKeys.PUBLIC) } + // UTF-8 bytes count, not characters: 82 × 3 bytes = 246 + invalid { rsaEncrypt("密".repeat(82), RsaTestKeys.PUBLIC) } + } + + @Test + fun rejectsTrailingDataAndAMissingExponent() { + val spki = Base64.decode(RsaTestKeys.PUBLIC) + invalid { RsaPublicKey.parse(Base64.encode(spki + byteArrayOf(0))) } + // cut the exponent off the end: the inner lengths no longer add up + invalid { RsaPublicKey.parse(Base64.encode(spki.copyOf(spki.size - 5))) } + } + + @Test + fun rejectsKeysTooSmallToProtectAnything() = invalid { RsaPublicKey.parse(SMALL_512) } + + @Test + fun anEmptyTextIsStillEncrypted() = assertEquals(256, Base64.decode(rsaEncrypt("", RsaTestKeys.PUBLIC)).size) + + @Test + fun paddingIsRandom() = assertNotEquals(rsaEncrypt("s3cret", RsaTestKeys.PUBLIC), rsaEncrypt("s3cret", RsaTestKeys.PUBLIC)) +} diff --git a/compose/src/commonTest/kotlin/app/tinyui/PageHostTest.kt b/compose/src/commonTest/kotlin/app/tinyui/PageHostTest.kt index 722b4ad..af59273 100644 --- a/compose/src/commonTest/kotlin/app/tinyui/PageHostTest.kt +++ b/compose/src/commonTest/kotlin/app/tinyui/PageHostTest.kt @@ -61,6 +61,8 @@ class PageHostTest { // 2026-01-01 and 2026-07-01 UTC: either side of daylight saving if (event === "onZone") patches.push(["p",1,"text", [__host_query("i18n.timeZone", "{}"), __host_query("i18n.zoneOffset", JSON.stringify({ ms: 1767225600000 })), __host_query("i18n.zoneOffset", JSON.stringify({ ms: 1782864000000 }))].join("|")]); if (event === "onI18nSubscribe") { __host_send("i18n.subscribe", "{}"); patches.push(["p",1,"text","subscribed"]); } + if (event === "onTagged") { __host_call("http.request", 21, JSON.stringify({ channel: "app", method: "POST", url: "/code", body: {}, tags: { captchaScene: "USER_REGISTER" } })); __host_call("http.request", 22, JSON.stringify({ channel: "app", method: "GET", url: "/code", tags: { n: 1 } })); } + if (event === "onEncrypt") { __host_call("crypto.rsaEncrypt", 23, JSON.stringify({ text: "s3cret", publicKey: payloadJson })); __host_call("crypto.rsaEncrypt", 24, JSON.stringify({ text: "s3cret", publicKey: "bm9wZQ==" })); } if (event === "onSeq") { seq.push(JSON.parse(payloadJson).i); patches.push(["p",1,"text","seq " + seq.length + " " + (seq.every((v, i) => v === i) ? "ordered" : "shuffled")]); } }, emit(topic, json) { emits++; patches.push(["p",1,"text", topic + " " + json + " #" + emits]); }, @@ -82,6 +84,7 @@ class PageHostTest { } }, channels = mapOf("app" to HttpChannel { request -> + if (request.url == "/code") return@HttpChannel HttpResponse(200, emptyMap(), "\"${request.tags["captchaScene"]}\"") if (request.url == "/todos") HttpResponse(200, mapOf("content-type" to "application/json"), """[{"id":1}]""") else HttpResponse(404, emptyMap(), """{"gone":true}""") }), locale = MutableStateFlow("en"), @@ -144,6 +147,33 @@ class PageHostTest { host.close() } + @Test + fun tagsReachTheChannelAndMustBeStrings() = runTest { + val host = host() + host.start() + host.await { host.tree.root.children.isNotEmpty() } + host.dispatch(2, "onTagged", "{}") + host.await { (host.tree.node(1)!!.props["text"] as String).count { it == ';' } == 1 } + val text = host.tree.node(1)!!.props["text"] as String + assertTrue(text.contains("ok 21") && text.contains("USER_REGISTER"), text) + assertTrue(text.contains("rejected 22") && text.contains("E_INVALID"), text) + host.close() + } + + @Test + fun rsaEncryptGoesThroughTheBridge() = runTest { + val host = host() + host.start() + host.await { host.tree.root.children.isNotEmpty() } + host.dispatch(2, "onEncrypt", RsaTestKeys.PUBLIC) + host.await { (host.tree.node(1)!!.props["text"] as String).count { it == ';' } == 1 } + val text = host.tree.node(1)!!.props["text"] as String + // 256 bytes of ciphertext are 344 base64 characters, quoted + assertTrue(Regex("ok 23 \"[A-Za-z0-9+/=]{344}\"").containsMatchIn(text), text) + assertTrue(text.contains("rejected 24") && text.contains("E_INVALID"), text) + host.close() + } + @Test fun timersComeBackThroughResolveAndE1ReportsKeepTheirFields() = runTest { val host = host() diff --git a/compose/src/commonTest/kotlin/app/tinyui/RsaTestKeys.kt b/compose/src/commonTest/kotlin/app/tinyui/RsaTestKeys.kt new file mode 100644 index 0000000..54d7a77 --- /dev/null +++ b/compose/src/commonTest/kotlin/app/tinyui/RsaTestKeys.kt @@ -0,0 +1,11 @@ +package app.tinyui + +/** A throwaway 2048-bit key pair for the round trips; never a real one. */ +internal object RsaTestKeys { + /** X.509 SubjectPublicKeyInfo DER, base64: what pages pass. */ + const val PUBLIC = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAu1h+fpBuEFQRG4A5eKK2zLAc9a2+gKiLeKfbQxYAe8/ty4avmzchM9s5C7wbCGpvPu6ptU4IXNKWv9tjDAZdAW79uYhaLBIXARR4xUBbUTBicFIVCUTEoByaTDuqXnavkg4OoE5iSGqqq0klRMBtloKQ42V70epl89PTwlYEYp6MHGLAwNx2G95SufWoXca6AWZmxjWwIvImwpIN51mMjTXv8JLv5apwf6SitQwAwp4xnibb4Rlt4SpgbKFDBylCkfxuNXJ4TBExYC17/90+j6w8FMYs/IFfBPVS0MyAnV/jd5IcLmPMGBDHLl/ODhvn4lqO1XJ0fVikqR9Rj2skzwIDAQAB" + /** PKCS#8 PrivateKeyInfo DER, base64: what Java's KeyFactory reads. */ + const val PRIVATE_PKCS8 = "MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC7WH5+kG4QVBEbgDl4orbMsBz1rb6AqIt4p9tDFgB7z+3Lhq+bNyEz2zkLvBsIam8+7qm1Tghc0pa/22MMBl0Bbv25iFosEhcBFHjFQFtRMGJwUhUJRMSgHJpMO6pedq+SDg6gTmJIaqqrSSVEwG2WgpDjZXvR6mXz09PCVgRinowcYsDA3HYb3lK59ahdxroBZmbGNbAi8ibCkg3nWYyNNe/wku/lqnB/pKK1DADCnjGeJtvhGW3hKmBsoUMHKUKR/G41cnhMETFgLXv/3T6PrDwUxiz8gV8E9VLQzICdX+N3khwuY8wYEMcuX84OG+fiWo7VcnR9WKSpH1GPayTPAgMBAAECggEADbDfOEtAaoZk5FLv4EED2twulvBQ26t1hdET1PM8AHr8MG4cpzCAMf9IoUg93CpAYaOkrfs7No1X4ZgcGjoyJx4AAhmqJ/j/yrRpnwCfq8SSwmKSkaV/jl6IvXQeyd3NNtSQJ7w9XSLDwNYM7dlM05L3GPxxmrA72HXCeTvlViTfsqM0cXyOFycprdLMpl4opYI1WAIfqd1KfxYcC05lr7/2KagRCiUJSMJxZXBwmnIUknguJeBMmwwvcQkba3T52VYtJtZkg0C63bK3VIMlHHiA/BRj2305zEa6JxV4ppQ9u8qoN45pxGa0lGY2CcJLGim8IShBDg/DT2z/jXMvAQKBgQD7ag7yeBxRCyaTntOG9BIBVBVqUZGHVLWWAUnTEUi8cUvJ8P7RsWkh29QsKZIjBSMk6vkAjO9YeHnRPU/AHQLLpyhHa6h//XdyP8LZYE1+UscknyAzLYWALgB43EeuoA/eJRX1xs1RQRNZBQTDn8hP+sqDBSiryx9MM21cYuLIFQKBgQC+w0biRiGp/54LhoLIQ3926b4pPKbimxXYyc1WIMSk4FEH1g1GSCu+OSNys9vlI/cca8xTvE0D+5rnIyxbC3u0TJvt7qjdR3p9EpOz6WguTGqk5feC7/65RYnjes+GXrC3foI1HEIGqaMmlWS+4wxZYHZWffOSepcQF+NUr86uUwKBgC1FbsWYfTjWsi6YxewiHO/iTSzk3TiqNzx4pOt9zEQqfoQxgiEOOikfpXgCUE9ACJEIQqgnuJ2MrBrSWH72el9jjSw3JZNOd+i5tINiBPI/Q57DjI/KnaR0JB+fQsUeC11r/iVQk/4SosI/1hjHt9E3DqpDDozIua0GAaZGJCH1AoGBALMNg9kbTrnMDkJvLQ5gHKI236DBJzuHqcfkEvIGq8y+KBCc37TaRQS1iQo+u+WmDmoE3LK0Ce31Gf5dy2JhsukXBz0bSDD6yIPhDfZO3NSoMWk52UpzjbnFBZoZSfSrqF9zRTAop+itDVBH5VourwatPiYM7rTMkEIwydZRdkO1AoGBAMjSaHHIUpR/kr1Xt/AE7Ol3ilCLTbbBh48OJfchnusLfpnnBB+nRUgR6C4USvXP2urOExpNi1c+dRbzhIys1f40FkNufB3lBcKAXz8XqiUwa2mM/6RT++C9LYznmEj+BT4XZNVRQz0x43zVJkW/AoW6Qyg2PVYFCv0wXd9FKwnT" + /** PKCS#1 RSAPrivateKey DER, base64: what SecKeyCreateWithData reads. */ + const val PRIVATE_PKCS1 = "MIIEpAIBAAKCAQEAu1h+fpBuEFQRG4A5eKK2zLAc9a2+gKiLeKfbQxYAe8/ty4avmzchM9s5C7wbCGpvPu6ptU4IXNKWv9tjDAZdAW79uYhaLBIXARR4xUBbUTBicFIVCUTEoByaTDuqXnavkg4OoE5iSGqqq0klRMBtloKQ42V70epl89PTwlYEYp6MHGLAwNx2G95SufWoXca6AWZmxjWwIvImwpIN51mMjTXv8JLv5apwf6SitQwAwp4xnibb4Rlt4SpgbKFDBylCkfxuNXJ4TBExYC17/90+j6w8FMYs/IFfBPVS0MyAnV/jd5IcLmPMGBDHLl/ODhvn4lqO1XJ0fVikqR9Rj2skzwIDAQABAoIBAA2w3zhLQGqGZORS7+BBA9rcLpbwUNurdYXRE9TzPAB6/DBuHKcwgDH/SKFIPdwqQGGjpK37OzaNV+GYHBo6MiceAAIZqif4/8q0aZ8An6vEksJikpGlf45eiL10HsndzTbUkCe8PV0iw8DWDO3ZTNOS9xj8cZqwO9h1wnk75VYk37KjNHF8jhcnKa3SzKZeKKWCNVgCH6ndSn8WHAtOZa+/9imoEQolCUjCcWVwcJpyFJJ4LiXgTJsML3EJG2t0+dlWLSbWZINAut2yt1SDJRx4gPwUY9t9OcxGuicVeKaUPbvKqDeOacRmtJRmNgnCSxopvCEoQQ4Pw09s/41zLwECgYEA+2oO8ngcUQsmk57ThvQSAVQValGRh1S1lgFJ0xFIvHFLyfD+0bFpIdvULCmSIwUjJOr5AIzvWHh50T1PwB0Cy6coR2uof/13cj/C2WBNflLHJJ8gMy2FgC4AeNxHrqAP3iUV9cbNUUETWQUEw5/IT/rKgwUoq8sfTDNtXGLiyBUCgYEAvsNG4kYhqf+eC4aCyEN/dum+KTym4psV2MnNViDEpOBRB9YNRkgrvjkjcrPb5SP3HGvMU7xNA/ua5yMsWwt7tEyb7e6o3Ud6fRKTs+loLkxqpOX3gu/+uUWJ43rPhl6wt36CNRxCBqmjJpVkvuMMWWB2Vn3zknqXEBfjVK/OrlMCgYAtRW7FmH041rIumMXsIhzv4k0s5N04qjc8eKTrfcxEKn6EMYIhDjopH6V4AlBPQAiRCEKoJ7idjKwa0lh+9npfY40sNyWTTnfoubSDYgTyP0Oew4yPyp2kdCQfn0LFHgtda/4lUJP+EqLCP9YYx7fRNw6qQw6MyLmtBgGmRiQh9QKBgQCzDYPZG065zA5Cby0OYByiNt+gwSc7h6nH5BLyBqvMvigQnN+02kUEtYkKPrvlpg5qBNyytAnt9Rn+XctiYbLpFwc9G0gw+siD4Q32TtzUqDFpOdlKc425xQWaGUn0q6hfc0UwKKforQ1QR+VaLq8GrT4mDO60zJBCMMnWUXZDtQKBgQDI0mhxyFKUf5K9V7fwBOzpd4pQi022wYePDiX3IZ7rC36Z5wQfp0VIEeguFEr1z9rqzhMaTYtXPnUW84SMrNX+NBZDbnwd5QXCgF8/F6olMGtpjP+kU/vgvS2M55hI/gU+F2TVUUM9MeN81SZFvwKFukMoNj1WBQr9MF3fRSsJ0w==" +} diff --git a/compose/src/iosMain/kotlin/app/tinyui/Crypto.ios.kt b/compose/src/iosMain/kotlin/app/tinyui/Crypto.ios.kt new file mode 100644 index 0000000..46f3977 --- /dev/null +++ b/compose/src/iosMain/kotlin/app/tinyui/Crypto.ios.kt @@ -0,0 +1,60 @@ +package app.tinyui + +import kotlinx.cinterop.BetaInteropApi +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.UByteVar +import kotlinx.cinterop.addressOf +import kotlinx.cinterop.ptr +import kotlinx.cinterop.readBytes +import kotlinx.cinterop.reinterpret +import kotlinx.cinterop.usePinned +import platform.CoreFoundation.CFDataCreate +import platform.CoreFoundation.CFDataGetBytePtr +import platform.CoreFoundation.CFDataGetLength +import platform.CoreFoundation.CFDataRef +import platform.CoreFoundation.CFDictionaryAddValue +import platform.CoreFoundation.CFDictionaryCreateMutable +import platform.CoreFoundation.CFRelease +import platform.CoreFoundation.kCFTypeDictionaryKeyCallBacks +import platform.CoreFoundation.kCFTypeDictionaryValueCallBacks +import platform.Security.SecKeyCreateEncryptedData +import platform.Security.SecKeyCreateWithData +import platform.Security.kSecAttrKeyClass +import platform.Security.kSecAttrKeyClassPublic +import platform.Security.kSecAttrKeyType +import platform.Security.kSecAttrKeyTypeRSA +import platform.Security.kSecKeyAlgorithmRSAEncryptionPKCS1 + +// SecKeyCreateWithData takes RSA public keys as PKCS#1 RSAPublicKey, not the X.509 wrapper around it +@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class) +internal actual fun platformRsaEncrypt(plain: ByteArray, key: RsaPublicKey): ByteArray { + val keyData = cfData(key.pkcs1) + val attributes = CFDictionaryCreateMutable(null, 2, kCFTypeDictionaryKeyCallBacks.ptr, kCFTypeDictionaryValueCallBacks.ptr) + CFDictionaryAddValue(attributes, kSecAttrKeyType, kSecAttrKeyTypeRSA) + CFDictionaryAddValue(attributes, kSecAttrKeyClass, kSecAttrKeyClassPublic) + val plainData = cfData(plain) + try { + val secKey = SecKeyCreateWithData(keyData, attributes, null) ?: throw HostException("E_INVALID", "the platform rejected the public key") + try { + val cipher = SecKeyCreateEncryptedData(secKey, kSecKeyAlgorithmRSAEncryptionPKCS1, plainData, null) + ?: throw HostException("E_INVALID", "RSA encryption failed") + try { + return CFDataGetBytePtr(cipher)!!.reinterpret().readBytes(CFDataGetLength(cipher).toInt()) + } finally { + CFRelease(cipher) + } + } finally { + CFRelease(secKey) + } + } finally { + CFRelease(plainData) + CFRelease(attributes) + CFRelease(keyData) + } +} + +@OptIn(ExperimentalForeignApi::class) +private fun cfData(bytes: ByteArray): CFDataRef = + // an empty array has no element 0 to point at; an empty text is still a valid plaintext + if (bytes.isEmpty()) CFDataCreate(null, null, 0)!! + else bytes.usePinned { CFDataCreate(null, it.addressOf(0).reinterpret(), bytes.size.toLong()) }!! diff --git a/compose/src/iosTest/kotlin/app/tinyui/RsaRoundTripTest.kt b/compose/src/iosTest/kotlin/app/tinyui/RsaRoundTripTest.kt new file mode 100644 index 0000000..9faae97 --- /dev/null +++ b/compose/src/iosTest/kotlin/app/tinyui/RsaRoundTripTest.kt @@ -0,0 +1,44 @@ +package app.tinyui + +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.UByteVar +import kotlinx.cinterop.addressOf +import kotlinx.cinterop.ptr +import kotlinx.cinterop.readBytes +import kotlinx.cinterop.reinterpret +import kotlinx.cinterop.usePinned +import platform.CoreFoundation.CFDataCreate +import platform.CoreFoundation.CFDataGetBytePtr +import platform.CoreFoundation.CFDataGetLength +import platform.CoreFoundation.CFDictionaryAddValue +import platform.CoreFoundation.CFDictionaryCreateMutable +import platform.CoreFoundation.kCFTypeDictionaryKeyCallBacks +import platform.CoreFoundation.kCFTypeDictionaryValueCallBacks +import platform.Security.SecKeyCreateDecryptedData +import platform.Security.SecKeyCreateWithData +import platform.Security.kSecAttrKeyClass +import platform.Security.kSecAttrKeyClassPrivate +import platform.Security.kSecAttrKeyType +import platform.Security.kSecAttrKeyTypeRSA +import platform.Security.kSecKeyAlgorithmRSAEncryptionPKCS1 +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. */ +@OptIn(ExperimentalForeignApi::class) +class RsaRoundTripTest { + @Test + fun thePrivateKeyGetsTheTextBack() { + val text = "Passw0rd!中文" + val cipher = Base64.decode(rsaEncrypt(text, RsaTestKeys.PUBLIC)) + val attributes = CFDictionaryCreateMutable(null, 2, kCFTypeDictionaryKeyCallBacks.ptr, kCFTypeDictionaryValueCallBacks.ptr) + CFDictionaryAddValue(attributes, kSecAttrKeyType, kSecAttrKeyTypeRSA) + CFDictionaryAddValue(attributes, kSecAttrKeyClass, kSecAttrKeyClassPrivate) + val privateKey = SecKeyCreateWithData(data(Base64.decode(RsaTestKeys.PRIVATE_PKCS1)), attributes, null)!! + val plain = SecKeyCreateDecryptedData(privateKey, kSecKeyAlgorithmRSAEncryptionPKCS1, data(cipher), null)!! + assertEquals(text, CFDataGetBytePtr(plain)!!.reinterpret().readBytes(CFDataGetLength(plain).toInt()).decodeToString()) + } + + private fun data(bytes: ByteArray) = bytes.usePinned { CFDataCreate(null, it.addressOf(0).reinterpret(), bytes.size.toLong()) }!! +} diff --git a/docs/native-api.md b/docs/native-api.md index 4e8c8dc..1b51f26 100644 --- a/docs/native-api.md +++ b/docs/native-api.md @@ -1,6 +1,6 @@ # `tinyui-native` 与宿主接口 -- 状态:已定(2026-09-16);2026-09-25 按 [ADR-007](./adr-007-host-boundary.md) 重写:框架自己实现网络、存储、i18n、toast / 对话框、打开链接、埋点门面与会话,宿主只提供标准接口的实现;删 `HostServices`、`config.get`、宿主 `I18n` / `Config` / `HttpClient` +- 状态:已定(2026-09-16);2026-10-28 加 `http` 请求 `tags`(§6)与 `crypto.rsaEncrypt`(§16);2026-09-25 按 [ADR-007](./adr-007-host-boundary.md) 重写:框架自己实现网络、存储、i18n、toast / 对话框、打开链接、埋点门面与会话,宿主只提供标准接口的实现;删 `HostServices`、`config.get`、宿主 `I18n` / `Config` / `HttpClient` - 来源:ADR-002 §3.1(J2 白名单、J3 / J4)、[app-model.md](./app-model.md)(路由、store、会话、事件)、ADR-004 §3.1(连续事件阈值)、ADR-007(职责边界) - 两侧:JS 的 `tinyui-native` 是 `__host_*` 的类型化封装;Kotlin 侧宿主给页面的东西只有 App 级的 `TinyUIHost`(§1),启动时建一次,交给每个 `TinyUIPage` @@ -92,7 +92,7 @@ const api = http.client("app"); // 宿主注册的通道 await api.post(`${BASE}/api/billing/checkout`, { plan }); ``` -J3 `http.request`,参数 `{ channel, method, url, headers?, body?, timeout? }`;`http.get` / `post` / `request` 走 `channel: "default"`。URL 由页面给全,框架不拼 base。 +J3 `http.request`,参数 `{ channel, method, url, headers?, body?, timeout?, tags? }`;`http.get` / `post` / `request` 走 `channel: "default"`。URL 由页面给全,框架不拼 base。 | 通道 | 实现 | 用途 | |---|---|---| @@ -105,7 +105,7 @@ J3 `http.request`,参数 `{ channel, method, url, headers?, body?, timeout? }` fun interface HttpChannel { suspend fun request(request: HttpRequest): HttpResponse } -class HttpRequest(val method: String, val url: String, val headers: Map, val body: String?, val timeoutMs: Long?) +class HttpRequest(val method: String, val url: String, val headers: Map, val body: String?, val timeoutMs: Long?, val tags: Map = emptyMap()) class HttpResponse(val status: Int, val headers: Map, val body: String) KtorChannel(client: io.ktor.client.HttpClient) // 把宿主已有的 client 包成通道 @@ -114,8 +114,24 @@ KtorChannel(client: io.ktor.client.HttpClient) // 把宿主已有的 client - 请求体:对象按 JSON 编码并设 `Content-Type: application/json`,字符串原样发送 - 响应体:`Content-Type` 为 JSON 时解析成对象,否则为文本 - 2xx resolve `{ status, headers, body }`;非 2xx 以 `E_HTTP` reject,`HostError` 带 `status`、`headers`、`body`(解析规则同上) +- `tags`:页面写给宿主通道的备注,**任何通道都不把它发上网**(2026-10-28 加,注册流程触发:页面经通道发的验证码请求要告诉宿主"这个要过极验、场景是哪个")。值只能是字符串,否则 `E_INVALID`;含义由页面与那个宿主的通道约定,框架不定义任何键,`default` 与 `KtorChannel` 忽略它;不进 manifest(不是宿主契约,宿主不认识的键照常发请求)。HTTP 本身没有这个概念,这是客户端库的惯例:OkHttp `Request.tag()`、Retrofit `@Tag`、Ktor `attributes`。`KtorChannel` 暂不把它转成 ktor `attributes`(宿主插件读不到):现有宿主都自写通道,等第一个需要的再加,不破坏接口 - 通道名 `default` 保留,宿主不能注册;页面用了宿主没注册的名字 → `E_UNSUPPORTED`。`http.client` 的参数必须是字符串字面量(build-chain.md §5.1),通道名随 `requires` 进 manifest、发布前对照快照核对 +```ts +await api.post("/api/app/v1/auth/send-code", { phone }, { tags: { captchaScene: "USER_REGISTER" } }); +``` + +```kotlin +class AppChannel(…) : HttpChannel { + override suspend fun request(request: HttpRequest): HttpResponse { + val scene = request.tags["captchaScene"] // 交给宿主自己的极验拦截,标签本身不上网 + … + } +} +``` + +**通道是身份边界**(最佳实践,2026-10-28):建立或撤销身份的响应在宿主通道里消费,页面永远只见会话状态(§9)。登录 / 注册接口返回的 token 由通道写进宿主的会话存储,交给页面的 body 先剥掉凭证字段;之后 `session.state()` 变成已登录,宿主的导航自己切走,页面什么都不用做。临时的"注册中"凭证同理:通道收下、后续请求自动带上,页面看不见。这样包被热下发、被调试器看到时都不含凭证。 + `setTimeout` / `clearTimeout` 是 core 提供的全局,经 J3 `timer.schedule` / J4 `timer.cancel`,宿主侧由 `PageHost` 实现,随页面关闭取消。 ## 7. `storage` @@ -283,3 +299,20 @@ await ui.toast(i18n.t("copied")) // 要不要提示由页面决定 - 没给 `text` → `E_INVALID`;页面不在屏幕上(`TinyUIPage` 不在组合里)→ `E_UNSUPPORTED` - Android 13 起系统会自己弹"已复制"浮层,页面再 toast 会重复;这是平台行为,框架不判断 - 2026-10-12 加(第一个需要的业务页面触发:复制订单号) + +## 16. `crypto` + +```ts +const cipher = await crypto.rsaEncrypt(password, { publicKey }) // base64 进,base64 出 +await api.post("/api/app/v1/auth/register", { email, password: cipher }) +``` + +- J3 `crypto.rsaEncrypt`,参数 `{ text, publicKey }`:RSA 公钥加密,PKCS#1 v1.5 填充,`text` 按 UTF-8 编码;resolve 密文的 base64。框架实现(Android `Cipher("RSA/ECB/PKCS1Padding")`,iOS `SecKeyCreateEncryptedData`),宿主不参与 +- `publicKey` 是 X.509 SubjectPublicKeyInfo 的 DER 的 base64,即 `-----BEGIN PUBLIC KEY-----` 里那段(换行可留);裸 PKCS#1 `RSAPublicKey` 不收。iOS 只认 PKCS#1,框架从 X.509 里取出来再交给系统,两端同一个入参 +- `text` 最多「密钥字节数 − 11」字节(2048 位密钥 245 字节),超长、公钥读不出或不是 RSA → `E_INVALID`;两端在同一处检查,报错一致 +- 填充带随机数:同一输入每次密文都不同,不能拿密文比对是否相等;验证只能用私钥解回来 +- 公钥须是完整合法的 X.509(各层无多余字节、带指数),且不少于 1024 位(512 / 768 位已被分解);不合格一律 `E_INVALID`,不交给平台再报别的码 +- 选 PKCS#1 v1.5 是为了兼容现有后端(PingPong 现网密码加密即此)。它的已知风险在解密端:服务端若让人分辨出「填充错误」与其他错误,就可能被 Bleichenbacher 式选择密文攻击;解密接口要对所有失败回同一个响应。后端支持后改用 OAEP(新增参数,不改默认) +- 为什么是框架能力而不是 `host.call`:加密是通用原生能力,第二个宿主一样要;在 QuickJS 里跑 JS 的 RSA 库既慢,又把加密实现放进了可热下发的包里。公钥由页面经自己的通道取、页内缓存,换钥只改页面 +- 只做 RSA 公钥加密一项;OAEP、`sha256`、`hmac`、对称加密等第一个需要的页面出现时再加,参数对象为它们留了位置 +- 2026-10-28 加(注册流程触发:密码在第一步就转成密文,明文不离开那一页) diff --git a/docs/roadmap.md b/docs/roadmap.md index 0c1c780..c7480c6 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -67,6 +67,9 @@ | 大数据存储(SQLite / 文件 API) | ADR-007 §3.4 | 第一个要缓存大量数据的页面 | | 声明式 `Dialog` | ADR-007 §3.6 | 第一个需要自定义内容对话框的页面。`BottomSheet` 已完成(2026-10-12,第一个需要的业务页面触发,[components.md](./components.md) §3) | | 分享、震动、选图等通用能力(均按"框架默认实现、宿主可接管") | ADR-007 §3.6 | 第一个需要的页面。剪贴板写入已完成(2026-10-12,第一个需要的业务页面触发,[native-api.md](./native-api.md) §15;只写不读,宿主不接管) | +| 加密的其余算法(OAEP、`sha256`、`hmac`、对称加密) | native-api.md §16 | 第一个需要的页面。RSA PKCS#1 v1.5 公钥加密已完成(2026-10-28,注册流程触发:`crypto.rsaEncrypt`) | +| `KtorChannel` 把 http `tags` 转成 ktor `attributes`,宿主的 ktor 插件可读 | native-api.md §6 | 第一个用 `KtorChannel` 又要读 tags 的宿主(现有宿主都自写通道)。`tags` 本身已完成(2026-10-28,注册流程触发) | +| 服务端拒绝建名为 `host` 等保留词的包 | — | 不做:2026-10-28 曾拟保留包名 `host` 给原生路由,因是潜规则撤销;页面要原生页的结果走 `host.call`(launch-for-result),只跳不要结果走 deep link(`linking.openUrl`) | | `openUrl` 的打开方式提示(`mode`) | ADR-007 §3.6 | 页面确实要求应用内或外部打开 | | 流式响应 / SSE、上传下载、请求缓存 | ADR-007 §3.1 | 第一个需要的页面 | | 会话之外的身份模型(多账号等) | ADR-007 §3.5 | 出现这类宿主 | diff --git a/packages/native/src/crypto.ts b/packages/native/src/crypto.ts new file mode 100644 index 0000000..412d2ed --- /dev/null +++ b/packages/native/src/crypto.ts @@ -0,0 +1,16 @@ +// docs/native-api.md §16: platform crypto, so neither the algorithm nor a JS library ships in a hot-updatable package. +import { internal } from "tinyui-core"; + +export interface RsaEncryptOptions { + /** The public key as X.509 SubjectPublicKeyInfo DER, base64 (what `-----BEGIN PUBLIC KEY-----` wraps). */ + publicKey: string; +} + +/** + * RSA public-key encryption with PKCS#1 v1.5 padding; resolves with the ciphertext in base64. [text] is encoded as + * UTF-8 and may be at most the key size in bytes minus 11. The padding is random: the same input never gives the + * same ciphertext twice. Rejects E_INVALID for a key it cannot read or a text too long for it. + */ +export function rsaEncrypt(text: string, options: RsaEncryptOptions): Promise { + return internal.call("crypto.rsaEncrypt", { text, publicKey: options.publicKey }); +} diff --git a/packages/native/src/http.ts b/packages/native/src/http.ts index 11c5a0e..3e858ba 100644 --- a/packages/native/src/http.ts +++ b/packages/native/src/http.ts @@ -5,6 +5,11 @@ export interface HttpOptions { headers?: Record; /** ms; rejects with E_TIMEOUT */ timeout?: number; + /** + * Notes for the host's channel, never sent: no channel puts them on the wire (`{ captchaScene: "USER_REGISTER" }`). + * What a tag means is between the page and that host's channel; the built-in `default` channel ignores them. + */ + tags?: Record; } export interface HttpResponse { diff --git a/packages/native/src/index.ts b/packages/native/src/index.ts index 6b509e5..58f7091 100644 --- a/packages/native/src/index.ts +++ b/packages/native/src/index.ts @@ -10,10 +10,12 @@ export * as session from "./session.ts"; export * as ui from "./ui.ts"; export * as linking from "./linking.ts"; export * as clipboard from "./clipboard.ts"; +export * as crypto from "./crypto.ts"; export * as analytics from "./analytics.ts"; export { cached } from "./storage.ts"; export type { HttpResponse, HttpOptions, HttpClient } from "./http.ts"; export type { CachedActions } from "./storage.ts"; +export type { RsaEncryptOptions } from "./crypto.ts"; export type { I18nKeys } from "./i18n.ts"; export type { Session } from "./session.ts"; export { HostError } from "tinyui-core"; diff --git a/packages/native/test/native.test.ts b/packages/native/test/native.test.ts index f562e48..4cd90ee 100644 --- a/packages/native/test/native.test.ts +++ b/packages/native/test/native.test.ts @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import { afterEach, describe, it } from "node:test"; import { bridge } from "./host-stub.ts"; import { Column, h, Text, thunk } from "tinyui-core"; -import { analytics, cached, clipboard, events, HostError, host, http, i18n, linking, navigation, session, storage, store, ui } from "../src/index.ts"; +import { analytics, cached, clipboard, crypto, events, HostError, host, http, i18n, linking, navigation, session, storage, store, ui } from "../src/index.ts"; type Entries = { mount(page: unknown, props: string, host: string): void; unmount(): void; flush(): void; resolve(id: number, json: string): void; emit(topic: string, json: string): void }; const tinyui = (): Entries => (globalThis as Record)["__tinyui"] as Entries; @@ -31,6 +31,11 @@ describe("tinyui-native", () => { await rejected; }); + it("tags ride along for the host's channel", () => { + void http.client("app").post("/api/app/v1/code", { phone: "1" }, { tags: { captchaScene: "USER_REGISTER" } }); + assert.deepEqual(bridge.calls.at(-1)!.args, { channel: "app", method: "POST", url: "/api/app/v1/code", body: { phone: "1" }, tags: { captchaScene: "USER_REGISTER" } }); + }); + it("storage reads and writes through J2; a returned code throws", () => { bridge.queries["storage.get"] = { v: 1 }; assert.deepEqual(storage.get("k"), { v: 1 }); @@ -139,6 +144,8 @@ describe("tinyui-native", () => { assert.deepEqual([bridge.calls.at(-1)!.name, bridge.calls.at(-1)!.args], ["linking.openUrl", { url: "https://x" }]); void clipboard.setText("ORD-1024"); assert.deepEqual([bridge.calls.at(-1)!.name, bridge.calls.at(-1)!.args], ["clipboard.setText", { text: "ORD-1024" }]); + void crypto.rsaEncrypt("s3cret", { publicKey: "MIIB" }); + assert.deepEqual([bridge.calls.at(-1)!.name, bridge.calls.at(-1)!.args], ["crypto.rsaEncrypt", { text: "s3cret", publicKey: "MIIB" }]); analytics.track("checkout_step", { step: "plan_selected", plan: "annual" }); assert.deepEqual(bridge.sent.at(-1), { name: "analytics.track", args: { name: "checkout_step", props: { step: "plan_selected", plan: "annual" } } }); assert.throws(() => analytics.track("x", { nested: {} as unknown as string }), (e: HostError) => e.code === "E_INVALID"); diff --git a/sample/js-extra/src/pages/form.tsx b/sample/js-extra/src/pages/form.tsx index 291c17b..93e4f63 100644 --- a/sample/js-extra/src/pages/form.tsx +++ b/sample/js-extra/src/pages/form.tsx @@ -1,17 +1,26 @@ -import { Checkbox, Column, Option, CodeField, ref, Row, Select, signal, Text, TextField, type CodeFieldCommands } from "tinyui-core"; -import { ui } from "tinyui-native"; +import { Button, Checkbox, CodeField, Column, Option, ref, Row, Select, signal, Text, TextField, type CodeFieldCommands } from "tinyui-core"; +import { crypto, ui } from "tinyui-native"; import visibility from "@material-symbols/svg-400/outlined/visibility.svg"; import visibilityOff from "@material-symbols/svg-400/outlined/visibility_off.svg"; -/** The form pieces a sign-up flow needs: checkbox, field adornments, PIN cells, dropdown, inline links. */ +// a throwaway demo key; a real page fetches its server's key over its channel +const DEMO_PUBLIC_KEY = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAu1h+fpBuEFQRG4A5eKK2zLAc9a2+gKiLeKfbQxYAe8/ty4avmzchM9s5C7wbCGpvPu6ptU4IXNKWv9tjDAZdAW79uYhaLBIXARR4xUBbUTBicFIVCUTEoByaTDuqXnavkg4OoE5iSGqqq0klRMBtloKQ42V70epl89PTwlYEYp6MHGLAwNx2G95SufWoXca6AWZmxjWwIvImwpIN51mMjTXv8JLv5apwf6SitQwAwp4xnibb4Rlt4SpgbKFDBylCkfxuNXJ4TBExYC17/90+j6w8FMYs/IFfBPVS0MyAnV/jd5IcLmPMGBDHLl/ODhvn4lqO1XJ0fVikqR9Rj2skzwIDAQAB"; + +/** The form pieces a sign-up flow needs: checkbox, field adornments, code cells, dropdown, inline links, RSA. */ export default function Form() { const [email, setEmail] = signal(""); + const [password, setPassword] = signal(""); const [shown, setShown] = signal(false); const [focused, setFocused] = signal(false); const [agreed, setAgreed] = signal(false); const [voice, setVoice] = signal(""); const [pinError, setPinError] = signal(false); const pin = ref(); + async function encrypt() { + // the plain password never leaves this page: only the ciphertext would go on to the next step + const cipher = await crypto.rsaEncrypt(password(), { publicKey: DEMO_PUBLIC_KEY }); + void ui.toast(`${cipher.length} base64 chars: ${cipher.slice(0, 16)}…`); + } const emailError = () => email() !== "" && !email().includes("@"); return ( @@ -25,8 +34,9 @@ export default function Form() { width="fill" placeholder="Password" keyboard="password" secure={!shown()} trailingIcon={shown() ? visibilityOff : visibility} onTrailingClick={() => setShown(!shown())} supportingText={focused() ? "8+ characters, letters and digits" : ""} - onFocusChange={(e) => setFocused(e.focused)} + onFocusChange={(e) => setFocused(e.focused)} onChange={(e) => setPassword(e.text)} /> +