Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(mobile): add server cert field, similar to client cert #14335

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions mobile/assets/i18n/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
25 changes: 25 additions & 0 deletions mobile/lib/entities/store.entity.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>(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);
Expand All @@ -199,6 +223,7 @@ enum StoreKey<T> {
backgroundBackup<bool>(14, type: bool),
sslClientCertData<String>(15, type: String),
sslClientPasswd<String>(16, type: String),
sslServerCertData<String>(17, type: String),
// user settings from [AppSettingsEnum] below:
loadPreview<bool>(100, type: bool),
loadOriginal<bool>(101, type: bool),
Expand Down
36 changes: 30 additions & 6 deletions mobile/lib/utils/http_ssl_cert_override.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}
Expand Down
2 changes: 2 additions & 0 deletions mobile/lib/widgets/settings/advanced_settings.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down
133 changes: 133 additions & 0 deletions mobile/lib/widgets/settings/ssl_server_cert_settings.dart
Original file line number Diff line number Diff line change
@@ -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<StatefulWidget> createState() => _SslServerCertSettingsState();
}

class _SslServerCertSettingsState extends State<SslServerCertSettings> {
_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<void> 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());
}
}
Loading