Skip to content

Commit

Permalink
feat(mobile): Adding setting in mobile app to import TLS client certi…
Browse files Browse the repository at this point in the history
…ficate and private key
  • Loading branch information
Yun Jiang authored and yjiang-c committed Jul 5, 2024
1 parent e1f25b4 commit e01a766
Show file tree
Hide file tree
Showing 6 changed files with 225 additions and 4 deletions.
18 changes: 16 additions & 2 deletions mobile/assets/i18n/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -531,5 +531,19 @@
"version_announcement_overlay_title": "New Server Version Available \uD83C\uDF89",
"viewer_remove_from_stack": "Remove from Stack",
"viewer_stack_use_as_main_asset": "Use as Main Asset",
"viewer_unstack": "Un-Stack"
}
"viewer_unstack": "Un-Stack",
"header_settings_header_name_input": "Header name",
"header_settings_header_value_input": "Header value",
"header_settings_page_title": "Proxy Headers",
"header_settings_add_header_tip": "Add Header",
"header_settings_field_validator_msg": "Value cannot be empty",
"client_cert_title": "SSL Client Certificate",
"client_cert_subtitle": "Supports PKCS12 (.p12, .pfx) format only. Certificate Import/Remove is available only before login",
"client_cert_import": "Import",
"client_cert_remove": "Remove",
"client_cert_remove_msg": "Client certificate is removed",
"client_cert_import_success_msg": "Client certificate is imported",
"client_cert_invalid_msg": "Invalid certificate file or wrong password",
"client_cert_dialog_msg_confirm": "OK",
"client_cert_enter_password": "Enter Password"
}
36 changes: 36 additions & 0 deletions mobile/lib/entities/store.entity.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@

import 'dart:convert';
import 'dart:typed_data';

import 'package:collection/collection.dart';
import 'package:immich_mobile/entities/user.entity.dart';
import 'package:isar/isar.dart';
Expand Down Expand Up @@ -140,6 +144,36 @@ class StoreValue {
}
}

class SSLClientCertStoreVal {
final Uint8List data;
final String? password;

SSLClientCertStoreVal(this.data, this.password);

void save() {
final b64Str = base64Encode(data);
Store.put(StoreKey.sslClientCertData, b64Str);
if (password != null) {
Store.put(StoreKey.sslClientPasswd, password!);
}
}

static SSLClientCertStoreVal? load() {
final b64Str = Store.tryGet<String>(StoreKey.sslClientCertData);
if (b64Str == null) {
return null;
}
final Uint8List certData = base64Decode(b64Str);
final passwd = Store.tryGet<String>(StoreKey.sslClientPasswd);
return SSLClientCertStoreVal(certData, passwd);
}

static void delete() {
Store.delete(StoreKey.sslClientCertData);
Store.delete(StoreKey.sslClientPasswd);
}
}

