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.
This commit is contained in:
evgeny committed 2026-10-27 14:22:32 +01:00
1 parent 254029be63
commit 3e96e429eb
4 files changed
+142 -23

No files matched your search

@@ -84,6 +84,7 @@ tasks.withType<org.gradle.api.tasks.testing.Test>().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")
@@ -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"
@@ -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) }
}
}
@@ -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;