From ecc99482ac4e2e53bc3af59b64ab97c3cf2c4137 Mon Sep 17 00:00:00 2001 From: okhsunrog Date: Fri, 14 Aug 2026 07:19:24 +0300 Subject: [PATCH] fix(app): honest diagnostics for unobservable checks; parity and hardening nits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Diagnostics honesty (user-visible): - checkNetworkCallbackVpn: a push callback that never arrives within the 3s deadline is a non-observation, not proof of hiding. It now classifies as not-measured instead of a green pass, so a broken/slow push path cannot read as protected. - checkNetworkInterfaceEnum: an exception during enumeration is unmeasurable, not a leak. It now classifies as not-measured instead of a false FAIL. Robustness, parity, and hardening: - GroundTruthProbe: run() and selfRoutedThroughVpn() shared one /data/local/tmp staged path (and one extraction dest) and both rm -f it, so concurrent runs could cp-overwrite or delete each other's binary mid-exec. Give each entry point its own staged/extraction name; prepare() keeps the stable runtime path. - StorageConfig: a native object with no explicit `enabled` now defaults to enabled, matching the Rust activator's serde default. The old default disagreed only for a bare `{}` object — display-only drift, now aligned. - AgentControlBridge: compare the bearer token in constant time (MessageDigest.isEqual) instead of String !=. - AgentControl.setNativeHooks: derive the base config from the snapshot already fetched rather than a second cache read whose correctness depended on the refresh above having seeded the cache. --- ...-no-longer-reports-an-unobservable-28b0.md | 9 +++++++ .../dev/okhsunrog/vpnhide/AgentControl.kt | 4 ++- .../okhsunrog/vpnhide/AgentControlBridge.kt | 13 +++++++++- .../dev/okhsunrog/vpnhide/GroundTruthProbe.kt | 26 +++++++++++++------ .../dev/okhsunrog/vpnhide/JavaChecks.kt | 11 ++++++-- .../dev/okhsunrog/vpnhide/StorageConfig.kt | 7 ++++- 6 files changed, 57 insertions(+), 13 deletions(-) create mode 100644 changelog.d/fixed-diagnostics-no-longer-reports-an-unobservable-28b0.md diff --git a/changelog.d/fixed-diagnostics-no-longer-reports-an-unobservable-28b0.md b/changelog.d/fixed-diagnostics-no-longer-reports-an-unobservable-28b0.md new file mode 100644 index 0000000..d5c1525 --- /dev/null +++ b/changelog.d/fixed-diagnostics-no-longer-reports-an-unobservable-28b0.md @@ -0,0 +1,9 @@ +_2026-08-14_ + +## English + +Diagnostics no longer reports an unobservable check as a pass or a leak: a push-callback that never arrives and a network-interface enumeration that throws are now shown as "not measured" instead of a misleading green or red verdict. + +## Русский + +Диагностика больше не выдаёт неизмеримую проверку за успех или утечку: не пришедший push-колбэк и упавшее перечисление сетевых интерфейсов теперь показываются как «не измерено», а не как ложный зелёный или красный вердикт. diff --git a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/AgentControl.kt b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/AgentControl.kt index 8188daf..28d36e4 100644 --- a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/AgentControl.kt +++ b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/AgentControl.kt @@ -321,7 +321,9 @@ internal object AgentControl { val hookFamily = family?.let(::parseNativeHookFamily) ?: snapshot.nativeHookFamily val entries = nativeHookEntriesFor(hookFamily) val hooks = resolveHookIds(hookIds, entries) - val base = currentCanonicalConfig(refresh = false) + // Derive base from the snapshot already fetched (not a second cache + // read), so this read-modify-write can't depend on cache ordering. + val base = snapshot.canonicalConfig ?: buildCanonicalConfigFromTargetsSnapshot(snapshot) val current = base.apps[pkg] ?: CanonicalApp() val selected = resolveNativeHookSelection(entries.map { it.hookName }, hooks.toSet()) val next = diff --git a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/AgentControlBridge.kt b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/AgentControlBridge.kt index 169a111..7d43e77 100644 --- a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/AgentControlBridge.kt +++ b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/AgentControlBridge.kt @@ -16,6 +16,7 @@ import java.net.InetSocketAddress import java.net.ServerSocket import java.net.Socket import java.net.SocketTimeoutException +import java.security.MessageDigest import java.security.SecureRandom import java.util.Base64 import kotlin.concurrent.thread @@ -136,6 +137,14 @@ private class BridgeServer( } } + private fun authorized(header: String?): Boolean { + val provided = (header ?: return false).toByteArray(Charsets.US_ASCII) + val expected = "Bearer $token".toByteArray(Charsets.US_ASCII) + // MessageDigest.isEqual is constant-time (no early return on first + // mismatch) on modern Android/JDK. + return MessageDigest.isEqual(provided, expected) + } + private fun handleClient(client: Socket) { try { val input = client.getInputStream() @@ -146,7 +155,9 @@ private class BridgeServer( } // Authenticate on the headers BEFORE allocating/reading the body, so // an unauthenticated peer can never trigger a body-sized allocation. - if (head.headers["authorization"] != "Bearer $token") { + // Constant-time compare so the check does not leak the token prefix + // through response timing (belt-and-suspenders for a 256-bit token). + if (!authorized(head.headers["authorization"])) { writeError(client, 401, "Unauthorized", "Missing or invalid bearer token") return } diff --git a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/GroundTruthProbe.kt b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/GroundTruthProbe.kt index f036cba..c3f7204 100644 --- a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/GroundTruthProbe.kt +++ b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/GroundTruthProbe.kt @@ -16,16 +16,23 @@ import java.io.File */ object GroundTruthProbe { private const val ASSET = "bin/arm64-v8a/vhprobe" - private const val STAGED = "/data/local/tmp/vpnhide_vhprobe" + + // Each entry point gets its own extraction dest and staged path so a + // concurrent run() / selfRoutedThroughVpn() cannot cp-overwrite or rm each + // other's binary mid-exec (which would spuriously fail one of them). prepare() + // keeps the stable "vhprobe" name because its path is stored as the runtime + // probe source and reused by the batched checks. + private const val STAGED_ALL = "/data/local/tmp/vpnhide_vhprobe_all" + private const val STAGED_UID = "/data/local/tmp/vpnhide_vhprobe_uid" private const val TAG = LogTags.DIAG /** id -> ground-truth outcome, or empty when root/exec is unavailable (the * caller then classifies those checks as NotMeasured(NoGroundTruth)). */ fun run(context: Context): Map { - val local = extractBinary(context) ?: return emptyMap() + val local = extractBinary(context, "vhprobe_all") ?: return emptyMap() val (exit, out) = suExec( - "cp '${local.absolutePath}' $STAGED && chmod 700 $STAGED && $STAGED; rm -f $STAGED", + "cp '${local.absolutePath}' $STAGED_ALL && chmod 700 $STAGED_ALL && $STAGED_ALL; rm -f $STAGED_ALL", ) val json = out.trim() if (exit != 0 || !json.startsWith("[")) { @@ -42,11 +49,11 @@ object GroundTruthProbe { * does not block, since a no-root device is already handled as "VPN off". */ fun selfRoutedThroughVpn(context: Context): Boolean? { - val local = extractBinary(context) ?: return null + val local = extractBinary(context, "vhprobe_uid") ?: return null val uid = android.os.Process.myUid() val (exit, out) = suExec( - "cp '${local.absolutePath}' $STAGED && chmod 700 $STAGED && $STAGED --uid $uid; rm -f $STAGED", + "cp '${local.absolutePath}' $STAGED_UID && chmod 700 $STAGED_UID && $STAGED_UID --uid $uid; rm -f $STAGED_UID", ) val json = out.trim() if (exit != 0 || !json.startsWith("{")) { @@ -57,11 +64,14 @@ object GroundTruthProbe { } /** Prepare the shared root-executable probe for batched runtime checks. */ - fun prepare(context: Context): File? = extractBinary(context) + fun prepare(context: Context): File? = extractBinary(context, "vhprobe") - private fun extractBinary(context: Context): File? = + private fun extractBinary( + context: Context, + name: String, + ): File? = runCatching { - val dest = File(context.filesDir, "vhprobe") + val dest = File(context.filesDir, name) context.assets.open(ASSET).use { input -> dest.outputStream().use { input.copyTo(it) } } dest }.onFailure { VpnHideLog.w(TAG, "failed to extract vhprobe asset: ${it.message}") } diff --git a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/JavaChecks.kt b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/JavaChecks.kt index a8ff2b5..675ad93 100644 --- a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/JavaChecks.kt +++ b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/JavaChecks.kt @@ -316,7 +316,10 @@ private fun checkNetworkInterfaceEnum(name: String): CheckResult = } javaCheck(name, vpnNames.isEmpty(), detail) } catch (e: Exception) { - javaCheck(name, false, "${e.message}") + // An exception means the enumeration could not be observed — that is + // not-measured, not a leak. Reporting it as a leak (clean=false) paints + // a false FAIL on an unmeasurable surface. + javaCheck(name, null, "${e.message}") } @Suppress("DEPRECATION") @@ -477,7 +480,11 @@ internal fun checkNetworkCallbackVpn( val fired = latch.await(3, TimeUnit.SECONDS) val caps = seen.get() if (!fired || caps == null) { - javaCheck(name, true, "no callback delivered") + // No callback within the deadline is a non-observation, not evidence + // of hiding: reporting it clean (green) would mask a broken/slow + // push path. Under the gate a default-network callback fires + // promptly, so this is a rare edge — surface it as not-measured. + javaCheck(name, null, "no callback delivered") } else { val hasVpn = caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN) val notVpn = caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN) diff --git a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/StorageConfig.kt b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/StorageConfig.kt index ca8538a..65eaa45 100644 --- a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/StorageConfig.kt +++ b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/StorageConfig.kt @@ -245,7 +245,12 @@ private fun parseNativeRole(value: Any?): NativeRole = } private fun parseNativeRoleObject(obj: JSONObject): NativeRole { - val enabled = obj.optBoolean("enabled", obj.has("kernel") || obj.has("zygisk")) + // A native object with no explicit `enabled` defaults to enabled, matching + // the Rust activator's serde default (`NativeSelectionDetail.enabled = true`), + // which is the authoritative projector. The old `has("kernel")||has("zygisk")` + // default disagreed only for a bare `{}` object — display-only drift, aligned + // here so the app shows what the activator would apply. + val enabled = obj.optBoolean("enabled", true) if (!enabled) return NativeRole.Disabled return NativeRole( enabled = true,