mirror of
				https://github.com/immich-app/immich.git
				synced 2025-11-03 19:17:11 -05:00 
			
		
		
		
	fix(mobile): Remote video playback and asset download on Android with mTLS (#16403)
* 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:
		
							parent
							
								
									3a1e3e82e7
								
							
						
					
					
						commit
						f75d853e9a
					
				@ -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)
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@ -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
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -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(
 | 
			
		||||
 | 
			
		||||
@ -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));
 | 
			
		||||
 | 
			
		||||
@ -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;
 | 
			
		||||
 | 
			
		||||
        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 (isLoggedIn && selfSignedCertsAllowed) {
 | 
			
		||||
          String serverHost =
 | 
			
		||||
              Uri.parse(Store.tryGet(StoreKey.serverEndpoint) ?? "").host;
 | 
			
		||||
 | 
			
		||||
          selfSignedCertsAllowed &= serverHost.contains(host);
 | 
			
		||||
          if (_serverHost == null || _serverHost.contains(host)) {
 | 
			
		||||
            return true;
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (!selfSignedCertsAllowed) {
 | 
			
		||||
        _log.severe("Invalid SSL certificate for $host:$port");
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return selfSignedCertsAllowed;
 | 
			
		||||
        return false;
 | 
			
		||||
      };
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										47
									
								
								mobile/lib/utils/http_ssl_options.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										47
									
								
								mobile/lib/utils/http_ssl_options.dart
									
									
									
									
									
										Normal 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);
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@ -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),
 | 
			
		||||
 | 
			
		||||
@ -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,
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user