Skip to content

Commit 17494d5

Browse files
committed
Security: Add mTLS client certificate support
Allow users to present a client certificate for mutual TLS authentication (e.g. Cloudflare mTLS). Uses Android KeyChain API so users pick from certificates already installed on device.
1 parent 96cb46f commit 17494d5

7 files changed

Lines changed: 213 additions & 3 deletions

File tree

opencloudApp/src/main/java/eu/opencloud/android/presentation/settings/security/SettingsSecurityFragment.kt

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import android.app.Activity
2424
import android.content.DialogInterface
2525
import android.content.Intent
2626
import android.os.Bundle
27+
import android.security.KeyChain
2728
import androidx.activity.result.contract.ActivityResultContracts
2829
import androidx.appcompat.app.AlertDialog
2930
import androidx.preference.CheckBoxPreference
@@ -56,6 +57,8 @@ class SettingsSecurityFragment : PreferenceFragmentCompat() {
5657
private var prefLockApplication: ListPreference? = null
5758
private var prefLockAccessDocumentProvider: CheckBoxPreference? = null
5859
private var prefTouchesWithOtherVisibleWindows: CheckBoxPreference? = null
60+
private var prefMtls: CheckBoxPreference? = null
61+
private var prefMtlsSelectCert: Preference? = null
5962

6063
private val enablePasscodeLauncher =
6164
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
@@ -222,6 +225,66 @@ class SettingsSecurityFragment : PreferenceFragmentCompat() {
222225
}
223226
true
224227
}
228+
229+
// mTLS client certificate
230+
prefMtls = findPreference(SettingsSecurityViewModel.PREFERENCE_ENABLE_MTLS)
231+
prefMtlsSelectCert = findPreference(SettingsSecurityViewModel.PREFERENCE_MTLS_SELECT_CERTIFICATE)
232+
233+
updateMtlsCertSummary()
234+
235+
prefMtls?.setOnPreferenceChangeListener { _: Preference?, newValue: Any ->
236+
val enabled = newValue as Boolean
237+
if (enabled && securityViewModel.getMtlsAlias() == null) {
238+
// Optimistically allow the toggle, prompt for a cert, revert if cancelled.
239+
launchKeyChainPicker { alias ->
240+
if (alias != null) {
241+
onMtlsCertPicked(alias)
242+
} else {
243+
prefMtls?.isChecked = false
244+
}
245+
}
246+
} else if (!enabled) {
247+
securityViewModel.clearMtlsAlias()
248+
updateMtlsCertSummary()
249+
securityViewModel.invalidateHttpClients()
250+
} else {
251+
securityViewModel.invalidateHttpClients()
252+
}
253+
true
254+
}
255+
256+
prefMtlsSelectCert?.setOnPreferenceClickListener {
257+
launchKeyChainPicker { alias ->
258+
// Null = user cancelled; preserve current selection.
259+
if (alias != null) onMtlsCertPicked(alias)
260+
}
261+
true
262+
}
263+
}
264+
265+
private fun onMtlsCertPicked(alias: String) {
266+
securityViewModel.setMtlsAlias(alias)
267+
showMessageInSnackbar(getString(R.string.prefs_mtls_cert_selected))
268+
updateMtlsCertSummary()
269+
securityViewModel.invalidateHttpClients()
270+
}
271+
272+
private fun launchKeyChainPicker(onResult: (String?) -> Unit) {
273+
val currentAlias = securityViewModel.getMtlsAlias()
274+
KeyChain.choosePrivateKeyAlias(
275+
requireActivity(),
276+
{ alias -> activity?.runOnUiThread { onResult(alias) } },
277+
null, null, null, KEYCHAIN_NO_PORT, currentAlias
278+
)
279+
}
280+
281+
private fun updateMtlsCertSummary() {
282+
val alias = securityViewModel.getMtlsAlias()
283+
prefMtlsSelectCert?.summary = if (alias != null) {
284+
getString(R.string.prefs_mtls_select_cert_summary, alias)
285+
} else {
286+
getString(R.string.prefs_mtls_select_cert_summary_none)
287+
}
225288
}
226289

227290
private fun enableBiometricAndLockApplication() {
@@ -246,5 +309,6 @@ class SettingsSecurityFragment : PreferenceFragmentCompat() {
246309
const val PREFERENCE_TOUCHES_WITH_OTHER_VISIBLE_WINDOWS = "touches_with_other_visible_windows"
247310
const val EXTRAS_LOCK_ENFORCED = "EXTRAS_LOCK_ENFORCED"
248311
const val PREFERENCE_LOCK_ATTEMPTS = "PrefLockAttempts"
312+
private const val KEYCHAIN_NO_PORT = -1
249313
}
250314
}

opencloudApp/src/main/java/eu/opencloud/android/presentation/settings/security/SettingsSecurityViewModel.kt

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ package eu.opencloud.android.presentation.settings.security
2323
import androidx.lifecycle.ViewModel
2424
import eu.opencloud.android.R
2525
import eu.opencloud.android.data.providers.SharedPreferencesProvider
26+
import eu.opencloud.android.lib.common.SingleSessionManager
27+
import eu.opencloud.android.lib.common.network.ClientCertificateManager
2628
import eu.opencloud.android.presentation.security.LockEnforcedType
2729
import eu.opencloud.android.presentation.security.LockEnforcedType.Companion.parseFromInteger
2830
import eu.opencloud.android.presentation.security.LockTimeout
@@ -63,4 +65,22 @@ class SettingsSecurityViewModel(
6365
integerKey = R.integer.lock_delay_enforced
6466
)
6567
) != LockTimeout.DISABLED
68+
69+
fun getMtlsAlias(): String? =
70+
preferencesProvider.getString(ClientCertificateManager.PREF_MTLS_ALIAS, null)
71+
72+
fun setMtlsAlias(alias: String) =
73+
preferencesProvider.putString(ClientCertificateManager.PREF_MTLS_ALIAS, alias)
74+
75+
fun clearMtlsAlias() =
76+
preferencesProvider.removePreference(ClientCertificateManager.PREF_MTLS_ALIAS)
77+
78+
fun invalidateHttpClients() {
79+
SingleSessionManager.getDefaultSingleton().invalidateAllClients()
80+
}
81+
82+
companion object {
83+
const val PREFERENCE_ENABLE_MTLS = ClientCertificateManager.PREF_MTLS_ENABLED
84+
const val PREFERENCE_MTLS_SELECT_CERTIFICATE = "mtls_select_certificate"
85+
}
6686
}

opencloudApp/src/main/res/values/strings.xml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,13 @@
5959
<string name="prefs_lock_access_from_document_provider_summary">Lock access from other apps to the files of the accounts in the app via the Android native file explorer.</string>
6060
<string name="prefs_touches_with_other_visible_windows">Touches with other visible windows</string>
6161
<string name="prefs_touches_with_other_visible_windows_summary">Allow touches when the view is obscured by another visible window. Enable it to use light filtering apps.</string>
62+
63+
<string name="prefs_mtls">mTLS client certificate</string>
64+
<string name="prefs_mtls_summary">Present a client certificate for mutual TLS authentication</string>
65+
<string name="prefs_mtls_select_cert">Select certificate</string>
66+
<string name="prefs_mtls_select_cert_summary_none">No certificate selected</string>
67+
<string name="prefs_mtls_select_cert_summary">Selected: %s</string>
68+
<string name="prefs_mtls_cert_selected">Client certificate selected</string>
6269
<string name="confirmation_touches_with_other_windows_title">Are you sure you want to enable this feature?</string>
6370
<string name="confirmation_touches_with_other_windows_message">Use this feature at your own risk. A malicious application could try to spoof you into unknowingly performing some actions, using other views.</string>
6471
<string name="prefs_subsection_picture_uploads">Automatic picture uploads</string>

