Skip to content

Commit

Permalink
Add support for client certificates (mutual TLS)
Browse files Browse the repository at this point in the history
also adds the missing logic for certificate verification (#18)
since i had to add a custom TrustManager anyway
  • Loading branch information
defkev committed May 29, 2023
1 parent 9e9826d commit b8d861d
Show file tree
Hide file tree
Showing 7 changed files with 212 additions and 12 deletions.
12 changes: 9 additions & 3 deletions app/schemas/me.alexbakker.webdav.data.AppDatabase/2.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -51,6 +51,12 @@
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "clientCertificate",
"columnName": "client_certificate",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "maxCacheFileSize",
"columnName": "max_cache_file_size",
Expand Down Expand Up @@ -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')"
]
}
}
14 changes: 13 additions & 1 deletion app/src/main/java/me/alexbakker/webdav/WebDavApplication.kt
Original file line number Diff line number Diff line change
@@ -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
}
}
}
28 changes: 27 additions & 1 deletion app/src/main/java/me/alexbakker/webdav/data/Account.kt
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
) {
Expand All @@ -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)
Expand Down Expand Up @@ -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!!
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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, _ ->
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down
86 changes: 84 additions & 2 deletions app/src/main/java/me/alexbakker/webdav/provider/WebDavClient.kt
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<String, String>? = null,
private val mutualCreds: Triple<String, PrivateKey, Array<X509Certificate>>? = null,
private val verify: Boolean = true,
private val noHttp2: Boolean = false
) {
private val api: WebDavService = buildApiService(url, creds)
Expand Down Expand Up @@ -186,7 +201,11 @@ class WebDavClient(
}

private fun buildApiService(url: HttpUrl, creds: Pair<String, String>?): 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))
}
Expand All @@ -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<KeyManager>(object : X509KeyManager {
override fun getClientAliases(
keyType: String?,
issuers: Array<Principal>
): Array<String> {
return arrayOf(mutualCreds!!.first)
}

override fun chooseClientAlias(
keyType: Array<out String>?,
issuers: Array<out Principal>?,
socket: Socket?
): String {
return mutualCreds!!.first
}

override fun getServerAliases(
keyType: String?,
issuers: Array<Principal>
): Array<String> {
return arrayOf()
}

override fun chooseServerAlias(
keyType: String?,
issuers: Array<Principal>,
socket: Socket
): String {
return ""
}

override fun getPrivateKey(alias: String?): PrivateKey {
return mutualCreds!!.second
}

override fun getCertificateChain(alias: String?): Array<X509Certificate> {
return mutualCreds!!.third
}
})
} else {
null
}
val trustManager: Array<TrustManager> = 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<X509Certificate> = arrayOf()
override fun checkClientTrusted(certs: Array<X509Certificate>, authType: String) = Unit
override fun checkServerTrusted(certs: Array<X509Certificate>, 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("<?xml version=\"1.0\" encoding=\"utf-8\"?>")
Expand Down
27 changes: 25 additions & 2 deletions app/src/main/res/layout/fragment_account.xml
Original file line number Diff line number Diff line change
Expand Up @@ -122,22 +122,45 @@
android:text="@={account.password}" />
</com.google.android.material.textfield.TextInputLayout>


<com.google.android.material.textfield.TextInputLayout
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
android:id="@+id/text_layout_certificate"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/client_certificate"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@+id/text_layout_password"
app:endIconMode="custom">
<com.google.android.material.textfield.TextInputEditText
android:cursorVisible="false"
android:focusable="false"
android:id="@+id/text_certificate"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:singleLine="true"
android:inputType="none"
android:text="@={account.clientCertificate}" />
</com.google.android.material.textfield.TextInputLayout>


<TextView
android:id="@+id/label_cache_file_size"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/label_max_cache_file_size"
android:layout_marginTop="15dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/text_layout_password" />
app:layout_constraintTop_toBottomOf="@+id/text_layout_certificate" />
<TextView
android:id="@+id/value_cache_file_size"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="15dp"
android:text="@{@string/value_max_cache_file_size(account.maxCacheFileSize)}"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@+id/text_layout_password" />
app:layout_constraintTop_toBottomOf="@+id/text_layout_certificate" />
<com.google.android.material.slider.Slider
android:id="@+id/slider_max_cache_file_size"
android:layout_width="match_parent"
Expand Down
3 changes: 3 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@
<string name="authentication">Authentication</string>
<string name="username">Username</string>
<string name="password">Password</string>
<string name="client_certificate">Client certificate</string>
<string name="notice_no_client_certificate">No certificate selected or no certificate(s) installed, see: Settings > Security > Encryption &amp; credentials</string>
<string name="notice_http_client_certificate">Client certificate requires a secure (https) connection</string>

<string name="add_account">Add account</string>
<string name="edit_account">Edit account</string>
Expand Down

0 comments on commit b8d861d

Please sign in to comment.