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

View file

@ -30,9 +30,13 @@ import com.cappielloantonio.tempo.subsonic.models.Playlist;
@UnstableApi @UnstableApi
@Database( @Database(
version = 13, version = 14,
entities = {Queue.class, Server.class, RecentSearch.class, Download.class, Chronology.class, Favorite.class, SessionMediaItem.class, Playlist.class, LyricsCache.class}, 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}) @TypeConverters({DateConverters.class})
public abstract class AppDatabase extends RoomDatabase { public abstract class AppDatabase extends RoomDatabase {

View file

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

View file

@ -3,6 +3,7 @@ package com.cappielloantonio.tempo.subsonic
import com.cappielloantonio.tempo.App import com.cappielloantonio.tempo.App
import com.cappielloantonio.tempo.subsonic.utils.CacheUtil import com.cappielloantonio.tempo.subsonic.utils.CacheUtil
import com.cappielloantonio.tempo.subsonic.utils.EmptyDateTypeAdapter import com.cappielloantonio.tempo.subsonic.utils.EmptyDateTypeAdapter
import com.cappielloantonio.tempo.util.ClientCertManager
import com.google.gson.GsonBuilder import com.google.gson.GsonBuilder
import okhttp3.Cache import okhttp3.Cache
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
@ -13,7 +14,7 @@ import java.util.Date
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
class RetrofitClient(subsonic: Subsonic) { class RetrofitClient(subsonic: Subsonic) {
var retrofit: Retrofit val retrofit: Retrofit
init { init {
val gson = GsonBuilder() val gson = GsonBuilder()
@ -50,6 +51,7 @@ class RetrofitClient(subsonic: Subsonic) {
.addInterceptor(cacheUtil.offlineInterceptor) .addInterceptor(cacheUtil.offlineInterceptor)
// .addNetworkInterceptor(cacheUtil.onlineInterceptor) // .addNetworkInterceptor(cacheUtil.onlineInterceptor)
.cache(getCache()) .cache(getCache())
.setupSsl()
.build() .build()
} }
@ -63,4 +65,11 @@ class RetrofitClient(subsonic: Subsonic) {
val cacheSize = 10 * 1024 * 1024 val cacheSize = 10 * 1024 * 1024
return Cache(App.getContext().cacheDir, cacheSize.toLong()) 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.Context;
import android.content.Intent; import android.content.Intent;
import android.content.IntentFilter;
import android.content.res.Configuration; import android.content.res.Configuration;
import android.graphics.Rect;
import android.content.IntentFilter; import android.content.IntentFilter;
import android.net.ConnectivityManager; import android.net.ConnectivityManager;
import android.net.NetworkInfo; import android.net.NetworkInfo;
@ -24,8 +26,8 @@ import androidx.fragment.app.FragmentManager;
import androidx.lifecycle.ViewModelProvider; import androidx.lifecycle.ViewModelProvider;
import androidx.media3.common.MediaItem; import androidx.media3.common.MediaItem;
import androidx.media3.common.MediaMetadata; import androidx.media3.common.MediaMetadata;
import androidx.media3.common.Player;
import androidx.media3.common.MimeTypes; import androidx.media3.common.MimeTypes;
import androidx.media3.common.Player;
import androidx.media3.common.util.UnstableApi; import androidx.media3.common.util.UnstableApi;
import androidx.navigation.NavController; import androidx.navigation.NavController;
import androidx.navigation.fragment.NavHostFragment; import androidx.navigation.fragment.NavHostFragment;
@ -448,6 +450,7 @@ public class MainActivity extends BaseActivity {
Preferences.setServer(null); Preferences.setServer(null);
Preferences.setLocalAddress(null); Preferences.setLocalAddress(null);
Preferences.setUser(null); Preferences.setUser(null);
Preferences.setClientCert(null);
// TODO Enter all settings to be reset // TODO Enter all settings to be reset
Preferences.setOpenSubsonic(false); Preferences.setOpenSubsonic(false);

View file

@ -2,8 +2,8 @@ package com.cappielloantonio.tempo.ui.dialog;
import android.app.Dialog; import android.app.Dialog;
import android.os.Bundle; import android.os.Bundle;
import android.security.KeyChain;
import android.text.TextUtils; import android.text.TextUtils;
import android.view.View;
import android.widget.Toast; import android.widget.Toast;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
@ -32,11 +32,21 @@ public class ServerSignupDialog extends DialogFragment {
private String server; private String server;
private String localAddress; private String localAddress;
private boolean lowSecurity = false; private boolean lowSecurity = false;
private String clientCertAlias;
@NonNull @NonNull
@Override @Override
public Dialog onCreateDialog(Bundle savedInstanceState) { public Dialog onCreateDialog(Bundle savedInstanceState) {
bind = DialogServerSignupBinding.inflate(getLayoutInflater()); 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); loginViewModel = new ViewModelProvider(requireActivity()).get(LoginViewModel.class);
@ -74,6 +84,7 @@ public class ServerSignupDialog extends DialogFragment {
bind.serverTextView.setText(loginViewModel.getServerToEdit().getAddress()); bind.serverTextView.setText(loginViewModel.getServerToEdit().getAddress());
bind.localAddressTextView.setText(loginViewModel.getServerToEdit().getLocalAddress()); bind.localAddressTextView.setText(loginViewModel.getServerToEdit().getLocalAddress());
bind.lowSecurityCheckbox.setChecked(loginViewModel.getServerToEdit().isLowSecurity()); bind.lowSecurityCheckbox.setChecked(loginViewModel.getServerToEdit().isLowSecurity());
bind.clientCertTextView.setText(loginViewModel.getServerToEdit().getClientCert());
} }
} else { } else {
loginViewModel.setServerToEdit(null); 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; 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; localAddress = bind.localAddressTextView.getText() != null && !bind.localAddressTextView.getText().toString().trim().isBlank() ? bind.localAddressTextView.getText().toString().trim() : null;
lowSecurity = bind.lowSecurityCheckbox.isChecked(); 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)) { if (TextUtils.isEmpty(serverName)) {
bind.serverNameTextView.setError(getString(R.string.error_required)); bind.serverNameTextView.setError(getString(R.string.error_required));
@ -137,6 +149,6 @@ public class ServerSignupDialog extends DialogFragment {
private void saveServerPreference() { private void saveServerPreference() {
String serverID = loginViewModel.getServerToEdit() != null ? loginViewModel.getServerToEdit().getServerId() : UUID.randomUUID().toString(); 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 @Override
public void onServerClick(Bundle bundle) { public void onServerClick(Bundle bundle) {
Server server = bundle.getParcelable("server_object"); 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 systemRepository = new SystemRepository();
systemRepository.checkUserCredential(new SystemCallback() { systemRepository.checkUserCredential(new SystemCallback() {
@ -142,13 +142,14 @@ public class LoginFragment extends Fragment implements ClickCallback {
dialog.show(activity.getSupportFragmentManager(), null); 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.setServerId(serverId);
Preferences.setServer(server); Preferences.setServer(server);
Preferences.setLocalAddress(localAddress); Preferences.setLocalAddress(localAddress);
Preferences.setUser(user); Preferences.setUser(user);
Preferences.setPassword(password); Preferences.setPassword(password);
Preferences.setLowSecurity(isLowSecurity); Preferences.setLowSecurity(isLowSecurity);
Preferences.setClientCert(clientCert);
App.getSubsonicClientInstance(true); App.getSubsonicClientInstance(true);
} }
@ -161,6 +162,7 @@ public class LoginFragment extends Fragment implements ClickCallback {
Preferences.setToken(null); Preferences.setToken(null);
Preferences.setSalt(null); Preferences.setSalt(null);
Preferences.setLowSecurity(false); Preferences.setLowSecurity(false);
Preferences.setClientCert(null);
App.getSubsonicClientInstance(true); 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 TOKEN = "token"
private const val SALT = "salt" private const val SALT = "salt"
private const val LOW_SECURITY = "low_security" private const val LOW_SECURITY = "low_security"
private const val CLIENT_CERT = "client_cert"
private const val BATTERY_OPTIMIZATION = "battery_optimization" private const val BATTERY_OPTIMIZATION = "battery_optimization"
private const val SERVER_ID = "server_id" private const val SERVER_ID = "server_id"
private const val OPEN_SUBSONIC = "open_subsonic" private const val OPEN_SUBSONIC = "open_subsonic"
@ -173,6 +174,16 @@ object Preferences {
App.getInstance().preferences.edit().putBoolean(LOW_SECURITY, isLowSecurity).apply() 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 @JvmStatic
fun getServerId(): String? { fun getServerId(): String? {
return App.getInstance().preferences.getString(SERVER_ID, null) return App.getInstance().preferences.getString(SERVER_ID, null)

View file

@ -129,6 +129,25 @@
android:layout_marginStart="24dp" android:layout_marginStart="24dp"
android:layout_marginEnd="24dp" android:layout_marginEnd="24dp"
android:text="@string/server_signup_dialog_action_low_security" /> 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> </LinearLayout>
</androidx.core.widget.NestedScrollView> </androidx.core.widget.NestedScrollView>
</androidx.constraintlayout.widget.ConstraintLayout> </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_password">Password</string>
<string name="server_signup_dialog_hint_url">Server URL</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_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_negative_button">Cancel</string>
<string name="server_signup_dialog_neutral_button">Delete</string> <string name="server_signup_dialog_neutral_button">Delete</string>
<string name="server_signup_dialog_positive_button">Save</string> <string name="server_signup_dialog_positive_button">Save</string>