mirror of
				https://github.com/immich-app/immich.git
				synced 2025-10-31 10:37:11 -04: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; | ||||
| 
 | ||||
|         // 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; | ||||
|       }; | ||||
|   } | ||||
| } | ||||
|  | ||||
							
								
								
									
										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