Skip to content

Commit 817e33a

Browse files
smhcsmhc
authored andcommitted
fix: pre-initialize mTLS flag from in-memory key state to handle TLS session resumption in Wear OS onboarding
When setting up a Wear OS device the mTLS certificate-selection screen was silently skipped if TLS session resumption occurred, causing the watch to register without a client certificate and the server to return HTTP 400. Root cause: isTLSClientAuthNeeded in TLSWebViewClient is only set inside onReceivedClientCertRequest. When the onboarding WebView reuses the main app's existing TLS session (abbreviated handshake), the server never issues a new CertificateRequest so the callback never fires — even with ssl_session_tickets disabled on the server, because Android's Chromium network stack maintains its own in-process TLS session cache. Fix: add TLSWebViewClient.preInitializeTLSClientAuthState() which checks whether a private key is already loaded in KeyChainRepository (non-null getPrivateKey()). A non-null key means the phone is currently connected to an mTLS-protected instance, so isTLSClientAuthNeeded is pre-set to true before the WebView starts loading. If the app was force-stopped first, in-memory state is cleared and no TLS session can be resumed, so onReceivedClientCertRequest fires naturally on the fresh handshake. ConnectionViewModel.init calls preInitializeTLSClientAuthState() before emitting the auth URL so the navigation layer sees the correct value. Also adds two unit tests to ConnectionViewModelTest covering the in-memory key present and absent cases.
1 parent f3e1cfa commit 817e33a

File tree

3 files changed

+64
-0
lines changed

3 files changed

+64
-0
lines changed

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,12 @@ internal class ConnectionViewModel @VisibleForTesting constructor(
116116

117117
init {
118118
viewModelScope.launch {
119+
// Pre-set the mTLS flag before emitting the auth URL. If the phone is currently
120+
// connected to an mTLS-protected instance, the private key is already loaded in
121+
// memory. The onboarding WebView will reuse the live TLS session (session resumption)
122+
// so onReceivedClientCertRequest never fires — pre-setting the flag ensures the
123+
// Wear OS cert-selection screen is not silently skipped.
124+
webViewClient.preInitializeTLSClientAuthState()
119125
buildAuthUrl(rawUrl)
120126
}
121127
}

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

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,36 @@ open class TLSWebViewClient(private var keyChainRepository: KeyChainRepository)
3939
private var key: PrivateKey? = null
4040
private var chain: Array<X509Certificate>? = null
4141

42+
/**
43+
* Pre-initializes [isTLSClientAuthNeeded] from the current in-memory key state to handle
44+
* TLS session resumption.
45+
*
46+
* Normally [isTLSClientAuthNeeded] is set when [onReceivedClientCertRequest] fires during
47+
* a full TLS handshake. However, when TLS session resumption occurs (the WebView reuses an
48+
* existing session from the same process), the server does not issue a new
49+
* `CertificateRequest`, so [onReceivedClientCertRequest] is never called — even if the
50+
* server requires a client certificate.
51+
*
52+
* This is the root cause of the Wear OS onboarding mTLS failure: the main app WebView
53+
* establishes a TLS session while the user is connected; the onboarding WebView immediately
54+
* resumes it, bypassing the callback that would reveal the mTLS requirement to the
55+
* navigation layer.
56+
*
57+
* The fix checks whether the repository already holds a loaded private key in memory. A
58+
* non-null key means the phone is currently connected to an instance that required a client
59+
* certificate, so [isTLSClientAuthNeeded] is pre-set to `true`. If the app was force-stopped
60+
* first (clearing in-memory state) no TLS session can be resumed either, so
61+
* [onReceivedClientCertRequest] will fire naturally on the fresh handshake.
62+
*
63+
* Must be called **before** the WebView starts loading (i.e. before the URL is emitted).
64+
* Idempotent: if the flag is already `true` (set by a real handshake) this is a no-op.
65+
*/
66+
fun preInitializeTLSClientAuthState() {
67+
if (!isTLSClientAuthNeeded) {
68+
isTLSClientAuthNeeded = keyChainRepository.getPrivateKey() != null
69+
}
70+
}
71+
4272
private fun getActivity(context: Context?): Activity? {
4373
if (context == null) {
4474
return null

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

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -417,4 +417,32 @@ class ConnectionViewModelTest {
417417
assertEquals("class java.lang.UnsatisfiedLinkError", error.rawErrorType)
418418
verify(exactly = 1) { connectivityCheckRepository.runChecks(rawUrl) }
419419
}
420+
421+
@Test
422+
fun `Given a private key already loaded in memory when initializing then isTLSClientAuthNeeded is pre-set to true`() = runTest {
423+
every { keyChainRepository.getPrivateKey() } returns mockk()
424+
425+
val viewModel = ConnectionViewModel(
426+
"http://homeassistant.local:8123",
427+
webViewClientFactory,
428+
connectivityCheckRepository,
429+
)
430+
advanceUntilIdle()
431+
432+
assertTrue(viewModel.webViewClient.isTLSClientAuthNeeded)
433+
}
434+
435+
@Test
436+
fun `Given no private key in memory when initializing then isTLSClientAuthNeeded remains false`() = runTest {
437+
every { keyChainRepository.getPrivateKey() } returns null
438+
439+
val viewModel = ConnectionViewModel(
440+
"http://homeassistant.local:8123",
441+
webViewClientFactory,
442+
connectivityCheckRepository,
443+
)
444+
advanceUntilIdle()
445+
446+
assertFalse(viewModel.webViewClient.isTLSClientAuthNeeded)
447+
}
420448
}

0 commit comments

Comments
 (0)