opencloudApp/src/main/res/xml/settings_security.xml

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,4 +49,16 @@
4949
app:summary="@string/prefs_touches_with_other_visible_windows_summary"
5050
app:title="@string/prefs_touches_with_other_visible_windows" />
5151

52-
</PreferenceScreen>
52+
<CheckBoxPreference
53+
app:iconSpaceReserved="false"
54+
app:key="enable_mtls"
55+
app:summary="@string/prefs_mtls_summary"
56+
app:title="@string/prefs_mtls" />
57+
<Preference
58+
app:dependency="enable_mtls"
59+
app:iconSpaceReserved="false"
60+
app:key="mtls_select_certificate"
61+
app:summary="@string/prefs_mtls_select_cert_summary_none"
62+
app:title="@string/prefs_mtls_select_cert" />
63+
64+
</PreferenceScreen>

opencloudComLibrary/src/main/java/eu/opencloud/android/lib/common/SingleSessionManager.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,15 @@ public void removeClientFor(OpenCloudAccount account) {
205205
Timber.d("removeClientFor finishing ");
206206
}
207207

208+
public void invalidateAllClients() {
209+
for (OpenCloudClient client : mClientsWithKnownUsername.values()) {
210+
client.invalidate();
211+
}
212+
for (OpenCloudClient client : mClientsWithUnknownUsername.values()) {
213+
client.invalidate();
214+
}
215+
}
216+
208217
public void refreshCredentialsForAccount(String accountName, OpenCloudCredentials credentials) {
209218
OpenCloudClient openCloudClient = mClientsWithKnownUsername.get(accountName);
210219
if (openCloudClient == null) {

opencloudComLibrary/src/main/java/eu/opencloud/android/lib/common/http/HttpClient.java

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828

2929
import eu.opencloud.android.lib.common.http.logging.LogInterceptor;
3030
import eu.opencloud.android.lib.common.network.AdvancedX509TrustManager;
31+
import eu.opencloud.android.lib.common.network.ClientCertificateManager;
3132
import eu.opencloud.android.lib.common.network.KnownServersHostnameVerifier;
3233
import eu.opencloud.android.lib.common.network.NetworkUtils;
3334
import okhttp3.Cookie;
@@ -38,6 +39,7 @@
3839
import okhttp3.TlsVersion;
3940
import timber.log.Timber;
4041

42+
import javax.net.ssl.KeyManager;
4143
import javax.net.ssl.SSLContext;
4244
import javax.net.ssl.SSLSocketFactory;
4345
import javax.net.ssl.TrustManager;
@@ -69,14 +71,16 @@ protected HttpClient(Context context) {
6971
mContext = context;
7072
}
7173

72-
public OkHttpClient getOkHttpClient() {
74+
public synchronized OkHttpClient getOkHttpClient() {
7375
if (mOkHttpClient == null) {
7476
try {
7577
final X509TrustManager trustManager = new AdvancedX509TrustManager(
7678
NetworkUtils.getKnownServersStore(mContext));
7779

7880
final SSLContext sslContext = buildSSLContext();
79-
sslContext.init(null, new TrustManager[]{trustManager}, null);
81+
82+
KeyManager[] keyManagers = ClientCertificateManager.INSTANCE.getKeyManagers(mContext);
83+
sslContext.init(keyManagers, new TrustManager[]{trustManager}, null);
8084
final SSLSocketFactory sslSocketFactory = sslContext.getSocketFactory();
8185

8286
// Automatic cookie handling, NOT PERSISTENT
@@ -94,6 +98,10 @@ public OkHttpClient getOkHttpClient() {
9498
return mOkHttpClient;
9599
}
96100

101+
public synchronized void invalidate() {
102+
mOkHttpClient = null;
103+
}
104+
97105
private SSLContext buildSSLContext() throws NoSuchAlgorithmException {
98106
try {
99107
return SSLContext.getInstance(TlsVersion.TLS_1_3.javaName());
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
/* openCloud Android Library is available under MIT license
2+
* Copyright (C) 2026 openCloud GmbH.
3+
*
4+
* Permission is hereby granted, free of charge, to any person obtaining a copy
5+
* of this software and associated documentation files (the "Software"), to deal
6+
* in the Software without restriction, including without limitation the rights
7+
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8+
* copies of the Software, and to permit persons to whom the Software is
9+
* furnished to do so, subject to the following conditions:
10+
*
11+
* The above copyright notice and this permission notice shall be included in
12+
* all copies or substantial portions of the Software.
13+
*
14+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15+
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16+
* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17+
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
18+
* BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
19+
* ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
20+
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21+
* THE SOFTWARE.
22+
*
23+
*/
24+
25+
package eu.opencloud.android.lib.common.network
26+
27+
import android.content.Context
28+
import android.content.SharedPreferences
29+
import android.preference.PreferenceManager
30+
import android.security.KeyChain
31+
import timber.log.Timber
32+
import java.net.Socket
33+
import java.security.Principal
34+
import java.security.PrivateKey
35+
import java.security.cert.X509Certificate
36+
import javax.net.ssl.KeyManager
37+
import javax.net.ssl.X509KeyManager
38+
39+
object ClientCertificateManager {
40+
41+
const val PREF_MTLS_ENABLED = "enable_mtls"
42+
const val PREF_MTLS_ALIAS = "mtls_cert_alias"
43+
44+
fun getAlias(context: Context): String? =
45+
prefs(context).getString(PREF_MTLS_ALIAS, null)
46+
47+
fun setAlias(context: Context, alias: String) {
48+
prefs(context).edit().putString(PREF_MTLS_ALIAS, alias).apply()
49+
}
50+
51+
fun clearAlias(context: Context) {
52+
prefs(context).edit().remove(PREF_MTLS_ALIAS).apply()
53+
}
54+
55+
fun isMtlsEnabled(context: Context): Boolean =
56+
prefs(context).getBoolean(PREF_MTLS_ENABLED, false)
57+
58+
fun getKeyManagers(context: Context): Array<KeyManager>? {
59+
if (!isMtlsEnabled(context)) return null
60+
val alias = getAlias(context) ?: return null
61+
return arrayOf(KeyChainKeyManager(context.applicationContext, alias))
62+
}
63+
64+
private fun prefs(context: Context): SharedPreferences =
65+
PreferenceManager.getDefaultSharedPreferences(context)
66+
67+
private class KeyChainKeyManager(
68+
private val appContext: Context,
69+
private val alias: String,
70+
) : X509KeyManager {
71+
72+
override fun chooseClientAlias(keyType: Array<String>?, issuers: Array<Principal>?, socket: Socket?): String = alias
73+
74+
override fun getClientAliases(keyType: String?, issuers: Array<Principal>?): Array<String> = arrayOf(alias)
75+
76+
override fun getCertificateChain(alias: String?): Array<X509Certificate>? =
77+
runCatching { KeyChain.getCertificateChain(appContext, this.alias) }
78+
.onFailure { Timber.e(it, "Failed to get certificate chain for alias: %s", this.alias) }
79+
.getOrNull()
80+
81+
override fun getPrivateKey(alias: String?): PrivateKey? =
82+
runCatching { KeyChain.getPrivateKey(appContext, this.alias) }
83+
.onFailure { Timber.e(it, "Failed to get private key for alias: %s", this.alias) }
84+
.getOrNull()
85+
86+
override fun chooseServerAlias(keyType: String?, issuers: Array<Principal>?, socket: Socket?): String? = null
87+
88+
override fun getServerAliases(keyType: String?, issuers: Array<Principal>?): Array<String>? = null
89+
}
90+
}

0 commit comments

Comments
 (0)