fix(mobile): Remote video playback and asset download on Android with mTLS ()

* Add class to apply SSL options

* Apply client certificate for native Android code

* Refactor self-signed check

* Allow self-signed certificates

* Fix Dart analysis

* Add HostnameVerifier

Android explicitly does NOT check the Common Name of a certificate,
only the Subject Alt Names. Chances are that someone who self-signs a
certificate doesn't go through the extra steps to add a SAN, and in
that case the connection would be prevented by the HostnameVerifier
even thought the TrustManager was fine with the certificate itself.

* Rename parameter like in Dart

* Fix NPE

* Catch all native errors in HttpSSLOptionsPlugin

* Workaround for too early onChanged() callback

* Fix formatting

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
Robert Vollmer 2025-05-08 15:45:11 +02:00 committed by GitHub
parent 3a1e3e82e7
commit f75d853e9a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 218 additions and 32 deletions

View file

@ -0,0 +1,146 @@
package app.alextran.immich
import android.annotation.SuppressLint
import android.content.Context
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import java.io.ByteArrayInputStream
import java.net.InetSocketAddress
import java.net.Socket
import java.security.KeyStore
import java.security.cert.X509Certificate
import javax.net.ssl.HostnameVerifier
import javax.net.ssl.HttpsURLConnection
import javax.net.ssl.KeyManager
import javax.net.ssl.KeyManagerFactory
import javax.net.ssl.SSLContext
import javax.net.ssl.SSLEngine
import javax.net.ssl.SSLSession
import javax.net.ssl.TrustManager
import javax.net.ssl.TrustManagerFactory
import javax.net.ssl.X509ExtendedTrustManager
/**
* Android plugin for Dart `HttpSSLOptions`
*/
class HttpSSLOptionsPlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
private var methodChannel: MethodChannel? = null
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
onAttachedToEngine(binding.applicationContext, binding.binaryMessenger)
}
private fun onAttachedToEngine(ctx: Context, messenger: BinaryMessenger) {
methodChannel = MethodChannel(messenger, "immich/httpSSLOptions")
methodChannel?.setMethodCallHandler(this)
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
onDetachedFromEngine()
}
private fun onDetachedFromEngine() {
methodChannel?.setMethodCallHandler(null)
methodChannel = null
}
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
try {
when (call.method) {
"apply" -> {
val args = call.arguments<ArrayList<*>>()!!
var tm: Array<TrustManager>? = null
if (args[0] as Boolean) {
tm = arrayOf(AllowSelfSignedTrustManager(args[1] as? String))
}
var km: Array<KeyManager>? = null
if (args[2] != null) {
val cert = ByteArrayInputStream(args[2] as ByteArray)
val password = (args[3] as String).toCharArray()
val keyStore = KeyStore.getInstance("PKCS12")
keyStore.load(cert, password)
val keyManagerFactory =
KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm())
keyManagerFactory.init(keyStore, null)
km = keyManagerFactory.keyManagers
}
val sslContext = SSLContext.getInstance("TLS")
sslContext.init(km, tm, null)
HttpsURLConnection.setDefaultSSLSocketFactory(sslContext.socketFactory)
HttpsURLConnection.setDefaultHostnameVerifier(AllowSelfSignedHostnameVerifier(args[1] as? String))
result.success(true)
}
else -> result.notImplemented()
}
} catch (e: Throwable) {
result.error("error", e.message, null)
}
}
@SuppressLint("CustomX509TrustManager")
class AllowSelfSignedTrustManager(private val serverHost: String?) : X509ExtendedTrustManager() {
private val defaultTrustManager: X509ExtendedTrustManager = getDefaultTrustManager()
override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?) =
defaultTrustManager.checkClientTrusted(chain, authType)
override fun checkClientTrusted(
chain: Array<out X509Certificate>?, authType: String?, socket: Socket?
) = defaultTrustManager.checkClientTrusted(chain, authType, socket)
override fun checkClientTrusted(
chain: Array<out X509Certificate>?, authType: String?, engine: SSLEngine?
) = defaultTrustManager.checkClientTrusted(chain, authType, engine)
override fun checkServerTrusted(chain: Array<out X509Certificate>?, authType: String?) {
if (serverHost == null) return
defaultTrustManager.checkServerTrusted(chain, authType)
}
override fun checkServerTrusted(
chain: Array<out X509Certificate>?, authType: String?, socket: Socket?
) {
if (serverHost == null) return
val socketAddress = socket?.remoteSocketAddress
if (socketAddress is InetSocketAddress && socketAddress.hostName == serverHost) return
defaultTrustManager.checkServerTrusted(chain, authType, socket)
}
override fun checkServerTrusted(
chain: Array<out X509Certificate>?, authType: String?, engine: SSLEngine?
) {
if (serverHost == null || engine?.peerHost == serverHost) return
defaultTrustManager.checkServerTrusted(chain, authType, engine)
}
override fun getAcceptedIssuers(): Array<X509Certificate> = defaultTrustManager.acceptedIssuers
private fun getDefaultTrustManager(): X509ExtendedTrustManager {
val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
factory.init(null as KeyStore?)
return factory.trustManagers.filterIsInstance<X509ExtendedTrustManager>().first()
}
}
class AllowSelfSignedHostnameVerifier(private val serverHost: String?) : HostnameVerifier {
companion object {
private val _defaultHostnameVerifier = HttpsURLConnection.getDefaultHostnameVerifier()
}
override fun verify(hostname: String?, session: SSLSession?): Boolean {
if (serverHost == null || hostname == serverHost) {
return true
} else {
return _defaultHostnameVerifier.verify(hostname, session)
}
}
}
}

