fix: pre-initialize mTLS flag from cert SAN/CN to handle TLS session resumption in Wear OS onboarding#6672
Conversation
…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(targetHost) which inspects the in-memory certificate chain (if any) and checks whether it covers targetHost by matching the server hostname against the certificate's Subject Alternative Names (SANs) — both DNS names with wildcard support (RFC 2818 §3.1) and IP addresses — falling back to the Common Name (CN) for legacy certificates without SANs. Matching against the certificate rather than mere private-key presence eliminates the false positive raised in review: a user with multiple servers where only Server A requires mTLS will no longer see the cert- selection screen when onboarding a watch for Server B, because the loaded certificate will not match Server B's hostname. ConnectionViewModel.init extracts the hostname from rawUrl and passes it to preInitializeTLSClientAuthState() before emitting the auth URL so the navigation layer sees the correct value. Also updates ConnectionViewModelTest: the two existing private-key- presence tests are replaced with five cert-host matching tests covering exact DNS SANs, wildcard DNS SANs, a cert for a different host, no certificate chain present, and CN-only fallback. Made-with: Cursor
Two bugs found in self-review of preInitializeTLSClientAuthState:
1. IP SAN (type 7) matching was silently broken: the Java X.509 API
returns iPAddress entries as ByteArray, not String, so the previous
�s? String cast always produced null and IP-addressed HA instances
were never matched. Fixed by casting to ByteArray and converting via
InetAddress.getByAddress().
2. CN extraction used removePrefix("CN=") (case-sensitive) after a
case-insensitive startsWith("CN=") check. If getName(RFC2253) ever
returned a lowercase "cn=" the prefix would not be stripped, leaving
the full AVA string in the hostname match. Fixed by using
substring(indexOf('=') + 1) which is inherently case-independent.
Also adds two new tests:
- wildcard SAN correctly rejects a multi-label subdomain (*.a.com
should not match foo.bar.a.com)
- IP SAN (ByteArray type 7) correctly matches an IP-addressed host
Made-with: Cursor
There was a problem hiding this comment.
Pull request overview
This PR fixes a critical issue where the mTLS certificate selection screen is silently skipped during Wear OS onboarding due to TLS session resumption. The root cause is that when a WebView reuses an existing TLS session from the same process, the server doesn't send a new CertificateRequest, so onReceivedClientCertRequest() is never invoked, leaving isTLSClientAuthNeeded set to false.
The fix adds pre-initialization logic that inspects the in-memory certificate chain and checks whether it covers the target hostname by matching against the certificate's Subject Alternative Names (SANs) with full wildcard support, as well as the Common Name (CN) as a fallback for legacy certificates.
Changes:
- Add
preInitializeTLSClientAuthState(targetHost: String)toTLSWebViewClientwith helper methodscertCoversHost()andhostMatchesSan()for certificate-to-hostname matching - Call the pre-initialization method from
ConnectionViewModel.initbefore the auth URL is emitted - Add 7 comprehensive unit tests covering exact DNS SAN matching, wildcard DNS SAN matching, mismatched hosts, missing certificates, CN matching, multi-label subdomain edge cases, and IP address SAN matching
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated no comments.
| File | Description |
|---|---|
app/src/main/kotlin/.../util/TLSWebViewClient.kt |
Adds preInitializeTLSClientAuthState() and supporting certificate-hostname matching logic with SANs (DNS and IP) and CN fallback |
app/src/main/kotlin/.../onboarding/connection/ConnectionViewModel.kt |
Calls preInitializeTLSClientAuthState() in init block before buildAuthUrl() |
app/src/test/kotlin/.../onboarding/connection/ConnectionViewModelTest.kt |
7 new tests covering certificate-host matching scenarios |
|
Note this change is AI generated and has not been manually tested. I worked around it via killing the main app, clearing cache/restart and invoking the intent directly via the watch to skip the initial HA TLS handshake. There is an alternate solution of doing 'does current host match target host' string matching, however this doesn't handle wildcard certs where you may have 'host1.ha.com' vs 'host2.ha.com' and a cert of '*.ha.com'. It's a pretty legitimate issue however and I wanted to raise it with suggested changes to better highlight the problem and the chances of a fix landing. |
TimoPtr
left a comment
There was a problem hiding this comment.
Your comment about AI scares us a bit, we need to ensure this is tested properly by you. Even if it seems that the worse that can happen is not starting the mTLS flow it could become a blocker in some cases. I invite you to test it more.
I'm not an expert with certificat structure so I rely on you to test properly.
| @VisibleForTesting | ||
| internal fun certCoversHost(cert: X509Certificate, host: String): Boolean { | ||
| val sans: Collection<List<*>>? = try { |
There was a problem hiding this comment.
Let's keep this private and test through the public API.
| internal fun certCoversHost(cert: X509Certificate, host: String): Boolean { | ||
| val sans: Collection<List<*>>? = try { | ||
| cert.subjectAlternativeNames | ||
| } catch (_: Exception) { |
There was a problem hiding this comment.
| } catch (_: Exception) { | |
| } catch (_: CertificateParsingException) { |
| // positive when the user has multiple servers where only one requires mTLS. | ||
| try { | ||
| webViewClient.preInitializeTLSClientAuthState(rawUrl.toHttpUrl().host) | ||
| } catch (_: Exception) { |
There was a problem hiding this comment.
You need to rethrow the cancelation properly still since you catch Exception.
| 2 -> { // dNSName — returned as String | ||
| val value = san[1] as? String ?: return@any false | ||
| hostMatchesSan(host, value) | ||
| } | ||
| 7 -> { // iPAddress — returned as ByteArray per the Java X.509 API contract | ||
| val ipBytes = san[1] as? ByteArray ?: return@any false |
There was a problem hiding this comment.
Is there a way to not have this magic numbers? 2 an 7 or at least link it to the proper documentation.
| // Pre-set the mTLS flag before emitting the auth URL. If the phone is currently | ||
| // connected to an mTLS-protected instance whose certificate covers this host, the | ||
| // onboarding WebView will reuse the live TLS session (session resumption) and | ||
| // onReceivedClientCertRequest will never fire — pre-setting the flag ensures the | ||
| // Wear OS cert-selection screen is not silently skipped. | ||
| // Matching against the cert's SANs/CN rather than mere key presence avoids a false | ||
| // positive when the user has multiple servers where only one requires mTLS. |
There was a problem hiding this comment.
You should rewrite this and remove the implementation details of the function that you already document on the function itself.
| hostMatchesSan(host, value) | ||
| } | ||
| 7 -> { // iPAddress — returned as ByteArray per the Java X.509 API contract | ||
| val ipBytes = san[1] as? ByteArray ?: return@any false |
There was a problem hiding this comment.
Are you sure it's always a ByteArray here and it never could be a String?
|
Please take a look at the requested changes, and use the Ready for review button when you are done, thanks 👍 |
Comment out the Create Release Notes action in the workflow.
| with: | ||
| tag-name: ${{ steps.rel_number.outputs.version }} | ||
| gh-token: ${{ secrets.GITHUB_TOKEN }} | ||
| # - uses: ./.github/actions/create-release-notes |
There was a problem hiding this comment.
Please don't modify the CI within this PR 🙏🏻
…hout secrets Made-with: Cursor
Made-with: Cursor
Narrow ConnectionViewModel catch to IllegalArgumentException and trim the init comment. In TLSWebViewClient, use SAN type constants, typed certificate/DNS exceptions, support IP SAN as String or ByteArray, skip empty CN, and make certCoversHost private. Add ConnectionViewModel tests for wildcard apex coverage and CN ignored when SANs are present. Made-with: Cursor
- Parse Subject Alternative Name and host through InetAddress so equivalent textual IPv6 forms (e.g. ::1 vs full form) compare equal. Made-with: Cursor
This reverts commit 373bf0c.
- Parse type-7 SAN as ByteArray or String per provider behavior - Compare hosts with InetAddress equality for IPv6 normalization - Add ConnectionViewModel tests for String SAN, compressed IPv6, DNS case, and SAN parse fallback to CN Made-with: Cursor
- Parse type-7 SAN as ByteArray or String per provider behavior - Compare hosts with InetAddress equality for IPv6 normalization - Add ConnectionViewModel tests for String SAN, compressed IPv6, DNS case, and SAN parse fallback to CN Made-with: Cursor
This reverts commit 724b228.
…ion - keeps onPush.yml at main version Made-with: Cursor
…ion - keeps onPush.yml at main version Made-with: Cursor
….com/smhc/android into fix/wear-mtls-tls-session-resumption
Problem
When setting up a Wear OS device through the Home Assistant phone app, the mTLS certificate
selection screen is silently skipped, causing the watch to register without a client certificate
and the server to respond with
HTTP 400.The root cause is TLS session resumption:
CertificateRequestfrom the server).WebViewClient.onReceivedClientCertRequest()is never invoked.TLSWebViewClient.isTLSClientAuthNeededremainsfalse.requiredMTLS = falseis passed throughConnectionNavigationEvent.Authenticated→OnboardingNavigationskipswearMTLSScreen.400 Bad Request.This happens regardless of whether
ssl_session_tickets/ssl_session_cacheare disabled on the server, because Android's Chromium network stack also maintains its own in-process TLS session cache that persists across WebView instances.Fix
TLSWebViewClientgainspreInitializeTLSClientAuthState(targetHost: String)that checks the in-memory certificate chain (if any) and tests whether it coverstargetHostby matching against the certificate's Subject Alternative Names (SANs):Matching against the certificate's SANs — rather than mere private-key presence — eliminates the false positive identified in review: if a user has multiple servers where only Server A requires mTLS, the cert for Server A will not match Server B's hostname, so the mTLS screen is suppressed correctly.
This is called from
ConnectionViewModel.initbeforebuildAuthUrl(), so the flag is correct whether or not the subsequent WebView connection is a resumed TLS session.Changes
app/.../util/TLSWebViewClient.ktpreInitializeTLSClientAuthState(targetHost)+certCoversHost()+hostMatchesSan()helpersapp/.../onboarding/connection/ConnectionViewModel.ktrawUrland pass topreInitializeTLSClientAuthState()app/.../onboarding/connection/ConnectionViewModelTest.ktBehaviour
isTLSClientAuthNeeded = truebefore WebView loads → mTLS cert screen shown during Wear onboarding ✓getCertificateChain()returnsnull→ flag staysfalse→ behaviour unchanged ✓falsefor Server B onboarding ✓onReceivedClientCertRequestfires and sets the flag;preInitializeTLSClientAuthStateis idempotent ✓Replaces #6663 which was accidentally closed during a force-push from a shallow clone.