Repository navigation
feat: http 请求 tags 与 crypto.rsaEncrypt - #44
Conversation
PingPong 注册试点的第二批框架能力: - http tags:页面写给宿主通道的备注(如极验场景),任何通道都不发上网;值只能是字符串,否则 E_INVALID; HttpRequest 多 tags(默认空,源码兼容);default / KtorChannel 忽略,不进 manifest - crypto.rsaEncrypt:RSA PKCS#1 v1.5 公钥加密,X.509 公钥 base64 进、密文 base64 出; Android Cipher、iOS SecKeyCreateEncryptedData(框架从 X.509 取出 PKCS#1 交给 iOS); 长度与公钥在公共代码里检查,两端报错一致;名字进框架保留名单 - 测试:公共解析 / 长度 / 随机填充,Android 与 iOS 模拟器各用私钥解密往返,PageHost 经桥的 tags 与加密 - 文档:native-api §6 tags 与「通道是身份边界」最佳实践,新增 §16 crypto;roadmap 记推迟项 (KtorChannel 转 attributes、其余算法;原拟 host 保留包名已撤,原生页结果走 host.call) - sample form 页加「Encrypt password」演示 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Reviewer's Guide本 PR 为 HTTP 请求增加不会上网的宿主通道 tags,并新增 Android/iOS 原生 RSA PKCS#1 v1.5 加密能力;实现包含统一输入校验、桥接/API 暴露、跨平台回环测试及相应文档和示例更新。 Sequence diagram for HTTP request tags reaching the host channelsequenceDiagram
actor Page
participant PageHost
participant AppChannel
participant Network
Page->>PageHost: http.request(tags)
PageHost->>PageHost: Validate tags are string values
PageHost->>AppChannel: request(HttpRequest with tags)
AppChannel->>AppChannel: Consume tags for channel-specific handling
AppChannel->>Network: Send HTTP request without tags
Network-->>AppChannel: HttpResponse
AppChannel-->>PageHost: HttpResponse
PageHost-->>Page: Resolve response
Sequence diagram for native RSA encryption through the bridgesequenceDiagram
actor Page
participant PageHost
participant CommonCrypto
participant PlatformCrypto
Page->>PageHost: crypto.rsaEncrypt(text, publicKey)
PageHost->>CommonCrypto: rsaEncrypt(text, publicKey)
CommonCrypto->>CommonCrypto: Parse X.509 SPKI and validate length
CommonCrypto->>PlatformCrypto: platformRsaEncrypt(UTF8 bytes, key)
PlatformCrypto->>PlatformCrypto: Encrypt with RSA PKCS1Padding
PlatformCrypto-->>CommonCrypto: Ciphertext bytes
CommonCrypto-->>PageHost: Base64 ciphertext
PageHost-->>Page: Resolve ciphertext
Flow diagram for RSA input validation and encryptionflowchart TD
Start[crypto.rsaEncrypt] --> Parse[Parse Base64 X.509 SPKI public key]
Parse --> ValidKey{Valid RSA key?}
ValidKey -- No --> Invalid[E_INVALID]
ValidKey -- Yes --> Length[Encode text as UTF-8 and check key bytes minus 11]
Length --> ValidLength{Within limit?}
ValidLength -- No --> Invalid
ValidLength -- Yes --> Encrypt[Platform RSA PKCS#1 v1.5 encryption]
Encrypt --> Output[Return ciphertext as Base64]
File-Level Changes
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
|
Important
This repository does not receive automatic reviews because it has fewer than 10 stars.
|
There was a problem hiding this comment.
CodeSeer 审查
结论:批准(附 1 条建议)
摘要
本次改动为 HTTP 请求增加了仅供宿主通道消费的 tags,并新增通过桥接调用的 crypto.rsaEncrypt,同时补充了 Android/iOS 实现、测试和文档。tags 的对象和值类型校验及透传路径完整。主要风险在 RSA 公钥的公共解析校验不够严格,Android 侧对后续解析失败的错误码与 iOS/文档约定不一致。
整个 PR 至 4beae7d · gpt-5.6-terra
There was a problem hiding this comment.
Hey - I've found 7 issues
Prompt for AI Agents
Please address the comments from this code review:
## Individual Comments
### Comment 1
<location path="compose/src/androidMain/kotlin/app/tinyui/Crypto.android.kt" line_range="9" />
<code_context>
+
+internal actual fun platformRsaEncrypt(plain: ByteArray, key: RsaPublicKey): ByteArray {
+ val publicKey = KeyFactory.getInstance("RSA").generatePublic(X509EncodedKeySpec(key.spki))
+ return Cipher.getInstance("RSA/ECB/PKCS1Padding").run {
+ init(Cipher.ENCRYPT_MODE, publicKey)
+ doFinal(plain)
</code_context>
<issue_to_address>
**Legacy padding enables oracle attacks**
When the server decrypts these ciphertexts and an attacker can distinguish valid-padding responses from invalid-padding responses, a network attacker who can submit ciphertexts to a registration endpoint that reveals whether PKCS#1 decoding succeeded can use adaptive Bleichenbacher queries to recover plaintext from captured ciphertexts, exposing the encrypted password.
Use RSA-OAEP with SHA-256 on both platforms and the server, or otherwise ensure the decryption endpoint cannot distinguish padding failures.
Also at `compose/src/iosMain/kotlin/app/tinyui/Crypto.ios.kt:39`.
</issue_to_address>
### Comment 2
<location path="compose/src/commonMain/kotlin/app/tinyui/Crypto.kt" line_range="23" />
<code_context>
+ if (bits.isEmpty() || bits[0] != 0.toByte()) invalid("the public key's BIT STRING is malformed")
+ val pkcs1 = bits.copyOfRange(1, bits.size)
+ val modulus = Der(pkcs1).sequence().next(0x02).dropWhile { it == 0.toByte() }
+ if (modulus.size < 64) invalid("the public key is too small")
+ return RsaPublicKey(der, pkcs1, modulus.size)
+ }
</code_context>
<issue_to_address>
**Weak keys expose encrypted passwords**
When the server supplies a 512-bit or similarly weak RSA key, `RsaPublicKey.parse` accepts the weak modulus and `rsaEncrypt` encrypts passwords to it, so an attacker who captures the ciphertext can factor the modulus and recover the password.
Reject RSA moduli below an adequate security minimum.
</issue_to_address>
### Comment 3
<location path="compose/src/iosMain/kotlin/app/tinyui/Crypto.ios.kt" line_range="58" />
<code_context>
+
+@OptIn(ExperimentalForeignApi::class)
+private fun cfData(bytes: ByteArray): CFDataRef =
+ bytes.usePinned { CFDataCreate(null, it.addressOf(0).reinterpret(), bytes.size.toLong()) }!!
</code_context>
<issue_to_address>
**Empty text fails on iOS**
When the caller encrypts an empty string on iOS, `cfData` calls `addressOf(0)` on the empty plaintext array and throws before `SecKeyCreateEncryptedData` runs, so the bridge returns `E_NET` instead of ciphertext; in the `rsaEncrypt` path, this happens before the cleanup `try`, leaving `keyData` and `attributes` unreleased.
Make `cfData` create zero-length `CFData` without calling `addressOf(0)`, so encryption proceeds and the existing cleanup runs.
Also at `compose/src/iosMain/kotlin/app/tinyui/Crypto.ios.kt:35-36`.
</issue_to_address>
### Comment 4
<location path="compose/src/commonMain/kotlin/app/tinyui/Crypto.kt" line_range="22" />
<code_context>
+ val bits = spki.next(0x03)
+ if (bits.isEmpty() || bits[0] != 0.toByte()) invalid("the public key's BIT STRING is malformed")
+ val pkcs1 = bits.copyOfRange(1, bits.size)
+ val modulus = Der(pkcs1).sequence().next(0x02).dropWhile { it == 0.toByte() }
+ if (modulus.size < 64) invalid("the public key is too small")
+ return RsaPublicKey(der, pkcs1, modulus.size)
</code_context>
<issue_to_address>
**Malformed keys return inconsistent errors**
When a DER key has a sufficiently large modulus but an incomplete or otherwise invalid RSA structure, `RsaPublicKey.parse` reads the modulus without validating the rest of the PKCS#1 sequence, so Android's `KeyFactory` rejects the key and `PageHost.settle` reports `E_NET`, while iOS reports `E_INVALID`.
Validate the complete RSA public-key structure during parsing and reject malformed keys as `E_INVALID`.
Also at `compose/src/androidMain/kotlin/app/tinyui/Crypto.android.kt:8-12`, `compose/src/iosMain/kotlin/app/tinyui/Crypto.ios.kt:37`.
</issue_to_address>
### Comment 5
<location path="compose/src/commonMain/kotlin/app/tinyui/PageHost.kt" line_range="362" />
<code_context>
if (name == "ui.confirm") confirmed.toString() else ""
}
+ "crypto.rsaEncrypt" -> settle(cbId) {
+ val text = a.str("text") ?: throw HostException("E_INVALID", "crypto.rsaEncrypt needs text")
+ val key = a.str("publicKey") ?: throw HostException("E_INVALID", "crypto.rsaEncrypt needs a publicKey")
+ // off the engine's lane: RSA is cheap but not free on a slow phone
</code_context>
<issue_to_address>
**Non-string text is encrypted**
When a caller sends a numeric or boolean `text` value through the bridge, `a.str("text")` uses `contentOrNull` without checking `JsonPrimitive.isString`, so numeric or boolean inputs are converted to their textual form and encrypted instead of being rejected; callers receive ciphertext for data different from their requested string.
Require the `text` field to be a string JSON primitive before encrypting it.
</issue_to_address>
### Comment 6
<location path="compose/src/commonMain/kotlin/app/tinyui/HostInterfaces.kt" line_range="72" />
<code_context>
+ val headers: Map<String, String>,
+ val body: String?,
+ val timeoutMs: Long?,
+ val tags: Map<String, String> = emptyMap(),
+)
</code_context>
<issue_to_address>
**Java callers lose the old constructor**
When a Java host constructs `HttpRequest` using the previous five-argument constructor, kotlin's default argument does not generate a five-argument Java overload, so the Java call no longer compiles.
Preserve a five-argument Java constructor overload, for example with `@JvmOverloads`.
</issue_to_address>
### Comment 7
<location path="compose/src/commonMain/kotlin/app/tinyui/PageHost.kt" line_range="403" />
<code_context>
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")
</code_context>
<issue_to_address>
**Null tags bypass validation**
When a caller supplies `tags: null`, the `JsonNull` branch treats an explicitly null `tags` field as an omitted field, so the request reaches the channel with empty tags instead of rejecting the invalid input with `E_INVALID`.
Treat only a missing `tags` field as empty and reject `JsonNull` as an invalid tags value.
</issue_to_address>Sourcery assessment
Needs a human reviewer. 7 findings to address first, and the new API encrypts caller-provided text with RSA PKCS#1 v1.5; if callers rely on it to protect credentials and the padding or key handling proves unsafe, ciphertext already sent to a backend cannot be recalled by reverting. The tags are only passed to the host channel and do not themselves go on the wire.
Blocking findings: compose/src/androidMain/kotlin/app/tinyui/Crypto.android.kt:9, compose/src/commonMain/kotlin/app/tinyui/Crypto.kt:23, compose/src/iosMain/kotlin/app/tinyui/Crypto.ios.kt:58, compose/src/commonMain/kotlin/app/tinyui/Crypto.kt:22, compose/src/commonMain/kotlin/app/tinyui/PageHost.kt:362, and 2 more
- 公钥 DER 逐层读完(无尾随字节、必须有指数、算法参数只能是 NULL 或省略),不少于 1024 位; Android KeyFactory 仍拒绝时也报 E_INVALID,两端错误码一致 - iOS:空字符串不再因 addressOf(0) 抛错,空文本照常加密 - text / publicKey 只收字符串,数字布尔不再被转成文本加密 - HttpRequest 构造函数加 @jvmoverloads,Java 宿主的五参构造仍可用 - native-api §16 写明公钥要求与 PKCS#1 v1.5 的解密端风险(服务端勿区分填充错误,后端支持后改 OAEP) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
CodeSeer 审查
结论:批准(附 1 条建议)
上轮意见:1 条,已处理 1 条,待处理 0 条
摘要
本次增量补齐了 RSA 公钥解析的容器消费、指数存在性校验和 Android 端的 InvalidKeySpecException 映射,上一轮关于损坏 SPKI 落入 E_NET 的问题已修复。也为 HttpRequest 添加了带默认值的 tags 构造参数,并收紧了 crypto.rsaEncrypt 的入参字符串校验。剩余风险是最小 RSA 密钥长度按字节数而非实际有效位数判断,少于 1024 位的特制密钥仍可能通过。
| 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.
[建议] modulus.size * 8 只是编码占用的整字节位数,不是模数的实际位数。例如首字节为 0x7f 的 1023 位模数在去掉符号用的前导零后仍有 128 字节,会通过这里的 1024 位下限并交给平台。应依据首个非零字节的有效位数计算实际 bit length 后再与 MIN_BITS 比较;modulusBytes 可继续用于计算 PKCS#1 的明文上限。
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
CodeSeer 审查
结论:批准(附 2 条建议)
上轮意见:1 条,已处理 0 条,待处理 1 条
compose/src/commonMain/kotlin/app/tinyui/Crypto.kt:37[建议]modulus.size * 8只是编码占用的整字节位数,不是模数的实际位数。例如首字节为0x7f的 1023 位模数在去掉符号…
摘要
本次增量为 HttpRequest 增加了 tags,并将 crypto.rsaEncrypt 加入框架保留能力列表。HttpRequest 构造函数同时移除了 @JvmOverloads,这会使已有 Java 宿主的五参数构造调用无法重新编译,与 PR 所述的源码兼容不符。
| /** 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.
[建议] 移除 @JvmOverloads 后,JVM 上不再生成原有的五参数构造函数;已有 Java 宿主若调用 new HttpRequest(method, url, headers, body, timeoutMs) 会直接编译失败。tags 已有默认值,保留 @JvmOverloads 即可继续提供该重载,并与 PR 声明的源码兼容保持一致。
注册流程所需框架能力的第二部分(第一部分 #43 已合;PageHost 随导航条目存活另起 PR,合齐后发 0.14.0)。
改动
tagsHttpOptions.tags?: Record<string, string>:页面写给宿主通道的备注({ captchaScene: "signup" }),任何通道都不发上网;值非字符串 →E_INVALID。KotlinHttpRequest多tags(默认空,源码兼容;构造函数二进制签名变了,宿主需重编)。default/KtorChannel忽略,不进 manifestcrypto.rsaEncryptcrypto.rsaEncrypt(text, { publicKey }):PKCS#1 v1.5,X.509 SPKI 公钥 base64 进、密文 base64 出。AndroidCipher("RSA/ECB/PKCS1Padding");iOSSecKeyCreateEncryptedData,框架从 X.509 取出 PKCS#1 交给系统。超长(密钥字节数 − 11)/ 公钥非法在公共代码里报E_INVALID,两端一致。名字进框架保留名单不做(记 roadmap):
KtorChannel把 tags 转成 ktorattributes(现有宿主都自写通道);OAEP / 哈希 / 对称加密;原拟的host保留包名已撤——原生页结果走host.call,只跳不要结果走 deep link。文档
tags与「通道是身份边界」最佳实践(凭证在通道里消费,页面只见会话状态)crypto验证
pnpm全套(native 新增 tags / crypto 用例):compose:testAndroidHostTest64 个通过:新增CryptoTest(解析、换行、拒绝裸 PKCS#1 与非法 DER、245 / 246 字节边界、UTF-8 计字节、随机填充)、RsaRoundTripTest(私钥解密回明文)、PageHost 经桥的 tags 透传 / 非字符串拒绝、加密 / 坏公钥:compose:iosSimulatorArm64Test(本地;CI 不跑 iOS):CryptoTest+ iOS 版RsaRoundTripTest(SecKeyCreateDecryptedData解回明文)全过apiCheck、iOS 编译、sample 构建;Android 模拟器 form 页「Encrypt password」得到 344 字符密文🤖 Generated with Claude Code
Summary by Sourcery
Add host-channel HTTP metadata and cross-platform RSA encryption support for registration flows.
New Features:
Enhancements:
Documentation:
Tests: