diff --git a/tools/chatgui-android/app/build.gradle.kts b/tools/chatgui-android/app/build.gradle.kts index 000edd511..4eb86300f 100644 --- a/tools/chatgui-android/app/build.gradle.kts +++ b/tools/chatgui-android/app/build.gradle.kts @@ -84,6 +84,7 @@ tasks.withType().configureEach { dependencies { testImplementation("junit:junit:4.13.2") + testImplementation("org.json:json:20240303") // Реальный JSON для проверки границы JNI, без Android mock stubs. implementation(platform("androidx.compose:compose-bom:2024.02.00")) implementation("androidx.compose.ui:ui") implementation("androidx.compose.ui:ui-tooling-preview") diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/InviteLink.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/InviteLink.kt index 4d1981040..cbd5053fb 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/InviteLink.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/InviteLink.kt @@ -1,7 +1,8 @@ package com.utun.chat.data -import com.utun.chat.data.LogManager - +/* Kotlin-представление invite: wire/base64 и допустимость полей проверяет общий C-кодек через NativeLib. + * Декодирование не запускает подключение. Для join передавать исходный link: из preview ссылка не собирается. + * Массивы моделей принадлежат получателю, copy() их не клонирует; после создания использовать только для чтения. */ const val INVITE_LINK_VERSION: Int = 0x03 const val INVITE_PUBKEY_SIZE: Int = 32 const val INVITE_SNAME_MAX: Int = 64 @@ -10,14 +11,32 @@ const val INVITE_PROTO_UDP: Int = 0x01 const val INVITE_PROTO_TCP: Int = 0x02 const val INVITE_PROTO_REALITY: Int = 0x04 // флаг reality-камуфляжа поверх TCP (reality-TCP = 0x06) +/** REALITY-снимок; has=true у непустого native-preview, byte-поля сравниваются по содержимому. + * shortId — 8 байт, serverPubkey — 32 байта, version — 3 байта; serverName — SNI до 63 UTF-8 байт. */ data class InviteReality( val has: Boolean, val shortId: ByteArray, val serverPubkey: ByteArray, val version: ByteArray, val serverName: String -) +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is InviteReality) return false + return has == other.has && shortId.contentEquals(other.shortId) && serverPubkey.contentEquals(other.serverPubkey) && + version.contentEquals(other.version) && serverName == other.serverName + } + override fun hashCode(): Int { + var result = has.hashCode() + result = result * 31 + shortId.contentHashCode() + result = result * 31 + serverPubkey.contentHashCode() + result = result * 31 + version.contentHashCode() + return result * 31 + serverName.hashCode() + } +} +/** Адрес узла подключения: family=4/6, address — 4/16 сетевых байт; port=1..65535 в порядке байтов хоста. + * socketId=0..255, proto — UDP=1/TCP=2/TCP+REALITY=6; поля native-preview проверены общим C-кодеком. */ data class InviteAddr( val family: Int, // 4 or 6 val address: ByteArray, @@ -34,6 +53,9 @@ data class InviteAddr( override fun hashCode(): Int = family * 31 + address.contentHashCode() + port * 31 + socketId * 31 + proto } +/** Полный снимок приглашения; равенство учитывает все поля, включая реквизиты входа и исходную ссылку. + * channelId — 1..Long.MAX_VALUE; joinKey/nodeId хранят все биты uint64 в Long (для unsigned-вывода использовать toULong()). + * pubkey — 32 байта X25519 узла подключения, addrs — 1..32 его адресов. password — UTF-8 preview, link — исходная wire-ссылка. */ data class InviteData( val channelId: Long, val joinKey: Long, @@ -47,36 +69,53 @@ data class InviteData( override fun equals(other: Any?): Boolean { if (this === other) return true if (other !is InviteData) return false - return channelId == other.channelId && pubkey.contentEquals(other.pubkey) && - nodeId == other.nodeId && addrs == other.addrs + return channelId == other.channelId && joinKey == other.joinKey && pubkey.contentEquals(other.pubkey) && + nodeId == other.nodeId && addrs == other.addrs && password == other.password && reality == other.reality && link == other.link + } + override fun hashCode(): Int { + var result = channelId.hashCode() + result = result * 31 + joinKey.hashCode() + result = result * 31 + pubkey.contentHashCode() + result = result * 31 + nodeId.hashCode() + result = result * 31 + addrs.hashCode() + result = result * 31 + password.hashCode() + result = result * 31 + (reality?.hashCode() ?: 0) + return result * 31 + link.hashCode() } - override fun hashCode(): Int = channelId.hashCode() * 31 + pubkey.contentHashCode() } object InviteLink { - private const val PREFIX = "utun://" - + /** Возвращает собственный preview или null при отказе C-кодека/повреждённом JSON; ошибок подключения здесь нет. + * Загруженная libutun обязательна. JNI-ответ не логируется: содержит join key и пароль. */ fun decode(link: String): InviteData? { val encoded = NativeLib.decodeInvite(link) ?: return null return try { - fun bytes(hex: String): ByteArray = hex.chunked(2).map { it.toInt(16).toByte() }.toByteArray() - val value = org.json.JSONObject(encoded) - val addresses = value.getJSONArray("addresses") - val reality = value.optJSONObject("reality")?.let { - InviteReality(true, bytes(it.getString("short")), bytes(it.getString("pubkey")), - bytes(it.getString("version")), String(bytes(it.getString("name")), Charsets.UTF_8)) - } - InviteData(value.getLong("channel"), value.getString("key").toULong().toLong(), - bytes(value.getString("pubkey")), value.getLong("node"), - (0 until addresses.length()).map { i -> - val a = addresses.getJSONObject(i) - InviteAddr(a.getInt("family"), bytes(a.getString("ip")), a.getInt("port"), a.getInt("socket"), a.getInt("proto")) - }, String(bytes(value.getString("password")), Charsets.UTF_8), reality, link) + fromNativePreview(encoded, link) } catch (e: Exception) { LogManager.addLog("ERROR", "INVITE", "invalid native invitation preview: ${e.javaClass.simpleName}"); null } } + /** Преобразует проверенный C-кодеком JSON JNI без повторного разбора wire; ошибки полей передаёт decode. + * key/node — десятичные строки uint64, остальные byte-поля — hex. Числовой JSON/Double для ID не допускается. */ + internal fun fromNativePreview(encoded: String, link: String): InviteData { + fun bytes(hex: String): ByteArray = hex.chunked(2).map { it.toInt(16).toByte() }.toByteArray() + val value = org.json.JSONObject(encoded) + val joinKey = (value.get("key") as String).toULong().toLong() + val nodeId = (value.get("node") as String).toULong().toLong() + val addresses = value.getJSONArray("addresses") + val reality = value.optJSONObject("reality")?.let { + InviteReality(true, bytes(it.getString("short")), bytes(it.getString("pubkey")), + bytes(it.getString("version")), String(bytes(it.getString("name")), Charsets.UTF_8)) + } + return InviteData(value.getLong("channel"), joinKey, bytes(value.getString("pubkey")), nodeId, + (0 until addresses.length()).map { i -> + val a = addresses.getJSONObject(i) + InviteAddr(a.getInt("family"), bytes(a.getString("ip")), a.getInt("port"), a.getInt("socket"), a.getInt("proto")) + }, String(bytes(value.getString("password")), Charsets.UTF_8), reality, link) + } + + /** Диагностическая подпись адреса/сокета; IPv6 выводится hex без сжатия, строка не является endpoint для подключения. */ fun addrToString(addr: InviteAddr): String { val protoStr = when { (addr.proto and INVITE_PROTO_REALITY) != 0 -> "TCP+REALITY" diff --git a/tools/chatgui-android/app/src/test/java/com/utun/chat/data/InviteLinkTest.kt b/tools/chatgui-android/app/src/test/java/com/utun/chat/data/InviteLinkTest.kt new file mode 100644 index 000000000..d4ef99596 --- /dev/null +++ b/tools/chatgui-android/app/src/test/java/com/utun/chat/data/InviteLinkTest.kt @@ -0,0 +1,78 @@ +package com.utun.chat.data + +import org.json.JSONObject +import org.junit.Assert.* +import org.junit.Test + +/** Значения JNI проверяются как строки uint64; Android Number/Double не сохраняет весь диапазон ID. */ +class InviteLinkTest { + private fun reality() = InviteReality(true, ByteArray(8) { 1 }, ByteArray(32) { 2 }, byteArrayOf(1, 2, 3), "example.test") + private fun invite() = InviteData(71, 13, ByteArray(32) { 3 }, 19, + listOf(InviteAddr(4, byteArrayOf(127, 0, 0, 1), 9999, 7, INVITE_PROTO_TCP or INVITE_PROTO_REALITY)), + "password", reality(), "utun://original") + + private fun preview(node: String, key: String = "13") = """ + {"channel":71,"key":"$key","node":"$node","pubkey":"${"03".repeat(32)}","password":"70617373776f7264", + "reality":{"short":"${"01".repeat(8)}","pubkey":"${"02".repeat(32)}","version":"010203","name":"6578616d706c652e74657374"}, + "addresses":[{"family":4,"socket":7,"proto":6,"port":9999,"ip":"7f000001"}]} + """.trimIndent() + + @Test fun nativePreviewPreservesEveryNodeBit() { + for (node in listOf(1uL, Long.MAX_VALUE.toULong(), 1uL shl 63, 0xfedcba9876543210uL, ULong.MAX_VALUE)) { + val actual = InviteLink.fromNativePreview(preview(node.toString()), "utun://original") + assertEquals(node.toLong(), actual.nodeId) + assertEquals(node, actual.nodeId.toULong()) + } + } + + @Test fun joinKeyAndAllPreviewFieldsSurviveMapping() { + assertEquals(invite().copy(joinKey = -1), InviteLink.fromNativePreview(preview("19", ULong.MAX_VALUE.toString()), "utun://original")) + } + + @Test fun floatingPointNodeIsRejected() { + // Именно такой тип может получиться после разбора JSON-числа > Long.MAX_VALUE в Android. + val value = JSONObject(preview("19")).put("node", 18446744073709551615.0) + assertThrows(Exception::class.java) { InviteLink.fromNativePreview(value.toString(), "utun://original") } + } + + @Test fun uint64FieldsRequireStringsRegardlessOfTheirValue() { + for (field in listOf("key", "node")) for (value in listOf(19L, 19.0, true, JSONObject.NULL)) { + val json = JSONObject(preview("19")).put(field, value) + assertThrows(Exception::class.java) { InviteLink.fromNativePreview(json.toString(), "utun://original") } + } + } + + @Test fun signedChannelRangeKeepsExactMaximumValue() { + val json = JSONObject(preview("19")).put("channel", Long.MAX_VALUE) + assertEquals(Long.MAX_VALUE, InviteLink.fromNativePreview(json.toString(), "utun://original").channelId) + } + + @Test fun unsignedNodeAndKeyRejectOutOfRangeStrings() { + for (bad in listOf("-1", "18446744073709551616", "1.5", "not-a-number")) { + assertThrows(Exception::class.java) { InviteLink.fromNativePreview(preview(bad), "utun://original") } + assertThrows(Exception::class.java) { InviteLink.fromNativePreview(preview("19", bad), "utun://original") } + } + } + + @Test fun independentSnapshotsHaveStructuralEquality() { + val first = invite(); val second = invite() + assertEquals(first, second); assertEquals(first.hashCode(), second.hashCode()) + assertEquals(first.reality, second.reality); assertEquals(first.reality.hashCode(), second.reality.hashCode()) + assertEquals(1, hashSetOf(first, second).size) + } + + @Test fun everyInvitationFieldParticipatesInEquality() { + val first = invite() + val changed = listOf(first.copy(channelId = 72), first.copy(joinKey = 14), first.copy(pubkey = ByteArray(32) { 4 }), + first.copy(nodeId = 20), first.copy(addrs = emptyList()), first.copy(password = "different"), + first.copy(reality = null), first.copy(link = "utun://different")) + changed.forEach { assertNotEquals(first, it) } + assertEquals(changed.size + 1, (changed + first).toSet().size) + } + + @Test fun everyRealityFieldParticipatesInEquality() { + val first = reality() + listOf(first.copy(has = false), first.copy(shortId = ByteArray(8)), first.copy(serverPubkey = ByteArray(32)), + first.copy(version = byteArrayOf(4, 5, 6)), first.copy(serverName = "other.test")).forEach { assertNotEquals(first, it) } + } +} diff --git a/tools/chatgui-android/jni_bridge/android_jni_bridge.c b/tools/chatgui-android/jni_bridge/android_jni_bridge.c index cbbd4febc..62c738c51 100644 --- a/tools/chatgui-android/jni_bridge/android_jni_bridge.c +++ b/tools/chatgui-android/jni_bridge/android_jni_bridge.c @@ -1912,7 +1912,8 @@ JNIEXPORT jint JNICALL Java_com_utun_chat_data_NativeLib_nativeJoinChannel( (*env)->ReleaseStringUTFChars(env, link, text); return result; } -/* Представление результата общего C-кодека для предпросмотра Kotlin. */ +/* Представление результата общего C-кодека для предпросмотра Kotlin. + * uint64 key/node передаются десятичными строками: числовой JSON может потерять биты при преобразовании в Double/Long. */ static int invite_hex_json(struct strbuf* out, const uint8_t* bytes, size_t len) { char hex[257]; if (len > 128) return -1; @@ -1930,7 +1931,7 @@ JNIEXPORT jstring JNICALL Java_com_utun_chat_data_NativeLib_nativeDecodeInvite( (*env)->ReleaseStringUTFChars(env, link, text); if (result) return NULL; struct strbuf out = strbuf_new(); - if (strbuf_addf(&out, "{\"channel\":%llu,\"key\":\"%llu\",\"node\":%llu,\"pubkey\":", + if (strbuf_addf(&out, "{\"channel\":%llu,\"key\":\"%llu\",\"node\":\"%llu\",\"pubkey\":", (unsigned long long)data.channelId, (unsigned long long)data.join_key, (unsigned long long)data.nodeId) < 0 || invite_hex_json(&out, data.pubkey, 32) < 0 || strbuf_addf(&out, ",\"password\":") < 0 || invite_hex_json(&out, (const uint8_t*)data.password, data.password_len) < 0) goto failed;