Skip to content

fix: pre-initialize mTLS flag from cert SAN/CN to handle TLS session resumption in Wear OS onboarding#6672

Draft
smhc wants to merge 16 commits intohome-assistant:mainfrom
smhc:fix/wear-mtls-tls-session-resumption
Draft

fix: pre-initialize mTLS flag from cert SAN/CN to handle TLS session resumption in Wear OS onboarding#6672
smhc wants to merge 16 commits intohome-assistant:mainfrom
smhc:fix/wear-mtls-tls-session-resumption

Conversation

@smhc
Copy link
Copy Markdown

@smhc smhc commented Apr 3, 2026

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:

  1. The main HA app WebView authenticates with the server — a TLS session ticket is created.
  2. The user opens the Wear OS onboarding flow (either from the watch or via the phone app).
  3. The onboarding WebView connects to the same host; the OS TLS stack resumes the existing session (abbreviated handshake — no new CertificateRequest from the server).
  4. WebViewClient.onReceivedClientCertRequest() is never invoked.
  5. TLSWebViewClient.isTLSClientAuthNeeded remains false.
  6. requiredMTLS = false is passed through ConnectionNavigationEvent.AuthenticatedOnboardingNavigation skips wearMTLSScreen.
  7. The watch is registered without a cert → server returns 400 Bad Request.

This happens regardless of whether ssl_session_tickets / ssl_session_cache are 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

TLSWebViewClient gains preInitializeTLSClientAuthState(targetHost: String) that checks the in-memory certificate chain (if any) and tests whether it covers targetHost by matching against the certificate's Subject Alternative Names (SANs):

  • dNSName entries (type 2) with full wildcard support per RFC 2818 §3.1
  • iPAddress entries (type 7) for IP-addressed instances
  • Common Name (CN) fallback for legacy certificates that lack 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.init before buildAuthUrl(), so the flag is correct whether or not the subsequent WebView connection is a resumed TLS session.

Changes

File Change
app/.../util/TLSWebViewClient.kt preInitializeTLSClientAuthState(targetHost) + certCoversHost() + hostMatchesSan() helpers
app/.../onboarding/connection/ConnectionViewModel.kt Extract host from rawUrl and pass to preInitializeTLSClientAuthState()
app/.../onboarding/connection/ConnectionViewModelTest.kt 5 cert-host matching tests replacing 2 key-presence tests

Behaviour

  • Happy path (TLS resumption): cert covers target host → isTLSClientAuthNeeded = true before WebView loads → mTLS cert screen shown during Wear onboarding ✓
  • No cert configured: getCertificateChain() returns null → flag stays false → behaviour unchanged ✓
  • Multi-server false positive resolved: cert for Server A does not match Server B's hostname → flag stays false for Server B onboarding ✓
  • Fresh TLS handshake: onReceivedClientCertRequest fires and sets the flag; preInitializeTLSClientAuthState is idempotent ✓

Replaces #6663 which was accidentally closed during a force-push from a shallow clone.

…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
@smhc smhc marked this pull request as ready for review April 3, 2026 03:40
Copilot AI review requested due to automatic review settings April 3, 2026 03:40
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) to TLSWebViewClient with helper methods certCoversHost() and hostMatchesSan() for certificate-to-hostname matching
  • Call the pre-initialization method from ConnectionViewModel.init before 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

@smhc
Copy link
Copy Markdown
Author

smhc commented Apr 3, 2026

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.

Copy link
Copy Markdown
Member

@TimoPtr TimoPtr left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +85 to +87
@VisibleForTesting
internal fun certCoversHost(cert: X509Certificate, host: String): Boolean {
val sans: Collection<List<*>>? = try {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
} catch (_: Exception) {
} catch (_: CertificateParsingException) {

// positive when the user has multiple servers where only one requires mTLS.
try {
webViewClient.preInitializeTLSClientAuthState(rawUrl.toHttpUrl().host)
} catch (_: Exception) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You need to rethrow the cancelation properly still since you catch Exception.

Comment on lines +97 to +102
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
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a way to not have this magic numbers? 2 an 7 or at least link it to the proper documentation.

Comment on lines +119 to +125
// 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.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you sure it's always a ByteArray here and it never could be a String?

@home-assistant home-assistant bot marked this pull request as draft April 14, 2026 07:05
@home-assistant
Copy link
Copy Markdown

Please take a look at the requested changes, and use the Ready for review button when you are done, thanks 👍

Learn more about our pull request process.

Comment out the Create Release Notes action in the workflow.
Comment thread .github/workflows/onPush.yml Outdated
with:
tag-name: ${{ steps.rel_number.outputs.version }}
gh-token: ${{ secrets.GITHUB_TOKEN }}
# - uses: ./.github/actions/create-release-notes
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please don't modify the CI within this PR 🙏🏻

smhc added 13 commits April 14, 2026 19:00
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
- 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
…ion - keeps onPush.yml at main version

Made-with: Cursor
…ion - keeps onPush.yml at main version

Made-with: Cursor
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants