mirror of
https://github.com/okhsunrog/vpnhide.git
synced 2026-08-18 04:53:47 +00:00
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:
parent
cb10de9ace
commit
ecc99482ac
6 changed files with 57 additions and 13 deletions
|
|
@ -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-колбэк и упавшее перечисление сетевых интерфейсов теперь показываются как «не измерено», а не как ложный зелёный или красный вердикт.
|
||||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}") }
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue