Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<PasswordsViewModel>()

val autofillRequested = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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(
Expand Down Expand Up @@ -227,6 +233,7 @@ fun NCPWebLoginScreen(
) {
NextcloudPasswordsTheme {
val context = LocalContext.current
val coroutineScope = rememberCoroutineScope()

var showTlsDialog by rememberSaveable { mutableStateOf(false) }

Expand Down Expand Up @@ -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()
}
}
}
}
}

Expand Down Expand Up @@ -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
}
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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")
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
5 changes: 5 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -130,4 +130,9 @@
<string name="preference_order_by_date_asc">Oldest first</string>
<string name="preference_order_by_date_desc">Newest first</string>
<string name="order_by_preference_title">Order content by</string>
<string name="client_certificate_preference_title">Client Certificate</string>
<string name="client_certificate_preference_subtitle">Manage client certificate for mTLS</string>
<string name="action_clear_client_certificate">Clear client certificate</string>
<string name="client_certificate_cleared_toast">Client certificate cleared</string>
<string name="select_client_certificate">Select Client Certificate</string>
</resources>