mirror of
https://github.com/immich-app/immich.git
synced 2025-05-24 01:12:58 -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) {
|
override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) {
|
||||||
super.configureFlutterEngine(flutterEngine)
|
super.configureFlutterEngine(flutterEngine)
|
||||||
flutterEngine.plugins.add(BackgroundServicePlugin())
|
flutterEngine.plugins.add(BackgroundServicePlugin())
|
||||||
|
flutterEngine.plugins.add(HttpSSLOptionsPlugin())
|
||||||
// No need to set up method channel here as it's now handled in the plugin
|
// 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/bootstrap.dart';
|
||||||
import 'package:immich_mobile/utils/cache/widgets_binding.dart';
|
import 'package:immich_mobile/utils/cache/widgets_binding.dart';
|
||||||
import 'package:immich_mobile/utils/download.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:immich_mobile/utils/migration.dart';
|
||||||
import 'package:intl/date_symbol_data_local.dart';
|
import 'package:intl/date_symbol_data_local.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
@ -42,7 +42,7 @@ void main() async {
|
|||||||
// Warm-up isolate pool for worker manager
|
// Warm-up isolate pool for worker manager
|
||||||
await workerManager.init(dynamicSpawning: true);
|
await workerManager.init(dynamicSpawning: true);
|
||||||
await migrateDatabaseIfNeeded(db);
|
await migrateDatabaseIfNeeded(db);
|
||||||
HttpOverrides.global = HttpSSLCertOverride();
|
HttpSSLOptions.apply();
|
||||||
|
|
||||||
runApp(
|
runApp(
|
||||||
ProviderScope(
|
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/backup_progress.dart';
|
||||||
import 'package:immich_mobile/utils/bootstrap.dart';
|
import 'package:immich_mobile/utils/bootstrap.dart';
|
||||||
import 'package:immich_mobile/utils/diff.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:path_provider_foundation/path_provider_foundation.dart';
|
||||||
import 'package:photo_manager/photo_manager.dart' show PMProgressHandler;
|
import 'package:photo_manager/photo_manager.dart' show PMProgressHandler;
|
||||||
|
|
||||||
@ -359,7 +359,7 @@ class BackgroundService {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
HttpOverrides.global = HttpSSLCertOverride();
|
HttpSSLOptions.apply();
|
||||||
ref
|
ref
|
||||||
.read(apiServiceProvider)
|
.read(apiServiceProvider)
|
||||||
.setAccessToken(Store.get(StoreKey.accessToken));
|
.setAccessToken(Store.get(StoreKey.accessToken));
|
||||||
|
@ -1,16 +1,20 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
|
||||||
import 'package:immich_mobile/entities/store.entity.dart';
|
import 'package:immich_mobile/entities/store.entity.dart';
|
||||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
|
|
||||||
class HttpSSLCertOverride extends HttpOverrides {
|
class HttpSSLCertOverride extends HttpOverrides {
|
||||||
static final Logger _log = Logger("HttpSSLCertOverride");
|
static final Logger _log = Logger("HttpSSLCertOverride");
|
||||||
|
final bool _allowSelfSignedSSLCert;
|
||||||
|
final String? _serverHost;
|
||||||
final SSLClientCertStoreVal? _clientCert;
|
final SSLClientCertStoreVal? _clientCert;
|
||||||
late final SecurityContext? _ctxWithCert;
|
late final SecurityContext? _ctxWithCert;
|
||||||
|
|
||||||
HttpSSLCertOverride() : _clientCert = SSLClientCertStoreVal.load() {
|
HttpSSLCertOverride(
|
||||||
|
this._allowSelfSignedSSLCert,
|
||||||
|
this._serverHost,
|
||||||
|
this._clientCert,
|
||||||
|
) {
|
||||||
if (_clientCert != null) {
|
if (_clientCert != null) {
|
||||||
_ctxWithCert = SecurityContext(withTrustedRoots: true);
|
_ctxWithCert = SecurityContext(withTrustedRoots: true);
|
||||||
if (_ctxWithCert != null) {
|
if (_ctxWithCert != null) {
|
||||||
@ -47,28 +51,15 @@ class HttpSSLCertOverride extends HttpOverrides {
|
|||||||
|
|
||||||
return super.createHttpClient(context)
|
return super.createHttpClient(context)
|
||||||
..badCertificateCallback = (X509Certificate cert, String host, int port) {
|
..badCertificateCallback = (X509Certificate cert, String host, int port) {
|
||||||
AppSettingsEnum setting = AppSettingsEnum.allowSelfSignedSSLCert;
|
if (_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
|
// Conduct server host checks if user is logged in to avoid making
|
||||||
// insecure SSL connections to services that are not the immich server.
|
// insecure SSL connections to services that are not the immich server.
|
||||||
if (isLoggedIn && selfSignedCertsAllowed) {
|
if (_serverHost == null || _serverHost.contains(host)) {
|
||||||
String serverHost =
|
return true;
|
||||||
Uri.parse(Store.tryGet(StoreKey.serverEndpoint) ?? "").host;
|
}
|
||||||
|
|
||||||
selfSignedCertsAllowed &= serverHost.contains(host);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!selfSignedCertsAllowed) {
|
|
||||||
_log.severe("Invalid SSL certificate for $host:$port");
|
_log.severe("Invalid SSL certificate for $host:$port");
|
||||||
}
|
return false;
|
||||||
|
|
||||||
return selfSignedCertsAllowed;
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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/repositories/local_files_manager.repository.dart';
|
||||||
import 'package:immich_mobile/services/app_settings.service.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/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/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/local_storage_settings.dart';
|
||||||
import 'package:immich_mobile/widgets/settings/settings_slider_list_tile.dart';
|
import 'package:immich_mobile/widgets/settings/settings_slider_list_tile.dart';
|
||||||
@ -104,7 +104,7 @@ class AdvancedSettings extends HookConsumerWidget {
|
|||||||
valueNotifier: allowSelfSignedSSLCert,
|
valueNotifier: allowSelfSignedSSLCert,
|
||||||
title: "advanced_settings_self_signed_ssl_title".tr(),
|
title: "advanced_settings_self_signed_ssl_title".tr(),
|
||||||
subtitle: "advanced_settings_self_signed_ssl_subtitle".tr(),
|
subtitle: "advanced_settings_self_signed_ssl_subtitle".tr(),
|
||||||
onChanged: (_) => HttpOverrides.global = HttpSSLCertOverride(),
|
onChanged: HttpSSLOptions.applyFromSettings,
|
||||||
),
|
),
|
||||||
const CustomeProxyHeaderSettings(),
|
const CustomeProxyHeaderSettings(),
|
||||||
SslClientCertSettings(isLoggedIn: ref.read(currentUserProvider) != null),
|
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/build_context_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/theme_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_cert_override.dart';
|
||||||
|
import 'package:immich_mobile/utils/http_ssl_options.dart';
|
||||||
|
|
||||||
class SslClientCertSettings extends StatefulWidget {
|
class SslClientCertSettings extends StatefulWidget {
|
||||||
const SslClientCertSettings({super.key, required this.isLoggedIn});
|
const SslClientCertSettings({super.key, required this.isLoggedIn});
|
||||||
@ -103,7 +104,7 @@ class _SslClientCertSettingsState extends State<SslClientCertSettings> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
cert.save();
|
cert.save();
|
||||||
HttpOverrides.global = HttpSSLCertOverride();
|
HttpSSLOptions.apply();
|
||||||
setState(
|
setState(
|
||||||
() => isCertExist = true,
|
() => isCertExist = true,
|
||||||
);
|
);
|
||||||
@ -152,7 +153,7 @@ class _SslClientCertSettingsState extends State<SslClientCertSettings> {
|
|||||||
|
|
||||||
void removeCert(BuildContext context) {
|
void removeCert(BuildContext context) {
|
||||||
SSLClientCertStoreVal.delete();
|
SSLClientCertStoreVal.delete();
|
||||||
HttpOverrides.global = HttpSSLCertOverride();
|
HttpSSLOptions.apply();
|
||||||
setState(
|
setState(
|
||||||
() => isCertExist = false,
|
() => isCertExist = false,
|
||||||
);
|
);
|
||||||
|
Loading…
x
Reference in New Issue
Block a user