diff --git a/mobile/assets/i18n/en-US.json b/mobile/assets/i18n/en-US.json index 0075f65de0557..4e34b2ed9ae28 100644 --- a/mobile/assets/i18n/en-US.json +++ b/mobile/assets/i18n/en-US.json @@ -163,6 +163,14 @@ "client_cert_remove_msg": "Client certificate is removed", "client_cert_subtitle": "Supports PKCS12 (.p12, .pfx) format only. Certificate Import/Remove is available only before login", "client_cert_title": "SSL Client Certificate", + "server_cert_dialog_msg_confirm": "OK", + "server_cert_import": "Import", + "server_cert_import_success_msg": "Server certificate is imported", + "server_cert_invalid_msg": "Invalid certificate file or wrong password", + "server_cert_remove": "Remove", + "server_cert_remove_msg": "Server certificate is removed", + "server_cert_subtitle": "Supports PKCS12 (.p12, .pfx) format only. Certificate Import/Remove is available only before login", + "server_cert_title": "SSL Server Certificate", "common_add_to_album": "Add to album", "common_change_password": "Change Password", "common_create_new_album": "Create new album", diff --git a/mobile/lib/entities/store.entity.dart b/mobile/lib/entities/store.entity.dart index 1dda2b9a12a03..3ff814b89c57f 100644 --- a/mobile/lib/entities/store.entity.dart +++ b/mobile/lib/entities/store.entity.dart @@ -173,6 +173,30 @@ class SSLClientCertStoreVal { } } +class SSLServerCertStoreVal { + final Uint8List data; + + SSLServerCertStoreVal(this.data); + + void save() { + final b64Str = base64Encode(data); + Store.put(StoreKey.sslServerCertData, b64Str); + } + + static SSLServerCertStoreVal? load() { + final b64Str = Store.tryGet(StoreKey.sslServerCertData); + if (b64Str == null) { + return null; + } + final Uint8List certData = base64Decode(b64Str); + return SSLServerCertStoreVal(certData); + } + + static void delete() { + Store.delete(StoreKey.sslServerCertData); + } +} + class StoreKeyNotFoundException implements Exception { final StoreKey key; StoreKeyNotFoundException(this.key); @@ -199,6 +223,7 @@ enum StoreKey { backgroundBackup(14, type: bool), sslClientCertData(15, type: String), sslClientPasswd(16, type: String), + sslServerCertData(17, type: String), // user settings from [AppSettingsEnum] below: loadPreview(100, type: bool), loadOriginal(101, type: bool), diff --git a/mobile/lib/utils/http_ssl_cert_override.dart b/mobile/lib/utils/http_ssl_cert_override.dart index 9ce7334be203f..0cc0ec9681bb8 100644 --- a/mobile/lib/utils/http_ssl_cert_override.dart +++ b/mobile/lib/utils/http_ssl_cert_override.dart @@ -6,15 +6,25 @@ import 'package:logging/logging.dart'; class HttpSSLCertOverride extends HttpOverrides { static final Logger _log = Logger("HttpSSLCertOverride"); final SSLClientCertStoreVal? _clientCert; + final SSLServerCertStoreVal? _rootCert; late final SecurityContext? _ctxWithCert; - HttpSSLCertOverride() : _clientCert = SSLClientCertStoreVal.load() { - if (_clientCert != null) { + HttpSSLCertOverride() : _clientCert = SSLClientCertStoreVal.load(), _rootCert = SSLServerCertStoreVal.load() { + if (_clientCert != null || _rootCert != null) { _ctxWithCert = SecurityContext(withTrustedRoots: true); - if (_ctxWithCert != null) { - setClientCert(_ctxWithCert, _clientCert); - } else { - _log.severe("Failed to create security context with client cert!"); + if (_clientCert != null) { + if (_ctxWithCert != null) { + setClientCert(_ctxWithCert, _clientCert); + } else { + _log.severe("Failed to create security context with client cert!"); + } + } + if (_rootCert != null) { + if (_ctxWithCert != null) { + setRootCert(_ctxWithCert, _rootCert); + } else { + _log.severe("Failed to create security context with server cert!"); + } } } else { _ctxWithCert = null; @@ -33,12 +43,26 @@ class HttpSSLCertOverride extends HttpOverrides { return true; } + static bool setRootCert(SecurityContext ctx, SSLServerCertStoreVal cert) { + try { + _log.info("Setting server certificate"); + ctx.setTrustedCertificatesBytes(cert.data); + } catch (e) { + _log.severe("Failed to set SSL server cert: $e"); + return false; + } + return true; + } + @override HttpClient createHttpClient(SecurityContext? context) { if (context != null) { if (_clientCert != null) { setClientCert(context, _clientCert); } + if (_rootCert != null) { + setRootCert(context, _rootCert); + } } else { context = _ctxWithCert; } diff --git a/mobile/lib/widgets/settings/advanced_settings.dart b/mobile/lib/widgets/settings/advanced_settings.dart index ec1ab79cf722b..697f447e7632f 100644 --- a/mobile/lib/widgets/settings/advanced_settings.dart +++ b/mobile/lib/widgets/settings/advanced_settings.dart @@ -14,6 +14,7 @@ import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/services/immich_logger.service.dart'; import 'package:immich_mobile/utils/http_ssl_cert_override.dart'; import 'package:immich_mobile/widgets/settings/ssl_client_cert_settings.dart'; +import 'package:immich_mobile/widgets/settings/ssl_server_cert_settings.dart'; import 'package:logging/logging.dart'; class AdvancedSettings extends HookConsumerWidget { @@ -66,6 +67,7 @@ class AdvancedSettings extends HookConsumerWidget { ), const CustomeProxyHeaderSettings(), SslClientCertSettings(isLoggedIn: ref.read(currentUserProvider) != null), + SslServerCertSettings(isLoggedIn: ref.read(currentUserProvider) != null), ]; return SettingsSubPageScaffold(settings: advancedSettings); diff --git a/mobile/lib/widgets/settings/ssl_server_cert_settings.dart b/mobile/lib/widgets/settings/ssl_server_cert_settings.dart new file mode 100644 index 0000000000000..2a628dd39f0eb --- /dev/null +++ b/mobile/lib/widgets/settings/ssl_server_cert_settings.dart @@ -0,0 +1,133 @@ +import 'dart:io'; + +import 'package:easy_localization/easy_localization.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +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'; + +class SslServerCertSettings extends StatefulWidget { + const SslServerCertSettings({super.key, required this.isLoggedIn}); + + final bool isLoggedIn; + + @override + State createState() => _SslServerCertSettingsState(); +} + +class _SslServerCertSettingsState extends State { + _SslServerCertSettingsState() + : isCertExist = SSLServerCertStoreVal.load() != null; + + bool isCertExist; + + @override + Widget build(BuildContext context) { + return ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 20), + horizontalTitleGap: 20, + isThreeLine: true, + title: Text( + "server_cert_title".tr(), + style: context.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "server_cert_subtitle".tr(), + style: context.textTheme.bodyMedium?.copyWith( + color: context.colorScheme.onSurfaceSecondary, + ), + ), + const SizedBox( + height: 6, + ), + Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + ElevatedButton( + onPressed: widget.isLoggedIn ? null : () => importCert(context), + child: Text("server_cert_import".tr()), + ), + const SizedBox( + width: 15, + ), + ElevatedButton( + onPressed: widget.isLoggedIn || !isCertExist + ? null + : () => removeCert(context), + child: Text("server_cert_remove".tr()), + ), + ], + ), + ], + ), + ); + } + + void showMessage(BuildContext context, String message) { + showDialog( + context: context, + builder: (ctx) => AlertDialog( + content: Text(message), + actions: [ + TextButton( + onPressed: () => ctx.pop(), + child: Text("server_cert_dialog_msg_confirm".tr()), + ), + ], + ), + ); + } + + void storeCert(BuildContext context, Uint8List data) { + final cert = SSLServerCertStoreVal(data); + // Test whether the certificate is valid + final isCertValid = HttpSSLCertOverride.setRootCert( + SecurityContext(withTrustedRoots: true), + cert, + ); + if (!isCertValid) { + showMessage(context, "server_cert_invalid_msg".tr()); + return; + } + cert.save(); + HttpOverrides.global = HttpSSLCertOverride(); + setState( + () => isCertExist = true, + ); + showMessage(context, "client_cert_import_success_msg".tr()); + } + + Future importCert(BuildContext ctx) async { + FilePickerResult? res = await FilePicker.platform.pickFiles( + type: FileType.custom, + allowedExtensions: [ + 'p12', + 'pfx', + ], + ); + if (res != null) { + File file = File(res.files.single.path!); + final data = await file.readAsBytes(); + storeCert(context, data); + } + } + + void removeCert(BuildContext context) { + SSLServerCertStoreVal.delete(); + HttpOverrides.global = HttpSSLCertOverride(); + setState( + () => isCertExist = false, + ); + showMessage(context, "server_cert_remove_msg".tr()); + } +}