diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/HttpSSLOptionsPlugin.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/HttpSSLOptionsPlugin.kt new file mode 100644 index 0000000000..44d2aee2ce --- /dev/null +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/HttpSSLOptionsPlugin.kt @@ -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>()!! + + var tm: Array? = null + if (args[0] as Boolean) { + tm = arrayOf(AllowSelfSignedTrustManager(args[1] as? String)) + } + + var km: Array? = 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?, authType: String?) = + defaultTrustManager.checkClientTrusted(chain, authType) + + override fun checkClientTrusted( + chain: Array?, authType: String?, socket: Socket? + ) = defaultTrustManager.checkClientTrusted(chain, authType, socket) + + override fun checkClientTrusted( + chain: Array?, authType: String?, engine: SSLEngine? + ) = defaultTrustManager.checkClientTrusted(chain, authType, engine) + + override fun checkServerTrusted(chain: Array?, authType: String?) { + if (serverHost == null) return + defaultTrustManager.checkServerTrusted(chain, authType) + } + + override fun checkServerTrusted( + chain: Array?, 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?, authType: String?, engine: SSLEngine? + ) { + if (serverHost == null || engine?.peerHost == serverHost) return + defaultTrustManager.checkServerTrusted(chain, authType, engine) + } + + override fun getAcceptedIssuers(): Array = defaultTrustManager.acceptedIssuers + + private fun getDefaultTrustManager(): X509ExtendedTrustManager { + val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) + factory.init(null as KeyStore?) + return factory.trustManagers.filterIsInstance().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) + } + } + } +} diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt index 2b6bf81148..752ded59ce 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt @@ -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 } } diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 73af81d69d..c39d5e3a66 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -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( diff --git a/mobile/lib/services/background.service.dart b/mobile/lib/services/background.service.dart index 84cbaae6ed..335f71acab 100644 --- a/mobile/lib/services/background.service.dart +++ b/mobile/lib/services/background.service.dart @@ -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)); diff --git a/mobile/lib/utils/http_ssl_cert_override.dart b/mobile/lib/utils/http_ssl_cert_override.dart index ce0384b998..f64757cf9d 100644 --- a/mobile/lib/utils/http_ssl_cert_override.dart +++ b/mobile/lib/utils/http_ssl_cert_override.dart @@ -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, 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; }; } } diff --git a/mobile/lib/utils/http_ssl_options.dart b/mobile/lib/utils/http_ssl_options.dart new file mode 100644 index 0000000000..04c01d36d9 --- /dev/null +++ b/mobile/lib/utils/http_ssl_options.dart @@ -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, 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((e, _) { + final log = Logger("HttpSSLOptions"); + log.severe('Failed to set SSL options', e.message); + }); + } + } +} diff --git a/mobile/lib/widgets/settings/advanced_settings.dart b/mobile/lib/widgets/settings/advanced_settings.dart index 6a67992712..eb13c67640 100644 --- a/mobile/lib/widgets/settings/advanced_settings.dart +++ b/mobile/lib/widgets/settings/advanced_settings.dart @@ -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), diff --git a/mobile/lib/widgets/settings/ssl_client_cert_settings.dart b/mobile/lib/widgets/settings/ssl_client_cert_settings.dart index d8ea51dddd..6fdbb156d9 100644 --- a/mobile/lib/widgets/settings/ssl_client_cert_settings.dart +++ b/mobile/lib/widgets/settings/ssl_client_cert_settings.dart @@ -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 { return; } cert.save(); - HttpOverrides.global = HttpSSLCertOverride(); + HttpSSLOptions.apply(); setState( () => isCertExist = true, ); @@ -152,7 +153,7 @@ class _SslClientCertSettingsState extends State { void removeCert(BuildContext context) { SSLClientCertStoreVal.delete(); - HttpOverrides.global = HttpSSLCertOverride(); + HttpSSLOptions.apply(); setState( () => isCertExist = false, );