Skip to content

Commit daf05bc

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 59cfa6e commit daf05bc

7 files changed

Lines changed: 256 additions & 1 deletion

File tree

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

Lines changed: 59 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,62 @@ 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+
securityViewModel.setMtlsEnabled(requireContext(), enabled)
238+
if (!enabled) {
239+
securityViewModel.removeAlias(requireContext())
240+
updateMtlsCertSummary()
241+
invalidateHttpClients()
242+
}
243+
true
244+
}
245+
246+
prefMtlsSelectCert?.setOnPreferenceClickListener {
247+
launchKeyChainPicker()
248+
true
249+
}
250+
}
251+
252+
private fun launchKeyChainPicker() {
253+
val currentAlias = securityViewModel.getSelectedAlias(requireContext())
254+
KeyChain.choosePrivateKeyAlias(
255+
requireActivity(),
256+
{ alias ->
257+
activity?.runOnUiThread {
258+
if (alias != null) {
259+
securityViewModel.setSelectedAlias(requireContext(), alias)
260+
showMessageInSnackbar(getString(R.string.prefs_mtls_cert_selected))
261+
} else {
262+
securityViewModel.removeAlias(requireContext())
263+
showMessageInSnackbar(getString(R.string.prefs_mtls_cert_removed))
264+
}
265+
updateMtlsCertSummary()
266+
invalidateHttpClients()
267+
}
268+
},
269+
null, null, null, -1, currentAlias
270+
)
271+
}
272+
273+
private fun updateMtlsCertSummary() {
274+
val alias = securityViewModel.getSelectedAlias(requireContext())
275+
prefMtlsSelectCert?.summary = if (alias != null) {
276+
getString(R.string.prefs_mtls_select_cert_summary, alias)
277+
} else {
278+
getString(R.string.prefs_mtls_select_cert_summary_none)
279+
}
280+
}
281+
282+
private fun invalidateHttpClients() {
283+
eu.opencloud.android.lib.common.SingleSessionManager.getDefaultSingleton().invalidateAllClients()
225284
}
226285

227286
private fun enableBiometricAndLockApplication() {

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

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,11 @@
2020

2121
package eu.opencloud.android.presentation.settings.security
2222

23+
import android.content.Context
2324
import androidx.lifecycle.ViewModel
2425
import eu.opencloud.android.R
2526
import eu.opencloud.android.data.providers.SharedPreferencesProvider
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,23 @@ class SettingsSecurityViewModel(
6365
integerKey = R.integer.lock_delay_enforced
6466
)
6567
) != LockTimeout.DISABLED
68+
69+
fun setMtlsEnabled(context: Context, enabled: Boolean) {
70+
ClientCertificateManager.setMtlsEnabled(context, enabled)
71+
}
72+
73+
fun getSelectedAlias(context: Context): String? = ClientCertificateManager.getAlias(context)
74+
75+
fun setSelectedAlias(context: Context, alias: String) {
76+
ClientCertificateManager.setAlias(context, alias)
77+
}
78+
79+
fun removeAlias(context: Context) {
80+
ClientCertificateManager.removeAlias(context)
81+
}
82+
83+
companion object {
84+
const val PREFERENCE_ENABLE_MTLS = "enable_mtls"
85+
const val PREFERENCE_MTLS_SELECT_CERTIFICATE = "mtls_select_certificate"
86+
}
6687
}

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,14 @@
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>
69+
<string name="prefs_mtls_cert_removed">Client certificate removed</string>
6270
<string name="confirmation_touches_with_other_windows_title">Are you sure you want to enable this feature?</string>
6371
<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>
6472
<string name="prefs_subsection_picture_uploads">Automatic picture uploads</string>

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

Lines changed: 12 additions & 0 deletions
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+
<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+
5264
</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: 9 additions & 1 deletion
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.NetworkUtils;
3233
import okhttp3.Cookie;
3334
import okhttp3.CookieJar;
@@ -37,6 +38,7 @@
3738
import okhttp3.TlsVersion;
3839
import timber.log.Timber;
3940

41+
import javax.net.ssl.KeyManager;
4042
import javax.net.ssl.SSLContext;
4143
import javax.net.ssl.SSLSocketFactory;
4244
import javax.net.ssl.TrustManager;
@@ -75,7 +77,9 @@ public OkHttpClient getOkHttpClient() {
7577
NetworkUtils.getKnownServersStore(mContext));
7678

