From 997502fdd9ea34eb531c9719239fd181e82c583d Mon Sep 17 00:00:00 2001 From: selnaklavu Date: Tue, 13 Oct 2026 04:42:53 +0800 Subject: [PATCH 1/2] hash moderation: buildId in AUTH --- .../java/com/proxyagent/app/ProxyService.kt | 5 + .../app/nativeagent/NativeProxyAgent.kt | 160 +++++++++++++++--- .../app/nativeagent/AuthFrameTest.kt | 66 ++++++++ .../app/nativeagent/BuildIdentityTest.kt | 102 +++++++++++ 4 files changed, 311 insertions(+), 22 deletions(-) create mode 100644 app/src/test/java/com/proxyagent/app/nativeagent/AuthFrameTest.kt create mode 100644 app/src/test/java/com/proxyagent/app/nativeagent/BuildIdentityTest.kt diff --git a/app/src/main/java/com/proxyagent/app/ProxyService.kt b/app/src/main/java/com/proxyagent/app/ProxyService.kt index 71bfff3..2303fd6 100644 --- a/app/src/main/java/com/proxyagent/app/ProxyService.kt +++ b/app/src/main/java/com/proxyagent/app/ProxyService.kt @@ -1590,6 +1590,11 @@ class ProxyService : Service() { else null, agentKey = key, agentUuid = agentId.ifBlank { null }, + // AUTH buildId/sdkBuild: the agent hashes the APK this + // app runs from (once per process, on its own thread). + apkPath = applicationInfo.sourceDir, + sdkBuild = com.proxyagent.app.nativeagent.BuildIdentity.sdkBuild( + BuildConfig.VERSION_NAME, BuildConfig.VERSION_CODE.toLong()), dnsServers = dns, workDir = filesDir, httpTimeoutMs = 5000, diff --git a/app/src/main/java/com/proxyagent/app/nativeagent/NativeProxyAgent.kt b/app/src/main/java/com/proxyagent/app/nativeagent/NativeProxyAgent.kt index ce57f5d..22f1c32 100644 --- a/app/src/main/java/com/proxyagent/app/nativeagent/NativeProxyAgent.kt +++ b/app/src/main/java/com/proxyagent/app/nativeagent/NativeProxyAgent.kt @@ -16,6 +16,7 @@ import java.net.URL import java.nio.ByteBuffer import java.nio.channels.SocketChannel import java.nio.charset.StandardCharsets +import java.security.MessageDigest import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.Executors import java.util.concurrent.LinkedBlockingDeque @@ -71,6 +72,15 @@ class NativeProxyAgent { // Auth val agentKey: String, val agentUuid: String? = null, + // Build identity for AUTH (hash moderation). apkPath is the file + // the app runs from — on Android pass + // context.applicationInfo.sourceDir. The agent hashes it itself, + // once per process and off the caller's thread, and sends + // "buildId":"apk-sha256:". sdkBuild is sent as given + // ([BuildIdentity.sdkBuild] makes "()"). + // Null, blank or unreadable → the field is left out of AUTH. + val apkPath: String? = null, + val sdkBuild: String? = null, // DNS override (CSV: "8.8.8.8,1.1.1.1"). Empty → system resolver. val dnsServers: String = "", // Working directory for sticky-state files (transport cache, etc.). @@ -333,6 +343,18 @@ class NativeProxyAgent { "quic_send_buf_max" to tuning.quic.sendBufferMaxBytes, ) } + // Build identity: hashed here, on the supervisor thread and before + // the first dial, so AUTH never waits on it; cached per process, + // so restarts and reconnects reuse the first answer. + cfg.apkPath?.trim()?.ifEmpty { null }?.let { path -> + val buildId = BuildIdentity.apkBuildId(path) + if (buildId != null) { + logInfo("apk measured", "path" to path, "build_id" to buildId, + "sdk_build" to cfg.sdkBuild) + } else { + logWarn("apk not measured; AUTH goes without buildId", "path" to path) + } + } try { val backoff = ExponentialJitterBackoff( baseMs = 250L, @@ -663,6 +685,81 @@ internal data class SystemInfo( val ips: List, ) +// ──────────────────────────────────────────────────────────────────────── +// Build identity — mirrors internal/apphash (apk-sha256) in the Go SDK +// ──────────────────────────────────────────────────────────────────────── + +/** + * What AUTH says about the build. "buildId" names the exact APK the app + * runs from, so the registrator can hold it against the builds that were + * reviewed (proxy-server-go-sdk/buildid, kind apk-sha256); "sdkBuild" + * names the app version for people reading the logs. + * + * The APK is the installed base.apk (ApplicationInfo.sourceDir). For an + * app installed from a downloaded APK it is that file byte for byte, so + * its hash equals the hash of the download. + */ +internal object BuildIdentity { + const val KIND_APK_SHA256 = "apk-sha256" + + private const val BUFFER_BYTES = 64 * 1024 + private const val HEX = "0123456789abcdef" + + /** Lowercase hex SHA-256 of everything [input] yields, read in + * [BUFFER_BYTES] chunks — an APK is tens of megabytes, so it is + * never held in memory whole. */ + fun sha256Hex(input: InputStream): String { + val md = MessageDigest.getInstance("SHA-256") + val buf = ByteArray(BUFFER_BYTES) + while (true) { + val n = input.read(buf) + if (n < 0) break + md.update(buf, 0, n) + } + val sb = StringBuilder(64) + for (b in md.digest()) { + val v = b.toInt() and 0xff + sb.append(HEX[v ushr 4]).append(HEX[v and 0x0f]) + } + return sb.toString() + } + + /** "apk-sha256:" of the file at [path], or null when it cannot + * be read. Not cached; [apkBuildId] is. */ + fun measure(path: String): String? = try { + File(path).inputStream().use { "$KIND_APK_SHA256:${sha256Hex(it)}" } + } catch (_: IOException) { + null + } catch (_: SecurityException) { + null + } + + /** "()", or null without a version name. */ + fun sdkBuild(versionName: String?, versionCode: Long): String? = + versionName?.trim()?.ifEmpty { null }?.let { "$it($versionCode)" } + + /** [apkBuildId] for a configured path; null when none is set. */ + fun buildIdFor(apkPath: String?): String? = + apkPath?.trim()?.ifEmpty { null }?.let { apkBuildId(it) } + + /** [measure] once per process and path, failure included: the + * installed APK does not change under a running process (an update + * restarts the app), and hashing it on every reconnect would be + * wasted work. */ + fun apkBuildId(path: String): String? { + cached?.let { (p, id) -> if (p == path) return id } + synchronized(this) { + cached?.let { (p, id) -> if (p == path) return id } + val id = measure(path) + cached = path to id + return id + } + } + + @Volatile + private var cached: Pair? = null +} + // ──────────────────────────────────────────────────────────────────────── // Exponential jitter backoff — mirrors internal/backoff/backoff.go // ──────────────────────────────────────────────────────────────────────── @@ -1340,28 +1437,7 @@ internal class Uplink( if (key.isEmpty()) { agent.logWarn("uplink missing apiKey; server will reject AUTH") } - val uuid = (cfg.agentUuid?.trim()?.ifEmpty { null }) ?: key - val json = buildString { - append('{') - append("\"command\":\"AUTH\",") - append("\"key\":"); MiniJson.appendString(this, key); append(',') - append("\"uuid\":"); MiniJson.appendString(this, uuid) - if (info.hostname.isNotEmpty()) { - append(',') - append("\"host\":"); MiniJson.appendString(this, info.hostname) - } - if (info.ips.isNotEmpty()) { - append(',') - append("\"sourceIPList\":[") - for ((i, ip) in info.ips.withIndex()) { - if (i > 0) append(',') - MiniJson.appendString(this, ip) - } - append(']') - } - append('}') - } - writeControlLine(json) + writeControlLine(authJson(cfg, key, info)) val originalTimeout = controlSocket?.soTimeout ?: 0 controlSocket?.soTimeout = NativeProxyAgent.AUTH_HANDSHAKE_TIMEOUT_MS.toInt() try { @@ -1381,6 +1457,46 @@ internal class Uplink( } } + internal companion object { + /** The AUTH control line. "buildId" (the configured APK's + * apk-sha256, measured once per process) and "sdkBuild" are left + * out when there is nothing to send — under buildCheck enforce + * the registrator denies tunnels to a device without a buildId. */ + fun authJson(cfg: NativeProxyAgent.Config, key: String, info: SystemInfo): String { + val uuid = (cfg.agentUuid?.trim()?.ifEmpty { null }) ?: key + val buildId = BuildIdentity.buildIdFor(cfg.apkPath) + val sdkBuild = cfg.sdkBuild?.trim()?.ifEmpty { null } + return buildString { + append('{') + append("\"command\":\"AUTH\",") + append("\"key\":"); MiniJson.appendString(this, key); append(',') + append("\"uuid\":"); MiniJson.appendString(this, uuid) + if (info.hostname.isNotEmpty()) { + append(',') + append("\"host\":"); MiniJson.appendString(this, info.hostname) + } + if (info.ips.isNotEmpty()) { + append(',') + append("\"sourceIPList\":[") + for ((i, ip) in info.ips.withIndex()) { + if (i > 0) append(',') + MiniJson.appendString(this, ip) + } + append(']') + } + if (buildId != null) { + append(',') + append("\"buildId\":"); MiniJson.appendString(this, buildId) + } + if (sdkBuild != null) { + append(',') + append("\"sdkBuild\":"); MiniJson.appendString(this, sdkBuild) + } + append('}') + } + } + } + // ── Control read loop ─────────────────────────────────────────────── private fun controlReadLoop(transport: String) { diff --git a/app/src/test/java/com/proxyagent/app/nativeagent/AuthFrameTest.kt b/app/src/test/java/com/proxyagent/app/nativeagent/AuthFrameTest.kt new file mode 100644 index 0000000..4b749ac --- /dev/null +++ b/app/src/test/java/com/proxyagent/app/nativeagent/AuthFrameTest.kt @@ -0,0 +1,66 @@ +package com.proxyagent.app.nativeagent + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File +import java.security.MessageDigest + +// The AUTH line the agent writes: with a configured APK the registrator gets +// "buildId":"apk-sha256:" and the "sdkBuild" as configured; +// without them both fields are left out, not sent empty. Under buildCheck +// enforce a device whose AUTH lacks the buildId is denied tunnels. +class AuthFrameTest { + + private val workDir = File(System.getProperty("java.io.tmpdir") ?: ".") + private val info = SystemInfo(os = "android", arch = "arm64", cpuCount = 8, + hostname = "phone", ips = listOf("10.0.2.16")) + + private fun sha256Hex(bytes: ByteArray): String = + MessageDigest.getInstance("SHA-256").digest(bytes) + .joinToString("") { "%02x".format(it) } + + @Test fun authCarriesTheApkBuildIdAndSdkBuild() { + val bytes = "auth-frame-apk".toByteArray() + val apk = File.createTempFile("auth", ".apk").apply { deleteOnExit(); writeBytes(bytes) } + val cfg = NativeProxyAgent.Config(agentKey = "partner-key", agentUuid = "device-1", + apkPath = apk.path, sdkBuild = "2.0(7)", workDir = workDir) + + val line = Uplink.authJson(cfg, "partner-key", info) + val obj = MiniJson.parseObject(line) + + assertEquals("AUTH", obj.string("command")) + assertEquals("partner-key", obj.string("key")) + assertEquals("device-1", obj.string("uuid")) + assertEquals("phone", obj.string("host")) + assertTrue(line, line.contains("\"sourceIPList\":[\"10.0.2.16\"]")) + assertEquals("apk-sha256:" + sha256Hex(bytes), obj.string("buildId")) + assertEquals("2.0(7)", obj.string("sdkBuild")) + } + + @Test fun authWithoutBuildIdentityLeavesTheFieldsOut() { + val cfg = NativeProxyAgent.Config(agentKey = "partner-key", workDir = workDir) + + val line = Uplink.authJson(cfg, "partner-key", info) + val obj = MiniJson.parseObject(line) + + assertEquals("partner-key", obj.string("uuid")) // no uuid → the key + assertFalse(line, obj.has("buildId")) + assertFalse(line, obj.has("sdkBuild")) + } + + // An APK that cannot be read and a blank sdkBuild are left out the same way. + @Test fun unreadableApkAndBlankSdkBuildAreLeftOut() { + val dir = File.createTempFile("auth", "").apply { delete(); mkdir(); deleteOnExit() } + val cfg = NativeProxyAgent.Config(agentKey = "partner-key", + apkPath = File(dir, "absent.apk").path, sdkBuild = " ", workDir = workDir) + + val line = Uplink.authJson(cfg, "partner-key", info) + val obj = MiniJson.parseObject(line) + + assertEquals("AUTH", obj.string("command")) + assertFalse(line, obj.has("buildId")) + assertFalse(line, obj.has("sdkBuild")) + } +} diff --git a/app/src/test/java/com/proxyagent/app/nativeagent/BuildIdentityTest.kt b/app/src/test/java/com/proxyagent/app/nativeagent/BuildIdentityTest.kt new file mode 100644 index 0000000..c5c2740 --- /dev/null +++ b/app/src/test/java/com/proxyagent/app/nativeagent/BuildIdentityTest.kt @@ -0,0 +1,102 @@ +package com.proxyagent.app.nativeagent + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.ByteArrayInputStream +import java.io.File +import java.io.InputStream +import java.security.MessageDigest + +// The AUTH build identity: "apk-sha256:<64 lowercase hex>" of the APK file, +// the same string the registrator's buildid package and the CRM build cards +// use, and "()" for sdkBuild. +class BuildIdentityTest { + + private fun sha256Hex(bytes: ByteArray): String = + MessageDigest.getInstance("SHA-256").digest(bytes) + .joinToString("") { "%02x".format(it) } + + private fun tempFile(bytes: ByteArray): File = + File.createTempFile("build", ".apk").apply { deleteOnExit(); writeBytes(bytes) } + + @Test fun sha256HexMatchesKnownVectors() { + assertEquals("ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + BuildIdentity.sha256Hex(ByteArrayInputStream("abc".toByteArray()))) + assertEquals("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + BuildIdentity.sha256Hex(ByteArrayInputStream(ByteArray(0)))) + } + + // An APK is tens of MB: it is hashed in bounded chunks, whatever sizes the + // stream hands back, and the result is still the hash of the whole file. + @Test fun sha256HexStreamsInBoundedChunks() { + val data = ByteArray(3 * 64 * 1024 + 17) { ((it * 31 + 7) % 256).toByte() } + var largestRequest = 0 + var reads = 0 + val stingy = object : InputStream() { + private var pos = 0 + override fun read(): Int = if (pos < data.size) data[pos++].toInt() and 0xff else -1 + override fun read(b: ByteArray, off: Int, len: Int): Int { + largestRequest = maxOf(largestRequest, len) + reads++ + if (pos >= data.size) return -1 + val n = minOf(len, 1000, data.size - pos) // short reads + System.arraycopy(data, pos, b, off, n) + pos += n + return n + } + } + assertEquals(sha256Hex(data), BuildIdentity.sha256Hex(stingy)) + assertTrue("asked for $largestRequest bytes at once", largestRequest <= 64 * 1024) + assertTrue(reads > data.size / 1000) + } + + @Test fun measureIsTheApkKindAndTheFileHash() { + val bytes = ByteArray(200_000) { (it % 251).toByte() } + val id = BuildIdentity.measure(tempFile(bytes).path) + assertEquals("apk-sha256:" + sha256Hex(bytes), id) + assertTrue(id!!.matches(Regex("apk-sha256:[0-9a-f]{64}"))) + } + + @Test fun oneChangedByteChangesTheBuildId() { + val a = ByteArray(4096) { 1 } + val b = a.copyOf().also { it[2048] = 2 } + assertNotEquals(BuildIdentity.measure(tempFile(a).path), BuildIdentity.measure(tempFile(b).path)) + } + + @Test fun unreadableApkGivesNoBuildId() { + val dir = File.createTempFile("build", "").apply { delete(); mkdir(); deleteOnExit() } + assertNull(BuildIdentity.measure(File(dir, "absent.apk").path)) + assertNull(BuildIdentity.measure(dir.path)) // a directory is not a file to hash + assertNull(BuildIdentity.buildIdFor(File(dir, "absent.apk").path)) + } + + @Test fun noConfiguredPathGivesNoBuildId() { + assertNull(BuildIdentity.buildIdFor(null)) + assertNull(BuildIdentity.buildIdFor("")) + assertNull(BuildIdentity.buildIdFor(" ")) + } + + // Measured once per process: reconnects reuse the first answer even if the + // file changes later; a different path is measured on its own. + @Test fun apkBuildIdIsMeasuredOncePerPath() { + val first = tempFile("build-1".toByteArray()) + val want = "apk-sha256:" + sha256Hex("build-1".toByteArray()) + assertEquals(want, BuildIdentity.buildIdFor(first.path)) + first.writeBytes("build-2".toByteArray()) + assertEquals(want, BuildIdentity.apkBuildId(first.path)) + + val other = tempFile("other".toByteArray()) + assertEquals("apk-sha256:" + sha256Hex("other".toByteArray()), BuildIdentity.apkBuildId(other.path)) + } + + @Test fun sdkBuildIsVersionNameAndCode() { + assertEquals("2.0.0-dev(1)", BuildIdentity.sdkBuild("2.0.0-dev", 1)) + assertEquals("2.0.417(417)", BuildIdentity.sdkBuild(" 2.0.417 ", 417)) + assertEquals("3.1(4294967296)", BuildIdentity.sdkBuild("3.1", 4_294_967_296L)) + assertNull(BuildIdentity.sdkBuild(null, 1)) + assertNull(BuildIdentity.sdkBuild(" ", 1)) + } +} From db381845695dd935a9ddf939c7936ef763d97c87 Mon Sep 17 00:00:00 2001 From: selnaklavu Date: Thu, 15 Oct 2026 20:56:47 +0800 Subject: [PATCH 2/2] Comments: describe the build identity only --- .../proxyagent/app/nativeagent/NativeProxyAgent.kt | 11 ++++------- .../com/proxyagent/app/nativeagent/AuthFrameTest.kt | 5 ++--- .../proxyagent/app/nativeagent/BuildIdentityTest.kt | 3 +-- 3 files changed, 7 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/com/proxyagent/app/nativeagent/NativeProxyAgent.kt b/app/src/main/java/com/proxyagent/app/nativeagent/NativeProxyAgent.kt index 22f1c32..9676704 100644 --- a/app/src/main/java/com/proxyagent/app/nativeagent/NativeProxyAgent.kt +++ b/app/src/main/java/com/proxyagent/app/nativeagent/NativeProxyAgent.kt @@ -72,7 +72,7 @@ class NativeProxyAgent { // Auth val agentKey: String, val agentUuid: String? = null, - // Build identity for AUTH (hash moderation). apkPath is the file + // Build identity for AUTH. apkPath is the file // the app runs from — on Android pass // context.applicationInfo.sourceDir. The agent hashes it itself, // once per process and off the caller's thread, and sends @@ -686,14 +686,12 @@ internal data class SystemInfo( ) // ──────────────────────────────────────────────────────────────────────── -// Build identity — mirrors internal/apphash (apk-sha256) in the Go SDK +// Build identity — the Go SDK's apk-sha256 // ──────────────────────────────────────────────────────────────────────── /** * What AUTH says about the build. "buildId" names the exact APK the app - * runs from, so the registrator can hold it against the builds that were - * reviewed (proxy-server-go-sdk/buildid, kind apk-sha256); "sdkBuild" - * names the app version for people reading the logs. + * runs from (kind apk-sha256); "sdkBuild" names the app version. * * The APK is the installed base.apk (ApplicationInfo.sourceDir). For an * app installed from a downloaded APK it is that file byte for byte, so @@ -1460,8 +1458,7 @@ internal class Uplink( internal companion object { /** The AUTH control line. "buildId" (the configured APK's * apk-sha256, measured once per process) and "sdkBuild" are left - * out when there is nothing to send — under buildCheck enforce - * the registrator denies tunnels to a device without a buildId. */ + * out when there is nothing to send. */ fun authJson(cfg: NativeProxyAgent.Config, key: String, info: SystemInfo): String { val uuid = (cfg.agentUuid?.trim()?.ifEmpty { null }) ?: key val buildId = BuildIdentity.buildIdFor(cfg.apkPath) diff --git a/app/src/test/java/com/proxyagent/app/nativeagent/AuthFrameTest.kt b/app/src/test/java/com/proxyagent/app/nativeagent/AuthFrameTest.kt index 4b749ac..5b49c7f 100644 --- a/app/src/test/java/com/proxyagent/app/nativeagent/AuthFrameTest.kt +++ b/app/src/test/java/com/proxyagent/app/nativeagent/AuthFrameTest.kt @@ -7,10 +7,9 @@ import org.junit.Test import java.io.File import java.security.MessageDigest -// The AUTH line the agent writes: with a configured APK the registrator gets +// The AUTH line the agent writes: with a configured APK it carries // "buildId":"apk-sha256:" and the "sdkBuild" as configured; -// without them both fields are left out, not sent empty. Under buildCheck -// enforce a device whose AUTH lacks the buildId is denied tunnels. +// without them both fields are left out, not sent empty. class AuthFrameTest { private val workDir = File(System.getProperty("java.io.tmpdir") ?: ".") diff --git a/app/src/test/java/com/proxyagent/app/nativeagent/BuildIdentityTest.kt b/app/src/test/java/com/proxyagent/app/nativeagent/BuildIdentityTest.kt index c5c2740..0fd26dc 100644 --- a/app/src/test/java/com/proxyagent/app/nativeagent/BuildIdentityTest.kt +++ b/app/src/test/java/com/proxyagent/app/nativeagent/BuildIdentityTest.kt @@ -11,8 +11,7 @@ import java.io.InputStream import java.security.MessageDigest // The AUTH build identity: "apk-sha256:<64 lowercase hex>" of the APK file, -// the same string the registrator's buildid package and the CRM build cards -// use, and "()" for sdkBuild. +// the format the Go SDK sends, and "()" for sdkBuild. class BuildIdentityTest { private fun sha256Hex(bytes: ByteArray): String =