fix(app): honest diagnostics for unobservable checks; parity and hardening nits

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.
This commit is contained in:
okhsunrog 2026-08-14 07:19:24 +03:00
parent cb10de9ace
commit ecc99482ac
6 changed files with 57 additions and 13 deletions

View file

@ -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-колбэк и упавшее перечисление сетевых интерфейсов теперь показываются как «не измерено», а не как ложный зелёный или красный вердикт.

View file

@ -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 =

View file

@ -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
}

View file

@ -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<String, CheckOutput> {
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}") }

View file

@ -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)

View file

@ -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,