Skip to content

Commit a1cb322

Browse files
smhcsmhc
authored andcommitted
fix: pre-initialize mTLS flag from stored alias 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: expose KeyChainRepository.loadAlias() which reads the persisted key alias from PrefsRepository without requiring load() to have been called first. Add TLSWebViewClient.preInitializeTLSClientAuthState() which pre-sets isTLSClientAuthNeeded = true if a stored alias is found. ConnectionViewModel.init calls this before emitting the auth URL so the navigation layer sees the correct value regardless of whether the subsequent WebView connection is a resumed TLS session. Also adds two unit tests to ConnectionViewModelTest covering the stored-alias and no-alias cases.
1 parent f3e1cfa commit a1cb322

File tree

6 files changed

+87
-0
lines changed

6 files changed

+87
-0
lines changed

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

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

117117
init {
118118
viewModelScope.launch {
119+
// Pre-set the mTLS flag from any previously stored certificate alias before loading
120+
// the auth URL. This handles TLS session resumption: if the main app WebView already
121+
// has a live TLS session for this host, the onboarding WebView will resume it and
122+
// onReceivedClientCertRequest will never fire — even though the server requires a
123+
// client certificate. Without this, the Wear OS mTLS cert-selection screen would be
124+
// silently skipped and the watch would register without a certificate.
125+
webViewClient.preInitializeTLSClientAuthState()
119126
buildAuthUrl(rawUrl)
120127
}
121128
}

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

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,33 @@ 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 persistent storage to handle TLS session
44+
* 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 HA app WebView
53+
* establishes a TLS session, and the onboarding WebView immediately resumes it, bypassing
54+
* the callback that would reveal the mTLS requirement to the navigation layer.
55+
*
56+
* This method reads the persisted key alias via [KeyChainRepository.loadAlias]. If an alias
57+
* is found it means the user previously selected a client certificate for this HA instance,
58+
* so [isTLSClientAuthNeeded] is pre-set to `true`.
59+
*
60+
* Must be called **before** the WebView starts loading (i.e. before the URL is emitted).
61+
* Idempotent: if the flag is already `true` (set by a real handshake) this is a no-op.
62+
*/
63+
suspend fun preInitializeTLSClientAuthState() {
64+
if (!isTLSClientAuthNeeded) {
65+
isTLSClientAuthNeeded = keyChainRepository.loadAlias() != null
66+
}
67+
}
68+
4269
private fun getActivity(context: Context?): Activity? {
4370
if (context == null) {
4471
return null

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

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import io.homeassistant.companion.android.testing.unit.ConsoleLogExtension
1717
import io.homeassistant.companion.android.testing.unit.MainDispatcherJUnit5Extension
1818
import io.homeassistant.companion.android.util.HAWebViewClient
1919
import io.homeassistant.companion.android.util.HAWebViewClientFactory
20+
import io.mockk.coEvery
2021
import io.mockk.every
2122
import io.mockk.mockk
2223
import io.mockk.mockkStatic
@@ -417,4 +418,32 @@ class ConnectionViewModelTest {
417418
assertEquals("class java.lang.UnsatisfiedLinkError", error.rawErrorType)
418419
verify(exactly = 1) { connectivityCheckRepository.runChecks(rawUrl) }
419420
}
421+
422+
@Test
423+
fun `Given a stored key alias when initializing then isTLSClientAuthNeeded is pre-set to true`() = runTest {
424+
coEvery { keyChainRepository.loadAlias() } returns "my-cert-alias"
425+
426+
val viewModel = ConnectionViewModel(
427+
"http://homeassistant.local:8123",
428+
webViewClientFactory,
429+
connectivityCheckRepository,
430+
)
431+
advanceUntilIdle()
432+
433+
assertTrue(viewModel.webViewClient.isTLSClientAuthNeeded)
434+
}
435+
436+
@Test
437+
fun `Given no stored key alias when initializing then isTLSClientAuthNeeded remains false`() = runTest {
438+
coEvery { keyChainRepository.loadAlias() } returns null
439+
440+
val viewModel = ConnectionViewModel(
441+
"http://homeassistant.local:8123",
442+
webViewClientFactory,
443+
connectivityCheckRepository,
444+
)
445+
advanceUntilIdle()
446+
447+
assertFalse(viewModel.webViewClient.isTLSClientAuthNeeded)
448+
}
420449
}

common/src/main/kotlin/io/homeassistant/companion/android/common/data/keychain/KeyChainRepository.kt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,15 @@ interface KeyChainRepository {
2929

3030
suspend fun setData(alias: String, privateKey: PrivateKey, certificateChain: Array<X509Certificate>)
3131

32+
/**
33+
* Loads the stored key alias from persistent storage, if available, without loading the
34+
* full key material. Unlike [getAlias], this reads from persistent storage and does not
35+
* require [load] to have been called first (e.g. works after a cold start or force-stop).
36+
*
37+
* Returns the alias string if one was previously stored and is non-empty, otherwise `null`.
38+
*/
39+
suspend fun loadAlias(): String?
40+
3241
fun getAlias(): String?
3342

3443
fun getPrivateKey(): PrivateKey?

common/src/main/kotlin/io/homeassistant/companion/android/common/data/keychain/KeyChainRepositoryImpl.kt

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,17 @@ internal class KeyChainRepositoryImpl @Inject constructor(private val prefsRepos
3939
throw UnsupportedOperationException("setData not supported for KeyChainRepositoryImpl")
4040
}
4141

42+
override suspend fun loadAlias(): String? {
43+
// Read directly from persistent storage so this works even when load() has not yet
44+
// been called (e.g. after a force-stop / cold restart of the application).
45+
val stored = prefsRepository.getKeyAlias()
46+
// Populate the in-memory alias as a side-effect so subsequent getAlias() calls work.
47+
if (stored?.isNotEmpty() == true && alias == null) {
48+
alias = stored
49+
}
50+
return stored?.takeIf { it.isNotEmpty() }
51+
}
52+
4253
override fun getAlias(): String? {
4354
return alias
4455
}

common/src/main/kotlin/io/homeassistant/companion/android/common/data/keychain/KeyStoreRepositoryImpl.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,10 @@ internal class KeyStoreRepositoryImpl @Inject constructor() : KeyChainRepository
4747
doLoad()
4848
}
4949

50+
// KeyStoreRepositoryImpl does not persist the alias to PrefsRepository; the alias is
51+
// always supplied via load(context, alias) or setData(). Return the in-memory value.
52+
override suspend fun loadAlias(): String? = alias?.takeIf { it.isNotEmpty() }
53+
5054
override fun getAlias(): String? {
5155
return alias
5256
}

0 commit comments

Comments
 (0)