Skip to content

Commit f9b0f34

Browse files
cryptomilkclaude
andcommitted
WebView: validate SSL_UNTRUSTED certs via OkHttp trust store
When connecting to a Home Assistant instance using a private CA, the WebView's Chromium TLS stack reports SSL_UNTRUSTED (error code 3) even though network_security_config.xml includes user CAs. The app's OkHttpClient correctly trusts user-installed CAs via AndroidCAStore, so we use it as a second opinion before rejecting the connection. Observed log: onReceivedSslError: primary error: 3 certificate: Issued to: CN=antares.milli.ways; Issued by: CN=milly.ways CA Intermediate CA,O=milly.ways CA; on URL: https://homeassistant.milli.ways/auth/authorize?... TLSWebViewClient now overrides onReceivedSslError: for SSL_UNTRUSTED with a non-null URL it launches async validation via isTlsTrusted(); all other errors (IDMISMATCH, EXPIRED, etc.) are rejected immediately. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 5a43b4b commit f9b0f34

9 files changed

Lines changed: 401 additions & 19 deletions

File tree

app/src/main/kotlin/io/homeassistant/companion/android/frontend/FrontendViewModel.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,7 @@ internal class FrontendViewModel @VisibleForTesting constructor(
203203
)
204204

205205
val webViewClient: HAWebViewClient = webViewClientFactory.create(
206+
validationScope = viewModelScope,
206207
currentUrlFlow = urlFlow,
207208
onFrontendError = ::onError,
208209
onCrash = ::onRetry,

app/src/main/kotlin/io/homeassistant/companion/android/onboarding/connection/ConnectionViewModel.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ internal class ConnectionViewModel @VisibleForTesting constructor(
108108
}
109109

110110
val webViewClient: HAWebViewClient = webViewClientFactory.create(
111+
validationScope = viewModelScope,
111112
currentUrlFlow = urlFlow,
112113
onFrontendError = ::onError,
113114
onUrlIntercepted = ::interceptRedirectIfRequired,

app/src/main/kotlin/io/homeassistant/companion/android/util/HAWebViewClient.kt

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,17 @@ import io.homeassistant.companion.android.common.R as commonR
1515
import io.homeassistant.companion.android.common.data.keychain.KeyChainRepository
1616
import io.homeassistant.companion.android.common.data.keychain.NamedKeyChain
1717
import io.homeassistant.companion.android.frontend.error.FrontendConnectionError
18+
import java.io.IOException
1819
import javax.inject.Inject
20+
import javax.net.ssl.SSLException
21+
import kotlin.time.Duration.Companion.seconds
22+
import kotlin.time.toJavaDuration
23+
import kotlinx.coroutines.CoroutineScope
24+
import kotlinx.coroutines.Dispatchers
1925
import kotlinx.coroutines.flow.StateFlow
26+
import kotlinx.coroutines.withContext
27+
import okhttp3.OkHttpClient
28+
import okhttp3.Request
2029
import timber.log.Timber
2130

2231
/**
@@ -25,10 +34,16 @@ import timber.log.Timber
2534
* The created clients handle Home Assistant-specific concerns such as TLS client authentication,
2635
* error mapping to [FrontendConnectionError], and JavaScript injection into the WebView.
2736
*/
28-
class HAWebViewClientFactory @Inject constructor(@NamedKeyChain private val keyChainRepository: KeyChainRepository) {
37+
class HAWebViewClientFactory @Inject constructor(
38+
@NamedKeyChain private val keyChainRepository: KeyChainRepository,
39+
private val okHttpClient: OkHttpClient,
40+
) {
2941
/**
3042
* Creates a new [HAWebViewClient] with the specified configuration.
3143
*
44+
* @param validationScope Scope used for async TLS validation coroutines. Should be tied to the
45+
* caller's lifecycle (e.g., `viewModelScope`) so in-flight validations are cancelled on
46+
* destruction.
3247
* @param currentUrlFlow StateFlow providing the current URL being loaded.
3348
* Used to filter errors - only errors for this URL trigger [onFrontendError].
3449
* @param onFrontendError Callback when a WebView error is mapped to a [FrontendConnectionError].
@@ -41,6 +56,7 @@ class HAWebViewClientFactory @Inject constructor(@NamedKeyChain private val keyC
4156
* Receives the handler, host, the resource URL that triggered the request, and the realm.
4257
*/
4358
fun create(
59+
validationScope: CoroutineScope,
4460
currentUrlFlow: StateFlow<String?>,
4561
onFrontendError: (FrontendConnectionError) -> Unit,
4662
onCrash: (() -> Unit)? = null,
@@ -57,6 +73,8 @@ class HAWebViewClientFactory @Inject constructor(@NamedKeyChain private val keyC
5773
): HAWebViewClient {
5874
return HAWebViewClient(
5975
keyChainRepository = keyChainRepository,
76+
validationScope = validationScope,
77+
okHttpClient = okHttpClient,
6078
currentUrlFlow = currentUrlFlow,
6179
onFrontendError = onFrontendError,
6280
onCrash = onCrash,
@@ -67,6 +85,8 @@ class HAWebViewClientFactory @Inject constructor(@NamedKeyChain private val keyC
6785
}
6886
}
6987

88+
private val TLS_VALIDATION_TIMEOUT = 5.seconds
89+
7090
/**
7191
* WebViewClient dedicated to loading Home Assistant frontend.
7292
*
@@ -75,6 +95,8 @@ class HAWebViewClientFactory @Inject constructor(@NamedKeyChain private val keyC
7595
*/
7696
class HAWebViewClient internal constructor(
7797
keyChainRepository: KeyChainRepository,
98+
validationScope: CoroutineScope,
99+
private val okHttpClient: OkHttpClient,
78100
private val currentUrlFlow: StateFlow<String?>,
79101
private val onFrontendError: (FrontendConnectionError) -> Unit,
80102
private val onCrash: (() -> Unit)?,
@@ -83,7 +105,12 @@ class HAWebViewClient internal constructor(
83105
private val onReceivedHttpAuthRequest: (
84106
(handler: HttpAuthHandler, host: String, resource: String, realm: String) -> Unit
85107
)?,
86-
) : TLSWebViewClient(keyChainRepository) {
108+
) : TLSWebViewClient(keyChainRepository, validationScope) {
109+
110+
// callTimeout bounds the entire call (DNS + connect + TLS + transfer), no per-phase overrides needed
111+
private val tlsValidationClient: OkHttpClient = okHttpClient.newBuilder()
112+
.callTimeout(TLS_VALIDATION_TIMEOUT.toJavaDuration())
113+
.build()
87114

88115
/** Last resource URL loaded by the WebView, used to identify the resource requesting auth. */
89116
private var lastResourceUrl: String? = null
@@ -217,9 +244,27 @@ class HAWebViewClient internal constructor(
217244
}
218245

219246
override fun onReceivedSslError(view: WebView?, handler: SslErrorHandler?, error: SslError?) {
247+
Timber.w("onReceivedSslError: primary error ${error?.primaryError} on ${sensitive { error?.url.orEmpty() }}")
220248
super.onReceivedSslError(view, handler, error)
221-
Timber.e("onReceivedSslError: $error")
249+
}
250+
251+
override suspend fun isTlsTrusted(url: String): Boolean = withContext(Dispatchers.IO) {
252+
try {
253+
val request = Request.Builder().url(url).head().build()
254+
tlsValidationClient.newCall(request).execute().use { }
255+
true
256+
// SSLException extends IOException but gets a more specific message
257+
} catch (e: SSLException) {
258+
Timber.w(e, "TLS validation failed for ${sensitive(url)}")
259+
false
260+
} catch (e: IOException) {
261+
Timber.w(e, "Connection failed during TLS validation for ${sensitive(url)}")
262+
false
263+
}
264+
}
222265

266+
override fun onSslErrorRejected(view: WebView?, error: SslError?) {
267+
Timber.e("SSL connection rejected: primary error ${error?.primaryError} on ${sensitive { error?.url.orEmpty() }}")
223268
val messageRes = when (error?.primaryError) {
224269
SslError.SSL_DATE_INVALID -> commonR.string.webview_error_SSL_DATE_INVALID
225270
SslError.SSL_EXPIRED -> commonR.string.webview_error_SSL_EXPIRED

app/src/main/kotlin/io/homeassistant/companion/android/util/TLSWebViewClient.kt

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@ import android.app.Activity
44
import android.content.ActivityNotFoundException
55
import android.content.Context
66
import android.content.ContextWrapper
7+
import android.net.http.SslError
78
import android.security.KeyChain
89
import android.security.KeyChainAliasCallback
910
import android.webkit.ClientCertRequest
11+
import android.webkit.SslErrorHandler
1012
import android.webkit.WebView
1113
import android.webkit.WebViewClient
1214
import androidx.annotation.VisibleForTesting
@@ -19,6 +21,7 @@ import kotlinx.coroutines.CoroutineScope
1921
import kotlinx.coroutines.Dispatchers
2022
import kotlinx.coroutines.Job
2123
import kotlinx.coroutines.launch
24+
import kotlinx.coroutines.withContext
2225
import timber.log.Timber
2326

2427
/*
@@ -27,7 +30,10 @@ import timber.log.Timber
2730
* we don't want the webview code in the wear app.
2831
*/
2932

30-
open class TLSWebViewClient(private var keyChainRepository: KeyChainRepository) : WebViewClient() {
33+
open class TLSWebViewClient(
34+
private var keyChainRepository: KeyChainRepository,
35+
private val validationScope: CoroutineScope,
36+
) : WebViewClient() {
3137
var isTLSClientAuthNeeded = false
3238
@VisibleForTesting set
3339

@@ -37,6 +43,48 @@ open class TLSWebViewClient(private var keyChainRepository: KeyChainRepository)
3743
var isCertificateChainValid = false
3844
@VisibleForTesting set
3945

46+
/**
47+
* Performs an async trust check for the given URL using the app's configured trust store.
48+
*
49+
* Called only for [SslError.SSL_UNTRUSTED] errors. Returns `true` if the server certificate
50+
* chain is trusted and the connection should proceed. The default implementation denies all
51+
* connections; subclasses should override this with real validation logic.
52+
*/
53+
open suspend fun isTlsTrusted(url: String): Boolean = false
54+
55+
/**
56+
* Called when an SSL error has been rejected and the WebView will not load the resource.
57+
*
58+
* Subclasses can override this to surface the error to the user. Not called when
59+
* [isTlsTrusted] returns `true` (i.e., when the connection proceeds).
60+
*/
61+
open fun onSslErrorRejected(view: WebView?, error: SslError?) {}
62+
63+
override fun onReceivedSslError(view: WebView?, handler: SslErrorHandler?, error: SslError?) {
64+
if (error == null) {
65+
handler?.cancel()
66+
onSslErrorRejected(view, null)
67+
return
68+
}
69+
if (error.primaryError == SslError.SSL_UNTRUSTED && error.url != null) {
70+
val url = error.url!!
71+
val viewRef = view?.let { WeakReference(it) }
72+
validationScope.launch {
73+
if (isTlsTrusted(url)) {
74+
withContext(Dispatchers.Main) { handler?.proceed() }
75+
} else {
76+
withContext(Dispatchers.Main) {
77+
handler?.cancel()
78+
onSslErrorRejected(viewRef?.get(), error)
79+
}
80+
}
81+
}
82+
} else {
83+
handler?.cancel()
84+
onSslErrorRejected(view, error)
85+
}
86+
}
87+
4088
private var key: PrivateKey? = null
4189
private var chain: Array<X509Certificate>? = null
4290

app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewActivity.kt

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@ import android.webkit.JavascriptInterface
2828
import android.webkit.JsResult
2929
import android.webkit.PermissionRequest
3030
import android.webkit.RenderProcessGoneDetail
31-
import android.webkit.SslErrorHandler
3231
import android.webkit.URLUtil
3332
import android.webkit.ValueCallback
3433
import android.webkit.WebChromeClient
@@ -159,8 +158,14 @@ import io.homeassistant.companion.android.webview.externalbus.ExternalEntityAddT
159158
import io.homeassistant.companion.android.webview.externalbus.NavigateTo
160159
import io.homeassistant.companion.android.webview.externalbus.ShowSidebar
161160
import io.homeassistant.companion.android.webview.insecure.BlockInsecureFragment
161+
import java.io.IOException
162162
import javax.inject.Inject
163+
import javax.net.ssl.SSLException
164+
import kotlin.time.Duration.Companion.seconds
165+
import kotlin.time.toJavaDuration
163166
import kotlinx.coroutines.CancellationException
167+
import okhttp3.OkHttpClient
168+
import okhttp3.Request
164169
import kotlinx.coroutines.Dispatchers
165170
import kotlinx.coroutines.Job
166171
import kotlinx.coroutines.delay
@@ -270,6 +275,15 @@ class WebViewActivity :
270275
@Inject
271276
lateinit var dataSourceFactory: DataSource.Factory
272277

278+
@Inject
279+
lateinit var okHttpClient: OkHttpClient
280+
281+
private val tlsValidationClient: OkHttpClient by lazy {
282+
okHttpClient.newBuilder()
283+
.callTimeout(5.seconds.toJavaDuration())
284+
.build()
285+
}
286+
273287
private lateinit var webView: WebView
274288
private var loadedUrl: Uri? = null
275289
private lateinit var decor: FrameLayout
@@ -494,7 +508,7 @@ class WebViewActivity :
494508
}
495509
}
496510

497-
webViewClient = object : TLSWebViewClient(keyChainRepository) {
511+
webViewClient = object : TLSWebViewClient(keyChainRepository, lifecycleScope) {
498512
@Deprecated("Deprecated in Java for SDK >= 23")
499513
override fun onReceivedError(
500514
view: WebView?,
@@ -570,15 +584,29 @@ class WebViewActivity :
570584
authenticationDialog(handler, host, resourceURL, realm, authError)
571585
}
572586

573-
override fun onReceivedSslError(view: WebView?, handler: SslErrorHandler?, error: SslError?) {
574-
Timber.e("onReceivedSslError: $error")
587+
override fun onSslErrorRejected(view: WebView?, error: SslError?) {
588+
Timber.e("onSslErrorRejected: primary error ${error?.primaryError} on ${sensitive { error?.url.orEmpty() }}")
575589
showError(
576590
ErrorType.SSL,
577591
error,
578592
null,
579593
)
580594
}
581595

596+
override suspend fun isTlsTrusted(url: String): Boolean = withContext(Dispatchers.IO) {
597+
try {
598+
val request = Request.Builder().url(url).head().build()
599+
tlsValidationClient.newCall(request).execute().use { }
600+
true
601+
} catch (e: SSLException) {
602+
Timber.w(e, "TLS validation failed for ${sensitive(url)}")
603+
false
604+
} catch (e: IOException) {
605+
Timber.w(e, "Connection failed during TLS validation for ${sensitive(url)}")
606+
false
607+
}
608+
}
609+
582610
override fun onRenderProcessGone(view: WebView?, handler: RenderProcessGoneDetail?): Boolean {
583611
Timber.e("onRenderProcessGone: webView crashed")
584612
view?.let {
@@ -1844,7 +1872,9 @@ class WebViewActivity :
18441872
} else if (errorType == ErrorType.SSL) {
18451873
if (description != null) {
18461874
alert.setMessage(getString(commonR.string.webview_error_description) + " " + description)
1847-
} else if (error!!.primaryError == SslError.SSL_DATE_INVALID) {
1875+
} else if (error == null) {
1876+
alert.setMessage(commonR.string.error_ssl)
1877+
} else if (error.primaryError == SslError.SSL_DATE_INVALID) {
18481878
alert.setMessage(commonR.string.webview_error_SSL_DATE_INVALID)
18491879
} else if (error.primaryError == SslError.SSL_EXPIRED) {
18501880
alert.setMessage(commonR.string.webview_error_SSL_EXPIRED)

app/src/test/kotlin/io/homeassistant/companion/android/frontend/FrontendViewModelTest.kt

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -915,6 +915,7 @@ class FrontendViewModelTest {
915915
var capturedPageFinished: (() -> Unit)? = null
916916
every {
917917
webViewClientFactory.create(
918+
validationScope = any(),
918919
currentUrlFlow = any(),
919920
onFrontendError = any(),
920921
onCrash = any(),
@@ -923,8 +924,8 @@ class FrontendViewModelTest {
923924
onReceivedHttpAuthRequest = any(),
924925
)
925926
} answers {
926-
// onPageFinished is the 5th of the 6 named arguments (zero-based index 4)
927-
capturedPageFinished = arg(4)
927+
// onPageFinished is the 6th of the 7 named arguments (zero-based index 5)
928+
capturedPageFinished = arg(5)
928929
mockk(relaxed = true)
929930
}
930931

@@ -1014,6 +1015,7 @@ class FrontendViewModelTest {
10141015
var capturedCallback: ((HttpAuthHandler, String, String, String) -> Unit)? = null
10151016
every {
10161017
webViewClientFactory.create(
1018+
validationScope = any(),
10171019
currentUrlFlow = any(),
10181020
onFrontendError = any(),
10191021
onCrash = any(),

app/src/test/kotlin/io/homeassistant/companion/android/onboarding/connection/ConnectionViewModelTest.kt

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,17 @@ import io.mockk.slot
2424
import io.mockk.verify
2525
import java.net.URL
2626
import kotlin.reflect.KClass
27+
import kotlinx.coroutines.CoroutineScope
2728
import kotlinx.coroutines.ExperimentalCoroutinesApi
2829
import kotlinx.coroutines.flow.MutableSharedFlow
2930
import kotlinx.coroutines.flow.StateFlow
3031
import kotlinx.coroutines.flow.emptyFlow
32+
import kotlinx.coroutines.test.UnconfinedTestDispatcher
33+
import kotlinx.coroutines.test.TestScope
3134
import kotlinx.coroutines.test.advanceUntilIdle
3235
import kotlinx.coroutines.test.runCurrent
3336
import kotlinx.coroutines.test.runTest
37+
import okhttp3.OkHttpClient
3438
import org.junit.jupiter.api.Assertions.assertEquals
3539
import org.junit.jupiter.api.Assertions.assertFalse
3640
import org.junit.jupiter.api.Assertions.assertTrue
@@ -53,24 +57,29 @@ import org.junit.jupiter.params.provider.ValueSource
5357
class ConnectionViewModelTest {
5458

5559
private val keyChainRepository: KeyChainRepository = mockk(relaxed = true)
60+
private val okHttpClient: OkHttpClient = mockk(relaxed = true)
5661
private val webViewClientFactory: HAWebViewClientFactory = mockk {
5762
every {
5863
create(
64+
validationScope = any<CoroutineScope>(),
5965
currentUrlFlow = any<StateFlow<String?>>(),
6066
onFrontendError = any(),
6167
onCrash = any(),
6268
onUrlIntercepted = any(),
6369
onPageFinished = any(),
70+
onReceivedHttpAuthRequest = any(),
6471
)
6572
} answers {
6673
HAWebViewClient(
6774
keyChainRepository = keyChainRepository,
68-
currentUrlFlow = firstArg(),
69-
onFrontendError = secondArg(),
70-
onCrash = thirdArg(),
71-
onUrlIntercepted = arg(3),
72-
onPageFinished = arg(4),
73-
onReceivedHttpAuthRequest = arg(5),
75+
validationScope = TestScope(UnconfinedTestDispatcher()),
76+
okHttpClient = okHttpClient,
77+
currentUrlFlow = secondArg(),
78+
onFrontendError = thirdArg(),
79+
onCrash = arg(3),
80+
onUrlIntercepted = arg(4),
81+
onPageFinished = arg(5),
82+
onReceivedHttpAuthRequest = arg(6),
7483
)
7584
}
7685
}

0 commit comments

Comments
 (0)