diff --git a/app/src/main/kotlin/com/github/gotify/GotifyApplication.kt b/app/src/main/kotlin/com/github/gotify/GotifyApplication.kt index 9c683b7e..11553710 100644 --- a/app/src/main/kotlin/com/github/gotify/GotifyApplication.kt +++ b/app/src/main/kotlin/com/github/gotify/GotifyApplication.kt @@ -4,9 +4,13 @@ import android.app.Application import android.app.NotificationManager import android.os.Build import androidx.preference.PreferenceManager +import com.github.gotify.api.CertUtils import com.github.gotify.log.LoggerHelper import com.github.gotify.log.UncaughtExceptionHandler import com.github.gotify.settings.ThemeHelper +import java.io.File +import java.io.FileOutputStream +import java.io.IOException import org.tinylog.kotlin.Logger class GotifyApplication : Application() { @@ -26,6 +30,22 @@ class GotifyApplication : Application() { ) } + val settings = Settings(this) + if (settings.legacyCert != null) { + Logger.info("Migrating legacy CA cert to new location") + try { + val legacyCert = settings.legacyCert + settings.legacyCert = null + val caCertFile = File(settings.filesDir, CertUtils.CA_CERT_NAME) + FileOutputStream(caCertFile).use { + it.write(legacyCert?.encodeToByteArray()) + } + settings.caCertPath = caCertFile.absolutePath + Logger.info("Migration of legacy CA cert succeeded") + } catch (e: IOException) { + Logger.error(e, "Migration of legacy CA cert failed") + } + } super.onCreate() } } diff --git a/app/src/main/kotlin/com/github/gotify/SSLSettings.kt b/app/src/main/kotlin/com/github/gotify/SSLSettings.kt index f9c8bdee..cc88a607 100644 --- a/app/src/main/kotlin/com/github/gotify/SSLSettings.kt +++ b/app/src/main/kotlin/com/github/gotify/SSLSettings.kt @@ -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? +) diff --git a/app/src/main/kotlin/com/github/gotify/Settings.kt b/app/src/main/kotlin/com/github/gotify/Settings.kt index d2f6a4fd..0592a3cf 100644 --- a/app/src/main/kotlin/com/github/gotify/Settings.kt +++ b/app/src/main/kotlin/com/github/gotify/Settings.kt @@ -6,6 +6,7 @@ import com.github.gotify.client.model.User internal class Settings(context: Context) { private val sharedPreferences: SharedPreferences + val filesDir: String var url: String get() = sharedPreferences.getString("url", "")!! set(value) = sharedPreferences.edit().putString("url", value).apply() @@ -26,15 +27,25 @@ internal class Settings(context: Context) { var serverVersion: String get() = sharedPreferences.getString("version", "UNKNOWN")!! set(value) = sharedPreferences.edit().putString("version", value).apply() - var cert: String? + var legacyCert: String? get() = sharedPreferences.getString("cert", null) - set(value) = sharedPreferences.edit().putString("cert", value).apply() + set(value) = sharedPreferences.edit().putString("cert", value).commit().toUnit() + var caCertPath: String? + get() = sharedPreferences.getString("caCertPath", null) + set(value) = sharedPreferences.edit().putString("caCertPath", value).commit().toUnit() 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) + filesDir = context.filesDir.absolutePath } fun tokenExists(): Boolean = !token.isNullOrEmpty() @@ -43,7 +54,10 @@ internal class Settings(context: Context) { url = "" token = null validateSSL = true - cert = null + legacyCert = null + caCertPath = null + clientCertPath = null + clientCertPassword = null } fun setUser(name: String?, admin: Boolean) { @@ -51,6 +65,14 @@ internal class Settings(context: Context) { } fun sslSettings(): SSLSettings { - return SSLSettings(validateSSL, cert) + return SSLSettings( + validateSSL, + caCertPath, + clientCertPath, + clientCertPassword + ) } + + @Suppress("UnusedReceiverParameter") + private fun Any?.toUnit() = Unit } diff --git a/app/src/main/kotlin/com/github/gotify/Utils.kt b/app/src/main/kotlin/com/github/gotify/Utils.kt index 277b4be1..6330cca2 100644 --- a/app/src/main/kotlin/com/github/gotify/Utils.kt +++ b/app/src/main/kotlin/com/github/gotify/Utils.kt @@ -12,10 +12,6 @@ import coil.target.Target import com.github.gotify.client.JSON import com.google.android.material.snackbar.Snackbar import com.google.gson.Gson -import java.io.BufferedReader -import java.io.IOException -import java.io.InputStream -import java.io.InputStreamReader import java.net.MalformedURLException import java.net.URI import java.net.URISyntaxException @@ -24,7 +20,6 @@ import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import okio.Buffer import org.threeten.bp.OffsetDateTime import org.tinylog.kotlin.Logger @@ -80,25 +75,6 @@ internal object Utils { } } - fun readFileFromStream(inputStream: InputStream): String { - val sb = StringBuilder() - var currentLine: String? - try { - BufferedReader(InputStreamReader(inputStream)).use { reader -> - while (reader.readLine().also { currentLine = it } != null) { - sb.append(currentLine).append("\n") - } - } - } catch (e: IOException) { - throw IllegalArgumentException("failed to read input") - } - return sb.toString() - } - - fun stringToInputStream(str: String?): InputStream? { - return if (str == null) null else Buffer().writeUtf8(str).inputStream() - } - fun AppCompatActivity.launchCoroutine( dispatcher: CoroutineDispatcher = Dispatchers.IO, action: suspend (coroutineScope: CoroutineScope) -> Unit diff --git a/app/src/main/kotlin/com/github/gotify/api/CertUtils.kt b/app/src/main/kotlin/com/github/gotify/api/CertUtils.kt index ce0cb26c..a362c588 100644 --- a/app/src/main/kotlin/com/github/gotify/api/CertUtils.kt +++ b/app/src/main/kotlin/com/github/gotify/api/CertUtils.kt @@ -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() } - 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,24 +49,34 @@ 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(trustAll), SecureRandom()) - builder.sslSocketFactory(context.socketFactory, trustAll) + val trustManagers = mutableSetOf() + val keyManagers = mutableSetOf() + if (settings.validateSSL) { + // Custom SSL validation + settings.caCertPath?.let { trustManagers.addAll(certToTrustManager(it)) } + } else { + // Disable SSL validation + trustManagers.add(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 - ) + settings.clientCertPath?.let { + keyManagers.addAll(certToKeyManager(it, settings.clientCertPassword)) + } + if (trustManagers.isNotEmpty() || keyManagers.isNotEmpty()) { + if (trustManagers.isEmpty()) { + // Fall back to system trust managers + trustManagers.addAll(defaultSystemTrustManager()) } + val context = SSLContext.getInstance("TLS") + context.init( + keyManagers.toTypedArray(), + trustManagers.toTypedArray(), + SecureRandom() + ) + builder.sslSocketFactory( + context.socketFactory, + trustManagers.elementAt(0) as X509TrustManager + ) } } catch (e: Exception) { // We shouldn't have issues since the cert is verified on login. @@ -69,12 +85,14 @@ internal object CertUtils { } @Throws(GeneralSecurityException::class) - private fun certToTrustManager(cert: String): Array { + private fun certToTrustManager(certPath: String): Array { val certificateFactory = CertificateFactory.getInstance("X.509") - val certificates = certificateFactory.generateCertificates(Utils.stringToInputStream(cert)) + val certificates = FileInputStream(File(certPath)).use( + certificateFactory::generateCertificates + ) 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 +104,24 @@ 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 { + require(certPassword != null) { "empty client certificate password" } + + val keyStore = KeyStore.getInstance("PKCS12") + FileInputStream(File(certPath)).use { + keyStore.load(it, certPassword.toCharArray()) } + val keyManagerFactory = + KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()) + keyManagerFactory.init(keyStore, certPassword.toCharArray()) + return keyManagerFactory.keyManagers + } + + private fun defaultSystemTrustManager(): Array { + val trustManagerFactory = TrustManagerFactory.getInstance( + TrustManagerFactory.getDefaultAlgorithm() + ) + trustManagerFactory.init(null as KeyStore?) + return trustManagerFactory.trustManagers } } diff --git a/app/src/main/kotlin/com/github/gotify/api/ClientFactory.kt b/app/src/main/kotlin/com/github/gotify/api/ClientFactory.kt index 1edf4e03..9cb550b1 100644 --- a/app/src/main/kotlin/com/github/gotify/api/ClientFactory.kt +++ b/app/src/main/kotlin/com/github/gotify/api/ClientFactory.kt @@ -9,55 +9,55 @@ import com.github.gotify.client.auth.ApiKeyAuth import com.github.gotify.client.auth.HttpBasicAuth internal object ClientFactory { - private fun unauthorized(baseUrl: String, sslSettings: SSLSettings): ApiClient { - return defaultClient(arrayOf(), "$baseUrl/", sslSettings) + private fun unauthorized( + settings: Settings, + sslSettings: SSLSettings, + baseUrl: String + ): ApiClient { + return defaultClient(arrayOf(), settings, sslSettings, baseUrl) } fun basicAuth( - baseUrl: String, + settings: Settings, sslSettings: SSLSettings, username: String, password: String ): ApiClient { - val client = defaultClient( - arrayOf("basicAuth"), - "$baseUrl/", - sslSettings - ) + val client = defaultClient(arrayOf("basicAuth"), settings, sslSettings) val auth = client.apiAuthorizations["basicAuth"] as HttpBasicAuth auth.username = username auth.password = password return client } - fun clientToken(baseUrl: String, sslSettings: SSLSettings, token: String?): ApiClient { - val client = defaultClient( - arrayOf("clientTokenHeader"), - "$baseUrl/", - sslSettings - ) + fun clientToken(settings: Settings, token: String? = settings.token): ApiClient { + val client = defaultClient(arrayOf("clientTokenHeader"), settings) val tokenAuth = client.apiAuthorizations["clientTokenHeader"] as ApiKeyAuth tokenAuth.apiKey = token return client } - fun versionApi(baseUrl: String, sslSettings: SSLSettings): VersionApi { - return unauthorized(baseUrl, sslSettings).createService(VersionApi::class.java) + fun versionApi( + settings: Settings, + sslSettings: SSLSettings = settings.sslSettings(), + baseUrl: String = settings.url + ): VersionApi { + return unauthorized(settings, sslSettings, baseUrl).createService(VersionApi::class.java) } fun userApiWithToken(settings: Settings): UserApi { - return clientToken(settings.url, settings.sslSettings(), settings.token) - .createService(UserApi::class.java) + return clientToken(settings).createService(UserApi::class.java) } private fun defaultClient( authentications: Array, - baseUrl: String, - sslSettings: SSLSettings + settings: Settings, + sslSettings: SSLSettings = settings.sslSettings(), + baseUrl: String = settings.url ): ApiClient { val client = ApiClient(authentications) CertUtils.applySslSettings(client.okBuilder, sslSettings) - client.adapterBuilder.baseUrl(baseUrl) + client.adapterBuilder.baseUrl("$baseUrl/") return client } } diff --git a/app/src/main/kotlin/com/github/gotify/init/InitializationActivity.kt b/app/src/main/kotlin/com/github/gotify/init/InitializationActivity.kt index 474c3af9..ea42ef75 100644 --- a/app/src/main/kotlin/com/github/gotify/init/InitializationActivity.kt +++ b/app/src/main/kotlin/com/github/gotify/init/InitializationActivity.kt @@ -167,7 +167,7 @@ internal class InitializationActivity : AppCompatActivity() { callback: SuccessCallback, errorCallback: Callback.ErrorCallback ) { - ClientFactory.versionApi(settings.url, settings.sslSettings()) + ClientFactory.versionApi(settings) .version .enqueue(Callback.callInUI(this, callback, errorCallback)) } diff --git a/app/src/main/kotlin/com/github/gotify/login/AdvancedDialog.kt b/app/src/main/kotlin/com/github/gotify/login/AdvancedDialog.kt index 8c4809f2..6b5280df 100644 --- a/app/src/main/kotlin/com/github/gotify/login/AdvancedDialog.kt +++ b/app/src/main/kotlin/com/github/gotify/login/AdvancedDialog.kt @@ -3,6 +3,7 @@ package com.github.gotify.login import android.content.Context import android.view.LayoutInflater import android.widget.CompoundButton +import androidx.core.widget.doOnTextChanged import com.github.gotify.R import com.github.gotify.databinding.AdvancedSettingsDialogBinding import com.google.android.material.dialog.MaterialAlertDialogBuilder @@ -15,6 +16,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 +37,101 @@ 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: (password: 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 (!clientCertPassword.isNullOrEmpty()) { + binding.clientCertPasswordEdittext.setText(clientCertPassword) + } + binding.clientCertPasswordEdittext.doOnTextChanged { _, _, _, _ -> + if (binding.selectedClientCert.text.toString() == + context.getString(R.string.certificate_found) + ) { + showPasswordMissing(binding.clientCertPasswordEdittext.text.toString().isEmpty()) + } + } + if (caCertPath == null) { + showSelectCaCertificate() } else { - showRemoveCACertificate(selectedCertificate) + showRemoveCaCertificate(caCertCN!!) + } + if (clientCertPath == null) { + showSelectClientCertificate() + } else { + showRemoveClientCertificate() } 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) + showPasswordMissing(false) + binding.clientCertPasswordEdittext.text = null + } + + fun showRemoveClientCertificate() { + binding.toggleClientCert.setText(R.string.remove_client_certificate) + binding.toggleClientCert.setOnClickListener { + showSelectClientCertificate() + onClickRemoveClientCertificate.run() + } + binding.selectedClientCert.setText(R.string.certificate_found) + showPasswordMissing(binding.clientCertPasswordEdittext.text.toString().isEmpty()) + } + + private fun showPasswordMissing(toggled: Boolean) { + val error = if (toggled) { + context.getString(R.string.client_cert_password_missing) + } else { + null + } + binding.clientCertPassword.error = error } } diff --git a/app/src/main/kotlin/com/github/gotify/login/LoginActivity.kt b/app/src/main/kotlin/com/github/gotify/login/LoginActivity.kt index 79b1b08e..b9eaa3c3 100644 --- a/app/src/main/kotlin/com/github/gotify/login/LoginActivity.kt +++ b/app/src/main/kotlin/com/github/gotify/login/LoginActivity.kt @@ -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.Companion.toHttpUrlOrNull 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) ?: "unknown" + 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() @@ -115,7 +144,7 @@ internal class LoginActivity : AppCompatActivity() { binding.checkurl.visibility = View.GONE try { - ClientFactory.versionApi(url, tempSslSettings()) + ClientFactory.versionApi(settings, tempSslSettings(), url) .version .enqueue(Callback.callInUI(this, onValidUrl(url), onInvalidUrl(url))) } catch (e: Exception) { @@ -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,34 +176,53 @@ internal class LoginActivity : AppCompatActivity() { } .onClickSelectCaCertificate { invalidateUrl() - doSelectCACertificate() + doSelectCertificate(caDialogResultLauncher, R.string.select_ca_file) } .onClickRemoveCaCertificate { invalidateUrl() - caCertContents = null + caCertPath = null + clientCertPassword = null + } + .onClickSelectClientCertificate { + invalidateUrl() + doSelectCertificate(clientCertDialogResultLauncher, R.string.select_client_file) + } + .onClickRemoveClientCertificate { + invalidateUrl() + clientCertPath = null } - .show(disableSslValidation, selectedCertName) + .onClose { newPassword -> + clientCertPassword = newPassword + } + .show( + disableSslValidation, + caCertPath, + caCertCN, + clientCertPath, + clientCertPassword + ) } - private fun doSelectCACertificate() { + private fun doSelectCertificate( + resultLauncher: ActivityResultLauncher, + @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) - return (ca as X509Certificate).subjectDN.name + private fun getNameOfCertContent(file: File): String? { + val ca = FileInputStream(file).use { CertUtils.parseCertificate(it) } + return (ca as X509Certificate).subjectX500Principal.name } private fun onValidUrl(url: String): SuccessCallback { @@ -211,7 +253,7 @@ internal class LoginActivity : AppCompatActivity() { binding.login.visibility = View.GONE binding.loginProgress.visibility = View.VISIBLE - val client = ClientFactory.basicAuth(settings.url, tempSslSettings(), username, password) + val client = ClientFactory.basicAuth(settings, tempSslSettings(), username, password) client.createService(UserApi::class.java) .currentUser() .enqueue( @@ -265,7 +307,9 @@ internal class LoginActivity : AppCompatActivity() { private fun onCreatedClient(client: Client) { settings.token = client.token settings.validateSSL = !disableSslValidation - settings.cert = caCertContents + 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) + } } } diff --git a/app/src/main/kotlin/com/github/gotify/messages/MessagesActivity.kt b/app/src/main/kotlin/com/github/gotify/messages/MessagesActivity.kt index 006a9810..514acf22 100644 --- a/app/src/main/kotlin/com/github/gotify/messages/MessagesActivity.kt +++ b/app/src/main/kotlin/com/github/gotify/messages/MessagesActivity.kt @@ -544,7 +544,7 @@ internal class MessagesActivity : private fun deleteApp(appId: Long) { val settings = viewModel.settings - val client = ClientFactory.clientToken(settings.url, settings.sslSettings(), settings.token) + val client = ClientFactory.clientToken(settings) client.createService(ApplicationApi::class.java) .deleteApp(appId) .enqueue( @@ -602,8 +602,7 @@ internal class MessagesActivity : private fun deleteClientAndNavigateToLogin() { val settings = viewModel.settings - val api = ClientFactory.clientToken(settings.url, settings.sslSettings(), settings.token) - .createService(ClientApi::class.java) + val api = ClientFactory.clientToken(settings).createService(ClientApi::class.java) stopService(Intent(this@MessagesActivity, WebSocketService::class.java)) try { val clients = Api.execute(api.clients) diff --git a/app/src/main/kotlin/com/github/gotify/messages/MessagesModel.kt b/app/src/main/kotlin/com/github/gotify/messages/MessagesModel.kt index b2efcd2f..a7383069 100644 --- a/app/src/main/kotlin/com/github/gotify/messages/MessagesModel.kt +++ b/app/src/main/kotlin/com/github/gotify/messages/MessagesModel.kt @@ -14,7 +14,7 @@ import com.github.gotify.messages.provider.MessageState internal class MessagesModel(parentView: Activity) : ViewModel() { val settings = Settings(parentView) val coilHandler = CoilHandler(parentView, settings) - val client = ClientFactory.clientToken(settings.url, settings.sslSettings(), settings.token) + val client = ClientFactory.clientToken(settings) val appsHolder = ApplicationHolder(parentView, client) val messages = MessageFacade(client.createService(MessageApi::class.java), appsHolder) diff --git a/app/src/main/kotlin/com/github/gotify/service/WebSocketService.kt b/app/src/main/kotlin/com/github/gotify/service/WebSocketService.kt index 79e51a0c..402aa808 100644 --- a/app/src/main/kotlin/com/github/gotify/service/WebSocketService.kt +++ b/app/src/main/kotlin/com/github/gotify/service/WebSocketService.kt @@ -68,11 +68,7 @@ internal class WebSocketService : Service() { override fun onCreate() { super.onCreate() settings = Settings(this) - val client = ClientFactory.clientToken( - settings.url, - settings.sslSettings(), - settings.token - ) + val client = ClientFactory.clientToken(settings) missingMessageUtil = MissedMessageUtil(client.createService(MessageApi::class.java)) Logger.info("Create ${javaClass.simpleName}") coilHandler = CoilHandler(this, settings) @@ -129,7 +125,7 @@ internal class WebSocketService : Service() { } private fun fetchApps() { - ClientFactory.clientToken(settings.url, settings.sslSettings(), settings.token) + ClientFactory.clientToken(settings) .createService(ApplicationApi::class.java) .apps .enqueue( diff --git a/app/src/main/kotlin/com/github/gotify/sharing/ShareActivity.kt b/app/src/main/kotlin/com/github/gotify/sharing/ShareActivity.kt index 6cd769de..e7e8c70d 100644 --- a/app/src/main/kotlin/com/github/gotify/sharing/ShareActivity.kt +++ b/app/src/main/kotlin/com/github/gotify/sharing/ShareActivity.kt @@ -61,11 +61,7 @@ internal class ShareActivity : AppCompatActivity() { return } - val client = ClientFactory.clientToken( - settings.url, - settings.sslSettings(), - settings.token - ) + val client = ClientFactory.clientToken(settings) appsHolder = ApplicationHolder(this, client) appsHolder.onUpdate { val apps = appsHolder.get() @@ -136,11 +132,7 @@ internal class ShareActivity : AppCompatActivity() { } private fun executeMessageCall(appIndex: Int, message: Message): Boolean { - val pushClient = ClientFactory.clientToken( - settings.url, - settings.sslSettings(), - appsHolder.get()[appIndex].token - ) + val pushClient = ClientFactory.clientToken(settings, appsHolder.get()[appIndex].token) return try { val messageApi = pushClient.createService(MessageApi::class.java) Api.execute(messageApi.createMessage(message)) diff --git a/app/src/main/res/layout/advanced_settings_dialog.xml b/app/src/main/res/layout/advanced_settings_dialog.xml index e9a7d653..643edd5c 100644 --- a/app/src/main/res/layout/advanced_settings_dialog.xml +++ b/app/src/main/res/layout/advanced_settings_dialog.xml @@ -1,6 +1,8 @@ - @@ -18,8 +20,47 @@ android:text="@string/select_ca_certificate" /> + + + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b79f30ef..206fcbd3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -40,9 +40,15 @@ Password Disable SSL Validation Select CA Certificate - Select a Certificate File + Select Client Certificate (PKCS#12) + Select a CA Certificate File + Select a Client Certificate File + Certificate Password + Password required Please install a file browser - Failed to read CA: %s + Failed to read CA cert: %s + Failed to read client cert: %s + Certificate found Login Check URL Grant @@ -65,6 +71,7 @@ Done No certificate selected Remove CA Certificate + Remove Client Certificate Warning Using HTTP is insecure and it\'s recommend to use HTTPS instead. Use your favorite search engine to get more information about this topic. I Understand