From 3e96e429eb73c171f1f603c1f3000c5fd58b00cd Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 27 Oct 2026 13:46:53 +0100 Subject: [PATCH] Preserve unsigned node IDs in Android invite previews Encode JNI node IDs as decimal strings and decode both uint64 fields without floating point or signed parsing. Compare all invitation fields and binary contents. Cover boundary IDs, malformed JSON types, and model equality with nine JVM tests. Native build, invite codec/build tests, and both debug APK ABIs passed. --- tools/chatgui-android/app/build.gradle.kts | 1 + .../java/com/utun/chat/data/InviteLink.kt | 81 ++++++++++++++----- .../java/com/utun/chat/data/InviteLinkTest.kt | 78 ++++++++++++++++++ .../jni_bridge/android_jni_bridge.c | 5 +- 4 files changed, 142 insertions(+), 23 deletions(-) create mode 100644 tools/chatgui-android/app/src/test/java/com/utun/chat/data/InviteLinkTest.kt 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;