Skip to content

Commit

Permalink
Trust ISRG Root X1 certificate on Android < 7.1
Browse files Browse the repository at this point in the history
  • Loading branch information
equeim committed Jan 4, 2024
1 parent a9ba015 commit 5a15f0b
Show file tree
Hide file tree
Showing 7 changed files with 209 additions and 48 deletions.
2 changes: 1 addition & 1 deletion .reuse/dep5
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -15,14 +16,16 @@ 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
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<BackgroundRpcRequestError> = Channel(Channel.UNLIMITED)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

package org.equeim.tremotesf.torrentfile.rpc

import android.content.Context
import okhttp3.Credentials
import okhttp3.HttpUrl
import okhttp3.OkHttpClient
Expand All @@ -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())
Expand All @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<Result<ConnectionConfiguration>?>(null)
internal fun getConnectionConfiguration(): StateFlow<Result<ConnectionConfiguration>?> = connectionConfiguration

Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,26 +1,39 @@
// SPDX-FileCopyrightText: 2017-2023 Alexey Rochev <[email protected]>
// 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
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<Certificate>,
)

Expand All @@ -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<Certificate>
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<Certificate>
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<Certificate> {
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<TrustManagerAndExtensions> = trustManagers.map(::TrustManagerAndExtensions)

override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?) {
checkTrusted { trustManager.checkClientTrusted(chain, authType) }
}

override fun checkServerTrusted(chain: Array<out X509Certificate>?, authType: String?) {
checkTrusted { trustManager.checkServerTrusted(chain, authType) }
}

override fun getAcceptedIssuers(): Array<X509Certificate> =
trustManagers.asSequence().flatMap { it.trustManager.acceptedIssuers.asSequence() }.toList().toTypedArray()

@Suppress("unused")
@Keep
fun checkServerTrusted(
chain: Array<X509Certificate?>?, authType: String?, host: String?,
): List<X509Certificate> {
return checkTrusted { extensions.checkServerTrusted(chain, authType, host) }
}

private fun <T> checkTrusted(check: TrustManagerAndExtensions.() -> T): T {
val certificateExceptions = mutableListOf<CertificateException>()
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<Certificate>, clientCertificatesString: String): KeyManager {
private fun createKeyManagerForClientCertificate(clientCertificates: List<Certificate>, clientCertificatesString: String): KeyManager {
val clientKey = try {
val spec = PKCS8EncodedKeySpec(
Base64.decode(
Expand Down
31 changes: 31 additions & 0 deletions rpc/src/main/res/raw/isrgrootx1.pem
Original file line number Diff line number Diff line change
@@ -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-----
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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?) {
Expand Down

0 comments on commit 5a15f0b

Please sign in to comment.