Implement optional mTLS via client certificate option

This commit is contained in:
Niko Diamadis
2024-04-19 02:15:06 +02:00
parent 8a1802e5ed
commit 23ef899564
7 changed files with 272 additions and 77 deletions

View File

@@ -1,3 +1,8 @@
package com.github.gotify
internal class SSLSettings(val validateSSL: Boolean, val cert: String?)
internal class SSLSettings(
val validateSSL: Boolean,
val caCertPath: String?,
val clientCertPath: String?,
val clientCertPassword: String?
)

View File

@@ -26,12 +26,21 @@ internal class Settings(context: Context) {
var serverVersion: String
get() = sharedPreferences.getString("version", "UNKNOWN")!!
set(value) = sharedPreferences.edit().putString("version", value).apply()
var cert: String?
get() = sharedPreferences.getString("cert", null)
set(value) = sharedPreferences.edit().putString("cert", value).apply()
var caCertPath: String?
get() = sharedPreferences.getString("caCertPath", null)
set(value) = sharedPreferences.edit().putString("caCertPath", value).apply()
var caCertCN: String?
get() = sharedPreferences.getString("caCertCN", null)
set(value) = sharedPreferences.edit().putString("caCertCN", value).apply()
var validateSSL: Boolean
get() = sharedPreferences.getBoolean("validateSSL", true)
set(value) = sharedPreferences.edit().putBoolean("validateSSL", value).apply()
var clientCertPath: String?
get() = sharedPreferences.getString("clientCertPath", null)
set(value) = sharedPreferences.edit().putString("clientCertPath", value).apply()
var clientCertPassword: String?
get() = sharedPreferences.getString("clientCertPass", null)
set(value) = sharedPreferences.edit().putString("clientCertPass", value).apply()
init {
sharedPreferences = context.getSharedPreferences("gotify", Context.MODE_PRIVATE)
@@ -43,7 +52,10 @@ internal class Settings(context: Context) {
url = ""
token = null
validateSSL = true
cert = null
caCertPath = null
caCertCN = null
clientCertPath = null
clientCertPassword = null
}
fun setUser(name: String?, admin: Boolean) {
@@ -51,6 +63,11 @@ internal class Settings(context: Context) {
}
fun sslSettings(): SSLSettings {
return SSLSettings(validateSSL, cert)
return SSLSettings(
validateSSL,
caCertPath,
clientCertPath,
clientCertPassword
)
}
}

View File

@@ -2,14 +2,17 @@ package com.github.gotify.api
import android.annotation.SuppressLint
import com.github.gotify.SSLSettings
import com.github.gotify.Utils
import java.io.IOException
import java.io.File
import java.io.FileInputStream
import java.io.InputStream
import java.security.GeneralSecurityException
import java.security.KeyStore
import java.security.SecureRandom
import java.security.cert.Certificate
import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate
import javax.net.ssl.KeyManager
import javax.net.ssl.KeyManagerFactory
import javax.net.ssl.SSLContext
import javax.net.ssl.TrustManager
import javax.net.ssl.TrustManagerFactory
@@ -18,6 +21,9 @@ import okhttp3.OkHttpClient
import org.tinylog.kotlin.Logger
internal object CertUtils {
const val CA_CERT_NAME = "ca-cert.crt"
const val CLIENT_CERT_NAME = "client-cert.p12"
@SuppressLint("CustomX509TrustManager")
private val trustAll = object : X509TrustManager {
@SuppressLint("TrustAllX509TrustManager")
@@ -31,10 +37,10 @@ internal object CertUtils {
override fun getAcceptedIssuers() = arrayOf<X509Certificate>()
}
fun parseCertificate(cert: String): Certificate {
fun parseCertificate(inputStream: InputStream): Certificate {
try {
val certificateFactory = CertificateFactory.getInstance("X509")
return certificateFactory.generateCertificate(Utils.stringToInputStream(cert))
return certificateFactory.generateCertificate(inputStream)
} catch (e: Exception) {
throw IllegalArgumentException("certificate is invalid")
}
@@ -43,25 +49,37 @@ internal object CertUtils {
fun applySslSettings(builder: OkHttpClient.Builder, settings: SSLSettings) {
// Modified from ApiClient.applySslSettings in the client package.
try {
if (!settings.validateSSL) {
val context = SSLContext.getInstance("TLS")
context.init(arrayOf(), arrayOf<TrustManager>(trustAll), SecureRandom())
builder.sslSocketFactory(context.socketFactory, trustAll)
builder.hostnameVerifier { _, _ -> true }
return
}
val cert = settings.cert
if (cert != null) {
val trustManagers = certToTrustManager(cert)
if (trustManagers.isNotEmpty()) {
val context = SSLContext.getInstance("TLS")
context.init(arrayOf(), trustManagers, SecureRandom())
builder.sslSocketFactory(
context.socketFactory,
trustManagers[0] as X509TrustManager
)
var customManagers = false
var trustManagers: Array<TrustManager>? = null
var keyManagers: Array<KeyManager>? = null
if (settings.caCertPath != null) {
val tempTrustManagers = certToTrustManager(settings.caCertPath)
if (tempTrustManagers.isNotEmpty()) {
trustManagers = tempTrustManagers
customManagers = true
}
}
if (settings.clientCertPath != null) {
val tempKeyManagers = certToKeyManager(
settings.clientCertPath,
settings.clientCertPassword
)
if (tempKeyManagers.isNotEmpty()) {
keyManagers = tempKeyManagers
}
}
if (!settings.validateSSL) {
trustManagers = arrayOf(trustAll)
builder.hostnameVerifier { _, _ -> true }
}
if (customManagers || !settings.validateSSL) {
val context = SSLContext.getInstance("TLS")
context.init(keyManagers, trustManagers, SecureRandom())
builder.sslSocketFactory(
context.socketFactory,
trustManagers!![0] as X509TrustManager
)
}
} catch (e: Exception) {
// We shouldn't have issues since the cert is verified on login.
Logger.error(e, "Failed to apply SSL settings")
@@ -69,12 +87,13 @@ internal object CertUtils {
}
@Throws(GeneralSecurityException::class)
private fun certToTrustManager(cert: String): Array<TrustManager> {
private fun certToTrustManager(certPath: String): Array<TrustManager> {
val certificateFactory = CertificateFactory.getInstance("X.509")
val certificates = certificateFactory.generateCertificates(Utils.stringToInputStream(cert))
val inputStream = FileInputStream(File(certPath))
val certificates = certificateFactory.generateCertificates(inputStream)
require(certificates.isNotEmpty()) { "expected non-empty set of trusted certificates" }
val caKeyStore = newEmptyKeyStore()
val caKeyStore = KeyStore.getInstance(KeyStore.getDefaultType()).apply { load(null) }
certificates.forEachIndexed { index, certificate ->
val certificateAlias = "ca$index"
caKeyStore.setCertificateEntry(certificateAlias, certificate)
@@ -86,13 +105,15 @@ internal object CertUtils {
}
@Throws(GeneralSecurityException::class)
private fun newEmptyKeyStore(): KeyStore {
return try {
val keyStore = KeyStore.getInstance(KeyStore.getDefaultType())
keyStore.load(null, null)
keyStore
} catch (e: IOException) {
throw AssertionError(e)
}
private fun certToKeyManager(certPath: String, certPassword: String?): Array<KeyManager> {
require(certPassword != null) { "empty client certificate password" }
val keyStore = KeyStore.getInstance("PKCS12")
val inputStream = FileInputStream(File(certPath))
keyStore.load(inputStream, certPassword.toCharArray())
val keyManagerFactory =
KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm())
keyManagerFactory.init(keyStore, certPassword.toCharArray())
return keyManagerFactory.keyManagers
}
}

View File

@@ -15,6 +15,9 @@ internal class AdvancedDialog(
private var onCheckedChangeListener: CompoundButton.OnCheckedChangeListener? = null
private lateinit var onClickSelectCaCertificate: Runnable
private lateinit var onClickRemoveCaCertificate: Runnable
private lateinit var onClickSelectClientCertificate: Runnable
private lateinit var onClickRemoveClientCertificate: Runnable
private lateinit var onClose: (password: String) -> Unit
fun onDisableSSLChanged(
onCheckedChangeListener: CompoundButton.OnCheckedChangeListener?
@@ -33,35 +36,82 @@ internal class AdvancedDialog(
return this
}
fun show(disableSSL: Boolean, selectedCertificate: String?): AdvancedDialog {
fun onClickSelectClientCertificate(onClickSelectClientCertificate: Runnable): AdvancedDialog {
this.onClickSelectClientCertificate = onClickSelectClientCertificate
return this
}
fun onClickRemoveClientCertificate(onClickRemoveClientCertificate: Runnable): AdvancedDialog {
this.onClickRemoveClientCertificate = onClickRemoveClientCertificate
return this
}
fun onClose(onClose: (passworrd: String) -> Unit): AdvancedDialog {
this.onClose = onClose
return this
}
fun show(
disableSSL: Boolean,
caCertPath: String? = null,
caCertCN: String?,
clientCertPath: String? = null,
clientCertPassword: String?
): AdvancedDialog {
binding = AdvancedSettingsDialogBinding.inflate(layoutInflater)
binding.disableSSL.isChecked = disableSSL
binding.disableSSL.setOnCheckedChangeListener(onCheckedChangeListener)
if (selectedCertificate == null) {
showSelectCACertificate()
if (caCertPath == null) {
showSelectCaCertificate()
} else {
showRemoveCACertificate(selectedCertificate)
showRemoveCaCertificate(caCertCN!!)
}
if (clientCertPath == null) {
showSelectClientCertificate()
} else {
showRemoveClientCertificate()
}
if (!clientCertPassword.isNullOrEmpty()) {
binding.clientCertPasswordEdittext.setText(clientCertPassword)
}
MaterialAlertDialogBuilder(context)
.setView(binding.root)
.setTitle(R.string.advanced_settings)
.setPositiveButton(context.getString(R.string.done), null)
.setOnDismissListener {
onClose(binding.clientCertPasswordEdittext.text.toString())
}
.show()
return this
}
private fun showSelectCACertificate() {
private fun showSelectCaCertificate() {
binding.toggleCaCert.setText(R.string.select_ca_certificate)
binding.toggleCaCert.setOnClickListener { onClickSelectCaCertificate.run() }
binding.selecetedCaCert.setText(R.string.no_certificate_selected)
binding.selectedCaCert.setText(R.string.no_certificate_selected)
}
fun showRemoveCACertificate(certificate: String) {
fun showRemoveCaCertificate(certificateCN: String) {
binding.toggleCaCert.setText(R.string.remove_ca_certificate)
binding.toggleCaCert.setOnClickListener {
showSelectCACertificate()
showSelectCaCertificate()
onClickRemoveCaCertificate.run()
}
binding.selecetedCaCert.text = certificate
binding.selectedCaCert.text = certificateCN
}
private fun showSelectClientCertificate() {
binding.toggleClientCert.setText(R.string.select_client_certificate)
binding.toggleClientCert.setOnClickListener { onClickSelectClientCertificate.run() }
binding.selectedClientCert.setText(R.string.no_certificate_selected)
}
fun showRemoveClientCertificate() {
binding.toggleClientCert.setText(R.string.remove_client_certificate)
binding.toggleClientCert.setOnClickListener {
showSelectClientCertificate()
onClickRemoveClientCertificate.run()
}
binding.selectedClientCert.setText(R.string.certificate_found)
}
}

View File

@@ -8,7 +8,9 @@ import android.os.Bundle
import android.text.Editable
import android.text.TextWatcher
import android.view.View
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.StringRes
import androidx.appcompat.app.AppCompatActivity
import com.github.gotify.R
import com.github.gotify.SSLSettings
@@ -31,6 +33,10 @@ import com.github.gotify.log.LogsActivity
import com.github.gotify.log.UncaughtExceptionHandler
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.textfield.TextInputEditText
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.io.InputStream
import java.security.cert.X509Certificate
import okhttp3.HttpUrl
import org.tinylog.kotlin.Logger
@@ -40,10 +46,13 @@ internal class LoginActivity : AppCompatActivity() {
private lateinit var settings: Settings
private var disableSslValidation = false
private var caCertContents: String? = null
private var caCertCN: String? = null
private var caCertPath: String? = null
private var clientCertPath: String? = null
private var clientCertPassword: String? = null
private lateinit var advancedDialog: AdvancedDialog
private val certificateDialogResultLauncher =
private val caDialogResultLauncher =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
try {
require(result.resultCode == RESULT_OK) { "result was ${result.resultCode}" }
@@ -52,18 +61,38 @@ internal class LoginActivity : AppCompatActivity() {
val uri = result.data!!.data ?: throw IllegalArgumentException("file path was null")
val fileStream = contentResolver.openInputStream(uri)
?: throw IllegalArgumentException("file path was invalid")
val destinationFile = File(filesDir, CertUtils.CA_CERT_NAME)
copyStreamToFile(fileStream, destinationFile)
val content = Utils.readFileFromStream(fileStream)
val name = getNameOfCertContent(content)
// temporarily set the contents (don't store to settings until they decide to login)
caCertContents = content
advancedDialog.showRemoveCACertificate(name)
// temporarily store it (don't store to settings until they decide to login)
caCertCN = getNameOfCertContent(destinationFile)!!
caCertPath = destinationFile.absolutePath
advancedDialog.showRemoveCaCertificate(caCertCN!!)
} catch (e: Exception) {
Utils.showSnackBar(this, getString(R.string.select_ca_failed, e.message))
}
}
private val clientCertDialogResultLauncher =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
try {
require(result.resultCode == RESULT_OK) { "result was ${result.resultCode}" }
requireNotNull(result.data) { "file path was null" }
val uri = result.data!!.data ?: throw IllegalArgumentException("file path was null")
val fileStream = contentResolver.openInputStream(uri)
?: throw IllegalArgumentException("file path was invalid")
val destinationFile = File(filesDir, CertUtils.CLIENT_CERT_NAME)
copyStreamToFile(fileStream, destinationFile)
// temporarily store it (don't store to settings until they decide to login)
clientCertPath = destinationFile.absolutePath
advancedDialog.showRemoveClientCertificate()
} catch (e: Exception) {
Utils.showSnackBar(this, getString(R.string.select_client_failed, e.message))
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
UncaughtExceptionHandler.registerCurrentThread()
@@ -140,12 +169,6 @@ internal class LoginActivity : AppCompatActivity() {
}
private fun toggleShowAdvanced() {
val selectedCertName = if (caCertContents != null) {
getNameOfCertContent(caCertContents!!)
} else {
null
}
advancedDialog = AdvancedDialog(this, layoutInflater)
.onDisableSSLChanged { _, disable ->
invalidateUrl()
@@ -153,33 +176,51 @@ internal class LoginActivity : AppCompatActivity() {
}
.onClickSelectCaCertificate {
invalidateUrl()
doSelectCACertificate()
doSelectCertificate(caDialogResultLauncher, R.string.select_ca_file)
}
.onClickRemoveCaCertificate {
invalidateUrl()
caCertContents = null
caCertPath = null
}
.show(disableSslValidation, selectedCertName)
.onClickSelectClientCertificate {
invalidateUrl()
doSelectCertificate(clientCertDialogResultLauncher, R.string.select_client_file)
}
.onClickRemoveClientCertificate {
invalidateUrl()
clientCertPath = null
}
.onClose { newPassword ->
clientCertPassword = newPassword
}
.show(
disableSslValidation,
caCertPath,
caCertCN,
clientCertPath,
clientCertPassword
)
}
private fun doSelectCACertificate() {
private fun doSelectCertificate(
resultLauncher: ActivityResultLauncher<Intent>,
@StringRes descriptionId: Int
) {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT)
// we don't really care what kind of file it is as long as we can parse it
intent.type = "*/*"
intent.addCategory(Intent.CATEGORY_OPENABLE)
try {
certificateDialogResultLauncher.launch(
Intent.createChooser(intent, getString(R.string.select_ca_file))
)
resultLauncher.launch(Intent.createChooser(intent, getString(descriptionId)))
} catch (e: ActivityNotFoundException) {
// case for user not having a file browser installed
Utils.showSnackBar(this, getString(R.string.please_install_file_browser))
}
}
private fun getNameOfCertContent(content: String): String {
val ca = CertUtils.parseCertificate(content)
private fun getNameOfCertContent(file: File): String? {
val ca = CertUtils.parseCertificate(FileInputStream(file))
return (ca as X509Certificate).subjectDN.name
}
@@ -265,7 +306,10 @@ internal class LoginActivity : AppCompatActivity() {
private fun onCreatedClient(client: Client) {
settings.token = client.token
settings.validateSSL = !disableSslValidation
settings.cert = caCertContents
settings.caCertCN = caCertCN
settings.caCertPath = caCertPath
settings.clientCertPath = clientCertPath
settings.clientCertPassword = clientCertPassword
Utils.showSnackBar(this, getString(R.string.created_client))
startActivity(Intent(this, InitializationActivity::class.java))
@@ -288,6 +332,17 @@ internal class LoginActivity : AppCompatActivity() {
}
private fun tempSslSettings(): SSLSettings {
return SSLSettings(!disableSslValidation, caCertContents)
return SSLSettings(
!disableSslValidation,
caCertPath,
clientCertPath,
clientCertPassword
)
}
private fun copyStreamToFile(inputStream: InputStream, file: File) {
FileOutputStream(file).use {
inputStream.copyTo(it)
}
}
}