7779
final SSLContext sslContext = buildSSLContext();
78-
sslContext.init(null, new TrustManager[]{trustManager}, null);
80+
81+
KeyManager[] keyManagers = ClientCertificateManager.getKeyManagers(mContext);
82+
sslContext.init(keyManagers, new TrustManager[]{trustManager}, null);
7983
final SSLSocketFactory sslSocketFactory = sslContext.getSocketFactory();
8084

8185
// Automatic cookie handling, NOT PERSISTENT
@@ -93,6 +97,10 @@ public OkHttpClient getOkHttpClient() {
9397
return mOkHttpClient;
9498
}
9599

100+
public void invalidate() {
101+
mOkHttpClient = null;
102+
}
103+
96104
private SSLContext buildSSLContext() throws NoSuchAlgorithmException {
97105
try {
98106
return SSLContext.getInstance(TlsVersion.TLS_1_3.javaName());
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
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.security.KeyChain;
30+
31+
import timber.log.Timber;
32+
33+
import javax.net.ssl.KeyManager;
34+
import javax.net.ssl.X509KeyManager;
35+
import java.net.Socket;
36+
import java.security.Principal;
37+
import java.security.PrivateKey;
38+
import java.security.cert.X509Certificate;
39+
40+
public class ClientCertificateManager {
41+
42+
private static final String PREFS_NAME = "mtls_prefs";
43+
private static final String PREF_KEY_ALIAS = "key_alias";
44+
private static final String PREF_MTLS_ENABLED = "mtls_enabled";
45+
46+
public static void setAlias(Context context, String alias) {
47+
getPrefs(context).edit()
48+
.putString(PREF_KEY_ALIAS, alias)
49+
.putBoolean(PREF_MTLS_ENABLED, true)
50+
.apply();
51+
}
52+
53+
public static void removeAlias(Context context) {
54+
getPrefs(context).edit()
55+
.remove(PREF_KEY_ALIAS)
56+
.putBoolean(PREF_MTLS_ENABLED, false)
57+
.apply();
58+
}
59+
60+
public static String getAlias(Context context) {
61+
return getPrefs(context).getString(PREF_KEY_ALIAS, null);
62+
}
63+
64+
public static boolean isMtlsEnabled(Context context) {
65+
return getPrefs(context).getBoolean(PREF_MTLS_ENABLED, false);
66+
}
67+
68+
public static void setMtlsEnabled(Context context, boolean enabled) {
69+
getPrefs(context).edit().putBoolean(PREF_MTLS_ENABLED, enabled).apply();
70+
}
71+
72+
public static KeyManager[] getKeyManagers(Context context) {
73+
if (!isMtlsEnabled(context)) {
74+
return null;
75+
}
76+
77+
String alias = getAlias(context);
78+
if (alias == null) {
79+
return null;
80+
}
81+
82+
return new KeyManager[]{new KeyChainKeyManager(context, alias)};
83+
}
84+
85+
private static SharedPreferences getPrefs(Context context) {
86+
return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
87+
}
88+
89+
private static class KeyChainKeyManager implements X509KeyManager {
90+
private final Context context;
91+
private final String alias;
92+
93+
KeyChainKeyManager(Context context, String alias) {
94+
this.context = context.getApplicationContext();
95+
this.alias = alias;
96+
}
97+
98+
@Override
99+
public String chooseClientAlias(String[] keyType, Principal[] issuers, Socket socket) {
100+
return alias;
101+
}
102+
103+
@Override
104+
public X509Certificate[] getCertificateChain(String alias) {
105+
try {
106+
return KeyChain.getCertificateChain(context, alias);
107+
} catch (Exception e) {
108+
Timber.e(e, "Failed to get certificate chain for alias: %s", alias);
109+
return null;
110+
}
111+
}
112+
113+
@Override
114+
public PrivateKey getPrivateKey(String alias) {
115+
try {
116+
return KeyChain.getPrivateKey(context, alias);
117+
} catch (Exception e) {
118+
Timber.e(e, "Failed to get private key for alias: %s", alias);
119+
return null;
120+
}
121+
}
122+
123+
@Override
124+
public String[] getClientAliases(String keyType, Principal[] issuers) {
125+
return new String[]{alias};
126+
}
127+
128+
@Override
129+
public String chooseServerAlias(String keyType, Principal[] issuers, Socket socket) {
130+
return null;
131+
}
132+
133+
@Override
134+
public String[] getServerAliases(String keyType, Principal[] issuers) {
135+
return null;
136+
}
137+
}
138+
}

0 commit comments

Comments
 (0)