diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 386d06c..3460a38 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -16,8 +16,8 @@ plugins { android { defaultConfig { applicationId = "gq.kirmanak.mealient" - versionCode = 28 - versionName = "0.3.13" + versionCode = 29 + versionName = "0.4.0" testInstrumentationRunner = "gq.kirmanak.mealient.MealientTestRunner" testInstrumentationRunnerArguments += mapOf("clearPackageData" to "true") resourceConfigurations += listOf("en", "es", "ru", "fr", "nl", "pt", "de") diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/baseurl/BaseURLFragment.kt b/app/src/main/java/gq/kirmanak/mealient/ui/baseurl/BaseURLFragment.kt index 0b51028..63893c1 100644 --- a/app/src/main/java/gq/kirmanak/mealient/ui/baseurl/BaseURLFragment.kt +++ b/app/src/main/java/gq/kirmanak/mealient/ui/baseurl/BaseURLFragment.kt @@ -2,6 +2,7 @@ package gq.kirmanak.mealient.ui.baseurl import android.os.Bundle import android.view.View +import androidx.appcompat.app.AlertDialog import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.fragment.app.viewModels @@ -11,13 +12,16 @@ import by.kirich1409.viewbindingdelegate.viewBinding import dagger.hilt.android.AndroidEntryPoint import gq.kirmanak.mealient.R import gq.kirmanak.mealient.databinding.FragmentBaseUrlBinding +import gq.kirmanak.mealient.datasource.CertificateCombinedException import gq.kirmanak.mealient.datasource.NetworkError import gq.kirmanak.mealient.extensions.checkIfInputIsEmpty +import gq.kirmanak.mealient.extensions.collectWhenResumed import gq.kirmanak.mealient.logging.Logger import gq.kirmanak.mealient.ui.CheckableMenuItem import gq.kirmanak.mealient.ui.OperationUiState import gq.kirmanak.mealient.ui.activity.MainActivityViewModel import gq.kirmanak.mealient.ui.baseurl.BaseURLFragmentDirections.Companion.actionBaseURLFragmentToRecipesListFragment +import java.security.cert.X509Certificate import javax.inject.Inject @AndroidEntryPoint @@ -36,6 +40,7 @@ class BaseURLFragment : Fragment(R.layout.fragment_base_url) { logger.v { "onViewCreated() called with: view = $view, savedInstanceState = $savedInstanceState" } binding.button.setOnClickListener(::onProceedClick) viewModel.uiState.observe(viewLifecycleOwner, ::onUiStateChange) + collectWhenResumed(viewModel.invalidCertificatesFlow, ::onInvalidCertificate) activityViewModel.updateUiState { it.copy( navigationVisible = !args.isOnboarding, @@ -45,8 +50,35 @@ class BaseURLFragment : Fragment(R.layout.fragment_base_url) { } } + private fun onInvalidCertificate(certificate: X509Certificate) { + logger.v { "onInvalidCertificate() called with: certificate = $certificate" } + val dialogMessage = getString( + R.string.fragment_base_url_invalid_certificate_message, + certificate.issuerDN.toString(), + certificate.subjectDN.toString(), + certificate.notBefore.toString(), + certificate.notAfter.toString(), + ) + val dialog = AlertDialog.Builder(requireContext()) + .setTitle(R.string.fragment_base_url_invalid_certificate_title) + .setMessage(dialogMessage) + .setPositiveButton(R.string.fragment_base_url_invalid_certificate_accept) { _, _ -> + viewModel.acceptInvalidCertificate(certificate) + saveEnteredUrl() + }.setNegativeButton(R.string.fragment_base_url_invalid_certificate_deny) { _, _ -> + // Do nothing, let the user enter another address or try again + } + .create() + dialog.show() + } + private fun onProceedClick(view: View) { logger.v { "onProceedClick() called with: view = $view" } + saveEnteredUrl() + } + + private fun saveEnteredUrl() { + logger.v { "saveEnteredUrl() called" } val url = binding.urlInput.checkIfInputIsEmpty( inputLayout = binding.urlInputLayout, lifecycleOwner = viewLifecycleOwner, @@ -69,6 +101,8 @@ class BaseURLFragment : Fragment(R.layout.fragment_base_url) { val cause = exception.cause?.message ?: exception.message getString(R.string.fragment_base_url_malformed_url, cause) } + + is CertificateCombinedException -> getString(R.string.fragment_base_url_invalid_certificate_title) null -> null else -> getString(R.string.fragment_base_url_unknown_error) } diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/baseurl/BaseURLViewModel.kt b/app/src/main/java/gq/kirmanak/mealient/ui/baseurl/BaseURLViewModel.kt index 92c01f7..6c76d96 100644 --- a/app/src/main/java/gq/kirmanak/mealient/ui/baseurl/BaseURLViewModel.kt +++ b/app/src/main/java/gq/kirmanak/mealient/ui/baseurl/BaseURLViewModel.kt @@ -8,11 +8,16 @@ import dagger.hilt.android.lifecycle.HiltViewModel import gq.kirmanak.mealient.data.auth.AuthRepo import gq.kirmanak.mealient.data.baseurl.ServerInfoRepo import gq.kirmanak.mealient.data.recipes.RecipeRepo +import gq.kirmanak.mealient.datasource.CertificateCombinedException import gq.kirmanak.mealient.datasource.NetworkError +import gq.kirmanak.mealient.datasource.TrustedCertificatesStore +import gq.kirmanak.mealient.datasource.findCauseAsInstanceOf import gq.kirmanak.mealient.logging.Logger -import gq.kirmanak.mealient.shopping_lists.repo.ShoppingListsRepo import gq.kirmanak.mealient.ui.OperationUiState +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch +import java.security.cert.X509Certificate import javax.inject.Inject @HiltViewModel @@ -21,12 +26,15 @@ class BaseURLViewModel @Inject constructor( private val authRepo: AuthRepo, private val recipeRepo: RecipeRepo, private val logger: Logger, - private val shoppingListsRepo: ShoppingListsRepo, + private val trustedCertificatesStore: TrustedCertificatesStore, ) : ViewModel() { private val _uiState = MutableLiveData>(OperationUiState.Initial()) val uiState: LiveData> get() = _uiState + private val invalidCertificatesChannel = Channel(Channel.UNLIMITED) + val invalidCertificatesFlow = invalidCertificatesChannel.receiveAsFlow() + fun saveBaseUrl(baseURL: String) { logger.v { "saveBaseUrl() called with: baseURL = $baseURL" } _uiState.value = OperationUiState.Progress() @@ -49,7 +57,11 @@ class BaseURLViewModel @Inject constructor( val result: Result = serverInfoRepo.tryBaseURL(url).recoverCatching { logger.e(it) { "checkBaseURL: trying to recover, had prefix = $hasPrefix" } - if (hasPrefix || it is NetworkError.NotMealie) { + val certificateError = it.findCauseAsInstanceOf() + if (certificateError != null) { + invalidCertificatesChannel.send(certificateError.serverCert) + throw certificateError + } else if (hasPrefix || it is NetworkError.NotMealie) { throw it } else { val unencryptedUrl = url.replace("https", "http") @@ -66,4 +78,8 @@ class BaseURLViewModel @Inject constructor( _uiState.value = OperationUiState.fromResult(result) } + fun acceptInvalidCertificate(certificate: X509Certificate) { + logger.v { "acceptInvalidCertificate() called with: certificate = $certificate" } + trustedCertificatesStore.addTrustedCertificate(certificate) + } } \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 62853ba..dfe7fc5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -17,6 +17,10 @@ Check URL format: %s Proceed @string/fragment_authentication_unknown_error + The identity of the server could not be verified + Do you trust this certificate?\n\nCertificate Information:\nIssuer: %1$s\nSubject: %2$s\nValid From: %3$s\nValid Until: %4$s + Trust + No Login Okay Step: %d diff --git a/app/src/test/java/gq/kirmanak/mealient/ui/baseurl/BaseURLViewModelTest.kt b/app/src/test/java/gq/kirmanak/mealient/ui/baseurl/BaseURLViewModelTest.kt index 9be67d1..486bc1a 100644 --- a/app/src/test/java/gq/kirmanak/mealient/ui/baseurl/BaseURLViewModelTest.kt +++ b/app/src/test/java/gq/kirmanak/mealient/ui/baseurl/BaseURLViewModelTest.kt @@ -5,7 +5,7 @@ import gq.kirmanak.mealient.data.auth.AuthRepo import gq.kirmanak.mealient.data.baseurl.ServerInfoRepo import gq.kirmanak.mealient.data.recipes.RecipeRepo import gq.kirmanak.mealient.datasource.NetworkError -import gq.kirmanak.mealient.shopping_lists.repo.ShoppingListsRepo +import gq.kirmanak.mealient.datasource.TrustedCertificatesStore import gq.kirmanak.mealient.test.AuthImplTestData.TEST_BASE_URL import gq.kirmanak.mealient.test.BaseUnitTest import gq.kirmanak.mealient.ui.OperationUiState @@ -33,7 +33,7 @@ class BaseURLViewModelTest : BaseUnitTest() { lateinit var recipeRepo: RecipeRepo @MockK(relaxUnitFun = true) - lateinit var shoppingListsRepo: ShoppingListsRepo + lateinit var trustedCertificatesStore: TrustedCertificatesStore lateinit var subject: BaseURLViewModel @@ -45,7 +45,7 @@ class BaseURLViewModelTest : BaseUnitTest() { authRepo = authRepo, recipeRepo = recipeRepo, logger = logger, - shoppingListsRepo = shoppingListsRepo, + trustedCertificatesStore = trustedCertificatesStore, ) } diff --git a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/CertificateCombinedException.kt b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/CertificateCombinedException.kt new file mode 100644 index 0000000..baf740b --- /dev/null +++ b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/CertificateCombinedException.kt @@ -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 + } +} \ No newline at end of file diff --git a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/DataSourceExtensions.kt b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/DataSourceExtensions.kt index 4b7f291..6852007 100644 --- a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/DataSourceExtensions.kt +++ b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/DataSourceExtensions.kt @@ -20,3 +20,13 @@ inline fun runCatchingExceptCancel(block: () -> T): Result = try { @OptIn(ExperimentalSerializationApi::class) inline fun ResponseBody.decode(json: Json): R = json.decodeFromStream(byteStream()) + +inline fun 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 +} \ No newline at end of file diff --git a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/DataSourceModule.kt b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/DataSourceModule.kt index d33e4ce..656833b 100644 --- a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/DataSourceModule.kt +++ b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/DataSourceModule.kt @@ -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 } \ No newline at end of file diff --git a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/TrustedCertificatesStore.kt b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/TrustedCertificatesStore.kt new file mode 100644 index 0000000..b96befc --- /dev/null +++ b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/TrustedCertificatesStore.kt @@ -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) +} \ No newline at end of file diff --git a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/impl/AdvancedX509TrustManager.kt b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/impl/AdvancedX509TrustManager.kt new file mode 100644 index 0000000..5a99778 --- /dev/null +++ b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/impl/AdvancedX509TrustManager.kt @@ -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() + .first() + } + + override fun checkClientTrusted(certificates: Array?, authType: String?) { + standardTrustManager.checkClientTrusted(certificates, authType) + } + + override fun checkServerTrusted(certificates: Array, 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() + if (cause != null) { + result.certPathValidatorException = cause + } else { + result.otherCertificateException = c + } + } + + if (result.isException()) { + throw result + } + } + + override fun getAcceptedIssuers(): Array { + return standardTrustManager.acceptedIssuers + } +} \ No newline at end of file diff --git a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/impl/AuthInterceptor.kt b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/impl/AuthInterceptor.kt index a1ee900..1d98d8f 100644 --- a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/impl/AuthInterceptor.kt +++ b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/impl/AuthInterceptor.kt @@ -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, ) : LocalInterceptor { diff --git a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/impl/BaseUrlInterceptor.kt b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/impl/BaseUrlInterceptor.kt index 838c6eb..c5bb54a 100644 --- a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/impl/BaseUrlInterceptor.kt +++ b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/impl/BaseUrlInterceptor.kt @@ -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, ) : LocalInterceptor { diff --git a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/impl/CacheBuilderImpl.kt b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/impl/CacheBuilderImpl.kt index 662ce20..2e5f011 100644 --- a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/impl/CacheBuilderImpl.kt +++ b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/impl/CacheBuilderImpl.kt @@ -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 { diff --git a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/impl/NetworkRequestWrapperImpl.kt b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/impl/NetworkRequestWrapperImpl.kt index d19e496..7005fd6 100644 --- a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/impl/NetworkRequestWrapperImpl.kt +++ b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/impl/NetworkRequestWrapperImpl.kt @@ -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 { diff --git a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/impl/OkHttpBuilderImpl.kt b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/impl/OkHttpBuilderImpl.kt index f20a637..739813f 100644 --- a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/impl/OkHttpBuilderImpl.kt +++ b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/impl/OkHttpBuilderImpl.kt @@ -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(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() + } + } \ No newline at end of file diff --git a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/impl/RetrofitBuilder.kt b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/impl/RetrofitBuilder.kt index adbd77b..f2830f4 100644 --- a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/impl/RetrofitBuilder.kt +++ b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/impl/RetrofitBuilder.kt @@ -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, diff --git a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/impl/TrustedCertificatesStoreImpl.kt b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/impl/TrustedCertificatesStoreImpl.kt new file mode 100644 index 0000000..49181e1 --- /dev/null +++ b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/impl/TrustedCertificatesStoreImpl.kt @@ -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" + } +} \ No newline at end of file