class StoreKeyNotFoundException implements Exception {
final StoreKey key;
StoreKeyNotFoundException(this.key);
Expand All @@ -164,6 +198,8 @@ enum StoreKey<T> {
serverEndpoint<String>(12, type: String),
autoBackup<bool>(13, type: bool),
backgroundBackup<bool>(14, type: bool),
sslClientCertData<String>(15, type: String),
sslClientPasswd<String>(16, type: String),
// user settings from [AppSettingsEnum] below:
loadPreview<bool>(100, type: bool),
loadOriginal<bool>(101, type: bool),
Expand Down
43 changes: 41 additions & 2 deletions mobile/lib/utils/http_ssl_cert_override.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,50 @@ import 'package:immich_mobile/entities/store.entity.dart';
import 'package:logging/logging.dart';

class HttpSSLCertOverride extends HttpOverrides {
static final Logger _log = Logger("HttpSSLCertOverride");
final SSLClientCertStoreVal? _clientCert;
late final SecurityContext? _ctxWithCert;

HttpSSLCertOverride()
:_clientCert = SSLClientCertStoreVal.load() {
if (_clientCert != null) {
_ctxWithCert = SecurityContext(withTrustedRoots: true);
if (_ctxWithCert != null) {
setClientCert(_ctxWithCert, _clientCert);
} else {
_log.severe("Failed to create security context with client cert!");
}
} else {
_ctxWithCert = null;
}
}

static bool setClientCert(SecurityContext ctx, SSLClientCertStoreVal cert) {
try {
_log.info("Setting client certificate");
ctx.usePrivateKeyBytes(cert.data, password: cert.password);
if (!Platform.isIOS) {
ctx.useCertificateChainBytes(cert.data, password: cert.password);
}
} catch(e) {
_log.severe("Failed to set SSL client cert: $e");
return false;
}
return true;
}

@override
HttpClient createHttpClient(SecurityContext? context) {
if (context != null) {
if (_clientCert != null) {
setClientCert(context, _clientCert);
}
} else {
context = _ctxWithCert;
}

return super.createHttpClient(context)
..badCertificateCallback = (X509Certificate cert, String host, int port) {
var log = Logger("HttpSSLCertOverride");

AppSettingsEnum setting = AppSettingsEnum.allowSelfSignedSSLCert;

Expand All @@ -28,7 +67,7 @@ class HttpSSLCertOverride extends HttpOverrides {
}

if (!selfSignedCertsAllowed) {
log.severe("Invalid SSL certificate for $host:$port");
_log.severe("Invalid SSL certificate for $host:$port");
}

return selfSignedCertsAllowed;
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 @@ -13,6 +13,7 @@ import 'package:immich_mobile/services/app_settings.service.dart';
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:logging/logging.dart';

class AdvancedSettings extends HookConsumerWidget {
Expand Down Expand Up @@ -64,6 +65,7 @@ class AdvancedSettings extends HookConsumerWidget {
onChanged: (_) => HttpOverrides.global = HttpSSLCertOverride(),
),
const CustomeProxyHeaderSettings(),
SslClientCertSettings(isLoggedIn: ref.read(currentUserProvider) != null),
];

return SettingsSubPageScaffold(settings: advancedSettings);
Expand Down
129 changes: 129 additions & 0 deletions mobile/lib/widgets/settings/ssl_client_cert_settings.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
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/utils/http_ssl_cert_override.dart';

class SslClientCertSettings extends StatefulWidget {
const SslClientCertSettings({super.key, required this.isLoggedIn});

final bool isLoggedIn;

@override
State<StatefulWidget> createState() => _SslClientCertSettingsState();
}

class _SslClientCertSettingsState extends State<SslClientCertSettings> {
_SslClientCertSettingsState()
: isCertExist = SSLClientCertStoreVal.load() != null;

bool isCertExist;

@override
Widget build(BuildContext context) {
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 20),
horizontalTitleGap: 20,
isThreeLine: true,
title: Text(
"client_cert_title".tr(),
style: context.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w500,
),
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"client_cert_subtitle".tr(),
style: context.textTheme.bodyMedium,),
const SizedBox(height: 6,),
Row(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
ElevatedButton(
onPressed: widget.isLoggedIn ? null : () => importCert(context),
child: Text("client_cert_import".tr()),),
const SizedBox(width: 15,),
ElevatedButton(
onPressed: widget.isLoggedIn || !isCertExist ? null : () => removeCert(context),
child: Text("client_cert_remove".tr()),),
],),
],
),
);
}

void showMessage(BuildContext context, String msg) {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
content: Text(msg),
actions: [
TextButton(onPressed: () => ctx.pop(), child: Text("client_cert_dialog_msg_confirm".tr()),),
],
),);
}

void storeCert(BuildContext context, Uint8List data, String? passwd) {
if (passwd != null && passwd.isEmpty) {
passwd = null;
}
final cert = SSLClientCertStoreVal(data, passwd);
// Test whether the certificate is valid
final isCertValid = HttpSSLCertOverride.setClientCert(SecurityContext(withTrustedRoots: true), cert);
if (!isCertValid) {
showMessage(context, "client_cert_invalid_msg".tr());
return;
}
cert.save();
HttpOverrides.global = HttpSSLCertOverride();
setState(() => isCertExist = true,);
showMessage(context, "client_cert_import_success_msg".tr());
}

void setPassword(BuildContext context, Uint8List data) {
final passwd = TextEditingController();
showDialog(
context: context,
barrierDismissible: false,
builder: (ctx) => AlertDialog(
content: TextField(
controller: passwd,
obscureText: true,
obscuringCharacter: "*",
decoration: InputDecoration(
hintText: "client_cert_enter_password".tr(),
),),
actions: [
TextButton(
onPressed: () => { ctx.pop(), storeCert(context, data, passwd.text) },
child: Text("client_cert_dialog_msg_confirm".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 bytes = await file.readAsBytes();
setPassword(ctx, bytes);
}
}

void removeCert(BuildContext context) {
SSLClientCertStoreVal.delete();
HttpOverrides.global = HttpSSLCertOverride();
setState(() => isCertExist = false,);
showMessage(context, "client_cert_remove_msg".tr());
}
}
1 change: 1 addition & 0 deletions mobile/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ dependencies:
# easy to remove packages:
image_picker: ^1.0.7 # only used to select user profile image from system gallery -> we can simply select an image from within immich?
logging: ^1.2.0
file_picker: ^8.0.0+1

# This is uncommented in F-Droid build script
# Taken from https://github.com/Myzel394/locus/blob/445013d22ec1d759027d4303bd65b30c5c8588c8/pubspec.yaml#L105
Expand Down

0 comments on commit e01a766

Please sign in to comment.