diff --git a/.github/workflows/android-ci.yml b/.github/workflows/android-ci.yml index 3f55f143..9657f601 100644 --- a/.github/workflows/android-ci.yml +++ b/.github/workflows/android-ci.yml @@ -46,7 +46,7 @@ jobs: - uses: actions/upload-artifact@v4 with: name: foxyvpn-debug-apk - path: app/build/outputs/apk/debug/*.apk + path: app/build/outputs/apk/debug/*arm64-v8a*.apk if-no-files-found: error - name: Upload diagnostics if: always() diff --git a/app/build.gradle.kts b/app/build.gradle.kts index da4c6bac..2ef5b8ea 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -23,8 +23,8 @@ android { applicationId = "com.vauth.foxyvpn" minSdk = 26 targetSdk = 35 - versionCode = 47 - versionName = "2.0.1" + versionCode = 48 + versionName = "2.0.2" } splits { @@ -112,6 +112,7 @@ dependencies { implementation("androidx.core:core-ktx:1.15.0") implementation("com.squareup.okhttp3:okhttp:4.12.0") + implementation("com.squareup.okhttp3:okhttp-dnsoverhttps:4.12.0") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0") implementation("androidx.security:security-crypto:1.1.0") diff --git a/app/src/main/java/com/vauth/foxyvpn/FoxyVpnApp.kt b/app/src/main/java/com/vauth/foxyvpn/FoxyVpnApp.kt index ee1688a2..6e50ba14 100644 --- a/app/src/main/java/com/vauth/foxyvpn/FoxyVpnApp.kt +++ b/app/src/main/java/com/vauth/foxyvpn/FoxyVpnApp.kt @@ -6,6 +6,7 @@ import android.app.NotificationManager import android.content.res.Configuration import com.vauth.foxyvpn.data.ControlPlaneHttp import com.vauth.foxyvpn.data.CrashReporter +import com.vauth.foxyvpn.data.FoxyRemoteConfig import com.vauth.foxyvpn.data.FxaAuthRepository import com.vauth.foxyvpn.data.GuardianClient import com.vauth.foxyvpn.data.ProxyStateStore @@ -54,6 +55,8 @@ class FoxyVpnApp : Application(), AppContainer { ControlPlaneHttp.bootstrapDnsConfigProvider = { settingsStore.bootstrapDnsProvider to settingsStore.effectiveBootstrapDohUrl } + FoxyRemoteConfig.init(this) + FoxyRemoteConfig.triggerRefresh() guardianClient = GuardianClient() serverListClient = ServerListClient(this) authRepository = FxaAuthRepository(tokenStore) diff --git a/app/src/main/java/com/vauth/foxyvpn/MainActivity.kt b/app/src/main/java/com/vauth/foxyvpn/MainActivity.kt index a2ecdbd3..596a237f 100644 --- a/app/src/main/java/com/vauth/foxyvpn/MainActivity.kt +++ b/app/src/main/java/com/vauth/foxyvpn/MainActivity.kt @@ -14,6 +14,7 @@ import androidx.compose.material3.Surface import androidx.compose.ui.Modifier import androidx.core.content.ContextCompat import androidx.navigation.compose.rememberNavController +import com.vauth.foxyvpn.data.FoxyRemoteConfig import com.vauth.foxyvpn.ui.navigation.FoxyNavGraph import com.vauth.foxyvpn.ui.theme.FoxyVpnTheme import com.vauth.foxyvpn.ui.theme.rememberThemeController @@ -68,6 +69,7 @@ class MainActivity : ComponentActivity() { override fun onStart() { super.onStart() + FoxyRemoteConfig.triggerRefresh() (application as FoxyVpnApp).appUpdater.check(automatic = true) } diff --git a/app/src/main/java/com/vauth/foxyvpn/data/ControlPlaneHttp.kt b/app/src/main/java/com/vauth/foxyvpn/data/ControlPlaneHttp.kt index 0f333572..ea702838 100644 --- a/app/src/main/java/com/vauth/foxyvpn/data/ControlPlaneHttp.kt +++ b/app/src/main/java/com/vauth/foxyvpn/data/ControlPlaneHttp.kt @@ -2,25 +2,19 @@ package com.vauth.foxyvpn.data import android.net.Network import android.webkit.CookieManager -import java.io.ByteArrayInputStream -import java.io.ByteArrayOutputStream -import java.io.DataInputStream -import java.io.DataOutputStream import java.net.InetAddress import java.net.InetSocketAddress import java.net.Socket -import java.net.URI import java.util.concurrent.TimeUnit import javax.net.SocketFactory import okhttp3.Cookie import okhttp3.CookieJar import okhttp3.HttpUrl import okhttp3.HttpUrl.Companion.toHttpUrlOrNull -import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Protocol import okhttp3.Request -import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.dnsoverhttps.DnsOverHttps fun Request.Builder.applyFirefoxHeaders(): Request.Builder = FirefoxAccountHeaders.apply(this) @@ -53,75 +47,6 @@ class SimpleCookieJar : CookieJar { cookies.clear() } - @Synchronized - fun syncFromWebView( - urls: List = - listOf( - "https://accounts.firefox.com", - "https://api.accounts.firefox.com", - "https://oauth.accounts.firefox.com", - "https://vpn.mozilla.org", - ), - ) { - val manager = runCatching { CookieManager.getInstance() }.getOrNull() ?: return - for (urlStr in urls) { - val httpUrl = urlStr.toHttpUrlOrNull() ?: continue - val cookieHeader = runCatching { manager.getCookie(urlStr) }.getOrNull() ?: continue - val parsedCookies = mutableListOf() - for (raw in cookieHeader.split(";")) { - val trimmed = raw.trim() - if (trimmed.isEmpty()) continue - val eqIdx = trimmed.indexOf('=') - if (eqIdx <= 0) continue - val name = trimmed.substring(0, eqIdx).trim() - val value = trimmed.substring(eqIdx + 1).trim() - if (name.isEmpty() || value.isEmpty()) continue - - val hostCookie = - runCatching { - Cookie.Builder() - .name(name) - .value(value) - .domain(httpUrl.host) - .path("/") - .apply { if (httpUrl.isHttps) secure() } - .build() - }.getOrNull() - if (hostCookie != null) parsedCookies.add(hostCookie) - - if (httpUrl.host.endsWith("firefox.com") && httpUrl.host != "firefox.com") { - val domainCookie = - runCatching { - Cookie.Builder() - .name(name) - .value(value) - .domain("firefox.com") - .path("/") - .apply { if (httpUrl.isHttps) secure() } - .build() - }.getOrNull() - if (domainCookie != null) parsedCookies.add(domainCookie) - for (target in listOf("api.accounts.firefox.com", "oauth.accounts.firefox.com", "vpn.mozilla.org")) { - val targetCookie = - runCatching { - Cookie.Builder() - .name(name) - .value(value) - .domain(target) - .path("/") - .apply { if (httpUrl.isHttps) secure() } - .build() - }.getOrNull() - if (targetCookie != null) parsedCookies.add(targetCookie) - } - } - } - if (parsedCookies.isNotEmpty()) { - saveFromResponse(httpUrl, parsedCookies) - } - } - } - @Synchronized fun syncToWebView(cookies: List) { val manager = runCatching { CookieManager.getInstance() }.getOrNull() ?: return @@ -209,130 +134,71 @@ object ControlPlaneHttp { return provider() ?: throw java.io.IOException("No underlying network is available") } - private val dohClient: OkHttpClient by lazy { + private val dohBootstrapClient: OkHttpClient by lazy { OkHttpClient.Builder() - .protocols(listOf(Protocol.HTTP_1_1)) + .protocols(listOf(Protocol.HTTP_2, Protocol.HTTP_1_1)) .socketFactory(ProtectingSocketFactory()) - .connectTimeout(5, TimeUnit.SECONDS) - .readTimeout(5, TimeUnit.SECONDS) - .callTimeout(5, TimeUnit.SECONDS) + .connectTimeout(10, TimeUnit.SECONDS) + .readTimeout(10, TimeUnit.SECONDS) + .callTimeout(10, TimeUnit.SECONDS) .build() } - private fun queryDoh(dohUrl: String, hostname: String): List { - val dohHost = runCatching { URI(dohUrl).host?.removeSurrounding("[", "]") }.getOrNull() - if (dohHost != null && dohHost.equals(hostname, ignoreCase = true)) { - return emptyList() - } - - var httpReachable = false - fun sendDohQuery(type: Int): List { - val queryBytes = buildDnsQuery(hostname, type) - val request = - Request.Builder() - .url(dohUrl) - .post(queryBytes.toRequestBody("application/dns-message".toMediaType())) - .header("Accept", "application/dns-message") - .build() - - return runCatching { - dohClient.newCall(request).execute().use { response -> - if (!response.isSuccessful) return emptyList() - httpReachable = true - val responseBody = response.body?.bytes() ?: return emptyList() - parseDnsResponse(responseBody, hostname) - } - }.getOrDefault(emptyList()) - } - - val ipv4Results = sendDohQuery(type = 1) - if (ipv4Results.isNotEmpty() || !httpReachable) return ipv4Results - return sendDohQuery(type = 28) - } - - private fun buildDnsQuery(hostname: String, type: Int): ByteArray { - val baos = ByteArrayOutputStream() - val dos = DataOutputStream(baos) - dos.writeShort(0x1a2b) - dos.writeShort(0x0100) - dos.writeShort(1) - dos.writeShort(0) - dos.writeShort(0) - dos.writeShort(0) + @Volatile private var cachedDohUrl: String? = null + @Volatile private var cachedDnsOverHttps: DnsOverHttps? = null + private val dohLock = Any() - for (label in hostname.split('.')) { - if (label.isEmpty()) continue - val bytes = label.toByteArray(Charsets.US_ASCII) - dos.writeByte(bytes.size) - dos.write(bytes) + private fun getOrCreateDoh(dohUrl: String): DnsOverHttps? { + if (cachedDohUrl == dohUrl) { + val cached = cachedDnsOverHttps + if (cached != null) return cached } - dos.writeByte(0) - dos.writeShort(type) - dos.writeShort(1) - return baos.toByteArray() - } - - private fun parseDnsResponse(data: ByteArray, hostname: String): List { - if (data.size < 12) return emptyList() - val dis = DataInputStream(ByteArrayInputStream(data)) - dis.readShort() - val flags = dis.readUnsignedShort() - val isResponse = (flags and 0x8000) != 0 - val rcode = flags and 0x000F - if (!isResponse || rcode != 0) return emptyList() - val qdCount = dis.readUnsignedShort() - val anCount = dis.readUnsignedShort() - dis.readUnsignedShort() - dis.readUnsignedShort() - - for (i in 0 until qdCount) { - skipDomainName(dis) - dis.readShort() - dis.readShort() - } - - val results = mutableListOf() - for (i in 0 until anCount) { - skipDomainName(dis) - val type = dis.readUnsignedShort() - val clazz = dis.readUnsignedShort() - dis.readInt() - val rdLength = dis.readUnsignedShort() - val rdata = ByteArray(rdLength) - dis.readFully(rdata) - - if (clazz == 1) { - if (type == 1 && rdLength == 4) { - runCatching { InetAddress.getByAddress(hostname, rdata) } - .getOrNull() - ?.let { results.add(it) } - } else if (type == 28 && rdLength == 16) { - runCatching { InetAddress.getByAddress(hostname, rdata) } - .getOrNull() - ?.let { results.add(it) } - } + return synchronized(dohLock) { + if (cachedDohUrl == dohUrl) { + val cached = cachedDnsOverHttps + if (cached != null) return@synchronized cached } - } - return results - } - - private fun skipDomainName(dis: DataInputStream) { - var steps = 0 - while (steps++ < 128) { - val len = dis.readUnsignedByte() - if (len == 0) break - if ((len and 0xC0) == 0xC0) { - dis.readByte() - break + val httpUrl = dohUrl.toHttpUrlOrNull() ?: return@synchronized null + val host = httpUrl.host.removeSurrounding("[", "]") + val bootstrapHosts = mutableListOf() + if (SettingsStore.isValidIpAddress(host)) { + runCatching { InetAddress.getByName(host) }.getOrNull()?.let { bootstrapHosts.add(it) } } else { - dis.skipBytes(len) + when { + host.equals("dns.google", ignoreCase = true) -> { + bootstrapHosts.add(InetAddress.getByName("8.8.8.8")) + bootstrapHosts.add(InetAddress.getByName("8.8.4.4")) + } + host.equals("cloudflare-dns.com", ignoreCase = true) || + host.equals("one.one.one.one", ignoreCase = true) -> { + bootstrapHosts.add(InetAddress.getByName("1.1.1.1")) + bootstrapHosts.add(InetAddress.getByName("1.0.0.1")) + } + host.equals("dns.quad9.net", ignoreCase = true) -> { + bootstrapHosts.add(InetAddress.getByName("9.9.9.9")) + bootstrapHosts.add(InetAddress.getByName("149.112.112.112")) + } + } + } + val builder = + DnsOverHttps.Builder() + .client(dohBootstrapClient) + .url(httpUrl) + .post(true) + .includeIPv6(true) + if (bootstrapHosts.isNotEmpty()) { + builder.bootstrapDnsHosts(bootstrapHosts) } + val doh = builder.build() + cachedDohUrl = dohUrl + cachedDnsOverHttps = doh + doh } } val client: OkHttpClient by lazy { OkHttpClient.Builder() - .protocols(listOf(Protocol.HTTP_1_1)) + .protocols(listOf(Protocol.HTTP_2, Protocol.HTTP_1_1)) .cookieJar(cookieJar) .socketFactory(ProtectingSocketFactory()) .dns( @@ -345,9 +211,9 @@ object ControlPlaneHttp { val provider = config?.first ?: SettingsStore.DnsProvider.DIRECT_SYSTEM val dohUrl = config?.second.orEmpty() if (provider != SettingsStore.DnsProvider.DIRECT_SYSTEM && dohUrl.isNotBlank()) { - val dohResults = runCatching { queryDoh(dohUrl, hostname) }.getOrNull() - if (!dohResults.isNullOrEmpty()) { - return dohResults + val doh = getOrCreateDoh(dohUrl) + if (doh != null) { + return doh.lookup(hostname) } } val network = runCatching { underlyingNetwork() }.getOrNull() diff --git a/app/src/main/java/com/vauth/foxyvpn/data/FastlyChallengeSolver.kt b/app/src/main/java/com/vauth/foxyvpn/data/FastlyChallengeSolver.kt index e7feda03..974d7075 100644 --- a/app/src/main/java/com/vauth/foxyvpn/data/FastlyChallengeSolver.kt +++ b/app/src/main/java/com/vauth/foxyvpn/data/FastlyChallengeSolver.kt @@ -12,11 +12,11 @@ import okhttp3.Cookie import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient -import okhttp3.Protocol import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import org.json.JSONArray import org.json.JSONObject +import org.json.JSONTokener class FastlyChallengeError(message: String) : Exception(message) @@ -24,9 +24,6 @@ private const val SOLVE_TIMEOUT_SECONDS = 60L private const val MAX_POST_BACK_ROUNDS = 8 -private const val SOLVER_USER_AGENT = - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36" - private const val POW_ALPHABET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" private const val HTML_ACCEPT = "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8" @@ -43,7 +40,7 @@ class FastlyChallengeSolver(private val cookieJar: SimpleCookieJar) { suspend fun solveAndInstall( challengedBody: String? = null, challengedOrigin: String? = null, - userAgent: String = SOLVER_USER_AGENT, + userAgent: String = FirefoxAccountHeaders.controlPlaneUserAgent, ) = withTimeout(90_000L) { solverMutex.withLock { @@ -65,47 +62,30 @@ class FastlyChallengeSolver(private val cookieJar: SimpleCookieJar) { ) { "Untrusted challenge origin" } - cookieJar.syncFromWebView() val base = "https://${origin.host}" if (!challengedBody.isNullOrEmpty() && isChallengePage(challengedBody)) { - solveChallengePage(challengedBody, base, userAgent) + val solverJar = SimpleCookieJar() + val solver = newSolverClient(solverJar) + solveChallengePage(challengedBody, base, userAgent, solver, solverJar) return@withContext } val bases = listOf( base, - "https://accounts.firefox.com", "https://api.accounts.firefox.com", + "https://accounts.firefox.com", ).distinct() - val candidateUrls = - bases.flatMap { baseHost -> - if (baseHost.contains("accounts.firefox.com") && !baseHost.contains("api.")) { - listOf("$baseHost/", "$baseHost/?context=fx_desktop_v3&service=sync&action=email") - } else { - listOf("$baseHost/") - } - } var lastError: FastlyChallengeError? = null - for (candidateUrl in candidateUrls) { - val candidateBase = - if (candidateUrl.contains('?')) candidateUrl.substringBefore('?').trimEnd('/') - else candidateUrl.trimEnd('/') - for (candidateUA in listOf(userAgent, SOLVER_USER_AGENT, FirefoxAccountHeaders.userAgent).distinct()) { + for (candidateBase in bases) { + for (candidateUA in listOf(userAgent, FirefoxAccountHeaders.browserUserAgent, FirefoxAccountHeaders.desktopUserAgent).distinct()) { try { - val page = - fetchText( - newSolverClient(SimpleCookieJar()), - candidateUrl, - userAgent = candidateUA, - allowChallenge = true, - ) - if (!isChallengePage(page)) continue - solveChallengePage(page, candidateBase, candidateUA) - return@withContext + if (solveChallengeOnHost(candidateBase, candidateUA)) { + return@withContext + } } catch (error: FastlyChallengeError) { lastError = error } catch (e: Exception) { - lastError = FastlyChallengeError("Error probing $candidateUrl: ${e.message}") + lastError = FastlyChallengeError("Error probing $candidateBase: ${e.message}") } } } @@ -143,12 +123,15 @@ class FastlyChallengeSolver(private val cookieJar: SimpleCookieJar) { client: OkHttpClient, url: String, accept: String = HTML_ACCEPT, - userAgent: String = SOLVER_USER_AGENT, + userAgent: String = FirefoxAccountHeaders.controlPlaneUserAgent, allowChallenge: Boolean = false, ): String { + val builder = Request.Builder().url(url) + for ((key, value) in FoxyRemoteConfig.current.solver.headers) { + builder.header(key, value) + } val request = - Request.Builder() - .url(url) + builder .header("User-Agent", userAgent) .header("Accept", accept) .build() @@ -167,69 +150,44 @@ class FastlyChallengeSolver(private val cookieJar: SimpleCookieJar) { text.contains("script.js") || text.contains("fst-post-back")) - private fun parseChallengeInit(script: String): Pair { - for (match in INIT_CALL_REGEX.findAll(script).toList().asReversed()) { + private fun parseChallengeInit(script: String, prefix: String = ""): Pair { + val matches = INIT_CALL_REGEX.findAll(script).toList().asReversed() + var legacyResult: Pair? = null + + for (match in matches) { val start = match.range.last - var depth = 0 - var quoted = false - var escaped = false - for (index in start until script.length) { - val char = script[index] - if (quoted) { - when { - escaped -> escaped = false - char == '\\' -> escaped = true - char == '"' -> quoted = false + val tokener = JSONTokener(script.substring(start)) + val challenges = runCatching { tokener.nextValue() as? JSONArray }.getOrNull() + if (challenges == null || challenges.length() == 0 || challenges.length() > 16) continue + if (tokener.nextClean() != ',') continue + val token = runCatching { tokener.nextValue() as? String }.getOrNull() + if (token.isNullOrBlank()) continue + + val delim = tokener.nextClean() + if (delim == ',') { + val scriptPrefix = runCatching { tokener.nextValue() as? String }.getOrNull() + val nextDelim = tokener.nextClean() + val hasValidDelimiter = nextDelim == ')' || nextDelim == ',' + if (hasValidDelimiter && (prefix.isEmpty() || scriptPrefix == prefix)) { + val valid = (0 until challenges.length()).all { + val item = challenges.optJSONObject(it) + item != null && item.optString("ty").isNotBlank() } - continue + if (valid) return challenges to token } - when (char) { - '"' -> quoted = true - '[' -> depth++ - ']' -> { - depth-- - if (depth != 0) continue - val tail = script.substring(index + 1).trimStart() - if (!tail.startsWith(",")) break - val args = tail.drop(1).trimStart() - if (!args.startsWith('"')) break - var end = 1 - var escape = false - while (end < args.length) { - val next = args[end] - if (!escape && next == '"') break - escape = !escape && next == '\\' - end++ - } - if ( - end >= args.length || - !args.substring(end + 1).trimStart().startsWith(",") - ) - break - try { - val challenges = JSONArray(script.substring(start, index + 1)) - val token = - JSONArray("[" + args.substring(0, end + 1) + "]").getString(0) - if ( - challenges.length() > 0 && - challenges.length() <= 16 && - token.isNotBlank() && - (0 until challenges.length()).all { - val item = challenges.optJSONObject(it) - item != null && - item.opt("ty") is String && - item.getString("ty").isNotBlank() - } - ) - return challenges to token - } catch (_: org.json.JSONException) { - break - } - break - } + } else if (delim == ')') { + val valid = (0 until challenges.length()).all { + val item = challenges.optJSONObject(it) + item != null && item.optString("ty").isNotBlank() + } + if (valid && legacyResult == null) { + legacyResult = challenges to token } } } + + if (legacyResult != null) return legacyResult + throw FastlyChallengeError("Could not read the Fastly challenge") } @@ -240,9 +198,12 @@ class FastlyChallengeSolver(private val cookieJar: SimpleCookieJar) { userAgent: String, ): String { val patUrl = "$prefixUrl/pat?token=${URLEncoder.encode(token, "UTF-8")}" + val builder = Request.Builder().url(patUrl) + for ((key, value) in FoxyRemoteConfig.current.solver.headers) { + builder.header(key, value) + } val request = - Request.Builder() - .url(patUrl) + builder .header("Accept", "text/plain") .header("Content-Type", "application/json") .header("User-Agent", userAgent) @@ -402,7 +363,6 @@ class FastlyChallengeSolver(private val cookieJar: SimpleCookieJar) { private fun newSolverClient(solverJar: SimpleCookieJar): OkHttpClient = ControlPlaneHttp.client .newBuilder() - .protocols(listOf(Protocol.HTTP_1_1)) .cookieJar(solverJar) .followRedirects(false) .followSslRedirects(false) @@ -410,18 +370,40 @@ class FastlyChallengeSolver(private val cookieJar: SimpleCookieJar) { .readTimeout(SOLVE_TIMEOUT_SECONDS, TimeUnit.SECONDS) .build() - private suspend fun solveChallengePage(page: String, base: String, userAgent: String) { + private suspend fun solveChallengeOnHost(base: String, userAgent: String): Boolean { val solverJar = SimpleCookieJar() val solver = newSolverClient(solverJar) val pageUrl = "$base/" + val page = + try { + fetchText(solver, pageUrl, userAgent = userAgent, allowChallenge = true) + } catch (_: Exception) { + return false + } + if (!isChallengePage(page)) { + return false + } + solveChallengePage(page, base, userAgent, solver, solverJar) + return true + } + + private suspend fun solveChallengePage( + page: String, + base: String, + userAgent: String, + solver: OkHttpClient, + solverJar: SimpleCookieJar, + ) { + val pageUrl = "$base/" + val prefixMatch = CHALLENGE_PREFIX_REGEX.find(page) ?: throw FastlyChallengeError("challenge asset prefix not found on $base") val prefixUrl = base + prefixMatch.value val script = fetchText(solver, "$prefixUrl/script.js?reload=true", userAgent = userAgent) - var (challenges, token) = parseChallengeInit(script) + var (challenges, token) = parseChallengeInit(script, prefixMatch.value) repeat(MAX_POST_BACK_ROUNDS) { val answers = JSONArray() @@ -444,9 +426,12 @@ class FastlyChallengeSolver(private val cookieJar: SimpleCookieJar) { } .toString() .toRequestBody("application/json".toMediaType()) + val builder = Request.Builder().url("$prefixUrl/fst-post-back") + for ((key, value) in FoxyRemoteConfig.current.solver.headers) { + builder.header(key, value) + } val request = - Request.Builder() - .url("$prefixUrl/fst-post-back") + builder .header("Content-Type", "application/json") .header("Accept", "application/json") .header("User-Agent", userAgent) diff --git a/app/src/main/java/com/vauth/foxyvpn/data/FirefoxAccountHeaders.kt b/app/src/main/java/com/vauth/foxyvpn/data/FirefoxAccountHeaders.kt index ad25ca9d..62c00e57 100644 --- a/app/src/main/java/com/vauth/foxyvpn/data/FirefoxAccountHeaders.kt +++ b/app/src/main/java/com/vauth/foxyvpn/data/FirefoxAccountHeaders.kt @@ -13,16 +13,29 @@ internal object FirefoxAccountHeaders { private const val PRODUCT_DETAILS_URL = "https://product-details.mozilla.org/1.0/firefox_versions.json" private val mutex = Mutex() - @Volatile private var major = "135" + @Volatile private var major = "159" private var nextRefreshNanos: Long? = null - val userAgent: String + val controlPlaneUserAgent: String + get() = FoxyRemoteConfig.current.controlPlane.headers["User-Agent"] + ?: resolvePlaceholders("MozillaVPN/2.24.0 (sys:android {android_version})") + + val browserUserAgent: String + get() = FoxyRemoteConfig.current.browser.headers["User-Agent"] + ?: resolvePlaceholders("Mozilla/5.0 (Android {android_version}; Mobile; rv:$major.0) Gecko/$major.0 Firefox/$major.0") + + val desktopUserAgent: String get() = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:$major.0) Gecko/20100101 Firefox/$major.0" + val userAgent: String + get() = controlPlaneUserAgent + fun apply(builder: Request.Builder): Request.Builder { + val headers = FoxyRemoteConfig.current.controlPlane.headers + for ((key, value) in headers) { + builder.header(key, value) + } return builder - .header("User-Agent", userAgent) - .header("Accept", "*/*") } suspend fun refresh(client: OkHttpClient) { diff --git a/app/src/main/java/com/vauth/foxyvpn/data/FoxyRemoteConfig.kt b/app/src/main/java/com/vauth/foxyvpn/data/FoxyRemoteConfig.kt new file mode 100644 index 00000000..73109aa7 --- /dev/null +++ b/app/src/main/java/com/vauth/foxyvpn/data/FoxyRemoteConfig.kt @@ -0,0 +1,255 @@ +package com.vauth.foxyvpn.data + +import android.content.Context +import android.os.Build +import java.io.File +import java.util.concurrent.atomic.AtomicBoolean +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +import okhttp3.OkHttpClient +import okhttp3.Protocol +import okhttp3.Request +import org.json.JSONObject + +data class ComponentConfig( + val headers: Map, + val protocols: List = listOf(Protocol.HTTP_2, Protocol.HTTP_1_1), +) + +data class RemoteConfig( + val version: Int, + val controlPlane: ComponentConfig, + val guardian: ComponentConfig, + val solver: ComponentConfig, + val browser: ComponentConfig, +) + +internal val ANDROID_RELEASE: String = + Build.VERSION.RELEASE?.trim()?.takeIf { it.isNotEmpty() } ?: "14" + +internal val ANDROID_SDK: String = + Build.VERSION.SDK_INT.toString() + +internal fun resolvePlaceholders(raw: String): String = + raw.replace("\${android_version}", ANDROID_RELEASE, ignoreCase = true) + .replace("{android_version}", ANDROID_RELEASE, ignoreCase = true) + .replace("\${os_version}", ANDROID_RELEASE, ignoreCase = true) + .replace("{os_version}", ANDROID_RELEASE, ignoreCase = true) + .replace("\${android_sdk}", ANDROID_SDK, ignoreCase = true) + .replace("{android_sdk}", ANDROID_SDK, ignoreCase = true) + +private val DEFAULT_CONTROL_PLANE = + ComponentConfig( + headers = + mapOf( + "User-Agent" to resolvePlaceholders("MozillaVPN/2.24.0 (sys:android {android_version})"), + "Accept" to "*/*", + "Accept-Encoding" to "identity", + ), + protocols = listOf(Protocol.HTTP_2, Protocol.HTTP_1_1), + ) + +private val DEFAULT_GUARDIAN = + ComponentConfig( + headers = + mapOf( + "User-Agent" to resolvePlaceholders("MozillaVPN/2.24.0 (sys:android {android_version})"), + "Accept" to "application/json", + "Accept-Encoding" to "identity", + "Content-Type" to "application/json", + "Cache-Control" to "max-age=0", + ), + ) + +private val DEFAULT_SOLVER = + ComponentConfig( + headers = + mapOf( + "User-Agent" to resolvePlaceholders("MozillaVPN/2.24.0 (sys:android {android_version})"), + "Accept-Encoding" to "identity", + ), + ) + +private val DEFAULT_BROWSER = + ComponentConfig( + headers = + mapOf( + "User-Agent" to + resolvePlaceholders( + "Mozilla/5.0 (Android {android_version}; Mobile; rv:159.0) Gecko/159.0 Firefox/159.0" + ), + ), + ) + +val DEFAULT_REMOTE_CONFIG = + RemoteConfig( + version = 1, + controlPlane = DEFAULT_CONTROL_PLANE, + guardian = DEFAULT_GUARDIAN, + solver = DEFAULT_SOLVER, + browser = DEFAULT_BROWSER, + ) + +object FoxyRemoteConfig { + private const val TAG = "FoxyRemoteConfig" + private const val FILE_NAME = "foxy_config.json" + private const val PRIMARY_URL = + "https://raw.githubusercontent.com/Vauth/FoxyConfig/main/config.json" + private const val FALLBACK_URL = + "https://cdn.jsdelivr.net/gh/Vauth/FoxyConfig@main/config.json" + + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private val refreshMutex = Mutex() + private val isRefreshing = AtomicBoolean(false) + + @Volatile + private var appContext: Context? = null + + @Volatile + var current: RemoteConfig = DEFAULT_REMOTE_CONFIG + private set + + @Volatile + private var lastRawJson: String = "" + + fun resolvePlaceholders(raw: String): String = com.vauth.foxyvpn.data.resolvePlaceholders(raw) + + fun init(context: Context) { + val app = context.applicationContext + appContext = app + val file = File(app.filesDir, FILE_NAME) + if (file.isFile && file.length() > 0) { + val text = runCatching { file.readText() }.getOrNull().orEmpty() + val parsed = parseConfig(text) + if (parsed != null) { + current = parsed + lastRawJson = text + } + } + } + + fun triggerRefresh(client: OkHttpClient = ControlPlaneHttp.client) { + val context = appContext ?: return + if (!isRefreshing.compareAndSet(false, true)) return + scope.launch { + try { + refresh(context, client) + } finally { + isRefreshing.set(false) + } + } + } + + suspend fun refresh(context: Context, client: OkHttpClient = ControlPlaneHttp.client) { + withContext(Dispatchers.IO) { + refreshMutex.withLock { + val fetchedJson = + fetchRemoteJson(client, PRIMARY_URL) + ?: fetchRemoteJson(client, FALLBACK_URL) + ?: return@withContext + + if (fetchedJson == lastRawJson) return@withContext + + val parsed = parseConfig(fetchedJson) ?: return@withContext + + val file = File(context.filesDir, FILE_NAME) + val tempFile = File(context.filesDir, "$FILE_NAME.tmp") + val writeSuccess = + runCatching { + tempFile.writeText(fetchedJson) + if (tempFile.renameTo(file)) { + true + } else { + tempFile.copyTo(file, overwrite = true) + tempFile.delete() + true + } + }.getOrDefault(false) + + if (writeSuccess) { + current = parsed + lastRawJson = fetchedJson + AppLogger.i(TAG, "config updated") + } + } + } + } + + private suspend fun fetchRemoteJson(client: OkHttpClient, url: String): String? { + return try { + withTimeoutOrNull(8_000L) { + val request = + Request.Builder() + .url(url) + .header("Accept", "application/json") + .header("Cache-Control", "no-cache") + .build() + client.newCall(request).awaitResponse().use { response -> + if (!response.isSuccessful) return@withTimeoutOrNull null + val text = response.body?.readBoundedText(128 * 1024).orEmpty().trim() + if (text.startsWith("{") && text.endsWith("}")) text else null + } + } + } catch (cancellation: CancellationException) { + throw cancellation + } catch (_: Exception) { + null + } + } + + private fun parseConfig(jsonString: String): RemoteConfig? { + return runCatching { + val root = JSONObject(jsonString) + val version = root.optInt("version", 1) + + fun parseComponent(name: String, default: ComponentConfig): ComponentConfig { + val obj = root.optJSONObject(name) ?: return default + val headersObj = obj.optJSONObject("headers") + val headers = + if (headersObj != null) { + val map = mutableMapOf() + for (key in headersObj.keys()) { + val value = headersObj.optString(key, "") + if (value.isNotEmpty()) { + map[key] = resolvePlaceholders(value) + } + } + if (map.isNotEmpty()) map else default.headers + } else default.headers + + val settingsObj = obj.optJSONObject("settings") + val protocols = + if (settingsObj != null && settingsObj.has("protocols")) { + val arr = settingsObj.optJSONArray("protocols") + if (arr != null && arr.length() > 0) { + val list = mutableListOf() + for (i in 0 until arr.length()) { + when (arr.optString(i).uppercase()) { + "HTTP_2", "H2" -> list.add(Protocol.HTTP_2) + "HTTP_1_1", "HTTP1.1" -> list.add(Protocol.HTTP_1_1) + } + } + if (list.isNotEmpty()) list else default.protocols + } else default.protocols + } else default.protocols + + return ComponentConfig(headers = headers, protocols = protocols) + } + + RemoteConfig( + version = version, + controlPlane = parseComponent("controlPlane", DEFAULT_CONTROL_PLANE), + guardian = parseComponent("guardian", DEFAULT_GUARDIAN), + solver = parseComponent("solver", DEFAULT_SOLVER), + browser = parseComponent("browser", DEFAULT_BROWSER), + ) + }.getOrNull() + } +} diff --git a/app/src/main/java/com/vauth/foxyvpn/data/FxaAuthRepository.kt b/app/src/main/java/com/vauth/foxyvpn/data/FxaAuthRepository.kt index b346b5e4..776beab3 100644 --- a/app/src/main/java/com/vauth/foxyvpn/data/FxaAuthRepository.kt +++ b/app/src/main/java/com/vauth/foxyvpn/data/FxaAuthRepository.kt @@ -9,6 +9,7 @@ import javax.crypto.spec.SecretKeySpec import kotlin.coroutines.coroutineContext import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.ensureActive import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -17,7 +18,6 @@ import kotlinx.coroutines.withTimeout import okhttp3.HttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.MediaType.Companion.toMediaType -import okhttp3.Protocol import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.Response @@ -36,12 +36,15 @@ class FxaApiError(message: String, val errno: Int?, val statusCode: Int?) : Exce val FxaApiError.isUnconfirmedSession: Boolean get() = errno == 104 || - (statusCode == 400 && - message?.contains("Unconfirmed session", ignoreCase = true) == true) + errno == 138 || + ((statusCode == 400 || statusCode == 401) && + (message?.contains("unconfirmed", ignoreCase = true) == true || + message?.contains("unverified", ignoreCase = true) == true)) fun Throwable.isUnconfirmedSession(): Boolean = (this as? FxaApiError)?.isUnconfirmedSession == true || - message?.contains("Unconfirmed session", ignoreCase = true) == true + message?.contains("unconfirmed", ignoreCase = true) == true || + message?.contains("unverified", ignoreCase = true) == true class FxaRefreshFailure(message: String, val permanent: Boolean, cause: Throwable? = null) : Exception(message, cause) @@ -55,6 +58,8 @@ private const val OAUTH_SCOPE = "profile https://identity.mozilla.com/apps/vpn" private const val PROTOCOL_VERSION = "identity.mozilla.com/picl/v1/" private const val FXA_MAX_CHALLENGE_ATTEMPTS = 5 private const val DEFAULT_ACCESS_TOKEN_TTL_SECONDS = 24 * 60 * 60 +private const val MAX_CONFIRM_ATTEMPTS = 100 +private const val CONFIRM_POLL_DELAY_MS = 3000L private const val WEBCHANNEL_LOGIN_URL = "https://accounts.firefox.com/?context=fx_desktop_v3&service=sync&action=email" @@ -71,7 +76,6 @@ class FxaAuthRepository( private val client = ControlPlaneHttp.client .newBuilder() - .protocols(listOf(Protocol.HTTP_1_1)) .followRedirects(false) .followSslRedirects(false) .build() @@ -79,24 +83,33 @@ class FxaAuthRepository( fun getBrowserLoginUrl(): String = WEBCHANNEL_LOGIN_URL - fun buildOAuthHawkHeader(sessionToken: String): Pair { - val clean = sessionToken.trim().replace(Regex("[\\s\"]"), "").lowercase() - require(Regex("^[0-9a-f]{64}$").matches(clean)) { - "The sign-in page did not hand over a usable session token" - } - val url = "$FXA_AUTH_SERVER/oauth/token".toHttpUrl() - val body = - JSONObject().apply { - put("client_id", FIREFOX_CLIENT_ID) - put("grant_type", "fxa-credentials") - put("scope", OAUTH_SCOPE) - put("access_type", "offline") + suspend fun isSessionVerified(sessionToken: String): Boolean = + withContext(Dispatchers.IO) { + val clean = sessionToken.trim().replace(Regex("[\\s\"]"), "").lowercase() + if (!Regex("^[0-9a-f]{64}$").matches(clean)) return@withContext false + val url = "$FXA_AUTH_SERVER/recovery_email/status".toHttpUrl() + val (tokenId, hmacKey) = deriveHawkCredentials(clean) + val header = hawkHeader("GET", url, tokenId, hmacKey, ByteArray(0)) + val request = + Request.Builder() + .url(url) + .applyFirefoxHeaders() + .header("Authorization", header) + .get() + .build() + try { + client.newCall(request).awaitResponse().use { response -> + if (response.code != 200) return@withContext false + val text = response.body?.readBoundedText().orEmpty() + if (text.isBlank()) return@withContext false + JSONObject(text).optBoolean("verified", false) + } + } catch (cancellation: CancellationException) { + throw cancellation + } catch (_: Exception) { + false } - val bodyStr = body.toString() - val (tokenId, hmacKey) = deriveHawkCredentials(clean) - val header = hawkHeader("POST", url, tokenId, hmacKey, bodyStr.toByteArray(Charsets.UTF_8)) - return header to bodyStr - } + } suspend fun completeBrowserLoginWithTokens( accessToken: String, @@ -105,7 +118,6 @@ class FxaAuthRepository( ): Result = withContext(Dispatchers.IO) { val epoch = tokenStore.sessionEpoch - ControlPlaneHttp.cookieJar.syncFromWebView() runCatching { loginMutex.withLock { check(tokenStore.sessionEpoch == epoch) { "Sign-in was superseded" } @@ -129,7 +141,6 @@ class FxaAuthRepository( suspend fun completeBrowserLogin(sessionToken: String): Result = withContext(Dispatchers.IO) { val epoch = tokenStore.sessionEpoch - ControlPlaneHttp.cookieJar.syncFromWebView() runCatching { loginMutex.withLock { val clean = sessionToken.trim().replace(Regex("[\\s\"]"), "").lowercase() @@ -217,7 +228,11 @@ class FxaAuthRepository( val tokenData = try { FirefoxAccountHeaders.refresh(client) - executeFxaRequest(FXA_OAUTH_SERVER, "/oauth/token", jsonBody = body) + try { + executeFxaRequest(FXA_OAUTH_SERVER, "/token", jsonBody = body) + } catch (_: FastlyBlockedException) { + executeFxaRequest(FXA_AUTH_SERVER, "/oauth/token", jsonBody = body) + } } catch (rejected: FxaApiError) { throw FxaRefreshFailure( rejected.message ?: "FxA rejected the refresh token", @@ -276,7 +291,7 @@ class FxaAuthRepository( } private fun FxaApiError.isPermanentRefreshRejection(): Boolean { - return statusCode == 400 || statusCode == 401 || statusCode == 403 + return statusCode == 400 || statusCode == 401 || statusCode == 403 || errno == 108 } private fun expiryFrom(tokenData: JSONObject): Long { @@ -285,36 +300,146 @@ class FxaAuthRepository( return System.currentTimeMillis() / 1000 + expiresIn } + suspend fun destroySession(sessionToken: String) = + withContext(Dispatchers.IO) { + val clean = sessionToken.trim().replace(Regex("[\\s\"]"), "").lowercase() + if (!Regex("^[0-9a-f]{64}$").matches(clean)) return@withContext + val url = "$FXA_AUTH_SERVER/session/destroy".toHttpUrl() + val (tokenId, hmacKey) = deriveHawkCredentials(clean) + val bodyBytes = "{}".toByteArray(Charsets.UTF_8) + val header = hawkHeader("POST", url, tokenId, hmacKey, bodyBytes) + val request = + Request.Builder() + .url(url) + .applyFirefoxHeaders() + .header("Content-Type", "application/json") + .header("Authorization", header) + .post("{}".toRequestBody(jsonMediaType)) + .build() + runCatching { + client.newCall(request).awaitResponse().use { } + } + } + private suspend fun completeLogin(sessionToken: String, epoch: Long) { - val tokenData = oauthToken(sessionToken) + var confirmed = false + for (attempt in 0 until MAX_CONFIRM_ATTEMPTS) { + coroutineContext.ensureActive() + if (isSessionVerified(sessionToken)) { + confirmed = true + break + } + delay(CONFIRM_POLL_DELAY_MS) + } + if (!confirmed) { + throw FxaApiError("Sign-in verification timed out. Please try again.", null, 408) + } + var tokenData: JSONObject? = null + var lastError: Throwable? = null + for (attempt in 0 until 10) { + coroutineContext.ensureActive() + try { + tokenData = exchangeSessionWithOAuthCode(sessionToken) + break + } catch (cancellation: CancellationException) { + throw cancellation + } catch (e: Exception) { + lastError = e + if (e.isUnconfirmedSession() && attempt < 9) { + delay(CONFIRM_POLL_DELAY_MS) + continue + } + throw e + } + } + val data = tokenData ?: throw (lastError ?: FxaApiError("Sign-in failed", null, 500)) coroutineContext.ensureActive() + val accessToken = + data.optString("access_token").takeIf { it.isNotBlank() } + ?: error("Firefox Accounts returned no access token") + val refreshToken = data.optString("refresh_token", "").ifBlank { null } tokenStore.completeLoginIfCurrent( RuntimeAuth( - accessToken = - tokenData.optString("access_token").takeIf { it.isNotBlank() } - ?: error("Firefox Accounts returned no access token"), - refreshToken = tokenData.optString("refresh_token", "").ifBlank { null }, - expiresAtEpochSeconds = expiryFrom(tokenData), + accessToken = accessToken, + refreshToken = refreshToken, + expiresAtEpochSeconds = expiryFrom(data), ), epoch, ) + destroySession(sessionToken) } - private suspend fun oauthToken(sessionToken: String): JSONObject { - val body = - JSONObject().apply { - put("client_id", FIREFOX_CLIENT_ID) - put("grant_type", "fxa-credentials") - put("scope", OAUTH_SCOPE) - put("access_type", "offline") + private suspend fun exchangeSessionWithOAuthCode(sessionToken: String): JSONObject = + withContext(Dispatchers.IO) { + val secret = ByteArray(32).also { SecureRandom().nextBytes(it) } + val verifier = + Base64.encodeToString( + secret, + Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING, + ) + val challengeBytes = + MessageDigest.getInstance("SHA-256") + .digest(verifier.toByteArray(Charsets.US_ASCII)) + val codeChallenge = + Base64.encodeToString( + challengeBytes, + Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING, + ) + val stateBytes = ByteArray(16).also { SecureRandom().nextBytes(it) } + val state = + Base64.encodeToString( + stateBytes, + Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING, + ) + + val authorizeBody = + JSONObject().apply { + put("client_id", FIREFOX_CLIENT_ID) + put("response_type", "code") + put("scope", OAUTH_SCOPE) + put("state", state) + put("access_type", "offline") + put("code_challenge", codeChallenge) + put("code_challenge_method", "S256") + } + + val authResponse = + executeFxaRequest( + baseUrl = FXA_AUTH_SERVER, + path = "/oauth/authorization", + jsonBody = authorizeBody, + sessionToken = sessionToken, + ) + + val code = authResponse.optString("code", "") + val returnedState = authResponse.optString("state", "") + if (code.isBlank() || returnedState != state) { + throw FxaApiError("Invalid OAuth authorization response", null, 500) } - return executeFxaRequest( - baseUrl = FXA_AUTH_SERVER, - path = "/oauth/token", - jsonBody = body, - sessionToken = sessionToken, - ) - } + + val tokenBody = + JSONObject().apply { + put("client_id", FIREFOX_CLIENT_ID) + put("grant_type", "authorization_code") + put("code", code) + put("code_verifier", verifier) + } + + val tokens = + executeFxaRequest( + baseUrl = FXA_OAUTH_SERVER, + path = "/token", + jsonBody = tokenBody, + sessionToken = null, + ) + + if (tokens.optString("access_token").isBlank()) { + throw FxaApiError("OAuth token response is missing access token", null, 500) + } + + tokens + } + private suspend fun executeFxaRequest( baseUrl: String, @@ -351,16 +476,16 @@ class FxaAuthRepository( while (response.code == 406 && attempts < FXA_MAX_CHALLENGE_ATTEMPTS) { val challengedBody = response.use { it.body?.readBoundedText().orEmpty() } try { - ControlPlaneHttp.cookieJar.syncFromWebView() challengeSolver.solveAndInstall( challengedBody, origin, - FirefoxAccountHeaders.userAgent, + FirefoxAccountHeaders.controlPlaneUserAgent, ) } catch (cancellation: CancellationException) { throw cancellation } catch (solveFailure: Exception) { AppLogger.w(TAG, "could not solve the Fastly challenge", solveFailure) + FoxyRemoteConfig.triggerRefresh() throw FastlyBlockedException(FRIENDLY_406_MESSAGE, solveFailure) } response = client.newCall(buildRequest()).awaitResponse() @@ -371,6 +496,7 @@ class FxaAuthRepository( val text = resp.body?.readBoundedText().orEmpty() if (resp.code >= 400) { if (resp.code == 406) { + FoxyRemoteConfig.triggerRefresh() throw FastlyBlockedException(FRIENDLY_406_MESSAGE) } val errorBody = @@ -382,8 +508,10 @@ class FxaAuthRepository( val errno = if (errorBody.has("errno")) errorBody.optInt("errno") else null val message = when { - errorBody.length() > 0 -> - errorBody.optString("message", "HTTP ${resp.code}") + errorBody.has("message") && errorBody.optString("message").isNotBlank() -> + errorBody.optString("message") + errorBody.has("error") && errorBody.optString("error").isNotBlank() -> + errorBody.optString("error") text.isEmpty() -> "HTTP ${resp.code}" else -> text.take(200) } diff --git a/app/src/main/java/com/vauth/foxyvpn/data/GuardianClient.kt b/app/src/main/java/com/vauth/foxyvpn/data/GuardianClient.kt index d17b62d0..8e264abf 100644 --- a/app/src/main/java/com/vauth/foxyvpn/data/GuardianClient.kt +++ b/app/src/main/java/com/vauth/foxyvpn/data/GuardianClient.kt @@ -14,8 +14,9 @@ import org.json.JSONObject const val GUARDIAN_ENDPOINT_DEFAULT = "https://vpn.mozilla.org" private const val TAG = "GuardianClient" -private const val GUARDIAN_MAX_CHALLENGE_ATTEMPTS = 5 -private const val GUARDIAN_USER_AGENT = "MozillaVPN/2.35.0 (sys:android 14; iap:true)" +private val guardianUserAgent: String + get() = FoxyRemoteConfig.current.guardian.headers["User-Agent"] + ?: FirefoxAccountHeaders.controlPlaneUserAgent class GuardianHttpError( message: String, @@ -207,27 +208,26 @@ class GuardianClient( val origin = url.toHttpUrl().let { "${it.scheme}://${it.host}" } fun buildRequest(): Request { - val builder = - Request.Builder() - .url(url) - .header("User-Agent", GUARDIAN_USER_AGENT) - .header("Accept", "application/json") - .header("Content-Type", "application/json") - .header("Cache-Control", "max-age=0") - .header("Authorization", "Bearer $accessToken") + val builder = Request.Builder().url(url) + val headers = FoxyRemoteConfig.current.guardian.headers + for ((key, value) in headers) { + builder.header(key, value) + } + builder.header("Authorization", "Bearer $accessToken") if (method == "POST") builder.post(ByteArray(0).toRequestBody(null)) return builder.build() } var response = ControlPlaneHttp.client.newCall(buildRequest()).awaitResponse() var attempts = 1 - while (response.code == 406 && attempts < GUARDIAN_MAX_CHALLENGE_ATTEMPTS) { + while (response.code == 406 && attempts < 5) { val challengedBody = response.use { it.body?.readBoundedText().orEmpty() } try { - challengeSolver.solveAndInstall(challengedBody, origin, GUARDIAN_USER_AGENT) + challengeSolver.solveAndInstall(challengedBody, origin, guardianUserAgent) } catch (cancellation: CancellationException) { throw cancellation } catch (solveFailure: Exception) { AppLogger.w(TAG, "could not solve the Fastly challenge", solveFailure) + FoxyRemoteConfig.triggerRefresh() throw GuardianHttpError( "Mozilla's bot protection rejected this request (HTTP 406) and the challenge " + "could not be solved automatically. Try again later or on another network.", @@ -237,6 +237,9 @@ class GuardianClient( response = ControlPlaneHttp.client.newCall(buildRequest()).awaitResponse() attempts++ } + if (response.code == 406) { + FoxyRemoteConfig.triggerRefresh() + } return response } } diff --git a/app/src/main/java/com/vauth/foxyvpn/ui/LoginViewModel.kt b/app/src/main/java/com/vauth/foxyvpn/ui/LoginViewModel.kt index e567df80..6127cd58 100644 --- a/app/src/main/java/com/vauth/foxyvpn/ui/LoginViewModel.kt +++ b/app/src/main/java/com/vauth/foxyvpn/ui/LoginViewModel.kt @@ -97,7 +97,7 @@ class LoginViewModel(private val repository: FxaAuthRepository) : ViewModel() { viewModelScope.launch { try { val result = - withTimeout(120_000L) { repository.completeBrowserLogin(sessionToken) } + withTimeout(300_000L) { repository.completeBrowserLogin(sessionToken) } if (generation != requestGeneration) return@launch result .onSuccess { diff --git a/app/src/main/java/com/vauth/foxyvpn/ui/screens/BrowserLoginScreen.kt b/app/src/main/java/com/vauth/foxyvpn/ui/screens/BrowserLoginScreen.kt index c8e72c42..42a69602 100644 --- a/app/src/main/java/com/vauth/foxyvpn/ui/screens/BrowserLoginScreen.kt +++ b/app/src/main/java/com/vauth/foxyvpn/ui/screens/BrowserLoginScreen.kt @@ -3,7 +3,6 @@ package com.vauth.foxyvpn.ui.screens import android.annotation.SuppressLint import android.content.Intent import android.net.Uri -import android.os.Build import android.webkit.CookieManager import android.webkit.JavascriptInterface import android.webkit.WebChromeClient @@ -41,8 +40,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import com.vauth.foxyvpn.R import com.vauth.foxyvpn.data.AppLogger -import com.vauth.foxyvpn.data.ControlPlaneHttp -import com.vauth.foxyvpn.data.FxaAuthRepository +import com.vauth.foxyvpn.data.FirefoxAccountHeaders import java.lang.ref.WeakReference import org.json.JSONObject @@ -55,216 +53,72 @@ private const val CMD_LOGIN = "fxaccounts:login" private val SESSION_TOKEN_HEX = Regex("^[0-9a-f]{64}$") -private val FIREFOX_ANDROID_UA = - "Mozilla/5.0 (Android ${Build.VERSION.RELEASE.ifBlank { "14" }}; Mobile; rv:135.0) Gecko/135.0 Firefox/135.0" - private fun jsString(value: String): String = JSONObject.quote(value) private val WEBCHANNEL_HOOK_JS = """ (function() { if (window.__foxyChannelHooked) return; window.__foxyChannelHooked = true; - var forward = function (detail) { + var send = function(o) { try { var host = window.location.hostname; if (host !== 'accounts.firefox.com' && !host.endsWith('.firefox.com')) return; - var payload = typeof detail === 'string' ? detail : JSON.stringify(detail); + var payload = typeof o === 'string' ? o : JSON.stringify(o); foxyWebChannel.onMessage(payload, window.location.href); } catch (e) {} }; - window.addEventListener('WebChannelMessageToChrome', function (e) { forward(e.detail); }); - document.addEventListener('WebChannelMessageToChrome', function (e) { forward(e.detail); }); - window.__foxyReply = function (channelId, messageId, command, dataJson) { - try { - var parsedData = typeof dataJson === 'string' ? JSON.parse(dataJson) : dataJson; - var envelope = { - id: channelId, - message: { messageId: messageId, command: command, data: parsedData } - }; - var eventObj = new CustomEvent('WebChannelMessageToContent', { detail: envelope }); - window.dispatchEvent(eventObj); - document.dispatchEvent(eventObj); - var eventStr = new CustomEvent('WebChannelMessageToContent', { detail: JSON.stringify(envelope) }); - window.dispatchEvent(eventStr); - document.dispatchEvent(eventStr); - } catch (e) {} - }; - var getUrlString = function(arg) { - if (!arg) return ''; - if (typeof arg === 'string') return arg; - if (typeof arg.url === 'string') return arg.url; - if (typeof arg.href === 'string') return arg.href; - try { return arg.toString(); } catch (e) { return ''; } + var onChannel = function(e) { + var d = e.detail; + if (typeof d === 'string') { try { d = JSON.parse(d); } catch (e) { return; } } + if (d && d.message) send(d); }; - var checkVerifyUrl = function(url) { - var u = getUrlString(url).toLowerCase(); - return u.indexOf('/verify') !== -1 || u.indexOf('/totp') !== -1 || u.indexOf('/session/verify') !== -1 || - u.indexOf('enter_code') !== -1 || u.indexOf('confirm_signin') !== -1 || u.indexOf('confirm_email') !== -1; - }; - var checkSuccessUrl = function(url) { - var u = getUrlString(url).toLowerCase(); - return u.indexOf('confirmed') !== -1 || u.indexOf('connect_another_device') !== -1 || - u.indexOf('/pair') !== -1 || u.indexOf('/settings') !== -1 || u.indexOf('/complete') !== -1 || - u.indexOf('/verified') !== -1 || u.indexOf('success') !== -1; - }; - if (window.fetch) { - var origFetch = window.fetch; - window.fetch = function() { - var reqUrl = (arguments[0] && arguments[0].url) || arguments[0] || ''; - return origFetch.apply(this, arguments).then(function(res) { - try { - if (res && res.ok && checkVerifyUrl(reqUrl)) { - foxyWebChannel.onSessionVerified(); - } - } catch (e) {} - return res; - }); - }; - } - if (window.XMLHttpRequest) { - var origOpen = XMLHttpRequest.prototype.open; - var origSend = XMLHttpRequest.prototype.send; - XMLHttpRequest.prototype.open = function(method, url) { - this.__foxyReqUrl = url; - return origOpen.apply(this, arguments); - }; - XMLHttpRequest.prototype.send = function() { - this.addEventListener('load', function() { - try { - if (this.status >= 200 && this.status < 300 && checkVerifyUrl(this.__foxyReqUrl)) { - foxyWebChannel.onSessionVerified(); - } - } catch (e) {} - }); - return origSend.apply(this, arguments); - }; - } - var notifyNav = function() { - try { - if (checkSuccessUrl(window.location.href)) { - foxyWebChannel.onSessionVerified(); - } - } catch (e) {} + window.addEventListener('WebChannelMessageToChrome', onChannel); + document.addEventListener('WebChannelMessageToChrome', onChannel); + window.__foxyReply = function(id, messageId, command, data) { + var detail = { id: id, message: { messageId: messageId, command: command, data: data } }; + window.dispatchEvent(new CustomEvent('WebChannelMessageToContent', { detail: detail })); + document.dispatchEvent(new CustomEvent('WebChannelMessageToContent', { detail: detail })); }; - var origPushState = history.pushState; - if (origPushState) { - history.pushState = function() { - var ret = origPushState.apply(this, arguments); - notifyNav(); - return ret; - }; - } - var origReplaceState = history.replaceState; - if (origReplaceState) { - history.replaceState = function() { - var ret = origReplaceState.apply(this, arguments); - notifyNav(); - return ret; - }; - } - window.addEventListener('popstate', notifyNav); - window.addEventListener('hashchange', notifyNav); })(); """.trimIndent() internal class WebChannelBridge( webView: WebView, - private val authRepository: FxaAuthRepository, - private val onTokens: (String, String?, Long) -> Unit, private val onSessionToken: (String) -> Unit, ) { private val webViewRef = WeakReference(webView) @Volatile - var pendingSessionToken: String? = null - private set - @Volatile - private var exchangeInProgress = false + private var lastSessionToken: String? = null @JavascriptInterface fun onMessage(payload: String) { onMessage(payload, "https://accounts.firefox.com") } - @JavascriptInterface - fun onSessionVerified() { - val token = pendingSessionToken ?: return - exchangeInProgress = false - notifySessionToken(token) - } - - @JavascriptInterface - fun onOAuthResult(status: Int, responseText: String) { - val token = pendingSessionToken - if (status in 200..299) { - val parsed = runCatching { - val json = JSONObject(responseText) - val accessToken = json.getString("access_token") - val refreshToken = json.optString("refresh_token").ifBlank { null } - val expiresIn = json.optLong("expires_in", 0L) - Triple(accessToken, refreshToken, expiresIn) - }.getOrNull() - if (parsed != null) { - val view = webViewRef.get() - if (view != null) { - view.post { onTokens(parsed.first, parsed.second, parsed.third) } - } else { - onTokens(parsed.first, parsed.second, parsed.third) - } - return - } - } - val isUnconfirmed = status == 400 && - (responseText.contains("104") || - responseText.contains("Unconfirmed session", ignoreCase = true) || - responseText.contains("unverified", ignoreCase = true)) - if (isUnconfirmed) { - exchangeInProgress = false - return - } - if (token != null) { - fallbackSession(token) - } - } - - @JavascriptInterface - fun onOAuthError(error: String) { - AppLogger.w(TAG, "in-browser oauth exchange failed: $error") - val token = pendingSessionToken - if (token != null) { - fallbackSession(token) - } - } - - private fun fallbackSession(token: String) { - exchangeInProgress = false - val view = webViewRef.get() - if (view != null) { - view.post { onSessionToken(token) } - } else { - onSessionToken(token) - } - } - @JavascriptInterface fun onMessage(payload: String, originUrl: String) { val host = runCatching { Uri.parse(originUrl).host?.lowercase() }.getOrNull() if (host != "accounts.firefox.com" && host?.endsWith(".firefox.com") != true) { return } - val message = runCatching { - JSONObject(payload).optJSONObject("message") ?: JSONObject() + val envelope = runCatching { + JSONObject(payload) }.getOrNull() ?: return + val channelId = envelope.optString("id").ifBlank { FXA_CHANNEL_ID } + val message = envelope.optJSONObject("message") ?: JSONObject() val command = message.optString("command") val data = message.optJSONObject("data") ?: JSONObject() val messageId = message.optString("messageId") when (command) { CMD_FXA_STATUS -> reply( + channelId, messageId, CMD_FXA_STATUS, """{"capabilities":{},"signedInUser":null}""", ) CMD_CAN_LINK_ACCOUNT -> reply( + channelId, messageId, CMD_CAN_LINK_ACCOUNT, """{"ok":true}""", @@ -273,86 +127,38 @@ internal class WebChannelBridge( val token = data.optString("sessionToken", "").trim() .ifBlank { data.optString("token", "").trim() } if (SESSION_TOKEN_HEX.matches(token)) { - pendingSessionToken = token - val hasVerificationReason = data.has("verificationReason") && - data.optString("verificationReason").isNotBlank() - val isVerified = if (data.has("verified")) { - data.optBoolean("verified", true) - } else { - !hasVerificationReason - } - if (isVerified && !hasVerificationReason) { - notifySessionToken(token) - } + notifySessionToken(token) } if (messageId.isNotBlank()) { - reply(messageId, command, """{"ok":true}""") + reply(channelId, messageId, command, """{"ok":true}""") } } else -> { - if (command.contains("verified", ignoreCase = true)) { - val token = data.optString("sessionToken", "").trim() - .ifBlank { data.optString("token", "").trim() } - .ifBlank { pendingSessionToken ?: "" } - if (SESSION_TOKEN_HEX.matches(token)) { - pendingSessionToken = token - exchangeInProgress = false - notifySessionToken(token) - } - } if (messageId.isNotBlank()) { - reply(messageId, command, """{"ok":true}""") + reply(channelId, messageId, command, """{"ok":true}""") } } } } - fun notifySessionToken(token: String) { - if (!SESSION_TOKEN_HEX.matches(token)) return - if (exchangeInProgress) return - exchangeInProgress = true - pendingSessionToken = token + private fun notifySessionToken(token: String) { + if (!SESSION_TOKEN_HEX.matches(token) || token == lastSessionToken) return + lastSessionToken = token runCatching { CookieManager.getInstance().flush() - ControlPlaneHttp.cookieJar.syncFromWebView() } - val hawkInfo = runCatching { authRepository.buildOAuthHawkHeader(token) }.getOrNull() val view = webViewRef.get() - if (hawkInfo == null || view == null) { - fallbackSession(token) - return - } - val (hawkHeader, bodyJson) = hawkInfo - val script = """ - (function() { - fetch('https://api.accounts.firefox.com/v1/oauth/token', { - method: 'POST', - headers: { - 'Authorization': ${jsString(hawkHeader)}, - 'Content-Type': 'application/json' - }, - body: ${jsString(bodyJson)} - }).then(function(res) { - return res.text().then(function(text) { - foxyWebChannel.onOAuthResult(res.status, text); - }); - }).catch(function(err) { - foxyWebChannel.onOAuthError(err ? (err.message || ('' + err)) : 'fetch_failed'); - }); - })(); - """.trimIndent() - view.post { - runCatching { view.evaluateJavascript(script, null) } - .onFailure { - fallbackSession(token) - } + if (view != null) { + view.post { onSessionToken(token) } + } else { + onSessionToken(token) } } - private fun reply(messageId: String, command: String, dataJson: String) { + private fun reply(channelId: String, messageId: String, command: String, dataJson: String) { val view = webViewRef.get() ?: return val script = "window.__foxyReply && window.__foxyReply(" + - "${jsString(FXA_CHANNEL_ID)}, ${jsString(messageId)}, ${jsString(command)}, ${jsString(dataJson)});" + "${jsString(channelId)}, ${jsString(messageId)}, ${jsString(command)}, $dataJson);" view.post { runCatching { view.evaluateJavascript(script, null) } .onFailure { AppLogger.w(TAG, "could not answer webchannel command $command", it) } @@ -360,26 +166,12 @@ internal class WebChannelBridge( } } -private fun isSuccessUrl(url: String?): Boolean { - if (url.isNullOrBlank()) return false - val u = url.lowercase() - return u.contains("confirmed") || - u.contains("/connect_another_device") || - u.contains("/pair") || - u.contains("/settings") || - u.contains("/complete") || - u.contains("/verified") || - u.contains("success") -} - @SuppressLint("SetJavaScriptEnabled") @Composable fun BrowserLoginScreen( startUrl: String, busy: Boolean, - authRepository: FxaAuthRepository, errorMessage: String? = null, - onTokensCaptured: (String, String?, Long) -> Unit, onSessionCaptured: (String) -> Unit, onCanceled: () -> Unit, ) { @@ -388,25 +180,18 @@ fun BrowserLoginScreen( var pageTitle by remember { mutableStateOf("") } var pageProgress by remember { mutableIntStateOf(0) } var pageLoaded by remember { mutableStateOf(false) } - val currentOnTokens by rememberUpdatedState(onTokensCaptured) val currentOnSession by rememberUpdatedState(onSessionCaptured) val currentOnCanceled by rememberUpdatedState(onCanceled) BackHandler(enabled = true) { if (webViewInstance?.canGoBack() == true) { webViewInstance?.goBack() - } else if (!busy) { + } else { currentOnCanceled() } } - fun handleTokens(accessToken: String, refreshToken: String?, expiresIn: Long) { - if (busy) return - currentOnTokens(accessToken, refreshToken, expiresIn) - } - fun handleSession(token: String) { - if (busy) return currentOnSession(token) } @@ -427,11 +212,10 @@ fun BrowserLoginScreen( onClick = { if (webViewInstance?.canGoBack() == true) { webViewInstance?.goBack() - } else if (!busy) { + } else { currentOnCanceled() } }, - enabled = !busy, ) { Icon( Icons.AutoMirrored.Filled.ArrowBack, @@ -478,11 +262,11 @@ fun BrowserLoginScreen( settings.domStorageEnabled = true settings.loadWithOverviewMode = true settings.useWideViewPort = true - settings.userAgentString = FIREFOX_ANDROID_UA + settings.userAgentString = FirefoxAccountHeaders.browserUserAgent val cookieManager = CookieManager.getInstance() cookieManager.setAcceptCookie(true) cookieManager.setAcceptThirdPartyCookies(this, true) - val bridge = WebChannelBridge(this, authRepository, ::handleTokens, ::handleSession) + val bridge = WebChannelBridge(this, ::handleSession) bridgeInstance = bridge addJavascriptInterface(bridge, "foxyWebChannel") webViewClient = object : WebViewClient() { @@ -511,17 +295,6 @@ fun BrowserLoginScreen( return true } - override fun doUpdateVisitedHistory( - view: WebView, - url: String?, - isReload: Boolean, - ) { - super.doUpdateVisitedHistory(view, url, isReload) - if (isSuccessUrl(url)) { - bridge.onSessionVerified() - } - } - override fun onPageStarted( view: WebView, url: String?, @@ -529,9 +302,6 @@ fun BrowserLoginScreen( ) { super.onPageStarted(view, url, favicon) pageLoaded = false - if (isSuccessUrl(url)) { - bridge.onSessionVerified() - } runCatching { view.evaluateJavascript(WEBCHANNEL_HOOK_JS, null) } } @@ -541,10 +311,6 @@ fun BrowserLoginScreen( pageTitle = view.title.orEmpty() runCatching { CookieManager.getInstance().flush() - ControlPlaneHttp.cookieJar.syncFromWebView() - } - if (isSuccessUrl(url)) { - bridge.onSessionVerified() } runCatching { view.evaluateJavascript(WEBCHANNEL_HOOK_JS, null) } } diff --git a/app/src/main/java/com/vauth/foxyvpn/ui/screens/LoginScreen.kt b/app/src/main/java/com/vauth/foxyvpn/ui/screens/LoginScreen.kt index b4712e3a..feafcc24 100644 --- a/app/src/main/java/com/vauth/foxyvpn/ui/screens/LoginScreen.kt +++ b/app/src/main/java/com/vauth/foxyvpn/ui/screens/LoginScreen.kt @@ -47,9 +47,7 @@ fun LoginScreen(authRepository: FxaAuthRepository, onSignedIn: () -> Unit) { BrowserLoginScreen( startUrl = browserStartUrl, busy = completingBrowserLogin, - authRepository = authRepository, errorMessage = errorMessage, - onTokensCaptured = model::completeBrowserLoginWithTokens, onSessionCaptured = model::completeBrowserLogin, onCanceled = model::cancelVerification, )