Repository navigation
feat: http 请求 tags 与 crypto.rsaEncrypt #44
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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()) | ||
| } | ||
| } |
| 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 { | ||
| init(Cipher.ENCRYPT_MODE, publicKey) | ||
| doFinal(plain) | ||
| } | ||
| } | ||
| 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") | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [建议] |
||
| 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 | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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( | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [建议] 移除 |
||
| val method: String, | ||
| val url: String, | ||
| val headers: Map<String, String>, | ||
| val body: String?, | ||
| val timeoutMs: Long?, | ||
| val tags: Map<String, String> = emptyMap(), | ||
|
sourcery-ai[bot] marked this conversation as resolved.
Resolved
|
||
| ) | ||
|
|
||
| class HttpResponse(val status: Int, val headers: Map<String, String>, 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. */ | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.