From 67974b30cd69633aa798399bee7a615a21de3e1a Mon Sep 17 00:00:00 2001 From: Chamika Date: Mon, 27 Jul 2026 17:18:46 +0100 Subject: [PATCH 1/4] Let users trust a server certificate Android rejects Android's system CA store is frozen at the OS release, so head units on Android 12-14 reject certificates chaining to newer roots. A Polestar 4 (Android 12) cannot validate Sectigo Public Server Authentication Root R46, which AOSP only added in Android 15, and Play cannot backport it because the updatable CA store landed in Android 14. Chrome works because it ships its own root store, which makes the failure look like an app bug. Sign-in now distinguishes a rejected certificate from an unreachable server, shows its SHA-256 fingerprint, and lets the user pin it. This pins the exact leaf certificate rather than disabling verification. Platform validation still runs first and still rejects everything it normally would; only a fingerprint the user explicitly approved is accepted, so a machine-in-the-middle substituting its own certificate still fails. That matters for a head unit carrying an access token across untrusted hotspots. A single OkHttpClient now backs the Jellyfin SDK, album art and playback, so an approved certificate applies to streaming, buffering and prefetch rather than only to the login request. Playback moves from DefaultHttpDataSource to OkHttpDataSource to share that client. Pinned certificates are listed in Settings and can be removed individually. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01YUCBnCgapXargAeGfmUHGE --- automotive/build.gradle.kts | 1 + .../dashtune/AlbumArtContentProvider.kt | 29 +++- .../chamika/dashtune/DashTuneMusicService.kt | 12 +- .../com/chamika/dashtune/di/DashTuneModule.kt | 32 +++- .../dashtune/settings/SettingsFragment.kt | 55 +++++++ .../dashtune/signin/ServerSignInFragment.kt | 66 ++++++-- .../dashtune/signin/SignInViewModel.kt | 42 +++++- .../dashtune/tls/CertificateInspector.kt | 142 ++++++++++++++++++ .../dashtune/tls/PinnedHostnameVerifier.kt | 25 +++ .../dashtune/tls/PinnedTrustManager.kt | 98 ++++++++++++ .../dashtune/tls/TrustedCertificateStore.kt | 78 ++++++++++ automotive/src/main/res/values/strings.xml | 11 ++ automotive/src/main/res/xml/preferences.xml | 5 + gradle/libs.versions.toml | 1 + 14 files changed, 573 insertions(+), 24 deletions(-) create mode 100644 automotive/src/main/java/com/chamika/dashtune/tls/CertificateInspector.kt create mode 100644 automotive/src/main/java/com/chamika/dashtune/tls/PinnedHostnameVerifier.kt create mode 100644 automotive/src/main/java/com/chamika/dashtune/tls/PinnedTrustManager.kt create mode 100644 automotive/src/main/java/com/chamika/dashtune/tls/TrustedCertificateStore.kt diff --git a/automotive/build.gradle.kts b/automotive/build.gradle.kts index 1d91d96..cf3bfb2 100644 --- a/automotive/build.gradle.kts +++ b/automotive/build.gradle.kts @@ -74,6 +74,7 @@ dependencies { implementation(libs.androidx.lifecycle.viewmodel.ktx) implementation(libs.androidx.media3.exoplayer) implementation(libs.androidx.media3.session) + implementation(libs.androidx.media3.datasource.okhttp) implementation(libs.jellyfin.core) implementation(libs.slf4j.android) implementation(libs.okhttp) diff --git a/automotive/src/main/java/com/chamika/dashtune/AlbumArtContentProvider.kt b/automotive/src/main/java/com/chamika/dashtune/AlbumArtContentProvider.kt index b20b697..3ddc99a 100644 --- a/automotive/src/main/java/com/chamika/dashtune/AlbumArtContentProvider.kt +++ b/automotive/src/main/java/com/chamika/dashtune/AlbumArtContentProvider.kt @@ -8,6 +8,10 @@ import android.net.Uri import android.os.ParcelFileDescriptor import android.util.Log import com.chamika.dashtune.Constants.LOG_TAG +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.EntryPointAccessors +import dagger.hilt.components.SingletonComponent import okhttp3.OkHttpClient import okhttp3.Request import okio.buffer @@ -20,10 +24,27 @@ import java.util.concurrent.TimeUnit class AlbumArtContentProvider : ContentProvider() { - private val client = OkHttpClient.Builder() - .connectTimeout(10, TimeUnit.SECONDS) - .readTimeout(10, TimeUnit.SECONDS) - .build() + /** + * Hilt can't inject a ContentProvider (providers are created before the Application is fully + * initialised), so the shared client is pulled from the entry point instead. It's resolved + * lazily on the first artwork request — long after startup — and reuses the app-wide TLS + * configuration so pinned certificates apply to album art too. + */ + private val client: OkHttpClient by lazy { + EntryPointAccessors + .fromApplication(context!!.applicationContext, AlbumArtEntryPoint::class.java) + .okHttpClient() + .newBuilder() + .connectTimeout(10, TimeUnit.SECONDS) + .readTimeout(10, TimeUnit.SECONDS) + .build() + } + + @EntryPoint + @InstallIn(SingletonComponent::class) + interface AlbumArtEntryPoint { + fun okHttpClient(): OkHttpClient + } companion object { // Written from the media session/browse threads and read from binder threads, diff --git a/automotive/src/main/java/com/chamika/dashtune/DashTuneMusicService.kt b/automotive/src/main/java/com/chamika/dashtune/DashTuneMusicService.kt index f2e8c50..5865c5f 100644 --- a/automotive/src/main/java/com/chamika/dashtune/DashTuneMusicService.kt +++ b/automotive/src/main/java/com/chamika/dashtune/DashTuneMusicService.kt @@ -22,7 +22,7 @@ import androidx.media3.common.MediaItem import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi import androidx.media3.database.StandaloneDatabaseProvider -import androidx.media3.datasource.DefaultHttpDataSource +import androidx.media3.datasource.okhttp.OkHttpDataSource import androidx.media3.datasource.cache.CacheDataSource import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor import androidx.media3.datasource.cache.NoOpCacheEvictor @@ -160,6 +160,9 @@ class DashTuneMusicService : MediaLibraryService() { @Inject lateinit var mediaCacheDao: MediaCacheDao + @Inject + lateinit var okHttpClient: okhttp3.OkHttpClient + private lateinit var accountManager: com.chamika.dashtune.auth.JellyfinAccountManager private lateinit var jellyfinApi: ApiClient private lateinit var mediaSourceFactory: DefaultMediaSourceFactory @@ -195,7 +198,7 @@ class DashTuneMusicService : MediaLibraryService() { private lateinit var downloadCache: SimpleCache private lateinit var downloadManager: DownloadManager private lateinit var cacheDataSourceFactory: CacheDataSource.Factory - private lateinit var httpDataSourceFactory: DefaultHttpDataSource.Factory + private lateinit var httpDataSourceFactory: OkHttpDataSource.Factory override fun onCreate() { super.onCreate() @@ -230,7 +233,10 @@ class DashTuneMusicService : MediaLibraryService() { databaseProvider ) - httpDataSourceFactory = DefaultHttpDataSource.Factory() + // OkHttp rather than the default HttpURLConnection stack so streaming, buffering and + // prefetch go through the same TLS configuration as the API calls — including any + // certificate the user approved at sign-in. + httpDataSourceFactory = OkHttpDataSource.Factory(okHttpClient) cacheDataSourceFactory = CacheDataSource.Factory() .setCache(downloadCache) .setUpstreamDataSourceFactory(httpDataSourceFactory) diff --git a/automotive/src/main/java/com/chamika/dashtune/di/DashTuneModule.kt b/automotive/src/main/java/com/chamika/dashtune/di/DashTuneModule.kt index 6c9412d..5a2a58d 100644 --- a/automotive/src/main/java/com/chamika/dashtune/di/DashTuneModule.kt +++ b/automotive/src/main/java/com/chamika/dashtune/di/DashTuneModule.kt @@ -4,22 +4,51 @@ import android.accounts.AccountManager import android.content.Context import com.chamika.dashtune.R import com.chamika.dashtune.auth.JellyfinAccountManager +import com.chamika.dashtune.tls.PinnedHostnameVerifier +import com.chamika.dashtune.tls.PinnedTrustManager +import com.chamika.dashtune.tls.TrustedCertificateStore import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent +import okhttp3.OkHttpClient import org.jellyfin.sdk.Jellyfin import org.jellyfin.sdk.android.androidDevice +import org.jellyfin.sdk.api.okhttp.OkHttpFactory import org.jellyfin.sdk.createJellyfin import org.jellyfin.sdk.model.ClientInfo +import javax.inject.Singleton +import javax.net.ssl.SSLContext @Module @InstallIn(SingletonComponent::class) class DashTuneModule { + /** + * The single OkHttp client every network path shares — Jellyfin API calls, album art, playback + * and prefetch. Sharing it is what makes a certificate the user approves at sign-in apply to + * streaming and buffering too, rather than only to the login request. + */ @Provides - fun provideJellyfin(@ApplicationContext appContext: Context): Jellyfin { + @Singleton + fun provideOkHttpClient(store: TrustedCertificateStore): OkHttpClient { + val trustManager = PinnedTrustManager(PinnedTrustManager.platformTrustManager(), store) + val sslContext = SSLContext.getInstance("TLS").apply { + init(null, arrayOf(trustManager), null) + } + + return OkHttpClient.Builder() + .sslSocketFactory(sslContext.socketFactory, trustManager) + .hostnameVerifier(PinnedHostnameVerifier(store)) + .build() + } + + @Provides + fun provideJellyfin( + @ApplicationContext appContext: Context, + okHttpClient: OkHttpClient, + ): Jellyfin { val version = appContext.packageManager.getPackageInfo(appContext.packageName, 0).versionName @@ -27,6 +56,7 @@ class DashTuneModule { clientInfo = ClientInfo(appContext.getString(R.string.app_name), version ?: "unknown") deviceInfo = androidDevice(appContext) context = appContext + apiClientFactory = OkHttpFactory(base = okHttpClient) } } diff --git a/automotive/src/main/java/com/chamika/dashtune/settings/SettingsFragment.kt b/automotive/src/main/java/com/chamika/dashtune/settings/SettingsFragment.kt index 0dd12d7..e283f19 100644 --- a/automotive/src/main/java/com/chamika/dashtune/settings/SettingsFragment.kt +++ b/automotive/src/main/java/com/chamika/dashtune/settings/SettingsFragment.kt @@ -19,15 +19,20 @@ import com.chamika.dashtune.DashTuneMusicService import com.chamika.dashtune.DashTuneSessionCallback.Companion.SYNC_COMMAND import com.chamika.dashtune.R import com.chamika.dashtune.signin.SignInActivity +import com.chamika.dashtune.tls.TrustedCertificateStore import com.google.common.util.concurrent.ListenableFuture import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch import java.text.DateFormat import java.util.Date +import javax.inject.Inject @AndroidEntryPoint class SettingsFragment : PreferenceFragmentCompat() { + @Inject + lateinit var trustedCertificateStore: TrustedCertificateStore + private lateinit var viewModel: SettingsViewModel private lateinit var controllerFuture: ListenableFuture @@ -93,6 +98,15 @@ class SettingsFragment : PreferenceFragmentCompat() { true } + val trustedCertsPref = findPreference("trusted_certificates") + trustedCertsPref?.let { pref -> + refreshTrustedCertificates(pref) + pref.setOnPreferenceClickListener { + showTrustedCertificates(pref) + true + } + } + findPreference("force_exit")?.setOnPreferenceClickListener { AlertDialog.Builder(requireContext()) .setMessage(R.string.force_exit_confirmation) @@ -136,6 +150,47 @@ class SettingsFragment : PreferenceFragmentCompat() { super.onStop() } + private fun refreshTrustedCertificates(pref: Preference) { + val count = trustedCertificateStore.pinnedCertificates().size + pref.summary = if (count == 0) { + getString(R.string.trusted_certificates_none) + } else { + getString(R.string.trusted_certificates_count, count) + } + pref.isEnabled = count > 0 + } + + /** Lists approved certificates so a user can withdraw trust without signing out. */ + private fun showTrustedCertificates(pref: Preference) { + val pinned = trustedCertificateStore.pinnedCertificates().toList() + if (pinned.isEmpty()) return + + val labels = pinned.map { (host, fingerprints) -> + "$host\n${fingerprints.joinToString("\n")}" + }.toTypedArray() + + AlertDialog.Builder(requireContext()) + .setTitle(R.string.trusted_certificates) + .setItems(labels) { _, index -> + val host = pinned[index].first + AlertDialog.Builder(requireContext()) + .setMessage(getString(R.string.trusted_certificate_remove_confirmation, host)) + .setPositiveButton(R.string.trusted_certificate_remove_confirm) { _, _ -> + trustedCertificateStore.remove(host) + refreshTrustedCertificates(pref) + Toast.makeText( + requireContext(), + R.string.trusted_certificate_removed, + Toast.LENGTH_SHORT + ).show() + } + .setNegativeButton(R.string.cancel, null) + .show() + } + .setNegativeButton(R.string.cancel, null) + .show() + } + private fun lastSyncSummary(): String { val lastSync = PreferenceManager.getDefaultSharedPreferences(requireContext()) .getLong("last_sync_timestamp", 0L) diff --git a/automotive/src/main/java/com/chamika/dashtune/signin/ServerSignInFragment.kt b/automotive/src/main/java/com/chamika/dashtune/signin/ServerSignInFragment.kt index dbfd1af..34c608d 100644 --- a/automotive/src/main/java/com/chamika/dashtune/signin/ServerSignInFragment.kt +++ b/automotive/src/main/java/com/chamika/dashtune/signin/ServerSignInFragment.kt @@ -1,5 +1,6 @@ package com.chamika.dashtune.signin +import android.app.AlertDialog import android.os.Bundle import android.text.Editable import android.text.TextUtils @@ -15,7 +16,9 @@ import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.lifecycleScope import com.chamika.dashtune.R import com.chamika.dashtune.signin.SignInViewModel.Companion.JELLYFIN_SERVER_URL +import com.chamika.dashtune.tls.ServerCertificate import kotlinx.coroutines.launch +import java.text.DateFormat class ServerSignInFragment : Fragment() { @@ -45,24 +48,61 @@ class ServerSignInFragment : Fragment() { submitServer.setOnClickListener { val serverUrl = serverInput.text if (!TextUtils.isEmpty(serverUrl)) { - progressBar.visibility = View.VISIBLE - errorText.visibility = View.GONE - - viewLifecycleOwner.lifecycleScope.launch { - val pingServer = viewModel.pingServer(serverUrl.toString()) - - if (pingServer) { - signInToServer(serverUrl) - } else { - progressBar.visibility = View.INVISIBLE - errorText.setText(R.string.server_unreachable) - errorText.visibility = View.VISIBLE - } + connect(serverUrl) + } + } + } + + private fun connect(serverUrl: Editable) { + progressBar.visibility = View.VISIBLE + errorText.visibility = View.GONE + + viewLifecycleOwner.lifecycleScope.launch { + when (val result = viewModel.pingServer(serverUrl.toString())) { + is PingResult.Success -> signInToServer(serverUrl) + + is PingResult.UntrustedCertificate -> { + progressBar.visibility = View.INVISIBLE + promptToTrust(result.certificate, serverUrl) + } + + is PingResult.Unreachable -> { + progressBar.visibility = View.INVISIBLE + errorText.setText(R.string.server_unreachable) + errorText.visibility = View.VISIBLE } } } } + /** + * Show what the server presented and let the user decide. The fingerprint is the part worth + * checking against the server, so it gets its own line rather than being buried in the subject. + */ + private fun promptToTrust(certificate: ServerCertificate, serverUrl: Editable) { + val expiry = DateFormat.getDateInstance(DateFormat.MEDIUM).format(certificate.notAfter) + val details = getString( + R.string.untrusted_certificate_details, + certificate.host, + certificate.issuer, + expiry, + certificate.fingerprintSha256, + ) + + AlertDialog.Builder(requireContext()) + .setTitle(R.string.untrusted_certificate_title) + .setMessage(details) + .setPositiveButton(R.string.untrusted_certificate_trust) { _, _ -> + viewModel.trustCertificate(certificate) + connect(serverUrl) + } + .setNegativeButton(R.string.cancel) { _, _ -> + errorText.setText(R.string.untrusted_certificate_rejected) + errorText.visibility = View.VISIBLE + } + .show() + } + private fun signInToServer(serverUrl: Editable) { val args = Bundle() args.putString(JELLYFIN_SERVER_URL, serverUrl.toString()) diff --git a/automotive/src/main/java/com/chamika/dashtune/signin/SignInViewModel.kt b/automotive/src/main/java/com/chamika/dashtune/signin/SignInViewModel.kt index 5d90ba7..5a7a6d6 100644 --- a/automotive/src/main/java/com/chamika/dashtune/signin/SignInViewModel.kt +++ b/automotive/src/main/java/com/chamika/dashtune/signin/SignInViewModel.kt @@ -8,6 +8,10 @@ import androidx.lifecycle.viewModelScope import com.chamika.dashtune.Constants.LOG_TAG import com.chamika.dashtune.FirebaseUtils import com.chamika.dashtune.auth.JellyfinAccountManager +import com.chamika.dashtune.tls.CertificateInspector +import com.chamika.dashtune.tls.ServerCertificate +import com.chamika.dashtune.tls.TrustedCertificateStore +import com.chamika.dashtune.tls.isCertificateTrustFailure import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay @@ -32,6 +36,12 @@ class SignInViewModel @Inject constructor() : ViewModel() { @Inject lateinit var accountManager: JellyfinAccountManager + @Inject + lateinit var trustedCertificateStore: TrustedCertificateStore + + @Inject + lateinit var certificateInspector: CertificateInspector + private var quickConnectSecret: String = "" private val _loggedIn = MutableLiveData() @@ -43,22 +53,41 @@ class SignInViewModel @Inject constructor() : ViewModel() { private var quickConnectJob: kotlinx.coroutines.Job? = null - suspend fun pingServer(serverUrl: String): Boolean { + suspend fun pingServer(serverUrl: String): PingResult { return try { Log.i(LOG_TAG, "Pinging $serverUrl") val response = withContext(Dispatchers.IO) { jellyfin.createApi(serverUrl).systemApi.getPingSystem() } - response.status == 200 + if (response.status == 200) PingResult.Success else PingResult.Unreachable } catch (e: Exception) { Log.w(LOG_TAG, "Error", e) val host = try { java.net.URI(serverUrl).host ?: "unknown" } catch (_: Exception) { "invalid_url" } FirebaseUtils.safeSetCustomKey("server_url_host", host) + + // A rejected certificate is a state the user can resolve, so offer the prompt instead + // of recording it as a crash. Only report it when we can't read the certificate back, + // which means something else is wrong. + if (isCertificateTrustFailure(e)) { + val certificate = certificateInspector.inspect(serverUrl) + if (certificate != null) { + FirebaseUtils.safeLog("Untrusted certificate presented by $host") + return PingResult.UntrustedCertificate(certificate) + } + } + FirebaseUtils.safeRecordException(e) - false + PingResult.Unreachable } } + /** Accept [certificate] for its host, so every later connection to it succeeds. */ + fun trustCertificate(certificate: ServerCertificate) { + Log.i(LOG_TAG, "Trusting certificate ${certificate.fingerprintSha256} for ${certificate.host}") + FirebaseUtils.safeLog("User trusted a certificate manually") + trustedCertificateStore.pin(certificate.host, certificate.certificate) + } + fun startQuickConnect(serverUrl: String) { if (quickConnectJob?.isActive == true) return Log.i(LOG_TAG, "Initiate QuickConnect") @@ -166,3 +195,10 @@ class SignInViewModel @Inject constructor() : ViewModel() { internal const val JELLYFIN_SERVER_URL = "jellyfinServer" } } + +/** Outcome of reaching a server, separating a rejected certificate from a plain network failure. */ +sealed interface PingResult { + data object Success : PingResult + data object Unreachable : PingResult + data class UntrustedCertificate(val certificate: ServerCertificate) : PingResult +} diff --git a/automotive/src/main/java/com/chamika/dashtune/tls/CertificateInspector.kt b/automotive/src/main/java/com/chamika/dashtune/tls/CertificateInspector.kt new file mode 100644 index 0000000..60c5282 --- /dev/null +++ b/automotive/src/main/java/com/chamika/dashtune/tls/CertificateInspector.kt @@ -0,0 +1,142 @@ +package com.chamika.dashtune.tls + +import android.util.Log +import com.chamika.dashtune.Constants.LOG_TAG +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.net.Socket +import java.net.URI +import java.security.cert.CertificateException +import java.security.cert.X509Certificate +import java.util.Date +import javax.inject.Inject +import javax.inject.Singleton +import javax.net.ssl.SSLContext +import javax.net.ssl.SSLEngine +import javax.net.ssl.SSLSocket +import javax.net.ssl.X509ExtendedTrustManager + +/** What a server presented, so the user can decide whether to trust it. */ +data class ServerCertificate( + val certificate: X509Certificate, + val host: String, + val fingerprintSha256: String, + val subject: String, + val issuer: String, + val notBefore: Date, + val notAfter: Date, +) + +/** + * Retrieves the certificate a server presents, including one the platform rejects. + * + * The recording TrustManager below still delegates to the platform and still throws — it only + * captures the chain on the way past. The handshake fails exactly as it normally would; we just + * keep enough detail to show the user a fingerprint before they decide. + */ +@Singleton +class CertificateInspector @Inject constructor() { + + suspend fun inspect(serverUrl: String): ServerCertificate? = withContext(Dispatchers.IO) { + val uri = runCatching { URI(serverUrl) }.getOrNull() ?: return@withContext null + val host = uri.host ?: return@withContext null + if (!uri.scheme.equals("https", ignoreCase = true)) return@withContext null + val port = if (uri.port != -1) uri.port else DEFAULT_HTTPS_PORT + + val recorder = RecordingTrustManager(PinnedTrustManager.platformTrustManager()) + val context = SSLContext.getInstance("TLS").apply { + init(null, arrayOf(recorder), null) + } + + try { + (context.socketFactory.createSocket(host, port) as SSLSocket).use { socket -> + socket.soTimeout = HANDSHAKE_TIMEOUT_MS + // Throws when the certificate is untrusted, which is the case we care about. + // The chain has already been recorded by then. + runCatching { socket.startHandshake() } + } + } catch (e: Exception) { + Log.w(LOG_TAG, "Could not reach $host:$port to read its certificate", e) + return@withContext null + } + + val leaf = recorder.captured?.firstOrNull() ?: return@withContext null + ServerCertificate( + certificate = leaf, + host = host, + fingerprintSha256 = TrustedCertificateStore.fingerprintOf(leaf), + subject = leaf.subjectX500Principal.name, + issuer = leaf.issuerX500Principal.name, + notBefore = leaf.notBefore, + notAfter = leaf.notAfter, + ) + } + + private class RecordingTrustManager( + private val delegate: X509ExtendedTrustManager, + ) : X509ExtendedTrustManager() { + + var captured: Array? = null + private set + + private fun record(chain: Array) { + captured = chain + } + + override fun checkServerTrusted(chain: Array, authType: String) { + record(chain) + delegate.checkServerTrusted(chain, authType) + } + + override fun checkServerTrusted( + chain: Array, + authType: String, + socket: Socket?, + ) { + record(chain) + delegate.checkServerTrusted(chain, authType, socket) + } + + override fun checkServerTrusted( + chain: Array, + authType: String, + engine: SSLEngine?, + ) { + record(chain) + delegate.checkServerTrusted(chain, authType, engine) + } + + override fun checkClientTrusted(chain: Array, authType: String) = + delegate.checkClientTrusted(chain, authType) + + override fun checkClientTrusted( + chain: Array, + authType: String, + socket: Socket?, + ) = delegate.checkClientTrusted(chain, authType, socket) + + override fun checkClientTrusted( + chain: Array, + authType: String, + engine: SSLEngine?, + ) = delegate.checkClientTrusted(chain, authType, engine) + + override fun getAcceptedIssuers(): Array = delegate.acceptedIssuers + } + + private companion object { + const val DEFAULT_HTTPS_PORT = 443 + const val HANDSHAKE_TIMEOUT_MS = 10_000 + } +} + +/** True when [throwable] or anything it wraps is a certificate trust failure. */ +fun isCertificateTrustFailure(throwable: Throwable?): Boolean { + var cause = throwable + val seen = mutableSetOf() + while (cause != null && seen.add(cause)) { + if (cause is CertificateException || cause is javax.net.ssl.SSLHandshakeException) return true + cause = cause.cause + } + return false +} diff --git a/automotive/src/main/java/com/chamika/dashtune/tls/PinnedHostnameVerifier.kt b/automotive/src/main/java/com/chamika/dashtune/tls/PinnedHostnameVerifier.kt new file mode 100644 index 0000000..9b41fce --- /dev/null +++ b/automotive/src/main/java/com/chamika/dashtune/tls/PinnedHostnameVerifier.kt @@ -0,0 +1,25 @@ +package com.chamika.dashtune.tls + +import java.security.cert.X509Certificate +import javax.net.ssl.HostnameVerifier +import javax.net.ssl.HttpsURLConnection +import javax.net.ssl.SSLSession + +/** + * Standard hostname verification, relaxed only for certificates the user pinned. + * + * A self-signed certificate often carries a CN that doesn't match the URL the user typed, so + * pinning the certificate without this would still fail at the hostname check. Approving a + * certificate for a host is taken as approving that name mismatch for that host alone. + */ +class PinnedHostnameVerifier( + private val store: TrustedCertificateStore, + private val delegate: HostnameVerifier = HttpsURLConnection.getDefaultHostnameVerifier(), +) : HostnameVerifier { + + override fun verify(hostname: String, session: SSLSession): Boolean { + if (delegate.verify(hostname, session)) return true + val leaf = runCatching { session.peerCertificates.firstOrNull() }.getOrNull() + return leaf is X509Certificate && store.isPinned(hostname, leaf) + } +} diff --git a/automotive/src/main/java/com/chamika/dashtune/tls/PinnedTrustManager.kt b/automotive/src/main/java/com/chamika/dashtune/tls/PinnedTrustManager.kt new file mode 100644 index 0000000..1405c4c --- /dev/null +++ b/automotive/src/main/java/com/chamika/dashtune/tls/PinnedTrustManager.kt @@ -0,0 +1,98 @@ +package com.chamika.dashtune.tls + +import java.net.Socket +import java.security.KeyStore +import java.security.cert.CertificateException +import java.security.cert.X509Certificate +import javax.net.ssl.SSLEngine +import javax.net.ssl.SSLSocket +import javax.net.ssl.TrustManagerFactory +import javax.net.ssl.X509ExtendedTrustManager + +/** + * Platform certificate validation, with a fallback to certificates the user explicitly approved. + * + * Every check is delegated to the system TrustManager first, so normal certificates validate + * normally. Only when the platform rejects one do we consult [store] — and only an exact + * fingerprint match the user approved is accepted. Anything else rethrows, so this never degrades + * into blanket "accept any certificate". + */ +class PinnedTrustManager( + private val delegate: X509ExtendedTrustManager, + private val store: TrustedCertificateStore, +) : X509ExtendedTrustManager() { + + override fun checkServerTrusted(chain: Array, authType: String) { + acceptOrRethrow(chain, host = null) { delegate.checkServerTrusted(chain, authType) } + } + + override fun checkServerTrusted( + chain: Array, + authType: String, + socket: Socket?, + ) { + acceptOrRethrow(chain, hostOf(socket)) { + delegate.checkServerTrusted(chain, authType, socket) + } + } + + override fun checkServerTrusted( + chain: Array, + authType: String, + engine: SSLEngine?, + ) { + acceptOrRethrow(chain, engine?.peerHost) { + delegate.checkServerTrusted(chain, authType, engine) + } + } + + override fun checkClientTrusted(chain: Array, authType: String) = + delegate.checkClientTrusted(chain, authType) + + override fun checkClientTrusted( + chain: Array, + authType: String, + socket: Socket?, + ) = delegate.checkClientTrusted(chain, authType, socket) + + override fun checkClientTrusted( + chain: Array, + authType: String, + engine: SSLEngine?, + ) = delegate.checkClientTrusted(chain, authType, engine) + + override fun getAcceptedIssuers(): Array = delegate.acceptedIssuers + + private inline fun acceptOrRethrow( + chain: Array, + host: String?, + validate: () -> Unit, + ) { + try { + validate() + } catch (e: CertificateException) { + val leaf = chain.firstOrNull() ?: throw e + if (!store.isPinned(host, leaf)) throw e + } + } + + /** + * The peer hostname as the socket knows it. During the handshake OkHttp has already supplied + * it via the SNI-carrying socket, so [SSLSocket.getHandshakeSession] is the reliable source — + * `inetAddress.hostName` would trigger a reverse DNS lookup and can return the IP instead. + */ + private fun hostOf(socket: Socket?): String? = + (socket as? SSLSocket)?.handshakeSession?.peerHost + + companion object { + /** The system default TrustManager — the one that consults Android's CA store. */ + fun platformTrustManager(): X509ExtendedTrustManager { + val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) + factory.init(null as KeyStore?) + return factory.trustManagers + .filterIsInstance() + .firstOrNull() + ?: error("No X509ExtendedTrustManager available from the platform") + } + } +} diff --git a/automotive/src/main/java/com/chamika/dashtune/tls/TrustedCertificateStore.kt b/automotive/src/main/java/com/chamika/dashtune/tls/TrustedCertificateStore.kt new file mode 100644 index 0000000..06c2798 --- /dev/null +++ b/automotive/src/main/java/com/chamika/dashtune/tls/TrustedCertificateStore.kt @@ -0,0 +1,78 @@ +package com.chamika.dashtune.tls + +import android.content.Context +import dagger.hilt.android.qualifiers.ApplicationContext +import java.security.MessageDigest +import java.security.cert.X509Certificate +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Certificates the user has explicitly accepted, keyed by host. + * + * This is deliberately a pin store rather than a "skip verification" flag. We record the SHA-256 + * fingerprint of the exact leaf certificate the user approved, so a machine-in-the-middle + * presenting its own certificate still fails: the fingerprint won't match. That matters for a head + * unit, which roams across cellular and untrusted hotspots carrying a Jellyfin access token. + * + * Pins are persisted so playback, prefetch and album art keep working after a restart without + * re-prompting — the sign-in screen is the only place that can add one. + */ +@Singleton +class TrustedCertificateStore @Inject constructor( + @param:ApplicationContext private val context: Context, +) { + + private val prefs by lazy { + context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + } + + /** Trust [certificate] for [host] from now on. */ + fun pin(host: String, certificate: X509Certificate) { + val key = host.lowercase() + val existing = prefs.getStringSet(key, emptySet()).orEmpty() + prefs.edit() + .putStringSet(key, existing + fingerprintOf(certificate)) + .apply() + } + + /** + * True when the user has approved this exact certificate. + * + * [host] is null for the TrustManager overload that carries no peer information; the + * fingerprint is the real security boundary, so we fall back to matching any pinned host. + */ + fun isPinned(host: String?, certificate: X509Certificate): Boolean { + val fingerprint = fingerprintOf(certificate) + if (host != null) { + return prefs.getStringSet(host.lowercase(), emptySet()).orEmpty().contains(fingerprint) + } + return prefs.all.values.any { it is Set<*> && it.contains(fingerprint) } + } + + /** Every pinned host mapped to its approved fingerprints, for display in Settings. */ + fun pinnedCertificates(): Map> = + prefs.all.mapNotNull { (host, value) -> + @Suppress("UNCHECKED_CAST") + val fingerprints = (value as? Set)?.takeIf { it.isNotEmpty() } ?: return@mapNotNull null + host to fingerprints + }.toMap() + + fun remove(host: String) { + prefs.edit().remove(host.lowercase()).apply() + } + + fun clear() { + prefs.edit().clear().apply() + } + + companion object { + private const val PREFS_NAME = "trusted_certificates" + + /** Colon-separated uppercase SHA-256 of the certificate's DER encoding. */ + fun fingerprintOf(certificate: X509Certificate): String = + MessageDigest.getInstance("SHA-256") + .digest(certificate.encoded) + .joinToString(":") { "%02X".format(it) } + } +} diff --git a/automotive/src/main/res/values/strings.xml b/automotive/src/main/res/values/strings.xml index 0a24e08..31a00c9 100644 --- a/automotive/src/main/res/values/strings.xml +++ b/automotive/src/main/res/values/strings.xml @@ -51,6 +51,17 @@ Stops playback and shuts down DashTune. Reopen it from the media apps list. Force exit DashTune? Playback stops and the app closes. Reopen it from the media apps list. Force exit + Certificate not trusted + Android does not trust the certificate for %1$s.\n\nIssued by: %2$s\nExpires: %3$s\n\nSHA-256 fingerprint:\n%4$s\n\nOnly continue if this fingerprint matches your server. Trusting the wrong certificate lets someone intercept your login and music. + Trust this certificate + Certificate not trusted. Connection cancelled. + Trusted certificates + No manually trusted certificates + %d manually trusted + Certificates you approved during sign-in + Stop trusting the certificate for %s? You will need to approve it again to reconnect. + Stop trusting + Certificate removed Retry Library unavailable. Check your connection and retry. diff --git a/automotive/src/main/res/xml/preferences.xml b/automotive/src/main/res/xml/preferences.xml index 0dc8de5..27dd16a 100644 --- a/automotive/src/main/res/xml/preferences.xml +++ b/automotive/src/main/res/xml/preferences.xml @@ -48,6 +48,11 @@ android:summary="@string/clear_cache_summary" android:title="@string/clear_cache" /> + + Date: Mon, 27 Jul 2026 17:42:05 +0100 Subject: [PATCH 2/4] Prove pinned certificates reach playback and prefetch Instrumented test opens the media data source against a server whose certificate Android 12 rejects, asserting it fails without a pin and succeeds with one. It builds the client through the real production provider and wraps it as DashTuneMusicService does, so it covers the wiring rather than a copy. Uses an unauthenticated Jellyfin endpoint so it needs no credentials. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01YUCBnCgapXargAeGfmUHGE --- .../dashtune/tls/PinnedMediaPathTest.kt | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 automotive/src/androidTest/java/com/chamika/dashtune/tls/PinnedMediaPathTest.kt diff --git a/automotive/src/androidTest/java/com/chamika/dashtune/tls/PinnedMediaPathTest.kt b/automotive/src/androidTest/java/com/chamika/dashtune/tls/PinnedMediaPathTest.kt new file mode 100644 index 0000000..6835d4f --- /dev/null +++ b/automotive/src/androidTest/java/com/chamika/dashtune/tls/PinnedMediaPathTest.kt @@ -0,0 +1,69 @@ +package com.chamika.dashtune.tls + +import android.net.Uri +import androidx.media3.datasource.DataSpec +import androidx.media3.datasource.okhttp.OkHttpDataSource +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.chamika.dashtune.di.DashTuneModule +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import javax.net.ssl.SSLHandshakeException + +/** + * Exercises the media data source — the path playback, buffering and prefetch use — against a + * server whose certificate Android rejects. + * + * Builds the client with the real production provider and wraps it exactly as + * DashTuneMusicService does, so this proves the wiring rather than a copy of it. + */ +@RunWith(AndroidJUnit4::class) +class PinnedMediaPathTest { + + private val context = InstrumentationRegistry.getInstrumentation().targetContext + private val store = TrustedCertificateStore(context) + + // Unauthenticated Jellyfin endpoint, so this needs no credentials. + private val url = "https://diotify.dedyn.io:4433/System/Info/Public" + + @Before + fun clearPins() = store.clear() + + @After + fun tearDown() = store.clear() + + private fun openMediaDataSource(): Long { + val client = DashTuneModule().provideOkHttpClient(store) + val dataSource = OkHttpDataSource.Factory(client).createDataSource() + return dataSource.open(DataSpec(Uri.parse(url))) + } + + @Test + fun untrustedCertificateFailsTheMediaPath() { + try { + openMediaDataSource() + fail("Expected the media data source to reject an untrusted certificate") + } catch (e: Exception) { + assertTrue( + "Expected a TLS failure but got: $e", + generateSequence(e as Throwable) { it.cause }.any { it is SSLHandshakeException } + ) + } + } + + @Test + fun pinnedCertificateAllowsTheMediaPath() { + val certificate = runBlocking { CertificateInspector().inspect(url) } + assertNotNull("Could not read the server certificate", certificate) + store.pin(certificate!!.host, certificate.certificate) + + // Throws if the handshake fails; returning means playback/prefetch can stream from here. + openMediaDataSource() + } +} From bc8647297ff01521c8fa85df0749cec6e60cbf36 Mon Sep 17 00:00:00 2001 From: Chamika Date: Mon, 27 Jul 2026 17:43:35 +0100 Subject: [PATCH 3/4] Release v1.3.2(28) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Connect to servers whose HTTPS certificate your car does not recognise Full release notes: - Sign in to servers your car rejects with an SSL error. DashTune now shows the certificate details and lets you approve it, instead of only saying "Could not reach server" - Approving a certificate applies everywhere — browsing, playback, buffering, offline downloads and album art — so music streams normally afterwards - Certificates are matched exactly, so an approved server stays protected against interception on public networks - Approved certificates are listed under Settings, where they can be removed at any time --- automotive/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/automotive/build.gradle.kts b/automotive/build.gradle.kts index cf3bfb2..ce22f80 100644 --- a/automotive/build.gradle.kts +++ b/automotive/build.gradle.kts @@ -14,8 +14,8 @@ android { applicationId = "com.chamika.dashtune" minSdk = 28 targetSdk = 36 - versionCode = 27 - versionName = "1.3.1" + versionCode = 28 + versionName = "1.3.2" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } From f821b44d0ecf32aee5241e2f315ade24dcf4f6d5 Mon Sep 17 00:00:00 2001 From: Chamika Date: Mon, 27 Jul 2026 18:06:35 +0100 Subject: [PATCH 4/4] Update SignInViewModel tests for the PingResult return type pingServer returns PingResult rather than Boolean now, which broke compilation of the existing tests. Adds coverage for the two new branches: reporting the certificate when one is rejected, and falling back to unreachable when it cannot be read back. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01YUCBnCgapXargAeGfmUHGE --- .../dashtune/signin/SignInViewModelTest.kt | 60 +++++++++++++++++-- 1 file changed, 54 insertions(+), 6 deletions(-) diff --git a/automotive/src/test/java/com/chamika/dashtune/signin/SignInViewModelTest.kt b/automotive/src/test/java/com/chamika/dashtune/signin/SignInViewModelTest.kt index bc26fec..49f0028 100644 --- a/automotive/src/test/java/com/chamika/dashtune/signin/SignInViewModelTest.kt +++ b/automotive/src/test/java/com/chamika/dashtune/signin/SignInViewModelTest.kt @@ -2,6 +2,9 @@ package com.chamika.dashtune.signin import androidx.arch.core.executor.testing.InstantTaskExecutorRule import com.chamika.dashtune.auth.JellyfinAccountManager +import com.chamika.dashtune.tls.CertificateInspector +import com.chamika.dashtune.tls.ServerCertificate +import com.chamika.dashtune.tls.TrustedCertificateStore import com.google.firebase.crashlytics.FirebaseCrashlytics import io.mockk.every import io.mockk.mockk @@ -29,6 +32,7 @@ import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import io.mockk.coEvery +import javax.net.ssl.SSLHandshakeException @OptIn(ExperimentalCoroutinesApi::class) @RunWith(RobolectricTestRunner::class) @@ -43,6 +47,8 @@ class SignInViewModelTest { private lateinit var accountManager: JellyfinAccountManager private lateinit var apiClient: ApiClient private lateinit var systemApi: SystemApi + private lateinit var trustedCertificateStore: TrustedCertificateStore + private lateinit var certificateInspector: CertificateInspector private lateinit var viewModel: SignInViewModel @Before @@ -65,9 +71,14 @@ class SignInViewModelTest { mockkStatic("org.jellyfin.sdk.api.client.extensions.ApiClientExtensionsKt") every { any().systemApi } returns systemApi + trustedCertificateStore = mockk(relaxed = true) + certificateInspector = mockk(relaxed = true) + viewModel = SignInViewModel() viewModel.jellyfin = jellyfin viewModel.accountManager = accountManager + viewModel.trustedCertificateStore = trustedCertificateStore + viewModel.certificateInspector = certificateInspector } @After @@ -85,32 +96,69 @@ class SignInViewModelTest { // --- pingServer --- @Test - fun `pingServer returns true when server responds with status 200`() = runTest { + fun `pingServer succeeds when server responds with status 200`() = runTest { val pingResponse: Response = mockk { every { status } returns 200 } coEvery { systemApi.getPingSystem() } returns pingResponse val result = viewModel.pingServer("http://jellyfin.local:8096") - assertTrue(result) + assertEquals(PingResult.Success, result) } @Test - fun `pingServer returns false when server responds with non-200 status`() = runTest { + fun `pingServer is unreachable when server responds with non-200 status`() = runTest { val pingResponse: Response = mockk { every { status } returns 503 } coEvery { systemApi.getPingSystem() } returns pingResponse val result = viewModel.pingServer("http://jellyfin.local:8096") - assertFalse(result) + assertEquals(PingResult.Unreachable, result) } @Test - fun `pingServer returns false when network exception is thrown`() = runTest { + fun `pingServer is unreachable when network exception is thrown`() = runTest { coEvery { systemApi.getPingSystem() } throws RuntimeException("connection refused") val result = viewModel.pingServer("http://unreachable.host") - assertFalse(result) + assertEquals(PingResult.Unreachable, result) + } + + @Test + fun `pingServer reports the certificate when the server presents an untrusted one`() = runTest { + val certificate: ServerCertificate = mockk(relaxed = true) + coEvery { systemApi.getPingSystem() } throws + SSLHandshakeException("Trust anchor for certification path not found.") + coEvery { certificateInspector.inspect(any()) } returns certificate + + val result = viewModel.pingServer("https://jellyfin.local:8920") + + assertEquals(PingResult.UntrustedCertificate(certificate), result) + } + + @Test + fun `pingServer is unreachable when the untrusted certificate cannot be read back`() = runTest { + coEvery { systemApi.getPingSystem() } throws + SSLHandshakeException("Trust anchor for certification path not found.") + coEvery { certificateInspector.inspect(any()) } returns null + + val result = viewModel.pingServer("https://jellyfin.local:8920") + + assertEquals(PingResult.Unreachable, result) + } + + @Test + fun `trustCertificate pins the certificate for its host`() { + val x509 = mockk() + val certificate: ServerCertificate = mockk { + every { host } returns "jellyfin.local" + every { this@mockk.certificate } returns x509 + every { fingerprintSha256 } returns "AA:BB" + } + + viewModel.trustCertificate(certificate) + + verify { trustedCertificateStore.pin("jellyfin.local", x509) } } // --- login ---