Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions app/src/main/java/com/proxyagent/app/ProxyService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
157 changes: 135 additions & 22 deletions app/src/main/java/com/proxyagent/app/nativeagent/NativeProxyAgent.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -71,6 +72,15 @@ class NativeProxyAgent {
// Auth
val agentKey: String,
val agentUuid: String? = null,
// 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
// "buildId":"apk-sha256:<hex>". sdkBuild is sent as given
// ([BuildIdentity.sdkBuild] makes "<versionName>(<versionCode>)").
// 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.).
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -663,6 +685,79 @@ internal data class SystemInfo(
val ips: List<String>,
)

// ────────────────────────────────────────────────────────────────────────
// Build identity — the Go SDK's apk-sha256
// ────────────────────────────────────────────────────────────────────────

/**
* What AUTH says about the build. "buildId" names the exact APK the app
* 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
* 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:<hex>" 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
}

/** "<versionName>(<versionCode>)", 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<String, String?>? = null
}

// ────────────────────────────────────────────────────────────────────────
// Exponential jitter backoff — mirrors internal/backoff/backoff.go
// ────────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -1340,28 +1435,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 {
Expand All @@ -1381,6 +1455,45 @@ 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. */
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) {
Expand Down
65 changes: 65 additions & 0 deletions app/src/test/java/com/proxyagent/app/nativeagent/AuthFrameTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
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 it carries
// "buildId":"apk-sha256:<hex of that file>" and the "sdkBuild" as configured;
// without them both fields are left out, not sent empty.
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"))
}
}
101 changes: 101 additions & 0 deletions app/src/test/java/com/proxyagent/app/nativeagent/BuildIdentityTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
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 format the Go SDK sends, and "<versionName>(<versionCode>)" 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))
}
}
Loading