From b8d861d616931a37d4fd7c614653c5638e891a85 Mon Sep 17 00:00:00 2001 From: defkev Date: Mon, 29 May 2023 10:29:03 +0200 Subject: [PATCH] Add support for client certificates (mutual TLS) also adds the missing logic for certificate verification (#18) since i had to add a custom TrustManager anyway --- .../2.json | 12 ++- .../me/alexbakker/webdav/WebDavApplication.kt | 14 ++- .../java/me/alexbakker/webdav/data/Account.kt | 28 +++++- .../webdav/fragments/AccountFragment.kt | 54 +++++++++++- .../webdav/provider/WebDavClient.kt | 86 ++++++++++++++++++- app/src/main/res/layout/fragment_account.xml | 27 +++++- app/src/main/res/values/strings.xml | 3 + 7 files changed, 212 insertions(+), 12 deletions(-) diff --git a/app/schemas/me.alexbakker.webdav.data.AppDatabase/2.json b/app/schemas/me.alexbakker.webdav.data.AppDatabase/2.json index f9c64ef..bd18aeb 100644 --- a/app/schemas/me.alexbakker.webdav.data.AppDatabase/2.json +++ b/app/schemas/me.alexbakker.webdav.data.AppDatabase/2.json @@ -2,11 +2,11 @@ "formatVersion": 1, "database": { "version": 2, - "identityHash": "c8481ab6705693f178069e2a0d1a5353", + "identityHash": "b1eb451494a5e476054e5afd3710307a", "entities": [ { "tableName": "account", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT, `url` TEXT, `protocol` TEXT NOT NULL DEFAULT 'AUTO', `verify_certs` INTEGER NOT NULL, `username` TEXT, `password` TEXT, `max_cache_file_size` INTEGER NOT NULL)", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT, `url` TEXT, `protocol` TEXT NOT NULL DEFAULT 'AUTO', `verify_certs` INTEGER NOT NULL, `username` TEXT, `password` TEXT, `client_certificate` TEXT, `max_cache_file_size` INTEGER NOT NULL)", "fields": [ { "fieldPath": "id", @@ -51,6 +51,12 @@ "affinity": "TEXT", "notNull": false }, + { + "fieldPath": "clientCertificate", + "columnName": "client_certificate", + "affinity": "TEXT", + "notNull": false + }, { "fieldPath": "maxCacheFileSize", "columnName": "max_cache_file_size", @@ -149,7 +155,7 @@ "views": [], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'c8481ab6705693f178069e2a0d1a5353')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'b1eb451494a5e476054e5afd3710307a')" ] } } \ No newline at end of file diff --git a/app/src/main/java/me/alexbakker/webdav/WebDavApplication.kt b/app/src/main/java/me/alexbakker/webdav/WebDavApplication.kt index dd2891b..d5b29ac 100644 --- a/app/src/main/java/me/alexbakker/webdav/WebDavApplication.kt +++ b/app/src/main/java/me/alexbakker/webdav/WebDavApplication.kt @@ -1,7 +1,19 @@ package me.alexbakker.webdav import android.app.Application +import android.content.Context import dagger.hilt.android.HiltAndroidApp @HiltAndroidApp -class WebDavApplication : Application() +class WebDavApplication : Application() { + init { + instance = this + } + companion object { + private var instance: WebDavApplication? = null + + fun applicationContext() : Context { + return instance!!.applicationContext + } + } +} diff --git a/app/src/main/java/me/alexbakker/webdav/data/Account.kt b/app/src/main/java/me/alexbakker/webdav/data/Account.kt index f74fa62..2c8efe1 100644 --- a/app/src/main/java/me/alexbakker/webdav/data/Account.kt +++ b/app/src/main/java/me/alexbakker/webdav/data/Account.kt @@ -1,12 +1,15 @@ package me.alexbakker.webdav.data +import android.content.Context import android.net.Uri import android.provider.DocumentsContract +import android.security.KeyChain import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.Ignore import androidx.room.PrimaryKey import me.alexbakker.webdav.BuildConfig +import me.alexbakker.webdav.WebDavApplication import me.alexbakker.webdav.provider.WebDavClient import okhttp3.HttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl @@ -36,6 +39,9 @@ data class Account( @ColumnInfo(name = "password") var password: String? = null, + @ColumnInfo(name = "client_certificate") + var clientCertificate: String? = null, + @ColumnInfo(name = "max_cache_file_size") var maxCacheFileSize: Long = 20 ) { @@ -44,6 +50,11 @@ data class Account( return username != null && password != null } + private val mutual_authentication: Boolean + get() { + return !clientCertificate.isNullOrBlank() + } + val rootPath: Path get() { val path = ensureTrailingSlash(baseUrl.encodedPath) @@ -72,7 +83,22 @@ data class Account( get() { if (_client == null) { val creds = if (authentication) Pair(username!!, password!!) else null - _client = WebDavClient(baseUrl, creds, noHttp2 = protocol != Protocol.AUTO) + val mutualCreds = if (mutual_authentication) { + val context: Context = WebDavApplication.applicationContext() + try { + Triple( + clientCertificate!!, + KeyChain.getPrivateKey(context, clientCertificate!!)!!, + KeyChain.getCertificateChain(context, clientCertificate!!)!! + ) + } catch (e: NullPointerException) { + // TODO: maybe add some UI element in case the user removes the key from the KeyStore without updating the account? + null + } + } else { + null + } + _client = WebDavClient(baseUrl, creds, mutualCreds, verifyCerts, noHttp2 = protocol != Protocol.AUTO) } return _client!! diff --git a/app/src/main/java/me/alexbakker/webdav/fragments/AccountFragment.kt b/app/src/main/java/me/alexbakker/webdav/fragments/AccountFragment.kt index 3ec3ab9..3c1480b 100644 --- a/app/src/main/java/me/alexbakker/webdav/fragments/AccountFragment.kt +++ b/app/src/main/java/me/alexbakker/webdav/fragments/AccountFragment.kt @@ -1,9 +1,11 @@ package me.alexbakker.webdav.fragments import android.os.Bundle +import android.security.KeyChain import android.view.* import android.widget.ArrayAdapter import android.widget.AutoCompleteTextView +import android.widget.Toast import androidx.activity.OnBackPressedCallback import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity @@ -61,6 +63,44 @@ class AccountFragment : Fragment() { val adapter = ArrayAdapter.createFromResource(requireContext(), R.array.protocol_options, R.layout.dropdown_list_item) binding.dropdownProtocol.setAdapter(adapter) + if (binding.account!!.clientCertificate.isNullOrBlank()) { + binding.textLayoutCertificate.setEndIconDrawable(R.drawable.ic_add_black_24dp) + } else { + binding.textLayoutCertificate.setEndIconDrawable(R.drawable.ic_delete) + } + binding.textLayoutCertificate.setEndIconOnClickListener { + if (binding.textCertificate.text.toString().isBlank()) { + if (validateForm(true)) { + var url = binding.textUrl.text.toString().toHttpUrl() + KeyChain.choosePrivateKeyAlias( + requireActivity(), { alias -> + requireActivity().runOnUiThread { + if (alias != null) { + binding.textLayoutCertificate.setEndIconDrawable(R.drawable.ic_delete) + binding.textCertificate.setText(alias) + } else { + // TODO: there is probably a better way to only show the toast if no certificate(s) are installed + Toast.makeText( + requireActivity(), + getString(R.string.notice_no_client_certificate), + Toast.LENGTH_LONG + ).show() + } + } + }, + null, + null, + url.host, + url.port, + url.host + ) + } + } else { + binding.textLayoutCertificate.setEndIconDrawable(R.drawable.ic_add_black_24dp) + binding.textLayoutCertificate.editText?.text?.clear() + } + } + binding.sliderMaxCacheFileSize.apply { setLabelFormatter { getString(R.string.value_max_cache_file_size, it.toInt()) } addOnChangeListener(Slider.OnChangeListener { _, value, _ -> @@ -92,7 +132,7 @@ class AccountFragment : Fragment() { when (item.itemId) { R.id.action_save -> { - if (validateForm()) { + if (validateForm(binding.textCertificate.text.toString().isNotBlank())) { updateTestStatus(true) val job = lifecycleScope.launch(Dispatchers.IO) { account.resetState() @@ -151,15 +191,23 @@ class AccountFragment : Fragment() { return true } - private fun validateForm(): Boolean { + private fun validateForm(client_certificate: Boolean = false): Boolean { var res = true if (binding.textName.text.toString().isBlank()) { getInputLayout(binding.textName).error = getString(R.string.error_field_required) res = false + } else { + getInputLayout(binding.textName).error = null } try { - binding.textUrl.text.toString().toHttpUrl() + val url = binding.textUrl.text.toString().toHttpUrl() + if (client_certificate && !url.isHttps) { + getInputLayout(binding.textUrl).error = getString(R.string.notice_http_client_certificate) + res = false + } else { + getInputLayout(binding.textUrl).error = null + } } catch (e: IllegalArgumentException) { getInputLayout(binding.textUrl).error = getString(R.string.error_invalid_url) res = false diff --git a/app/src/main/java/me/alexbakker/webdav/provider/WebDavClient.kt b/app/src/main/java/me/alexbakker/webdav/provider/WebDavClient.kt index b671895..c371725 100644 --- a/app/src/main/java/me/alexbakker/webdav/provider/WebDavClient.kt +++ b/app/src/main/java/me/alexbakker/webdav/provider/WebDavClient.kt @@ -1,6 +1,6 @@ package me.alexbakker.webdav.provider -import android.util.Log +import android.annotation.SuppressLint import com.thegrizzlylabs.sardineandroid.model.Prop import com.thegrizzlylabs.sardineandroid.model.Property import com.thegrizzlylabs.sardineandroid.model.Resourcetype @@ -21,11 +21,26 @@ import retrofit2.Retrofit import retrofit2.converter.simplexml.SimpleXmlConverterFactory import java.io.IOException import java.io.InputStream +import java.net.Socket import java.nio.file.Path +import java.security.KeyStore +import java.security.Principal +import java.security.PrivateKey +import java.security.SecureRandom +import java.security.cert.X509Certificate +import javax.net.ssl.KeyManager +import javax.net.ssl.SSLContext +import javax.net.ssl.TrustManager +import javax.net.ssl.TrustManagerFactory +import javax.net.ssl.X509KeyManager +import javax.net.ssl.X509TrustManager + class WebDavClient( private val url: HttpUrl, private val creds: Pair? = null, + private val mutualCreds: Triple>? = null, + private val verify: Boolean = true, private val noHttp2: Boolean = false ) { private val api: WebDavService = buildApiService(url, creds) @@ -186,7 +201,11 @@ class WebDavClient( } private fun buildApiService(url: HttpUrl, creds: Pair?): WebDavService { - val builder = OkHttpClient.Builder() + val builder = OkHttpClient.Builder().apply { + if (mutualCreds != null || !verify) { + useCustomTLS(mutualCreds != null, verify) + } + } if (noHttp2) { builder.protocols(listOf(Protocol.HTTP_1_1)) } @@ -210,6 +229,69 @@ class WebDavClient( .create(WebDavService::class.java) } + private fun OkHttpClient.Builder.useCustomTLS(mutual: Boolean, verify: Boolean): OkHttpClient.Builder { + val sslContext = SSLContext.getInstance("TLS") + val keyManager = if (mutual) { + arrayOf(object : X509KeyManager { + override fun getClientAliases( + keyType: String?, + issuers: Array + ): Array { + return arrayOf(mutualCreds!!.first) + } + + override fun chooseClientAlias( + keyType: Array?, + issuers: Array?, + socket: Socket? + ): String { + return mutualCreds!!.first + } + + override fun getServerAliases( + keyType: String?, + issuers: Array + ): Array { + return arrayOf() + } + + override fun chooseServerAlias( + keyType: String?, + issuers: Array, + socket: Socket + ): String { + return "" + } + + override fun getPrivateKey(alias: String?): PrivateKey { + return mutualCreds!!.second + } + + override fun getCertificateChain(alias: String?): Array { + return mutualCreds!!.third + } + }) + } else { + null + } + val trustManager: Array = if (verify) { + val trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) + trustManagerFactory.init(null as KeyStore?) + trustManagerFactory.trustManagers + } else { + hostnameVerifier { _, _ -> true } + arrayOf(@SuppressLint("CustomX509TrustManager") + object : X509TrustManager { + override fun getAcceptedIssuers(): Array = arrayOf() + override fun checkClientTrusted(certs: Array, authType: String) = Unit + override fun checkServerTrusted(certs: Array, authType: String) = Unit + }) + } + sslContext.init(keyManager, trustManager, SecureRandom()) + sslSocketFactory(sslContext.socketFactory, trustManager.first() as X509TrustManager) + return this + } + private fun buildSerializer(): Serializer { // source: https://git.io/Jkf9B val format = Format("") diff --git a/app/src/main/res/layout/fragment_account.xml b/app/src/main/res/layout/fragment_account.xml index 8a45612..a2249fb 100644 --- a/app/src/main/res/layout/fragment_account.xml +++ b/app/src/main/res/layout/fragment_account.xml @@ -122,6 +122,29 @@ android:text="@={account.password}" /> + + + + + + + app:layout_constraintTop_toBottomOf="@+id/text_layout_certificate" /> + app:layout_constraintTop_toBottomOf="@+id/text_layout_certificate" /> Authentication Username Password + Client certificate + No certificate selected or no certificate(s) installed, see: Settings > Security > Encryption & credentials + Client certificate requires a secure (https) connection Add account Edit account