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

@@ -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")

View File

@@ -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)
}

View File

@@ -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<Unit>>(OperationUiState.Initial())
val uiState: LiveData<OperationUiState<Unit>> get() = _uiState
private val invalidCertificatesChannel = Channel<X509Certificate>(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<Unit> = serverInfoRepo.tryBaseURL(url).recoverCatching {
logger.e(it) { "checkBaseURL: trying to recover, had prefix = $hasPrefix" }
if (hasPrefix || it is NetworkError.NotMealie) {
val certificateError = it.findCauseAsInstanceOf<CertificateCombinedException>()
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)
}
}

View File

@@ -17,6 +17,10 @@
<string name="fragment_base_url_malformed_url">Check URL format: %s</string>
<string name="fragment_base_url_save">Proceed</string>
<string name="fragment_base_url_unknown_error" translatable="false">@string/fragment_authentication_unknown_error</string>
<string name="fragment_base_url_invalid_certificate_title">The identity of the server could not be verified</string>
<string name="fragment_base_url_invalid_certificate_message">Do you trust this certificate?\n\nCertificate Information:\nIssuer: %1$s\nSubject: %2$s\nValid From: %3$s\nValid Until: %4$s</string>
<string name="fragment_base_url_invalid_certificate_accept">Trust</string>
<string name="fragment_base_url_invalid_certificate_deny">No</string>
<string name="menu_navigation_drawer_login">Login</string>
<string name="fragment_disclaimer_button_okay">Okay</string>
<string name="view_holder_recipe_instructions_step">Step: %d</string>

View File

@@ -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,
)
}