Skip to content

Commit 4f23b44

Browse files
committed
Extract lower-level proxy setup out of the activity
1 parent 14df633 commit 4f23b44

File tree

2 files changed

+173
-150
lines changed

2 files changed

+173
-150
lines changed

app/src/main/java/tech/httptoolkit/android/MainActivity.kt

Lines changed: 7 additions & 150 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,15 @@
11
package tech.httptoolkit.android
22

3-
import android.app.KeyguardManager
43
import android.content.BroadcastReceiver
54
import android.content.Context
65
import android.content.Intent
76
import android.content.IntentFilter
87
import android.net.Uri
98
import android.net.VpnService
10-
import android.os.Build
119
import android.os.Bundle
1210
import android.security.KeyChain
1311
import android.security.KeyChain.EXTRA_CERTIFICATE
1412
import android.security.KeyChain.EXTRA_NAME
15-
import android.util.Base64
1613
import android.util.Log
1714
import android.view.View
1815
import android.widget.Button
@@ -21,23 +18,10 @@ import android.widget.TextView
2118
import androidx.annotation.StringRes
2219
import androidx.appcompat.app.AppCompatActivity
2320
import androidx.localbroadcastmanager.content.LocalBroadcastManager
24-
import com.beust.klaxon.Klaxon
2521
import com.google.android.material.dialog.MaterialAlertDialogBuilder
2622
import io.sentry.Sentry
2723
import kotlinx.coroutines.*
28-
import okhttp3.OkHttpClient
29-
import okhttp3.Request
30-
import java.io.ByteArrayInputStream
31-
import java.net.ConnectException
32-
import java.net.InetSocketAddress
33-
import java.net.Proxy
34-
import java.nio.charset.StandardCharsets
35-
import java.security.KeyStore
36-
import java.security.MessageDigest
37-
import java.security.cert.CertificateException
38-
import java.security.cert.CertificateFactory
3924
import java.security.cert.X509Certificate
40-
import java.util.concurrent.TimeUnit
4125

4226

4327
const val START_VPN_REQUEST = 123
@@ -52,15 +36,8 @@ enum class MainState {
5236
FAILED
5337
}
5438

55-
private fun getCertificateFingerprint(cert: X509Certificate): String {
56-
val md = MessageDigest.getInstance("SHA-256")
57-
md.update(cert.publicKey.encoded)
58-
val fingerprint = md.digest()
59-
return Base64.encodeToString(fingerprint, Base64.NO_WRAP)
60-
}
61-
62-
private val ACTIVATE_INTENT = "tech.httptoolkit.android.ACTIVATE"
63-
private val DEACTIVATE_INTENT = "tech.httptoolkit.android.DEACTIVATE"
39+
private const val ACTIVATE_INTENT = "tech.httptoolkit.android.ACTIVATE"
40+
private const val DEACTIVATE_INTENT = "tech.httptoolkit.android.DEACTIVATE"
6441

