From c989cc95bf627560554eec64bfaff36a354a270c Mon Sep 17 00:00:00 2001 From: Paolo Stivanin Date: Thu, 9 Jul 2026 13:15:08 +0200 Subject: [PATCH] Security: Add mTLS client certificate support Allow users to present a client certificate for mutual TLS authentication (e.g. Cloudflare mTLS). Uses the Android KeyChain API so users pick from certificates already installed on the device. Certificates are per-account rather than a single global setting: - Store the certificate alias in the account's AccountManager userData (auto-removed with the account) - Resolve and present it per OpenCloudClient when the account is bound, re-resolved on each request so a change is picked up immediately - Choose the certificate at login time via the Android KeyChain picker so servers gated behind mTLS can be reached for the initial server-check/login, before the account exists - Manage (set / change / remove) the certificate per account from the Manage Accounts dialog - Evict idle connections on change so the new certificate takes effect In the login wizard the option is kept unobtrusive: a "Connection..." button opens a popup menu (mirroring Manage Accounts) with "Set client certificate", plus "Remove client certificate" once one is selected, reusing the Manage Accounts strings for consistency. LoginActivity now injects ClientManager and dereferences it during onCreate (restoreClientCertAlias); the instrumented LoginActivityTest Koin module provides a relaxed ClientManager mock so the test graph can resolve it. --- .../authentication/LoginActivityTest.kt | 6 + .../accounts/ManageAccountsAdapter.kt | 6 + .../accounts/ManageAccountsDialogFragment.kt | 65 ++++++++++ .../authentication/LoginActivity.kt | 114 ++++++++++++++++++ .../main/res/layout-land/account_setup.xml | 22 ++++ .../src/main/res/layout/account_item.xml | 22 +++- .../src/main/res/layout/account_setup.xml | 22 ++++ opencloudApp/src/main/res/values/strings.xml | 9 ++ .../src/main/res/xml/settings_security.xml | 2 +- .../lib/common/SingleSessionManager.java | 15 +++ .../lib/common/accounts/AccountUtils.java | 21 ++++ .../android/lib/common/http/HttpClient.java | 32 ++++- .../network/ClientCertificateManager.kt | 76 ++++++++++++ .../opencloud/android/data/ClientManager.kt | 7 ++ 14 files changed, 414 insertions(+), 5 deletions(-) create mode 100644 opencloudComLibrary/src/main/java/eu/opencloud/android/lib/common/network/ClientCertificateManager.kt diff --git a/opencloudApp/src/androidTest/java/eu/opencloud/android/authentication/LoginActivityTest.kt b/opencloudApp/src/androidTest/java/eu/opencloud/android/authentication/LoginActivityTest.kt index 29d79d2447..a48e8d91e4 100644 --- a/opencloudApp/src/androidTest/java/eu/opencloud/android/authentication/LoginActivityTest.kt +++ b/opencloudApp/src/androidTest/java/eu/opencloud/android/authentication/LoginActivityTest.kt @@ -46,6 +46,7 @@ import androidx.test.espresso.matcher.ViewMatchers.isDisplayed import androidx.test.espresso.matcher.ViewMatchers.withId import androidx.test.espresso.matcher.ViewMatchers.withText import eu.opencloud.android.R +import eu.opencloud.android.data.ClientManager import eu.opencloud.android.domain.exceptions.NoNetworkConnectionException import eu.opencloud.android.domain.exceptions.OpencloudVersionNotSupportedException import eu.opencloud.android.domain.exceptions.ServerNotReachableException @@ -113,6 +114,7 @@ class LoginActivityTest { private lateinit var settingsViewModel: SettingsViewModel private lateinit var ocContextProvider: ContextProvider private lateinit var mdmProvider: MdmProvider + private lateinit var clientManager: ClientManager private lateinit var context: Context private lateinit var loginResultLiveData: MutableLiveData>> @@ -130,6 +132,7 @@ class LoginActivityTest { settingsViewModel = mockk(relaxUnitFun = true) ocContextProvider = mockk(relaxed = true) mdmProvider = mockk(relaxed = true) + clientManager = mockk(relaxed = true) val accountManager = mockk(relaxed = true) every { accountManager.getUserData(any(), any()) } returns null every { accountManager.getPassword(any()) } returns null @@ -172,6 +175,9 @@ class LoginActivityTest { factory { mdmProvider } + single { + clientManager + } } ) } diff --git a/opencloudApp/src/main/java/eu/opencloud/android/presentation/accounts/ManageAccountsAdapter.kt b/opencloudApp/src/main/java/eu/opencloud/android/presentation/accounts/ManageAccountsAdapter.kt index 3327ef4443..4aa327c085 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/presentation/accounts/ManageAccountsAdapter.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/presentation/accounts/ManageAccountsAdapter.kt @@ -135,6 +135,11 @@ class ManageAccountsAdapter( setImageResource(R.drawable.ic_clean_account) setOnClickListener { accountListener.cleanAccountLocalStorage(account) } } + /// bind listener to manage the client certificate (mTLS) of the account + holder.binding.mtlsAccountButton.apply { + setImageResource(R.drawable.ic_lock) + setOnClickListener { accountListener.manageClientCertificate(account, it) } + } /// bind listener to remove account holder.binding.removeButton.apply { setImageResource(R.drawable.ic_action_delete_grey) @@ -240,6 +245,7 @@ class ManageAccountsAdapter( fun cleanAccountLocalStorage(account: Account) fun createAccount() fun switchAccount(position: Int) + fun manageClientCertificate(account: Account, anchor: View) } } diff --git a/opencloudApp/src/main/java/eu/opencloud/android/presentation/accounts/ManageAccountsDialogFragment.kt b/opencloudApp/src/main/java/eu/opencloud/android/presentation/accounts/ManageAccountsDialogFragment.kt index 6bc7a06546..d471b22b20 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/presentation/accounts/ManageAccountsDialogFragment.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/presentation/accounts/ManageAccountsDialogFragment.kt @@ -29,11 +29,14 @@ import android.app.AlertDialog import android.app.Dialog import android.content.Intent import android.os.Bundle +import android.security.KeyChain import android.view.ContextThemeWrapper import android.view.View import android.widget.ImageView import android.widget.LinearLayout +import android.widget.PopupMenu import android.widget.ProgressBar +import android.widget.Toast import androidx.core.view.isVisible import androidx.fragment.app.DialogFragment import androidx.recyclerview.widget.LinearLayoutManager @@ -44,6 +47,8 @@ import eu.opencloud.android.domain.user.model.UserQuota import eu.opencloud.android.extensions.avoidScreenshotsIfNeeded import eu.opencloud.android.extensions.collectLatestLifecycleFlow import eu.opencloud.android.extensions.showErrorInSnackbar +import eu.opencloud.android.lib.common.SingleSessionManager +import eu.opencloud.android.lib.common.accounts.AccountUtils as LibAccountUtils import eu.opencloud.android.presentation.authentication.AccountUtils import eu.opencloud.android.presentation.common.UIResult import eu.opencloud.android.ui.activity.FileActivity @@ -145,6 +150,60 @@ class ManageAccountsDialogFragment : DialogFragment(), ManageAccountsAdapter.Acc dialog.show() } + /** + * Lets the user set, change or remove the client certificate (mTLS) presented for [account]. + * The chosen alias is stored in the account's userData and the cached HTTP clients are + * invalidated so the next request uses the updated certificate. + */ + override fun manageClientCertificate(account: Account, anchor: View) { + val hasCert = !LibAccountUtils.getClientCertAliasForAccount(requireContext(), account).isNullOrBlank() + PopupMenu(requireContext(), anchor).apply { + menu.add(0, MENU_SET_CERT, 0, getString(R.string.account_mtls_set_cert)) + if (hasCert) { + menu.add(0, MENU_CLEAR_CERT, 1, getString(R.string.account_mtls_clear_cert)) + } + setOnMenuItemClickListener { item -> + when (item.itemId) { + MENU_SET_CERT -> { + launchClientCertPicker(account) + true + } + MENU_CLEAR_CERT -> { + setAccountClientCertAlias(account, null) + true + } + else -> { + false + } + } + } + show() + } + } + + private fun launchClientCertPicker(account: Account) { + val currentAlias = LibAccountUtils.getClientCertAliasForAccount(requireContext(), account) + KeyChain.choosePrivateKeyAlias( + requireActivity(), + // Null = user cancelled; preserve the current selection. + { alias -> activity?.runOnUiThread { alias?.let { setAccountClientCertAlias(account, it) } } }, + null, null, null, KEYCHAIN_NO_PORT, currentAlias + ) + } + + private fun setAccountClientCertAlias(account: Account, alias: String?) { + AccountManager.get(requireContext().applicationContext) + .setUserData(account, LibAccountUtils.Constants.KEY_MTLS_CERT_ALIAS, alias?.takeIf { it.isNotBlank() }) + // Drop cached clients so the next request renegotiates TLS with the updated certificate. + SingleSessionManager.getDefaultSingleton().invalidateAllClients() + val message = if (alias.isNullOrBlank()) { + getString(R.string.account_mtls_cert_cleared) + } else { + getString(R.string.account_mtls_cert_selected, alias) + } + Toast.makeText(requireContext(), message, Toast.LENGTH_LONG).show() + } + override fun createAccount() { val accountManager = AccountManager.get(MainApp.appContext) accountManager.addAccount( @@ -277,6 +336,12 @@ class ManageAccountsDialogFragment : DialogFragment(), ManageAccountsAdapter.Acc const val MANAGE_ACCOUNTS_DIALOG = "MANAGE_ACCOUNTS_DIALOG" const val KEY_CURRENT_ACCOUNT = "KEY_CURRENT_ACCOUNT" + private const val MENU_SET_CERT = 1 + private const val MENU_CLEAR_CERT = 2 + + // KeyChain.choosePrivateKeyAlias: -1 means no port constraint on the host hint. + private const val KEYCHAIN_NO_PORT = -1 + fun newInstance(currentAccount: Account?): ManageAccountsDialogFragment { val args = Bundle().apply { putParcelable(KEY_CURRENT_ACCOUNT, currentAccount) diff --git a/opencloudApp/src/main/java/eu/opencloud/android/presentation/authentication/LoginActivity.kt b/opencloudApp/src/main/java/eu/opencloud/android/presentation/authentication/LoginActivity.kt index ea440310d9..1569a098e1 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/presentation/authentication/LoginActivity.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/presentation/authentication/LoginActivity.kt @@ -38,8 +38,11 @@ import android.content.ActivityNotFoundException import android.content.Intent import android.net.Uri import android.os.Bundle +import android.security.KeyChain +import android.view.View import android.view.View.INVISIBLE import android.view.WindowManager.LayoutParams.FLAG_SECURE +import android.widget.PopupMenu import androidx.appcompat.app.AppCompatActivity import androidx.browser.customtabs.CustomTabsIntent import androidx.core.net.toUri @@ -49,6 +52,7 @@ import androidx.core.widget.doAfterTextChanged import eu.opencloud.android.BuildConfig import eu.opencloud.android.MainApp.Companion.accountType import eu.opencloud.android.R +import eu.opencloud.android.data.ClientManager import eu.opencloud.android.data.authentication.KEY_OIDC_ISSUER import eu.opencloud.android.data.authentication.KEY_PREFERRED_USERNAME import eu.opencloud.android.data.authentication.KEY_USER_ID @@ -107,12 +111,24 @@ private const val KEY_AUTH_SERVER_BASE_URL = "KEY_AUTH_SERVER_BASE_URL" private const val KEY_AUTH_OIDC_SUPPORTED = "KEY_AUTH_OIDC_SUPPORTED" private const val KEY_AUTH_LOGIN_ACTION = "KEY_AUTH_LOGIN_ACTION" private const val KEY_AUTH_USER_ACCOUNT = "KEY_AUTH_USER_ACCOUNT" +private const val KEY_AUTH_MTLS_CERT_ALIAS = "KEY_AUTH_MTLS_CERT_ALIAS" + +// KeyChain.choosePrivateKeyAlias: -1 means no port constraint on the host hint. +private const val KEYCHAIN_NO_PORT = -1 + +// PopupMenu item ids for the wizard "Connection…" menu. +private const val MENU_SET_CERT = 1 +private const val MENU_CLEAR_CERT = 2 class LoginActivity : AppCompatActivity(), SslUntrustedCertDialog.OnSslUntrustedCertListener, SecurityEnforced { private val authenticationViewModel by viewModel() private val contextProvider by inject() private val mdmProvider by inject() + private val clientManager by inject() + + // Alias (from the Android KeyChain) of the client certificate to present for mTLS during login. + private var clientCertAlias: String? = null private var loginAction: Byte = ACTION_CREATE private var authTokenType: String? = null @@ -213,12 +229,16 @@ class LoginActivity : AppCompatActivity(), SslUntrustedCertDialog.OnSslUntrusted binding.root.filterTouchesWhenObscured = PreferenceUtils.shouldDisallowTouchesWithOtherVisibleWindows(this@LoginActivity) + restoreClientCertAlias(savedInstanceState) + initBrandableOptionsUI() binding.thumbnail.setOnClickListener { checkOcServer() } binding.embeddedCheckServerButton.setOnClickListener { checkOcServer() } + binding.connectionOptionsButton.setOnClickListener { showConnectionMenu(it) } + binding.loginButton.setOnClickListener { if (AccountTypeUtils.getAuthTokenTypeAccessToken(accountType) != authTokenType) { // Basic authenticationViewModel.loginBasic( @@ -258,6 +278,24 @@ class LoginActivity : AppCompatActivity(), SslUntrustedCertDialog.OnSslUntrusted // getServerInfo() completes (process death recovery flow). } + /** + * Restores the mTLS client certificate alias: from saved state on recreate, from the existing + * account on re-login, or none on a fresh login. Keeps [clientManager]'s transient alias in sync + * so the login connection (server check + login) presents the right certificate before the + * account exists. + */ + private fun restoreClientCertAlias(savedInstanceState: Bundle?) { + clientCertAlias = when { + savedInstanceState != null -> savedInstanceState.getString(KEY_AUTH_MTLS_CERT_ALIAS) + loginAction != ACTION_CREATE -> userAccount?.let { + AccountManager.get(this).getUserData(it, AccountUtils.Constants.KEY_MTLS_CERT_ALIAS) + } + else -> null + } + clientManager.loginClientCertAlias = clientCertAlias + updateClientCertStatus() + } + /** * If this onCreate is an OAuth redirect, either forward it to the existing instance * (when not task root) or let it proceed. Otherwise, track this instance so the @@ -626,6 +664,11 @@ class LoginActivity : AppCompatActivity(), SslUntrustedCertDialog.OnSslUntrusted am.setUserData(account, KEY_OIDC_ISSUER, serverInfo.oidcServerConfiguration.issuer) } + // Persist the mTLS client certificate chosen during login to the account, then clear the + // login-time transient so it does not leak into later anonymous clients. + am.setUserData(account, AccountUtils.Constants.KEY_MTLS_CERT_ALIAS, clientCertAlias?.takeIf { it.isNotBlank() }) + clientManager.loginClientCertAlias = null + authenticationViewModel.discoverAccount(accountName = accountName, discoveryNeeded = loginAction == ACTION_CREATE) clearAuthState() } @@ -1084,6 +1127,76 @@ class LoginActivity : AppCompatActivity(), SslUntrustedCertDialog.OnSslUntrusted } } + /** + * Opens the Android KeyChain picker so the user can choose a client certificate for mTLS, + * defaulting to the currently selected alias. The chosen alias is applied to the login client + * immediately so the next server check / login presents it. + */ + private fun launchClientCertPicker() { + KeyChain.choosePrivateKeyAlias( + this, + { alias -> runOnUiThread { onClientCertPicked(alias) } }, + null, null, null, KEYCHAIN_NO_PORT, clientCertAlias + ) + } + + private fun onClientCertPicked(alias: String?) { + // Null = user cancelled the picker; keep the current selection. + if (alias == null) return + clientCertAlias = alias + clientManager.loginClientCertAlias = alias + updateClientCertStatus() + } + + private fun updateClientCertStatus() { + binding.mtlsStatusText.run { + val alias = clientCertAlias + if (alias.isNullOrBlank()) { + isVisible = false + } else { + text = getString(R.string.auth_mtls_cert_selected, alias) + isVisible = true + } + } + } + + /** + * Shows the "Connection…" popup with the client-certificate (mTLS) actions, mirroring the + * per-account menu in ManageAccountsDialogFragment. "Remove" only appears once a certificate + * is selected, so a fresh login shows just "Set client certificate". + */ + private fun showConnectionMenu(anchor: View) { + val hasCert = !clientCertAlias.isNullOrBlank() + PopupMenu(this, anchor).apply { + menu.add(0, MENU_SET_CERT, 0, getString(R.string.account_mtls_set_cert)) + if (hasCert) { + menu.add(0, MENU_CLEAR_CERT, 1, getString(R.string.account_mtls_clear_cert)) + } + setOnMenuItemClickListener { item -> + when (item.itemId) { + MENU_SET_CERT -> { + launchClientCertPicker() + true + } + MENU_CLEAR_CERT -> { + clearClientCert() + true + } + else -> { + false + } + } + } + show() + } + } + + private fun clearClientCert() { + clientCertAlias = null + clientManager.loginClientCertAlias = null + updateClientCertStatus() + } + override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) outState.putString(KEY_AUTH_TOKEN_TYPE, authTokenType) @@ -1094,6 +1207,7 @@ class LoginActivity : AppCompatActivity(), SslUntrustedCertDialog.OnSslUntrusted outState.putString(KEY_CODE_VERIFIER, authenticationViewModel.codeVerifier) outState.putString(KEY_CODE_CHALLENGE, authenticationViewModel.codeChallenge) outState.putString(KEY_OIDC_STATE, authenticationViewModel.oidcState) + outState.putString(KEY_AUTH_MTLS_CERT_ALIAS, clientCertAlias) } override fun finish() { diff --git a/opencloudApp/src/main/res/layout-land/account_setup.xml b/opencloudApp/src/main/res/layout-land/account_setup.xml index 81c7251221..5c50f07a6c 100644 --- a/opencloudApp/src/main/res/layout-land/account_setup.xml +++ b/opencloudApp/src/main/res/layout-land/account_setup.xml @@ -200,6 +200,28 @@ android:visibility="gone" /> +