Support specifying a client certificate for mTLS auth (#458)

* feat: collect and save client certificate

* feat: use client certificate for Retrofit, Glide and ExoPlayer

---------

Co-authored-by: eddyizm <eddyizm@gmail.com>
This commit is contained in:
Angelo Suzuki 2026-02-27 06:20:01 +01:00 committed by GitHub
parent 3ba2255205
commit aa5d0f92db
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 1335 additions and 10 deletions

File diff suppressed because it is too large Load diff

View file

@ -11,6 +11,7 @@ import com.cappielloantonio.tempo.github.Github;
import com.cappielloantonio.tempo.helper.ThemeHelper;
import com.cappielloantonio.tempo.subsonic.Subsonic;
import com.cappielloantonio.tempo.subsonic.SubsonicPreferences;
import com.cappielloantonio.tempo.util.ClientCertManager;
import com.cappielloantonio.tempo.util.Preferences;
public class App extends Application {
@ -31,6 +32,8 @@ public class App extends Application {
instance = new App();
context = getApplicationContext();
preferences = PreferenceManager.getDefaultSharedPreferences(context);
ClientCertManager.setupSslSocketFactory(context);
}
public static App getInstance() {

View file

@ -30,9 +30,13 @@ import com.cappielloantonio.tempo.subsonic.models.Playlist;
@UnstableApi
@Database(
version = 13,
version = 14,
entities = {Queue.class, Server.class, RecentSearch.class, Download.class, Chronology.class, Favorite.class, SessionMediaItem.class, Playlist.class, LyricsCache.class},
autoMigrations = {@AutoMigration(from = 10, to = 11), @AutoMigration(from = 11, to = 12)}
autoMigrations = {
@AutoMigration(from = 10, to = 11),
@AutoMigration(from = 11, to = 12),
@AutoMigration(from = 13, to = 14),
}
)
@TypeConverters({DateConverters.class})
public abstract class AppDatabase extends RoomDatabase {

View file

@ -2,7 +2,6 @@ package com.cappielloantonio.tempo.model
import android.os.Parcelable
import androidx.annotation.Keep
import androidx.annotation.Nullable
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
@ -35,5 +34,8 @@ data class Server(
val timestamp: Long,
@ColumnInfo(name = "low_security", defaultValue = "false")
val isLowSecurity: Boolean
val isLowSecurity: Boolean,
@ColumnInfo(name = "client_cert")
val clientCert: String?,
) : Parcelable

View file

@ -3,6 +3,7 @@ package com.cappielloantonio.tempo.subsonic
import com.cappielloantonio.tempo.App
import com.cappielloantonio.tempo.subsonic.utils.CacheUtil
import com.cappielloantonio.tempo.subsonic.utils.EmptyDateTypeAdapter
import com.cappielloantonio.tempo.util.ClientCertManager
import com.google.gson.GsonBuilder
import okhttp3.Cache
import okhttp3.OkHttpClient
@ -13,7 +14,7 @@ import java.util.Date
import java.util.concurrent.TimeUnit
class RetrofitClient(subsonic: Subsonic) {
var retrofit: Retrofit
val retrofit: Retrofit
init {
val gson = GsonBuilder()
@ -50,6 +51,7 @@ class RetrofitClient(subsonic: Subsonic) {
.addInterceptor(cacheUtil.offlineInterceptor)
// .addNetworkInterceptor(cacheUtil.onlineInterceptor)
.cache(getCache())
.setupSsl()
.build()
}
@ -63,4 +65,11 @@ class RetrofitClient(subsonic: Subsonic) {
val cacheSize = 10 * 1024 * 1024
return Cache(App.getContext().cacheDir, cacheSize.toLong())
}
private fun OkHttpClient.Builder.setupSsl(): OkHttpClient.Builder {
ClientCertManager.sslSocketFactory?.let { sslSocketFactory ->
sslSocketFactory(sslSocketFactory, ClientCertManager.trustManager)
}
return this
}
}

View file

@ -2,7 +2,9 @@ package com.cappielloantonio.tempo.ui.activity;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.res.Configuration;
import android.graphics.Rect;
import android.content.IntentFilter;
import android.net.ConnectivityManager;
import android.net.NetworkInfo;
@ -24,8 +26,8 @@ import androidx.fragment.app.FragmentManager;
import androidx.lifecycle.ViewModelProvider;
import androidx.media3.common.MediaItem;
import androidx.media3.common.MediaMetadata;
import androidx.media3.common.Player;
import androidx.media3.common.MimeTypes;
import androidx.media3.common.Player;
import androidx.media3.common.util.UnstableApi;
import androidx.navigation.NavController;
import androidx.navigation.fragment.NavHostFragment;
@ -448,6 +450,7 @@ public class MainActivity extends BaseActivity {
Preferences.setServer(null);
Preferences.setLocalAddress(null);
Preferences.setUser(null);
Preferences.setClientCert(null);
// TODO Enter all settings to be reset
Preferences.setOpenSubsonic(false);

View file

@ -2,8 +2,8 @@ package com.cappielloantonio.tempo.ui.dialog;
import android.app.Dialog;
import android.os.Bundle;
import android.security.KeyChain;
import android.text.TextUtils;
import android.view.View;
import android.widget.Toast;
import androidx.annotation.NonNull;
@ -32,11 +32,21 @@ public class ServerSignupDialog extends DialogFragment {
private String server;
private String localAddress;
private boolean lowSecurity = false;
private String clientCertAlias;
@NonNull
@Override
public Dialog onCreateDialog(Bundle savedInstanceState) {
bind = DialogServerSignupBinding.inflate(getLayoutInflater());
bind.clientCertTextView.setOnClickListener(v -> {
if (TextUtils.isEmpty(bind.clientCertTextView.getText())) {
KeyChain.choosePrivateKeyAlias(requireActivity(), alias -> {
bind.clientCertTextView.setText(alias);
}, null, null, null, null);
} else {
bind.clientCertTextView.setText(null);
}
});
loginViewModel = new ViewModelProvider(requireActivity()).get(LoginViewModel.class);
@ -74,6 +84,7 @@ public class ServerSignupDialog extends DialogFragment {
bind.serverTextView.setText(loginViewModel.getServerToEdit().getAddress());
bind.localAddressTextView.setText(loginViewModel.getServerToEdit().getLocalAddress());
bind.lowSecurityCheckbox.setChecked(loginViewModel.getServerToEdit().isLowSecurity());
bind.clientCertTextView.setText(loginViewModel.getServerToEdit().getClientCert());
}
} else {
loginViewModel.setServerToEdit(null);
@ -106,6 +117,7 @@ public class ServerSignupDialog extends DialogFragment {
server = bind.serverTextView.getText() != null && !bind.serverTextView.getText().toString().trim().isBlank() ? bind.serverTextView.getText().toString().trim() : null;
localAddress = bind.localAddressTextView.getText() != null && !bind.localAddressTextView.getText().toString().trim().isBlank() ? bind.localAddressTextView.getText().toString().trim() : null;
lowSecurity = bind.lowSecurityCheckbox.isChecked();
clientCertAlias = bind.clientCertTextView.getText() != null && !bind.clientCertTextView.getText().toString().trim().isBlank() ? bind.clientCertTextView.getText().toString().trim() : null;
if (TextUtils.isEmpty(serverName)) {
bind.serverNameTextView.setError(getString(R.string.error_required));
@ -137,6 +149,6 @@ public class ServerSignupDialog extends DialogFragment {
private void saveServerPreference() {
String serverID = loginViewModel.getServerToEdit() != null ? loginViewModel.getServerToEdit().getServerId() : UUID.randomUUID().toString();
loginViewModel.addServer(new Server(serverID, this.serverName, this.username, this.password, this.server, this.localAddress, System.currentTimeMillis(), this.lowSecurity));
loginViewModel.addServer(new Server(serverID, this.serverName, this.username, this.password, this.server, this.localAddress, System.currentTimeMillis(), this.lowSecurity, this.clientCertAlias));
}
}

View file

@ -117,7 +117,7 @@ public class LoginFragment extends Fragment implements ClickCallback {
@Override
public void onServerClick(Bundle bundle) {
Server server = bundle.getParcelable("server_object");
saveServerPreference(server.getServerId(), server.getAddress(), server.getLocalAddress(), server.getUsername(), server.getPassword(), server.isLowSecurity());
saveServerPreference(server.getServerId(), server.getAddress(), server.getLocalAddress(), server.getUsername(), server.getPassword(), server.isLowSecurity(), server.getClientCert());
SystemRepository systemRepository = new SystemRepository();
systemRepository.checkUserCredential(new SystemCallback() {
@ -142,13 +142,14 @@ public class LoginFragment extends Fragment implements ClickCallback {
dialog.show(activity.getSupportFragmentManager(), null);
}
private void saveServerPreference(String serverId, String server, String localAddress, String user, String password, boolean isLowSecurity) {
private void saveServerPreference(String serverId, String server, String localAddress, String user, String password, boolean isLowSecurity, String clientCert) {
Preferences.setServerId(serverId);
Preferences.setServer(server);
Preferences.setLocalAddress(localAddress);
Preferences.setUser(user);
Preferences.setPassword(password);
Preferences.setLowSecurity(isLowSecurity);
Preferences.setClientCert(clientCert);
App.getSubsonicClientInstance(true);
}
@ -161,6 +162,7 @@ public class LoginFragment extends Fragment implements ClickCallback {
Preferences.setToken(null);
Preferences.setSalt(null);
Preferences.setLowSecurity(false);
Preferences.setClientCert(null);
App.getSubsonicClientInstance(true);
}

View file

@ -0,0 +1,95 @@
package com.cappielloantonio.tempo.util
import android.content.Context
import android.security.KeyChain
import android.util.Log
import androidx.core.net.toUri
import okhttp3.internal.platform.Platform
import java.net.Socket
import java.security.KeyManagementException
import java.security.NoSuchAlgorithmException
import java.security.Principal
import java.security.PrivateKey
import java.security.cert.X509Certificate
import javax.net.ssl.HttpsURLConnection
import javax.net.ssl.SSLContext
import javax.net.ssl.SSLSocketFactory
import javax.net.ssl.X509KeyManager
object ClientCertManager {
private const val TAG = "ClientCertManager"
val trustManager = Platform.get().platformTrustManager()
var sslSocketFactory: SSLSocketFactory? = null
private set
@JvmStatic
fun setupSslSocketFactory(context: Context) {
sslSocketFactory = createSslSocketFactory(context)
sslSocketFactory?.let {
// HttpsURLConnection is used both by:
// - Glide: in IPv6StringLoader
// - ExoPlayer: in DefaultHttpDataSource
HttpsURLConnection.setDefaultSSLSocketFactory(it)
}
}
private fun createSslSocketFactory(context: Context): SSLSocketFactory? {
return try {
val clientKeyManager = object : X509KeyManager {
override fun getClientAliases(keyType: String?, issuers: Array<Principal>?) = null
override fun chooseClientAlias(
keyType: Array<String>?,
issuers: Array<Principal>?,
socket: Socket?
): String? {
val clientCert = Preferences.getClientCert() ?: return null
val server = Preferences.getServer() ?: return null
return if (server.toUri().host == socket?.inetAddress?.hostName) {
clientCert
} else null
}
override fun getServerAliases(keyType: String?, issuers: Array<Principal>?) = null
override fun chooseServerAlias(
keyType: String?,
issuers: Array<Principal>?,
socket: Socket?
) = null
override fun getCertificateChain(alias: String?): Array<X509Certificate>? {
val clientCert = Preferences.getClientCert()
return if (alias == clientCert && clientCert != null) {
KeyChain.getCertificateChain(
context,
clientCert
)
} else null
}
override fun getPrivateKey(alias: String?): PrivateKey? {
val clientCert = Preferences.getClientCert()
return if (alias == clientCert && clientCert != null) {
KeyChain.getPrivateKey(
context,
clientCert
)
} else null
}
}
val sslContext = SSLContext.getInstance("TLS")
sslContext.init(arrayOf(clientKeyManager), arrayOf(trustManager), null)
sslContext.socketFactory
} catch (e: NoSuchAlgorithmException) {
Log.e(TAG, "Failed setting mTLS", e)
null
} catch (e: KeyManagementException) {
Log.e(TAG, "Failed setting mTLS", e)
null
}
}
}

View file

@ -16,6 +16,7 @@ object Preferences {
private const val TOKEN = "token"
private const val SALT = "salt"
private const val LOW_SECURITY = "low_security"
private const val CLIENT_CERT = "client_cert"
private const val BATTERY_OPTIMIZATION = "battery_optimization"
private const val SERVER_ID = "server_id"
private const val OPEN_SUBSONIC = "open_subsonic"
@ -173,6 +174,16 @@ object Preferences {
App.getInstance().preferences.edit().putBoolean(LOW_SECURITY, isLowSecurity).apply()
}
@JvmStatic
fun getClientCert(): String? {
return App.getInstance().preferences.getString(CLIENT_CERT, null)
}
@JvmStatic
fun setClientCert(clientCert: String?) {
App.getInstance().preferences.edit().putString(CLIENT_CERT, clientCert).apply()
}
@JvmStatic
fun getServerId(): String? {
return App.getInstance().preferences.getString(SERVER_ID, null)

View file

@ -129,6 +129,25 @@
android:layout_marginStart="24dp"
android:layout_marginEnd="24dp"
android:text="@string/server_signup_dialog_action_low_security" />
<com.google.android.material.textfield.TextInputLayout
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
android:id="@+id/client_cert_text_input_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="24dp"
android:layout_marginEnd="24dp"
android:textColorHint="?android:textColorHint">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/client_cert_text_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:focusableInTouchMode="false"
android:hint="@string/server_signup_dialog_hint_client_certificate"
android:inputType="textNoSuggestions"
android:textCursorDrawable="@null" />
</com.google.android.material.textfield.TextInputLayout>
</LinearLayout>
</androidx.core.widget.NestedScrollView>
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -322,6 +322,7 @@
<string name="server_signup_dialog_hint_password">Password</string>
<string name="server_signup_dialog_hint_url">Server URL</string>
<string name="server_signup_dialog_hint_username">Username</string>
<string name="server_signup_dialog_hint_client_certificate">Client certificate (optional)</string>
<string name="server_signup_dialog_negative_button">Cancel</string>
<string name="server_signup_dialog_neutral_button">Delete</string>
<string name="server_signup_dialog_positive_button">Save</string>