6542
class MainActivity : AppCompatActivity(), CoroutineScope by MainScope() {
6643

@@ -163,8 +140,8 @@ class MainActivity : AppCompatActivity(), CoroutineScope by MainScope() {
163140
.setIcon(R.drawable.ic_exclamation_triangle)
164141
.setMessage(
165142
"Do you want to share all this device's HTTP traffic with HTTP Toolkit?" +
166-
"\n\n" +
167-
"Only accept this if you trust the source."
143+
"\n\n" +
144+
"Only accept this if you trust the source."
168145
)
169146
.setPositiveButton("Enable") { _, _ ->
170147
Log.i(TAG, "Prompt confirmed")
@@ -309,7 +286,7 @@ class MainActivity : AppCompatActivity(), CoroutineScope by MainScope() {
309286
"trust your HTTP Toolkit's certificate authority. " +
310287
"\n\n" +
311288
"Please accept the following prompts to allow this." +
312-
if (!isDeviceSecured())
289+
if (!isDeviceSecured(applicationContext))
313290
"\n\n" +
314291
"Due to Android security requirements, trusting the certificate will " +
315292
"require you to set a PIN, password or pattern for this device."
@@ -445,18 +422,7 @@ class MainActivity : AppCompatActivity(), CoroutineScope by MainScope() {
445422

446423
withContext(Dispatchers.IO) {
447424
try {
448-
val dataBase64 = uri.getQueryParameter("data")
449-
450-
// Data is a JSON string, encoded as base64, to solve escaping & ensure that the
451-
// most popular standard barcode apps treat it as a single URL (some get confused by
452-
// JSON that contains ip addresses otherwise)
453-
val data = String(Base64.decode(dataBase64, Base64.URL_SAFE), StandardCharsets.UTF_8)
454-
Log.d(TAG, "URL data is $data")
455-
456-
val proxyInfo = Klaxon().parse<ProxyInfo>(data)
457-
?: throw IllegalArgumentException("Invalid proxy JSON: $data")
458-
459-
val config = getProxyConfig(proxyInfo)
425+
val config = getProxyConfig(parseConnectUri(uri))
460426
connectToVpn(config)
461427
} catch (e: Exception) {
462428
Log.e(TAG, e.toString())
@@ -471,119 +437,10 @@ class MainActivity : AppCompatActivity(), CoroutineScope by MainScope() {
471437
}
472438
}
473439

474-
private suspend fun getProxyConfig(proxyInfo: ProxyInfo): ProxyConfig {
475-
return withContext(Dispatchers.IO) {
476-
Log.v(TAG, "Validating proxy info $proxyInfo")
477-
478-
val proxyTests = proxyInfo.addresses.map { address ->
479-
supervisorScope {
480-
async {
481-
testProxyAddress(
482-
address,
483-
proxyInfo.port,
484-
proxyInfo.certFingerprint
485-
)
486-
}
487-
}
488-
}
489-
490-
// Returns with the first working proxy config (cert & address),
491-
// or throws if all possible addresses are unreachable/invalid
492-
// Once the first test succeeds, we cancel any others
493-
val result = proxyTests.awaitFirst()
494-
proxyTests.forEach { test ->
495-
test.cancel()
496-
}
497-
return@withContext result
498-
}
499-
}
500-
501-
private suspend fun testProxyAddress(
502-
address: String,
503-
port: Int,
504-
expectedFingerprint: String
505-
): ProxyConfig {
506-
return withContext(Dispatchers.IO) {
507-
val certFactory = CertificateFactory.getInstance("X.509")
508-
509-
val httpClient = OkHttpClient.Builder()
510-
.proxy(Proxy(Proxy.Type.HTTP, InetSocketAddress(address, port)))
511-
.connectTimeout(2, TimeUnit.SECONDS)
512-
.readTimeout(2, TimeUnit.SECONDS)
513-
.build()
514-
515-
val request = Request.Builder()
516-
.url("http://android.httptoolkit.tech/config")
517-
.build()
518-
519-
try {
520-
val configString = httpClient.newCall(request).execute().use { response ->
521-
if (response.code != 200) {
522-
throw ConnectException("Proxy responded with non-200: ${response.code}")
523-
}
524-
response.body!!.string()
525-
}
526-
val config = Klaxon().parse<ReceivedProxyConfig>(configString)!!
527-
528-
val foundCert = certFactory.generateCertificate(
529-
ByteArrayInputStream(config.certificate.toByteArray(Charsets.UTF_8))
530-
) as X509Certificate
531-
val foundCertFingerprint = getCertificateFingerprint(foundCert)
532-
533-
if (foundCertFingerprint == expectedFingerprint) {
534-
ProxyConfig(
535-
address,
536-
port,
537-
foundCert
538-
)
539-
} else {
540-
throw CertificateException(
541-
"Proxy returned mismatched certificate: '${
542-
expectedFingerprint
543-
}' != '$foundCertFingerprint' ($address)"
544-
)
545-
}
546-
} catch (e: Exception) {
547-
Log.i(TAG, "Error testing proxy address $address: $e")
548-
throw e
549-
}
550-
}
551-
}
552-
553-
/**
554-
* Does the device have a PIN/pattern/password set? Relevant because if not, the cert
555-
* setup will require the user to add one.
556-
*/
557-
private fun isDeviceSecured(): Boolean {
558-
val keyguardManager = getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
559-
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
560-
keyguardManager.isDeviceSecure
561-
} else {
562-
// Imperfect but close though: also returns true if the device has a locked SIM card.
563-
keyguardManager.isKeyguardSecure
564-
}
565-
}
566-
567440
private fun isVpnConfigured(): Boolean {
568441
return VpnService.prepare(this) == null
569442
}
570443

571-
// Returns the name of the cert store (if the cert is trusted) or null (if not)
572-
private fun whereIsCertTrusted(proxyConfig: ProxyConfig): String? {
573-
val keyStore = KeyStore.getInstance("AndroidCAStore")
574-
keyStore.load(null, null)
575-
576-
val certificateAlias = keyStore.getCertificateAlias(proxyConfig.certificate)
577-
Log.i(TAG, "Cert alias $certificateAlias")
578-
579-
return when {
580-
certificateAlias == null -> null
581-
certificateAlias.startsWith("system:") -> "system"
582-
certificateAlias.startsWith("user:") -> "user"
583-
else -> "unknown-store"
584-
}
585-
}
586-
587444
private fun ensureCertificateTrusted(proxyConfig: ProxyConfig) {
588445
if (whereIsCertTrusted(proxyConfig) == null) {
589446
app.trackEvent("Setup", "installing-cert")
@@ -603,4 +460,4 @@ class MainActivity : AppCompatActivity(), CoroutineScope by MainScope() {
603460
}
604461
}
605462

606-
}
463+
}
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
package tech.httptoolkit.android
2+
3+
import android.app.KeyguardManager
4+
import android.content.Context
5+
import android.net.Uri
6+
import android.os.Build
7+
import android.util.Base64
8+
import android.util.Log
9+
import androidx.core.content.ContextCompat.getSystemService
10+
import com.beust.klaxon.Klaxon
11+
import kotlinx.coroutines.Dispatchers
12+
import kotlinx.coroutines.async
13+
import kotlinx.coroutines.supervisorScope
14+
import kotlinx.coroutines.withContext
15+
import okhttp3.OkHttpClient
16+
import okhttp3.Request
17+
import java.io.ByteArrayInputStream
18+
import java.net.ConnectException
19+
import java.net.InetSocketAddress
20+
import java.net.Proxy
21+
import java.nio.charset.StandardCharsets
22+
import java.security.KeyStore
23+
import java.security.MessageDigest
24+
import java.security.cert.CertificateException
25+
import java.security.cert.CertificateFactory
26+
import java.security.cert.X509Certificate
27+
import java.util.concurrent.TimeUnit
28+
29+
private val TAG = "ProxySetup"
30+
31+
// Takes an android.httptoolkit.tech/connect URI, extracts & parses the connection config
32+
// within, into a format ready for testing and then usage.
33+
fun parseConnectUri(uri: Uri): ProxyInfo {
34+
val dataBase64 = uri.getQueryParameter("data")
35+
36+
// Data is a JSON string, encoded as base64, to solve escaping & ensure that the
37+
// most popular standard barcode apps treat it as a single URL (some get confused by
38+
// JSON that contains ip addresses otherwise)
39+
val data = String(Base64.decode(dataBase64, Base64.URL_SAFE), StandardCharsets.UTF_8)
40+
Log.d(TAG, "URL data is $data")
41+
42+
return Klaxon().parse<ProxyInfo>(data)
43+
?: throw IllegalArgumentException("Invalid proxy JSON: $data")
44+
}
45+
46+
suspend fun getProxyConfig(proxyInfo: ProxyInfo): ProxyConfig {
47+
return withContext(Dispatchers.IO) {
48+
Log.v(TAG, "Validating proxy info $proxyInfo")
49+
50+
val proxyTests = proxyInfo.addresses.map { address ->
51+
supervisorScope {
52+
async {
53+
testProxyAddress(
54+
address,
55+
proxyInfo.port,
56+
proxyInfo.certFingerprint
57+
)
58+
}
59+
}
60+
}
61+
62+
// Returns with the first working proxy config (cert & address),
63+
// or throws if all possible addresses are unreachable/invalid
64+
// Once the first test succeeds, we cancel any others
65+
val result = proxyTests.awaitFirst()
66+
proxyTests.forEach { test ->
67+
test.cancel()
68+
}
69+
return@withContext result
70+
}
71+
}
72+
73+
private suspend fun testProxyAddress(
74+
address: String,
75+
port: Int,
76+
expectedFingerprint: String
77+
): ProxyConfig {
78+
return withContext(Dispatchers.IO) {
79+
val certFactory = CertificateFactory.getInstance("X.509")
80+
81+
val httpClient = OkHttpClient.Builder()
82+
.proxy(Proxy(Proxy.Type.HTTP, InetSocketAddress(address, port)))
83+
.connectTimeout(2, TimeUnit.SECONDS)
84+
.readTimeout(2, TimeUnit.SECONDS)
85+
.build()
86+
87+
val request = Request.Builder()
88+
.url("http://android.httptoolkit.tech/config")
89+
.build()
90+
91+
try {
92+
val configString = httpClient.newCall(request).execute().use { response ->
93+
if (response.code != 200) {
94+
throw ConnectException("Proxy responded with non-200: ${response.code}")
95+
}
96+
response.body!!.string()
97+
}
98+
val config = Klaxon().parse<ReceivedProxyConfig>(configString)!!
99+
100+
val foundCert = certFactory.generateCertificate(
101+
ByteArrayInputStream(config.certificate.toByteArray(Charsets.UTF_8))
102+
) as X509Certificate
103+
val foundCertFingerprint = getCertificateFingerprint(foundCert)
104+
105+
if (foundCertFingerprint == expectedFingerprint) {
106+
ProxyConfig(
107+
address,
108+
port,
109+
foundCert
110+
)
111+
} else {
112+
throw CertificateException(
113+
"Proxy returned mismatched certificate: '${
114+
expectedFingerprint
115+
}' != '$foundCertFingerprint' ($address)"
116+
)
117+
}
118+
} catch (e: Exception) {
119+
Log.i(TAG, "Error testing proxy address $address: $e")
120+
throw e
121+
}
122+
}
123+
}
124+
125+
fun getCertificateFingerprint(cert: X509Certificate): String {
126+
val md = MessageDigest.getInstance("SHA-256")
127+
md.update(cert.publicKey.encoded)
128+
val fingerprint = md.digest()
129+
return Base64.encodeToString(fingerprint, Base64.NO_WRAP)
130+
}
131+
132+
133+
/**
134+
* Does the device have a PIN/pattern/password set? Relevant because if not, the cert
135+
* setup will require the user to add one. This is best guess - not 100% accurate.
136+
*/
137+
fun isDeviceSecured(context: Context): Boolean {
138+
val keyguardManager = getSystemService(context, KeyguardManager::class.java)
139+
140+
return when {
141+
// If we can't get a keyguard manager for some reason, assume there's no pin set
142+
keyguardManager == null -> false
143+
// If possible, accurately report device status
144+
Build.VERSION.SDK_INT >= Build.VERSION_CODES.M -> keyguardManager.isDeviceSecure
145+
// Imperfect but close though: also returns true if the device has a locked SIM card.
146+
else -> keyguardManager.isKeyguardSecure
147+
}
148+
}
149+
150+
/**
151+
* Returns the name of the cert store (if the cert is trusted) or null (if not)
152+
*/
153+
fun whereIsCertTrusted(proxyConfig: ProxyConfig): String? {
154+
val keyStore = KeyStore.getInstance("AndroidCAStore")
155+
keyStore.load(null, null)
156+
157+
val certificateAlias = keyStore.getCertificateAlias(proxyConfig.certificate)
158+
Log.i(TAG, "Cert alias $certificateAlias")
159+
160+
return when {
161+
certificateAlias == null -> null
162+
certificateAlias.startsWith("system:") -> "system"
163+
certificateAlias.startsWith("user:") -> "user"
164+
else -> "unknown-store"
165+
}
166+
}

0 commit comments

Comments
 (0)