View file

@ -8,6 +8,7 @@ class MainActivity : FlutterActivity() {
override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
flutterEngine.plugins.add(BackgroundServicePlugin())
flutterEngine.plugins.add(HttpSSLOptionsPlugin())
// No need to set up method channel here as it's now handled in the plugin
}
}

View file

@ -27,7 +27,7 @@ import 'package:immich_mobile/theme/theme_data.dart';
import 'package:immich_mobile/utils/bootstrap.dart';
import 'package:immich_mobile/utils/cache/widgets_binding.dart';
import 'package:immich_mobile/utils/download.dart';
import 'package:immich_mobile/utils/http_ssl_cert_override.dart';
import 'package:immich_mobile/utils/http_ssl_options.dart';
import 'package:immich_mobile/utils/migration.dart';
import 'package:intl/date_symbol_data_local.dart';
import 'package:logging/logging.dart';
@ -42,7 +42,7 @@ void main() async {
// Warm-up isolate pool for worker manager
await workerManager.init(dynamicSpawning: true);
await migrateDatabaseIfNeeded(db);
HttpOverrides.global = HttpSSLCertOverride();
HttpSSLOptions.apply();
runApp(
ProviderScope(

View file

@ -32,7 +32,7 @@ import 'package:immich_mobile/services/localization.service.dart';
import 'package:immich_mobile/utils/backup_progress.dart';
import 'package:immich_mobile/utils/bootstrap.dart';
import 'package:immich_mobile/utils/diff.dart';
import 'package:immich_mobile/utils/http_ssl_cert_override.dart';
import 'package:immich_mobile/utils/http_ssl_options.dart';
import 'package:path_provider_foundation/path_provider_foundation.dart';
import 'package:photo_manager/photo_manager.dart' show PMProgressHandler;
@ -359,7 +359,7 @@ class BackgroundService {
],
);
HttpOverrides.global = HttpSSLCertOverride();
HttpSSLOptions.apply();
ref
.read(apiServiceProvider)
.setAccessToken(Store.get(StoreKey.accessToken));

View file

@ -1,16 +1,20 @@
import 'dart:io';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:logging/logging.dart';
class HttpSSLCertOverride extends HttpOverrides {
static final Logger _log = Logger("HttpSSLCertOverride");
final bool _allowSelfSignedSSLCert;
final String? _serverHost;
final SSLClientCertStoreVal? _clientCert;
late final SecurityContext? _ctxWithCert;
HttpSSLCertOverride() : _clientCert = SSLClientCertStoreVal.load() {
HttpSSLCertOverride(
this._allowSelfSignedSSLCert,
this._serverHost,
this._clientCert,
) {
if (_clientCert != null) {
_ctxWithCert = SecurityContext(withTrustedRoots: true);
if (_ctxWithCert != null) {
@ -47,28 +51,15 @@ class HttpSSLCertOverride extends HttpOverrides {
return super.createHttpClient(context)
..badCertificateCallback = (X509Certificate cert, String host, int port) {
AppSettingsEnum setting = AppSettingsEnum.allowSelfSignedSSLCert;
// Check if user has allowed self signed SSL certificates.
bool selfSignedCertsAllowed =
Store.get(setting.storeKey as StoreKey<bool>, setting.defaultValue);
bool isLoggedIn = Store.tryGet(StoreKey.currentUser) != null;
// Conduct server host checks if user is logged in to avoid making
// insecure SSL connections to services that are not the immich server.
if (isLoggedIn && selfSignedCertsAllowed) {
String serverHost =
Uri.parse(Store.tryGet(StoreKey.serverEndpoint) ?? "").host;
selfSignedCertsAllowed &= serverHost.contains(host);
if (_allowSelfSignedSSLCert) {
// Conduct server host checks if user is logged in to avoid making
// insecure SSL connections to services that are not the immich server.
if (_serverHost == null || _serverHost.contains(host)) {
return true;
}
}
if (!selfSignedCertsAllowed) {
_log.severe("Invalid SSL certificate for $host:$port");
}
return selfSignedCertsAllowed;
_log.severe("Invalid SSL certificate for $host:$port");
return false;
};
}
}

View file

@ -0,0 +1,47 @@
import 'dart:io';
import 'package:flutter/services.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/utils/http_ssl_cert_override.dart';
import 'package:logging/logging.dart';
class HttpSSLOptions {
static const MethodChannel _channel = MethodChannel('immich/httpSSLOptions');
static void apply() {
AppSettingsEnum setting = AppSettingsEnum.allowSelfSignedSSLCert;
bool allowSelfSignedSSLCert =
Store.get(setting.storeKey as StoreKey<bool>, setting.defaultValue);
_apply(allowSelfSignedSSLCert);
}
static void applyFromSettings(bool newValue) {
_apply(newValue);
}
static void _apply(bool allowSelfSignedSSLCert) {
String? serverHost;
if (allowSelfSignedSSLCert && Store.tryGet(StoreKey.currentUser) != null) {
serverHost = Uri.parse(Store.tryGet(StoreKey.serverEndpoint) ?? "").host;
}
SSLClientCertStoreVal? clientCert = SSLClientCertStoreVal.load();
HttpOverrides.global =
HttpSSLCertOverride(allowSelfSignedSSLCert, serverHost, clientCert);
if (Platform.isAndroid) {
_channel.invokeMethod("apply", [
allowSelfSignedSSLCert,
serverHost,
clientCert?.data,
clientCert?.password,
]).onError<PlatformException>((e, _) {
final log = Logger("HttpSSLOptions");
log.severe('Failed to set SSL options', e.message);
});
}
}
}

View file

@ -10,7 +10,7 @@ import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart';
import 'package:immich_mobile/utils/http_ssl_cert_override.dart';
import 'package:immich_mobile/utils/http_ssl_options.dart';
import 'package:immich_mobile/widgets/settings/custom_proxy_headers_settings/custome_proxy_headers_settings.dart';
import 'package:immich_mobile/widgets/settings/local_storage_settings.dart';
import 'package:immich_mobile/widgets/settings/settings_slider_list_tile.dart';
@ -104,7 +104,7 @@ class AdvancedSettings extends HookConsumerWidget {
valueNotifier: allowSelfSignedSSLCert,
title: "advanced_settings_self_signed_ssl_title".tr(),
subtitle: "advanced_settings_self_signed_ssl_subtitle".tr(),
onChanged: (_) => HttpOverrides.global = HttpSSLCertOverride(),
onChanged: HttpSSLOptions.applyFromSettings,
),
const CustomeProxyHeaderSettings(),
SslClientCertSettings(isLoggedIn: ref.read(currentUserProvider) != null),

View file

@ -8,6 +8,7 @@ import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/utils/http_ssl_cert_override.dart';
import 'package:immich_mobile/utils/http_ssl_options.dart';
class SslClientCertSettings extends StatefulWidget {
const SslClientCertSettings({super.key, required this.isLoggedIn});
@ -103,7 +104,7 @@ class _SslClientCertSettingsState extends State<SslClientCertSettings> {
return;
}
cert.save();
HttpOverrides.global = HttpSSLCertOverride();
HttpSSLOptions.apply();
setState(
() => isCertExist = true,
);
@ -152,7 +153,7 @@ class _SslClientCertSettingsState extends State<SslClientCertSettings> {
void removeCert(BuildContext context) {
SSLClientCertStoreVal.delete();
HttpOverrides.global = HttpSSLCertOverride();
HttpSSLOptions.apply();
setState(
() => isCertExist = false,
);