Allow users to trust self-signed certificates (#160)

* Implement CERT-Store

* Trust user-added certificates

* Improve code readability

* Implement saving self-signed certs to storage

* Create interface for TrustedCertificatesStore

* Remove unused code

* Make datasource implementation internal

* Bump app version to 29 (0.4.0)

---------

Co-authored-by: fz72 <fz72@gmx.de>
This commit is contained in:
Kirill Kamakin
2023-07-04 22:53:05 +02:00
committed by GitHub
parent 2375be0329
commit 79dee6a9ad
17 changed files with 279 additions and 15 deletions

View File

@@ -0,0 +1,32 @@
package gq.kirmanak.mealient.datasource
import java.security.cert.CertPathValidatorException
import java.security.cert.CertificateException
import java.security.cert.CertificateExpiredException
import java.security.cert.CertificateNotYetValidException
import java.security.cert.X509Certificate
import javax.net.ssl.SSLPeerUnverifiedException
class CertificateCombinedException(val serverCert: X509Certificate) : RuntimeException() {
var certificateExpiredException: CertificateExpiredException? = null
var certificateNotYetValidException: CertificateNotYetValidException? = null
var certPathValidatorException: CertPathValidatorException? = null
var otherCertificateException: CertificateException? = null
var sslPeerUnverifiedException: SSLPeerUnverifiedException? = null
fun isException(): Boolean {
return listOf(
certificateExpiredException,
certificateNotYetValidException,
certPathValidatorException,
otherCertificateException,
sslPeerUnverifiedException
).any { it != null }
}
companion object {
private const val serialVersionUID: Long = -8875782030758554999L
}
}

View File

@@ -20,3 +20,13 @@ inline fun <T> runCatchingExceptCancel(block: () -> T): Result<T> = try {
@OptIn(ExperimentalSerializationApi::class)
inline fun <reified R> ResponseBody.decode(json: Json): R = json.decodeFromStream(byteStream())
inline fun <reified T> Throwable.findCauseAsInstanceOf(): T? {
var cause: Throwable? = this
var previousCause: Throwable? = null
while (cause != null && cause != previousCause && cause !is T) {
previousCause = cause
cause = cause.cause
}
return cause as? T
}

View File

@@ -13,6 +13,7 @@ import gq.kirmanak.mealient.datasource.impl.CacheBuilderImpl
import gq.kirmanak.mealient.datasource.impl.NetworkRequestWrapperImpl
import gq.kirmanak.mealient.datasource.impl.OkHttpBuilderImpl
import gq.kirmanak.mealient.datasource.impl.RetrofitBuilder
import gq.kirmanak.mealient.datasource.impl.TrustedCertificatesStoreImpl
import gq.kirmanak.mealient.datasource.v0.MealieDataSourceV0
import gq.kirmanak.mealient.datasource.v0.MealieDataSourceV0Impl
import gq.kirmanak.mealient.datasource.v0.MealieServiceV0
@@ -30,7 +31,7 @@ import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
interface DataSourceModule {
internal interface DataSourceModule {
companion object {
@@ -94,4 +95,7 @@ interface DataSourceModule {
@Binds
@IntoSet
fun bindBaseUrlInterceptor(baseUrlInterceptor: BaseUrlInterceptor): LocalInterceptor
@Binds
fun bindTrustedCertificatesStore(impl: TrustedCertificatesStoreImpl): TrustedCertificatesStore
}

View File

@@ -0,0 +1,10 @@
package gq.kirmanak.mealient.datasource
import java.security.cert.Certificate
interface TrustedCertificatesStore {
fun isTrustedCertificate(cert: Certificate): Boolean
fun addTrustedCertificate(cert: Certificate)
}

View File

@@ -0,0 +1,68 @@
package gq.kirmanak.mealient.datasource.impl
import android.annotation.SuppressLint
import gq.kirmanak.mealient.datasource.CertificateCombinedException
import gq.kirmanak.mealient.datasource.TrustedCertificatesStore
import gq.kirmanak.mealient.datasource.findCauseAsInstanceOf
import java.security.KeyStore
import java.security.cert.*
import javax.inject.Inject
import javax.net.ssl.TrustManagerFactory
import javax.net.ssl.X509TrustManager
@SuppressLint("CustomX509TrustManager")
internal class AdvancedX509TrustManager @Inject constructor(
private val trustedCertificatesStore: TrustedCertificatesStore
) : X509TrustManager {
private val standardTrustManager: X509TrustManager by lazy {
findStandardTrustManager()
}
private fun findStandardTrustManager(): X509TrustManager {
val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
factory.init(null as KeyStore?)
return factory.trustManagers
.filterIsInstance<X509TrustManager>()
.first()
}
override fun checkClientTrusted(certificates: Array<X509Certificate?>?, authType: String?) {
standardTrustManager.checkClientTrusted(certificates, authType)
}
override fun checkServerTrusted(certificates: Array<X509Certificate>, authType: String?) {
if (trustedCertificatesStore.isTrustedCertificate(certificates[0])) {
return
}
val result = CertificateCombinedException(certificates[0])
try {
certificates[0].checkValidity()
} catch (c: CertificateExpiredException) {
result.certificateExpiredException = c
} catch (c: CertificateNotYetValidException) {
result.certificateNotYetValidException = c
}
try {
standardTrustManager.checkServerTrusted(certificates, authType)
} catch (c: CertificateException) {
val cause = c.findCauseAsInstanceOf<CertPathValidatorException>()
if (cause != null) {
result.certPathValidatorException = cause
} else {
result.otherCertificateException = c
}
}
if (result.isException()) {
throw result
}
}
override fun getAcceptedIssuers(): Array<X509Certificate> {
return standardTrustManager.acceptedIssuers
}
}

View File

@@ -10,7 +10,7 @@ import okhttp3.Response
import javax.inject.Inject
import javax.inject.Provider
class AuthInterceptor @Inject constructor(
internal class AuthInterceptor @Inject constructor(
private val logger: Logger,
private val authenticationProviderProvider: Provider<AuthenticationProvider>,
) : LocalInterceptor {

View File

@@ -11,7 +11,7 @@ import java.io.IOException
import javax.inject.Inject
import javax.inject.Provider
class BaseUrlInterceptor @Inject constructor(
internal class BaseUrlInterceptor @Inject constructor(
private val logger: Logger,
private val serverUrlProviderProvider: Provider<ServerUrlProvider>,
) : LocalInterceptor {

View File

@@ -9,7 +9,7 @@ import okhttp3.Cache
import java.io.File
import javax.inject.Inject
class CacheBuilderImpl @Inject constructor(
internal class CacheBuilderImpl @Inject constructor(
@ApplicationContext private val context: Context,
private val logger: Logger,
) : CacheBuilder {

View File

@@ -7,7 +7,7 @@ import gq.kirmanak.mealient.logging.Logger
import retrofit2.HttpException
import javax.inject.Inject
class NetworkRequestWrapperImpl @Inject constructor(
internal class NetworkRequestWrapperImpl @Inject constructor(
private val logger: Logger,
) : NetworkRequestWrapper {

View File

@@ -6,22 +6,50 @@ import gq.kirmanak.mealient.datasource.OkHttpBuilder
import gq.kirmanak.mealient.logging.Logger
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.TlsVersion
import javax.inject.Inject
import javax.net.ssl.SSLContext
import javax.net.ssl.TrustManager
class OkHttpBuilderImpl @Inject constructor(
internal class OkHttpBuilderImpl @Inject constructor(
private val cacheBuilder: CacheBuilder,
// Use @JvmSuppressWildcards because otherwise dagger can't inject it (https://stackoverflow.com/a/43149382)
private val interceptors: Set<@JvmSuppressWildcards Interceptor>,
private val localInterceptors: Set<@JvmSuppressWildcards LocalInterceptor>,
private val advancedX509TrustManager: AdvancedX509TrustManager,
private val logger: Logger,
) : OkHttpBuilder {
override fun buildOkHttp(): OkHttpClient {
logger.v { "buildOkHttp() was called with cacheBuilder = $cacheBuilder, interceptors = $interceptors, localInterceptors = $localInterceptors" }
val sslContext = buildSSLContext()
sslContext.init(null, arrayOf<TrustManager>(advancedX509TrustManager), null)
val sslSocketFactory = sslContext.socketFactory
return OkHttpClient.Builder().apply {
localInterceptors.forEach(::addInterceptor)
interceptors.forEach(::addNetworkInterceptor)
sslSocketFactory(sslSocketFactory, advancedX509TrustManager)
cache(cacheBuilder.buildCache())
}.build()
}
private fun buildSSLContext(): SSLContext {
return runCatching {
SSLContext.getInstance(TlsVersion.TLS_1_3.javaName)
}.recoverCatching {
logger.w { "TLSv1.3 is not supported in this device; falling through TLSv1.2" }
SSLContext.getInstance(TlsVersion.TLS_1_2.javaName)
}.recoverCatching {
logger.w { "TLSv1.2 is not supported in this device; falling through TLSv1.1" }
SSLContext.getInstance(TlsVersion.TLS_1_1.javaName)
}.recoverCatching {
logger.w { "TLSv1.1 is not supported in this device; falling through TLSv1.0" }
// should be available in any device; see reference of supported protocols in
// http://developer.android.com/reference/javax/net/ssl/SSLSocket.html
SSLContext.getInstance(TlsVersion.TLS_1_0.javaName)
}.getOrThrow()
}
}

View File

@@ -6,7 +6,7 @@ import retrofit2.Converter.Factory
import retrofit2.Retrofit
import javax.inject.Inject
class RetrofitBuilder @Inject constructor(
internal class RetrofitBuilder @Inject constructor(
private val okHttpClient: OkHttpClient,
private val converterFactory: Factory,
private val logger: Logger,

View File

@@ -0,0 +1,58 @@
package gq.kirmanak.mealient.datasource.impl
import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import gq.kirmanak.mealient.datasource.TrustedCertificatesStore
import gq.kirmanak.mealient.logging.Logger
import java.io.File
import java.io.FileInputStream
import java.security.KeyStore
import java.security.KeyStoreException
import java.security.cert.Certificate
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
internal class TrustedCertificatesStoreImpl @Inject constructor(
@ApplicationContext private val context: Context,
private val logger: Logger,
) : TrustedCertificatesStore {
private val trustedCertificatesStore: KeyStore by lazy { initialiseTrustedCertificatesStore() }
private fun initialiseTrustedCertificatesStore(): KeyStore {
val store = KeyStore.getInstance(KeyStore.getDefaultType())
val localTrustStoreFile = File(context.filesDir, LOCAL_TRUSTSTORE_FILENAME)
logger.d { "Looking for a trusted certificate store at ${localTrustStoreFile.absolutePath}" }
if (localTrustStoreFile.exists()) {
FileInputStream(localTrustStoreFile).use {
store.load(it, LOCAL_TRUSTSTORE_PASSWORD.toCharArray())
}
} else {
// next is necessary to initialize an empty KeyStore instance
store.load(null, LOCAL_TRUSTSTORE_PASSWORD.toCharArray())
}
return store
}
override fun isTrustedCertificate(cert: Certificate): Boolean {
return try {
trustedCertificatesStore.getCertificateAlias(cert) != null
} catch (e: KeyStoreException) {
logger.e(e) { "Fail while checking certificate in the known-servers store" }
false
}
}
override fun addTrustedCertificate(cert: Certificate) {
trustedCertificatesStore.setCertificateEntry(cert.hashCode().toString(), cert)
context.openFileOutput(LOCAL_TRUSTSTORE_FILENAME, Context.MODE_PRIVATE).use {
trustedCertificatesStore.store(it, LOCAL_TRUSTSTORE_PASSWORD.toCharArray())
}
}
companion object {
private const val LOCAL_TRUSTSTORE_FILENAME = "trustedCertificates.bks"
private const val LOCAL_TRUSTSTORE_PASSWORD = "password"
}
}