diff --git a/app/src/main/java/com/hegocre/nextcloudpasswords/ui/activities/MainActivity.kt b/app/src/main/java/com/hegocre/nextcloudpasswords/ui/activities/MainActivity.kt index f27cb1b5..25642cb4 100644 --- a/app/src/main/java/com/hegocre/nextcloudpasswords/ui/activities/MainActivity.kt +++ b/app/src/main/java/com/hegocre/nextcloudpasswords/ui/activities/MainActivity.kt @@ -36,11 +36,16 @@ class MainActivity : FragmentActivity() { if (BuildConfig.DEBUG) LogHelper.getInstance() super.onCreate(savedInstanceState) + if (!UserController.getInstance(this).isLoggedIn) { login() return } + // Initialize OkHttp client with the previously selected client certificate if available, so it persists across app restarts. + // Moved after login check to avoid unnecessary initialization if redirecting. + OkHttpRequest.getInstance().initClient(this) + val passwordsViewModel by viewModels() val autofillRequested = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { diff --git a/app/src/main/java/com/hegocre/nextcloudpasswords/ui/components/LoginView.kt b/app/src/main/java/com/hegocre/nextcloudpasswords/ui/components/LoginView.kt index 7da37624..64dbf778 100644 --- a/app/src/main/java/com/hegocre/nextcloudpasswords/ui/components/LoginView.kt +++ b/app/src/main/java/com/hegocre/nextcloudpasswords/ui/components/LoginView.kt @@ -2,8 +2,10 @@ package com.hegocre.nextcloudpasswords.ui.components import android.annotation.SuppressLint import android.app.Activity +import android.content.Context import android.content.Intent import android.net.http.SslError +import android.security.KeyChain import android.view.View import android.view.ViewGroup import android.webkit.SslErrorHandler @@ -46,6 +48,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -62,6 +65,9 @@ import androidx.compose.ui.viewinterop.AndroidView import com.hegocre.nextcloudpasswords.R import com.hegocre.nextcloudpasswords.ui.theme.NextcloudPasswordsTheme import com.hegocre.nextcloudpasswords.utils.PreferencesManager +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import android.webkit.ClientCertRequest @Composable fun NCPLoginScreen( @@ -227,6 +233,7 @@ fun NCPWebLoginScreen( ) { NextcloudPasswordsTheme { val context = LocalContext.current + val coroutineScope = rememberCoroutineScope() var showTlsDialog by rememberSaveable { mutableStateOf(false) } @@ -281,6 +288,67 @@ fun NCPWebLoginScreen( super.onReceivedSslError(view, handler, error) } } + + override fun onReceivedClientCertRequest(view: WebView?, request: ClientCertRequest?) { + val alias = PreferencesManager.getInstance(context).getClientCertAlias() + if (alias != null) { + coroutineScope.launch(Dispatchers.IO) { + try { + val privateKey = KeyChain.getPrivateKey(context, alias) + val chain = KeyChain.getCertificateChain(context, alias) + if (privateKey != null && chain != null) { + request?.proceed(privateKey, chain) + } else { + PreferencesManager.getInstance(context).setClientCertAlias(null) + request?.cancel() + } + } catch (e: Exception) { + PreferencesManager.getInstance(context).setClientCertAlias(null) + request?.cancel() + } + } + } else { + val activity = context.findActivity() + if (activity != null) { + KeyChain.choosePrivateKeyAlias( + activity, + { selectedAlias -> + if (selectedAlias != null) { + PreferencesManager.getInstance(context) + .setClientCertAlias(selectedAlias) + com.hegocre.nextcloudpasswords.utils.OkHttpRequest.getInstance() + .initClient(context) + + coroutineScope.launch(Dispatchers.IO) { + try { + val privateKey = + KeyChain.getPrivateKey(context, selectedAlias) + val chain = KeyChain.getCertificateChain( + context, + selectedAlias + ) + request?.proceed(privateKey, chain) + } catch (e: Exception) { + PreferencesManager.getInstance(context) + .setClientCertAlias(null) + request?.cancel() + } + } + } else { + request?.cancel() + } + }, + request?.keyTypes, + request?.principals, + request?.host, + request?.port ?: -1, + null + ) + } else { + request?.cancel() + } + } + } } } @@ -382,3 +450,14 @@ fun PreviewCard() { LoginCard("", {}, "") {} } } + +private fun Context.findActivity(): Activity? { + var currentContext = this + while (currentContext is android.content.ContextWrapper) { + if (currentContext is Activity) { + return currentContext + } + currentContext = currentContext.baseContext + } + return null +} diff --git a/app/src/main/java/com/hegocre/nextcloudpasswords/utils/OkHttpRequest.kt b/app/src/main/java/com/hegocre/nextcloudpasswords/utils/OkHttpRequest.kt index f299f430..de486cf6 100644 --- a/app/src/main/java/com/hegocre/nextcloudpasswords/utils/OkHttpRequest.kt +++ b/app/src/main/java/com/hegocre/nextcloudpasswords/utils/OkHttpRequest.kt @@ -1,6 +1,15 @@ package com.hegocre.nextcloudpasswords.utils import android.annotation.SuppressLint +import android.content.Context +import android.util.Log +import android.security.KeyChain +import java.security.KeyStore +import java.security.cert.X509Certificate +import javax.net.ssl.KeyManagerFactory +import javax.net.ssl.SSLContext +import javax.net.ssl.TrustManagerFactory +import javax.net.ssl.X509TrustManager import okhttp3.Credentials import okhttp3.MediaType import okhttp3.MediaType.Companion.toMediaTypeOrNull @@ -11,10 +20,10 @@ import okhttp3.Response import java.io.IOException import java.net.MalformedURLException import java.net.URL -import java.security.cert.X509Certificate import java.util.concurrent.TimeUnit -import javax.net.ssl.SSLContext -import javax.net.ssl.X509TrustManager +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch /** * Class to manage the [OkHttpRequest] requests, and make them using always the same client, as suggested @@ -23,15 +32,28 @@ import javax.net.ssl.X509TrustManager */ class OkHttpRequest private constructor() { var allowInsecureRequests = false + private val initLock = java.lang.Object() + @Volatile private var initializing = false - private val secureClient = OkHttpClient.Builder() + private var secureClient = OkHttpClient.Builder() .readTimeout(30, TimeUnit.SECONDS) .writeTimeout(30, TimeUnit.SECONDS) .build() private val insecureClient: OkHttpClient val client: OkHttpClient - get() = if (allowInsecureRequests) insecureClient else secureClient + get() { + if (allowInsecureRequests) return insecureClient + + if (initializing) { + synchronized(initLock) { + while (initializing) { + try { initLock.wait() } catch (_: InterruptedException) {} + } + } + } + return secureClient + } init { val insecureTrustManager = @SuppressLint("CustomX509TrustManager") @@ -56,6 +78,63 @@ class OkHttpRequest private constructor() { .build() } + fun initClient(context: Context) { + val alias = PreferencesManager.getInstance(context).getClientCertAlias() + + if (alias == null) { + val newClient = OkHttpClient.Builder() + .readTimeout(30, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .build() + + synchronized(initLock) { + secureClient = newClient + initializing = false + initLock.notifyAll() + } + return + } + + initializing = true + CoroutineScope(Dispatchers.IO).launch { + try { + val privateKey = KeyChain.getPrivateKey(context, alias) + val chain = KeyChain.getCertificateChain(context, alias) + + if (privateKey != null && chain != null) { + val keyStore = KeyStore.getInstance(KeyStore.getDefaultType()) + keyStore.load(null, null) + keyStore.setKeyEntry(alias, privateKey, null, chain) + + val kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()) + kmf.init(keyStore, null) + + val sslContext = SSLContext.getInstance("TLS") + sslContext.init(kmf.keyManagers, null, null) + + val tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) + tmf.init(null as KeyStore?) + val trustManagers = tmf.trustManagers + val x509TrustManager = trustManagers.firstOrNull { it is X509TrustManager } as? X509TrustManager + + if (x509TrustManager != null) { + secureClient = secureClient.newBuilder() + .sslSocketFactory(sslContext.socketFactory, x509TrustManager) + .build() + } + } + } catch (e: Exception) { + Log.e("OkHttpRequest", "Failed to initialize SSL context with client certificate alias: $alias", e) + PreferencesManager.getInstance(context).setClientCertAlias(null) + } finally { + initializing = false + synchronized(initLock) { + initLock.notifyAll() + } + } + } + } + @Throws( MalformedURLException::class, diff --git a/app/src/main/java/com/hegocre/nextcloudpasswords/utils/PreferencesManager.kt b/app/src/main/java/com/hegocre/nextcloudpasswords/utils/PreferencesManager.kt index 60972e56..550a6106 100644 --- a/app/src/main/java/com/hegocre/nextcloudpasswords/utils/PreferencesManager.kt +++ b/app/src/main/java/com/hegocre/nextcloudpasswords/utils/PreferencesManager.kt @@ -90,6 +90,10 @@ class PreferencesManager private constructor(context: Context) { fun setMasterPassword(value: String?): Boolean = _encryptedSharedPrefs.edit().putString("MASTER_KEY", value).commit() + fun getClientCertAlias(): String? = _encryptedSharedPrefs.getString("CLIENT_CERT_ALIAS", null) + fun setClientCertAlias(value: String?): Boolean = + _encryptedSharedPrefs.edit().putString("CLIENT_CERT_ALIAS", value).commit() + fun getCSEv1Keychain(): String? = _encryptedSharedPrefs.getString("CSE_V1_KEYCHAIN", null) fun setCSEv1Keychain(value: String?): Boolean = _encryptedSharedPrefs.edit().putString("CSE_V1_KEYCHAIN", value).commit() diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0c5ab6ce..762da94d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -130,4 +130,9 @@ Oldest first Newest first Order content by + Client Certificate + Manage client certificate for mTLS + Clear client certificate + Client certificate cleared + Select Client Certificate \ No newline at end of file