diff --git a/.reuse/dep5 b/.reuse/dep5 index faafb5a5..b16c489e 100644 --- a/.reuse/dep5 +++ b/.reuse/dep5 @@ -9,7 +9,7 @@ Source: https://github.com/equeim/tremotesf-android # Copyright: $YEAR $NAME <$CONTACT> # License: ... -Files: CHANGELOG.md README.md app/src/main/res/raw*/*.html app/src/main/res/raw/translators fastlane/metadata/android/**/*.txt fastlane/metadata/android/**/*.png torrentfile/src/test/resources/org/equeim/tremotesf/torrentfile/*.torrent +Files: CHANGELOG.md README.md app/src/main/res/raw*/*.html app/src/main/res/raw/translators fastlane/metadata/android/**/*.txt fastlane/metadata/android/**/*.png rpc/src/main/res/raw/isrgrootx1.pem torrentfile/src/test/resources/org/equeim/tremotesf/torrentfile/*.torrent Copyright: 2017-2022 Alexey Rochev License: CC0-1.0 diff --git a/app/src/main/kotlin/org/equeim/tremotesf/rpc/GlobalRpcClient.kt b/app/src/main/kotlin/org/equeim/tremotesf/rpc/GlobalRpcClient.kt index 54e2d6b3..22e04bac 100644 --- a/app/src/main/kotlin/org/equeim/tremotesf/rpc/GlobalRpcClient.kt +++ b/app/src/main/kotlin/org/equeim/tremotesf/rpc/GlobalRpcClient.kt @@ -4,6 +4,7 @@ package org.equeim.tremotesf.rpc +import android.annotation.SuppressLint import android.content.Context import androidx.annotation.StringRes import kotlinx.coroutines.CoroutineScope @@ -15,6 +16,7 @@ import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch import org.equeim.tremotesf.R +import org.equeim.tremotesf.TremotesfApplication import org.equeim.tremotesf.torrentfile.rpc.RpcClient import org.equeim.tremotesf.torrentfile.rpc.RpcRequestError import org.equeim.tremotesf.torrentfile.rpc.requests.DUPLICATE_TORRENT_RESULT @@ -22,7 +24,8 @@ import org.equeim.tremotesf.torrentfile.rpc.shouldUpdateConnectionConfiguration import org.equeim.tremotesf.ui.AppForegroundTracker import java.util.concurrent.atomic.AtomicBoolean -object GlobalRpcClient : RpcClient(CoroutineScope(SupervisorJob() + Dispatchers.Default)) { +@SuppressLint("StaticFieldLeak") +object GlobalRpcClient : RpcClient(CoroutineScope(SupervisorJob() + Dispatchers.Default), TremotesfApplication.instance) { data class BackgroundRpcRequestError(val error: RpcRequestError, @StringRes val errorContext: Int) val backgroundRpcRequestsErrors: Channel = Channel(Channel.UNLIMITED) diff --git a/rpc/src/main/kotlin/org/equeim/tremotesf/torrentfile/rpc/ConnectionConfiguration.kt b/rpc/src/main/kotlin/org/equeim/tremotesf/torrentfile/rpc/ConnectionConfiguration.kt index 56015c3b..a47a3730 100644 --- a/rpc/src/main/kotlin/org/equeim/tremotesf/torrentfile/rpc/ConnectionConfiguration.kt +++ b/rpc/src/main/kotlin/org/equeim/tremotesf/torrentfile/rpc/ConnectionConfiguration.kt @@ -4,6 +4,7 @@ package org.equeim.tremotesf.torrentfile.rpc +import android.content.Context import okhttp3.Credentials import okhttp3.HttpUrl import okhttp3.OkHttpClient @@ -24,7 +25,7 @@ internal data class ConnectionConfiguration( /** * @throws RuntimeException */ -internal fun createConnectionConfiguration(server: Server): ConnectionConfiguration { +internal fun createConnectionConfiguration(server: Server, context: Context): ConnectionConfiguration { val url = createUrl(server) val builder = OkHttpClient.Builder() .addNetworkInterceptor(RealRequestHeadersInterceptor()) @@ -39,14 +40,16 @@ internal fun createConnectionConfiguration(server: Server): ConnectionConfigurat if (server.httpsEnabled) { val clientCertificate = if (server.clientCertificateEnabled) server.clientCertificate.takeIf { it.isNotBlank() } else null val serverCertificate = if (server.selfSignedCertificateEnabled) server.selfSignedCertificate.takeIf { it.isNotBlank() } else null - if (clientCertificate != null || serverCertificate != null) { - val configuration = createTlsConfiguration( - if (server.clientCertificateEnabled) server.clientCertificate.takeIf { it.isNotBlank() } else null, - if (server.selfSignedCertificateEnabled) server.selfSignedCertificate.takeIf { it.isNotBlank() } else null - ) + val configuration = createTlsConfiguration( + clientCertificatesString = clientCertificate, + selfSignedCertificatesString = serverCertificate, + serverHostname = url.host, + context = context + ) + if (configuration != null) { builder.sslSocketFactory(configuration.sslSocketFactory, configuration.trustManager) - if (serverCertificate != null) { - builder.hostnameVerifier { hostname, _ -> hostname == url.host } + configuration.hostnameVerifier?.let { + builder.hostnameVerifier(it) } clientCertificates = configuration.clientCertificates } diff --git a/rpc/src/main/kotlin/org/equeim/tremotesf/torrentfile/rpc/RpcClient.kt b/rpc/src/main/kotlin/org/equeim/tremotesf/torrentfile/rpc/RpcClient.kt index 2b1a48e4..1bab14dd 100644 --- a/rpc/src/main/kotlin/org/equeim/tremotesf/torrentfile/rpc/RpcClient.kt +++ b/rpc/src/main/kotlin/org/equeim/tremotesf/torrentfile/rpc/RpcClient.kt @@ -4,6 +4,7 @@ package org.equeim.tremotesf.torrentfile.rpc +import android.content.Context import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -30,7 +31,7 @@ import org.equeim.tremotesf.torrentfile.rpc.requests.ServerVersionResponseArgume import timber.log.Timber import java.net.HttpURLConnection -open class RpcClient(protected val coroutineScope: CoroutineScope) { +open class RpcClient(protected val coroutineScope: CoroutineScope, private val context: Context) { private val connectionConfiguration = MutableStateFlow?>(null) internal fun getConnectionConfiguration(): StateFlow?> = connectionConfiguration @@ -70,7 +71,7 @@ open class RpcClient(protected val coroutineScope: CoroutineScope) { Timber.d("setConnectionConfiguration() called with: server = $server") val newConnectionConfiguration = server?.let { try { - Result.success(createConnectionConfiguration(it)) + Result.success(createConnectionConfiguration(it, context)) } catch (e: Exception) { Timber.e(e, "Bad connection configuration") Result.failure(e) diff --git a/rpc/src/main/kotlin/org/equeim/tremotesf/torrentfile/rpc/TlsConfiguration.kt b/rpc/src/main/kotlin/org/equeim/tremotesf/torrentfile/rpc/TlsConfiguration.kt index a949949c..75567270 100644 --- a/rpc/src/main/kotlin/org/equeim/tremotesf/torrentfile/rpc/TlsConfiguration.kt +++ b/rpc/src/main/kotlin/org/equeim/tremotesf/torrentfile/rpc/TlsConfiguration.kt @@ -1,16 +1,27 @@ // SPDX-FileCopyrightText: 2017-2023 Alexey Rochev +// SPDX-FileCopyrightText: 2019 Thunderberry // // SPDX-License-Identifier: GPL-3.0-or-later package org.equeim.tremotesf.torrentfile.rpc +import android.annotation.SuppressLint +import android.content.Context +import android.net.http.X509TrustManagerExtensions +import android.os.Build import android.util.Base64 +import androidx.annotation.Keep +import org.equeim.tremotesf.rpc.R +import java.io.InputStream +import java.security.InvalidAlgorithmParameterException import java.security.KeyFactory import java.security.KeyStore -import java.security.SecureRandom import java.security.cert.Certificate +import java.security.cert.CertificateException import java.security.cert.CertificateFactory +import java.security.cert.X509Certificate import java.security.spec.PKCS8EncodedKeySpec +import javax.net.ssl.HostnameVerifier import javax.net.ssl.KeyManager import javax.net.ssl.KeyManagerFactory import javax.net.ssl.SSLContext @@ -18,9 +29,11 @@ import javax.net.ssl.SSLSocketFactory import javax.net.ssl.TrustManagerFactory import javax.net.ssl.X509TrustManager + class TlsConfiguration( val sslSocketFactory: SSLSocketFactory, val trustManager: X509TrustManager, + val hostnameVerifier: HostnameVerifier?, val clientCertificates: List, ) @@ -30,59 +43,168 @@ class TlsConfiguration( internal fun createTlsConfiguration( clientCertificatesString: String?, selfSignedCertificatesString: String?, -): TlsConfiguration = try { - val certificateFactory = CertificateFactory.getInstance("X.509") - val trustManager = createTrustManager(selfSignedCertificatesString, certificateFactory) - val clientCertificates: List - val keyManager: KeyManager? - if (clientCertificatesString != null) { - clientCertificates = parseCertificates(certificateFactory, clientCertificatesString, "client") - keyManager = createKeyManager(clientCertificates, clientCertificatesString) - } else { - clientCertificates = emptyList() - keyManager = null + serverHostname: String, + context: Context, +): TlsConfiguration? { + // We need to set up ISRG Root X1 certificate for Android < 7.1 + if (clientCertificatesString == null && selfSignedCertificatesString == null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) { + return null + } + return try { + val certificateFactory = CertificateFactory.getInstance("X.509") + + val trustManager = selfSignedCertificatesString?.let { + createTrustManagerForSelfSignedCertificates(it, certificateFactory) + } ?: createDefaultTrustManager(certificateFactory, context) + + val hostnameVerifier = if (selfSignedCertificatesString != null) { + HostnameVerifier { hostname, _ -> hostname == serverHostname } + } else { + null + } + + val clientCertificates: List + val keyManager: KeyManager? + if (clientCertificatesString != null) { + clientCertificates = try { + certificateFactory.generateCertificates(clientCertificatesString.byteInputStream()).toList() + } catch (e: Exception) { + throw RuntimeException("Failed to parse client's certificate chain", e) + } + keyManager = createKeyManagerForClientCertificate(clientCertificates, clientCertificatesString) + } else { + clientCertificates = emptyList() + keyManager = null + } + val sslContext = SSLContext.getInstance("TLS") + sslContext.init(keyManager?.let { arrayOf(it) }, arrayOf(trustManager), null) + TlsConfiguration( + sslSocketFactory = sslContext.socketFactory, + trustManager = trustManager, + hostnameVerifier = hostnameVerifier, + clientCertificates = clientCertificates, + ) + } catch (e: Exception) { + throw RuntimeException("Failed to set up TLS configuration", e) } - val sslContext = SSLContext.getInstance("TLS") - sslContext.init(keyManager?.let { arrayOf(it) }, arrayOf(trustManager), SecureRandom()) - TlsConfiguration( - sslSocketFactory = sslContext.socketFactory, - trustManager = trustManager, - clientCertificates = clientCertificates, - ) -} catch (e: Exception) { - throw RuntimeException("Failed to set up TLS configuration", e) } -private fun parseCertificates(factory: CertificateFactory, certificatesString: String, who: String): List { - return try { - factory.generateCertificates(certificatesString.byteInputStream()).toList() +private fun createTrustManagerForSelfSignedCertificates( + selfSignedCertificatesString: String, + certificateFactory: CertificateFactory, +): X509TrustManager = + try { + createTrustManagerForCertificateChain(selfSignedCertificatesString.byteInputStream(), certificateFactory) } catch (e: Exception) { - throw RuntimeException("Failed to parse $who's certificates") + throw RuntimeException("Failed to create TrustManager for server's self signed certificate chain", e) + } + +private fun createDefaultTrustManager( + certificateFactory: CertificateFactory, + context: Context, +): X509TrustManager = try { + val defaultTrustManager = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()).run { + init(null as KeyStore?) + trustManagers.single() as X509TrustManager + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) { + defaultTrustManager + } else { + CompositeX509TrustManager( + defaultTrustManager, + createTrustManagerForCertificateChain( + context.resources.openRawResource(R.raw.isrgrootx1), + certificateFactory + ) + ) } +} catch (e: Exception) { + throw RuntimeException("Failed to create default TrustManager", e) } -private fun createTrustManager(selfSignedCertificatesString: String?, certificateFactory: CertificateFactory): X509TrustManager { - val keyStore = selfSignedCertificatesString?.let { - val selfSignedCertificates = parseCertificates(certificateFactory, it, "server") - val keyStore = KeyStore.getInstance(KeyStore.getDefaultType()) - keyStore.load(null, null) - selfSignedCertificates.forEachIndexed { index, cert -> - keyStore.setCertificateEntry(index.toString(), cert) - } - keyStore +private fun createTrustManagerForCertificateChain( + certificatesStream: InputStream, + certificateFactory: CertificateFactory, +): X509TrustManager { + val certificates = certificatesStream.use(certificateFactory::generateCertificates)!! + if (certificates.isEmpty()) { + throw RuntimeException("Did not read any certificates") + } + val keyStore = KeyStore.getInstance(KeyStore.getDefaultType()) + keyStore.load(null, null) + certificates.forEachIndexed { index, cert -> + keyStore.setCertificateEntry(index.toString(), cert) } val trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) trustManagerFactory.init(keyStore) return trustManagerFactory.trustManagers.single() as X509TrustManager } +@SuppressLint("CustomX509TrustManager") +private class CompositeX509TrustManager(vararg trustManagers: X509TrustManager) : X509TrustManager { + private class TrustManagerAndExtensions( + val trustManager: X509TrustManager, + val extensions: X509TrustManagerExtensions, + ) { + constructor(trustManager: X509TrustManager) : this(trustManager, X509TrustManagerExtensions(trustManager)) + } + + private val trustManagers: List = trustManagers.map(::TrustManagerAndExtensions) + + override fun checkClientTrusted(chain: Array?, authType: String?) { + checkTrusted { trustManager.checkClientTrusted(chain, authType) } + } + + override fun checkServerTrusted(chain: Array?, authType: String?) { + checkTrusted { trustManager.checkServerTrusted(chain, authType) } + } + + override fun getAcceptedIssuers(): Array = + trustManagers.asSequence().flatMap { it.trustManager.acceptedIssuers.asSequence() }.toList().toTypedArray() + + @Suppress("unused") + @Keep + fun checkServerTrusted( + chain: Array?, authType: String?, host: String?, + ): List { + return checkTrusted { extensions.checkServerTrusted(chain, authType, host) } + } + + private fun checkTrusted(check: TrustManagerAndExtensions.() -> T): T { + val certificateExceptions = mutableListOf() + for (trustManager in trustManagers) { + try { + return trustManager.check() + } catch (e: CertificateException) { + certificateExceptions.add(e) + } catch (e: RuntimeException) { + val cause = e.cause + if (cause is InvalidAlgorithmParameterException) { + // Handling of [InvalidAlgorithmParameterException: the trustAnchors parameter must be non-empty] + // + // This is most likely a result of using a TrustManager created from an empty KeyStore. + // The exception will be thrown during the SSL Handshake. It is safe to suppress + // and can be bundle with the other exceptions to proceed validating the counterparty with + // the remaining TrustManagers. + certificateExceptions.add(CertificateException(cause)) + } else { + throw e + } + } + } + val certificateException = CertificateException("None of the TrustManagers trust this certificate chain") + certificateExceptions.forEach(certificateException::addSuppressed) + throw certificateException + } +} + private val PRIVATE_KEY_REGEX = Regex( """.*-----BEGIN PRIVATE KEY-----([A-Za-z0-9+/=\n]+)-----END PRIVATE KEY-----.*""", setOf(RegexOption.MULTILINE, RegexOption.DOT_MATCHES_ALL) ) -private fun createKeyManager(clientCertificates: List, clientCertificatesString: String): KeyManager { +private fun createKeyManagerForClientCertificate(clientCertificates: List, clientCertificatesString: String): KeyManager { val clientKey = try { val spec = PKCS8EncodedKeySpec( Base64.decode( diff --git a/rpc/src/main/res/raw/isrgrootx1.pem b/rpc/src/main/res/raw/isrgrootx1.pem new file mode 100644 index 00000000..b85c8037 --- /dev/null +++ b/rpc/src/main/res/raw/isrgrootx1.pem @@ -0,0 +1,31 @@ +-----BEGIN CERTIFICATE----- +MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw +TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh +cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4 +WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu +ZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY +MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygc +h77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+ +0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6U +A5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sW +T8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyH +B5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UC +B5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUv +KBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWn +OlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTn +jh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbw +qHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CI +rU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV +HRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkq +hkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZL +ubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ +3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KK +NFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5 +ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7Ur +TkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdC +jNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVc +oyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq +4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPA +mRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57d +emyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc= +-----END CERTIFICATE----- diff --git a/rpc/src/test/kotlin/org/equeim/tremotesf/torrentfile/rpc/RpcClientTest.kt b/rpc/src/test/kotlin/org/equeim/tremotesf/torrentfile/rpc/RpcClientTest.kt index a9f64614..8227ab06 100644 --- a/rpc/src/test/kotlin/org/equeim/tremotesf/torrentfile/rpc/RpcClientTest.kt +++ b/rpc/src/test/kotlin/org/equeim/tremotesf/torrentfile/rpc/RpcClientTest.kt @@ -6,6 +6,7 @@ package org.equeim.tremotesf.torrentfile.rpc import android.os.SystemClock import io.mockk.every +import io.mockk.mockk import io.mockk.mockkStatic import io.mockk.unmockkStatic import kotlinx.coroutines.Dispatchers @@ -43,7 +44,7 @@ import kotlin.time.Duration.Companion.seconds class RpcClientTest { private val dispatcher = StandardTestDispatcher() private val server = MockWebServer() - private val client = RpcClient(TestScope(dispatcher)) + private val client = RpcClient(TestScope(dispatcher), mockk()) private class TestTree : Timber.DebugTree() { override fun log(priority: Int, tag: String?, message: String, t: Throwable?) {