From 8377c223c51a90e7c2e4de0a892223738ec819c0 Mon Sep 17 00:00:00 2001 From: Patrick Schmidt Date: Thu, 7 Dec 2023 19:30:48 +0100 Subject: [PATCH] Closes #193, #280 - Allowed user to define ssl-cert for pinning --- assets/translations/en.json | 7 +- common/lib/network/dio_provider.dart | 5 +- .../decorator_suffix_icon_button.dart | 24 +++ .../printers/components/section_header.dart | 10 +- .../printers/components/ssl_settings.dart | 164 ++++++++++++++++++ .../edit/components/macro_group_list.dart | 5 +- .../edit/printers_edit_controller.dart | 6 +- .../printers/edit/printers_edit_page.dart | 35 ++-- pubspec.yaml | 6 + 9 files changed, 237 insertions(+), 25 deletions(-) create mode 100644 common/lib/ui/components/decorator_suffix_icon_button.dart create mode 100644 lib/ui/screens/printers/components/ssl_settings.dart diff --git a/assets/translations/en.json b/assets/translations/en.json index 798518d4..1d8edd8c 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -289,13 +289,18 @@ "moonraker_api_key": "Moonraker - API Key", "moonraker_api_desc": "Only needed if youre using trusted clients. FluiddPI enforces this!", "full_url": "Full URL", - "self_signed": "Trust Self-Signed Certificates", "timeout_label": "Client timeout", "timeout_helper": "The timeout for the client connection in seconds", "theme": "UI-Theme", "theme_helper": "UI theme for printer", "theme_unavailable": "UI theme for printer, only available for Supporters" }, + "ssl": { + "title": "SSL - Settings", + "pin_certificate_label": "Certificate Fingerprint Pinning", + "pin_certificate_helper": "Select a certificate file in PEM format for SSL-Pinning", + "self_signed": "Trust Self-Signed Certificates" + }, "motion_system": { "title": "Motion System", "invert_x": "Invert X - Axis", diff --git a/common/lib/network/dio_provider.dart b/common/lib/network/dio_provider.dart index 61fdff70..44656a1d 100644 --- a/common/lib/network/dio_provider.dart +++ b/common/lib/network/dio_provider.dart @@ -51,7 +51,7 @@ BaseOptions baseOptions(BaseOptionsRef ref, String machineUUID, ClientType clien throw MobilerakerException('Machine with UUID "$machineUUID" was not found!'); } - var pinnedSha256Fp = machine.pinnedCertificateDER?.let((it) => HashDigest(fromHex(it))); + var pinnedSha256Fp = machine.pinnedCertificateDERBase64?.let((it) => sha256.convert(fromBase64(it))); return switch (clientType) { ClientType.octo => BaseOptions( @@ -101,7 +101,7 @@ HttpClient httpClient(HttpClientRef ref, String machineUUID, ClientType clientTy ..idleTimeout = const Duration(seconds: 3) ..connectionTimeout = options.connectTimeout; - if (!options.trustUntrustedCertificate) return client; + if (!options.trustUntrustedCertificate && options.pinnedCertificateFingerPrint == null) return client; var fingerPrint = options.pinnedCertificateFingerPrint; @@ -110,7 +110,6 @@ HttpClient httpClient(HttpClientRef ref, String machineUUID, ClientType clientTy if (fingerPrint == null) { return true; } - // Manually verified that using DER of cert is correctly working to generate a SHA256 FP for the cert HashDigest sha256Fp = sha256.convert(cert.der); return fingerPrint == sha256Fp; diff --git a/common/lib/ui/components/decorator_suffix_icon_button.dart b/common/lib/ui/components/decorator_suffix_icon_button.dart new file mode 100644 index 00000000..e0dc62ec --- /dev/null +++ b/common/lib/ui/components/decorator_suffix_icon_button.dart @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2023. Patrick Schmidt. + * All rights reserved. + */ + +import 'package:flutter/material.dart'; + +class DecoratorSuffixIconButton extends StatelessWidget { + const DecoratorSuffixIconButton({super.key, required this.icon, this.onPressed}); + + final IconData icon; + final GestureTapCallback? onPressed; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 4.0), + child: InkWell( + onTap: onPressed, + child: Icon(icon, size: 18), + ), + ); + } +} diff --git a/lib/ui/screens/printers/components/section_header.dart b/lib/ui/screens/printers/components/section_header.dart index 5424dad0..6f294bb4 100644 --- a/lib/ui/screens/printers/components/section_header.dart +++ b/lib/ui/screens/printers/components/section_header.dart @@ -6,10 +6,16 @@ import 'package:flutter/material.dart'; class SectionHeader extends StatelessWidget { - const SectionHeader({Key? key, required this.title, this.trailing}) : super(key: key); + const SectionHeader({ + Key? key, + required this.title, + this.trailing, + this.padding = const EdgeInsets.only(top: 16.0), + }) : super(key: key); final String title; final Widget? trailing; + final EdgeInsets padding; @override Widget build(BuildContext context) { @@ -18,7 +24,7 @@ class SectionHeader extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Padding( - padding: const EdgeInsets.only(top: 8.0), + padding: padding, child: Align( alignment: Alignment.centerLeft, child: Text( diff --git a/lib/ui/screens/printers/components/ssl_settings.dart b/lib/ui/screens/printers/components/ssl_settings.dart new file mode 100644 index 00000000..3b436528 --- /dev/null +++ b/lib/ui/screens/printers/components/ssl_settings.dart @@ -0,0 +1,164 @@ +/* + * Copyright (c) 2023. Patrick Schmidt. + * All rights reserved. + */ + +import 'dart:convert'; + +import 'package:common/data/model/hive/machine.dart'; +import 'package:common/ui/components/decorator_suffix_icon_button.dart'; +import 'package:common/util/logger.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:hashlib/hashlib.dart'; +import 'package:hashlib_codecs/hashlib_codecs.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:mobileraker/ui/screens/printers/components/section_header.dart'; +import 'package:pem/pem.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'ssl_settings.freezed.dart'; + +part 'ssl_settings.g.dart'; + +class SslSettings extends HookConsumerWidget { + const SslSettings({super.key, required this.machine}); + + final Machine machine; + + @override + Widget build(BuildContext context, WidgetRef ref) { + var provider = sslSettingsControllerProvider(machine); + var model = ref.watch(provider); + var controller = ref.watch(provider.notifier); + + var textController = useTextEditingController(text: model.certificateDER); + useEffect( + () { + if (model.fingerprintSHA256 == null) { + textController.clear(); + } else { + textController.text = model.fingerprintSHA256!.toUpperCase(); + } + }, + [model.fingerprintSHA256], + ); + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + SectionHeader(title: tr('pages.printer_edit.ssl.title')), + InputDecorator( + decoration: const InputDecoration( + border: InputBorder.none, + contentPadding: EdgeInsets.all(0), + ), + child: SwitchListTile( + dense: true, + isThreeLine: false, + contentPadding: EdgeInsets.zero, + title: const Text('pages.printer_edit.ssl.self_signed').tr(), + value: model.trustSelfSigned || model.fingerprintSHA256 != null, + onChanged: model.fingerprintSHA256 == null ? (_) => controller.toggleTrustSelfSigned() : null, + controlAffinity: ListTileControlAffinity.trailing, + ), + ), + TextField( + canRequestFocus: false, + controller: textController, + decoration: InputDecoration( + labelText: tr('pages.printer_edit.ssl.pin_certificate_label'), + helperText: tr('pages.printer_edit.ssl.pin_certificate_helper'), + hintText: 'DER-Encoded Certificate', + helperMaxLines: 100, + suffix: model.fingerprintSHA256?.isNotEmpty == true + ? DecoratorSuffixIconButton( + icon: Icons.close, + onPressed: controller.clearCertificate, + ) + : null, + ), + readOnly: true, + onTap: controller.pickCertificate, + ), + ], + ); + } +} + +@riverpod +class SslSettingsController extends _$SslSettingsController { + @override + SslSettingsModel build(Machine machine) { + bool trustSelfSigned = machine.trustUntrustedCertificate; + + if (machine.pinnedCertificateDERBase64 == null) { + return SslSettingsModel(certificateDER: null, fingerprintSHA256: null, trustSelfSigned: trustSelfSigned); + } + + return SslSettingsModel( + certificateDER: machine.pinnedCertificateDERBase64, + fingerprintSHA256: _fingerPrint(machine.pinnedCertificateDERBase64!), + trustSelfSigned: trustSelfSigned, + ); + } + + void pickCertificate() async { + FilePickerResult? result = await FilePicker.platform.pickFiles( + type: FileType.custom, + allowedExtensions: ['pem', 'crt', 'cer'], + withReadStream: true, + withData: false, + ); + + if (result != null) { + var file = result.files.first; + var content = await utf8.decodeStream(file.readStream!); + + PemCodec pemCodec = PemCodec(PemLabel.certificate); + var derBytes = pemCodec.decode(content); + var base64 = toBase64(derBytes); + + state = SslSettingsModel( + certificateDER: base64, + fingerprintSHA256: _fingerPrint(base64), + trustSelfSigned: state.trustSelfSigned, + ); + } else { + // User canceled the picker + logger.i('User canceled certificate picker'); + } + } + + void clearCertificate() { + state = SslSettingsModel(certificateDER: null, fingerprintSHA256: null, trustSelfSigned: state.trustSelfSigned); + } + + void toggleTrustSelfSigned() { + state = SslSettingsModel( + certificateDER: state.certificateDER, + fingerprintSHA256: state.fingerprintSHA256, + trustSelfSigned: !state.trustSelfSigned, + ); + } + + String _fingerPrint(String derB64) { + var b64 = fromBase64(derB64); + + String hex = sha256.convert(b64).hex(); + + return [for (int i = 0; i < hex.length; i += 2) hex.substring(i, i + 2)].join(':'); + } +} + +@freezed +class SslSettingsModel with _$SslSettingsModel { + const factory SslSettingsModel({ + required String? certificateDER, + required String? fingerprintSHA256, + @Default(false) bool trustSelfSigned, + }) = _SslSettingsModel; +} diff --git a/lib/ui/screens/printers/edit/components/macro_group_list.dart b/lib/ui/screens/printers/edit/components/macro_group_list.dart index 8cc7dd31..1ac3fc17 100644 --- a/lib/ui/screens/printers/edit/components/macro_group_list.dart +++ b/lib/ui/screens/printers/edit/components/macro_group_list.dart @@ -12,6 +12,7 @@ import 'package:common/service/machine_service.dart'; import 'package:common/service/ui/bottom_sheet_service_interface.dart'; import 'package:common/service/ui/dialog_service_interface.dart'; import 'package:common/service/ui/snackbar_service_interface.dart'; +import 'package:common/ui/components/decorator_suffix_icon_button.dart'; import 'package:common/util/extensions/async_ext.dart'; import 'package:common/util/extensions/object_extension.dart'; import 'package:common/util/logger.dart'; @@ -163,8 +164,8 @@ class _MacroGroup extends HookConsumerWidget { keyboardType: TextInputType.text, decoration: InputDecoration( labelText: 'pages.printer_edit.general.displayname'.tr(), - suffix: IconButton( - icon: const Icon(Icons.delete), + suffix: DecoratorSuffixIconButton( + icon: Icons.delete, onPressed: enabled ? () => controller.removeMacroGroup(macroGroup) : null, ), ), diff --git a/lib/ui/screens/printers/edit/printers_edit_controller.dart b/lib/ui/screens/printers/edit/printers_edit_controller.dart index 59fe1227..11d83a6a 100644 --- a/lib/ui/screens/printers/edit/printers_edit_controller.dart +++ b/lib/ui/screens/printers/edit/printers_edit_controller.dart @@ -41,6 +41,7 @@ import 'package:mobileraker/ui/components/dialog/import_settings/import_settings import 'package:mobileraker/ui/components/dialog/webcam_preview_dialog.dart'; import 'package:mobileraker/ui/screens/printers/components/http_headers.dart'; import 'package:mobileraker/ui/screens/printers/components/ssid_preferences_list.dart'; +import 'package:mobileraker/ui/screens/printers/components/ssl_settings.dart'; import 'package:mobileraker/ui/screens/qr_scanner/qr_scanner_page.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; @@ -234,7 +235,10 @@ class PrinterEditController extends _$PrinterEditController { if (wsUri != null) { _machine.wsUri = wsUri; } - _machine.trustUntrustedCertificate = storedValues['trustSelfSigned']; + var sslSettings = ref.read(sslSettingsControllerProvider(_machine)); + _machine.trustUntrustedCertificate = sslSettings.trustSelfSigned; + _machine.pinnedCertificateDERBase64 = sslSettings.certificateDER; + _machine.httpHeaders = ref.read(headersControllerProvider(_machine.httpHeaders)); _machine.localSsids = ref.read(ssidPreferenceListControllerProvider(_machine.localSsids)); await ref.read(machineServiceProvider).updateMachine(_machine); diff --git a/lib/ui/screens/printers/edit/printers_edit_page.dart b/lib/ui/screens/printers/edit/printers_edit_page.dart index e30cbb6b..94135d07 100644 --- a/lib/ui/screens/printers/edit/printers_edit_page.dart +++ b/lib/ui/screens/printers/edit/printers_edit_page.dart @@ -14,6 +14,7 @@ import 'package:common/service/misc_providers.dart'; import 'package:common/service/moonraker/printer_service.dart'; import 'package:common/service/payment_service.dart'; import 'package:common/service/ui/theme_service.dart'; +import 'package:common/ui/components/decorator_suffix_icon_button.dart'; import 'package:common/ui/components/supporter_only_feature.dart'; import 'package:common/ui/theme/theme_pack.dart'; import 'package:common/util/extensions/async_ext.dart'; @@ -33,6 +34,7 @@ import 'package:mobileraker/ui/components/warning_card.dart'; import 'package:mobileraker/ui/screens/printers/components/http_headers.dart'; import 'package:mobileraker/ui/screens/printers/components/section_header.dart'; import 'package:mobileraker/ui/screens/printers/components/ssid_preferences_list.dart'; +import 'package:mobileraker/ui/screens/printers/components/ssl_settings.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:progress_indicators/progress_indicators.dart'; import 'package:stringr/stringr.dart'; @@ -116,10 +118,13 @@ class _Body extends ConsumerWidget { key: ref.watch(editPrinterFormKeyProvider), autovalidateMode: AutovalidateMode.onUserInteraction, child: Padding( - padding: const EdgeInsets.all(10.0), + padding: const EdgeInsets.all(6.0), child: Column( children: [ - SectionHeader(title: 'pages.setting.general.title'.tr()), + SectionHeader( + title: 'pages.setting.general.title'.tr(), + padding: EdgeInsets.zero, + ), FormBuilderTextField( enableInteractiveSelection: true, keyboardType: TextInputType.text, @@ -174,9 +179,12 @@ class _Body extends ConsumerWidget { keyboardType: TextInputType.text, decoration: InputDecoration( labelText: 'pages.printer_edit.general.moonraker_api_key'.tr(), - suffix: IconButton( - icon: const Icon(Icons.qr_code_sharp), - onPressed: () => controller.openQrScanner(context), + suffix: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4.0), + child: InkWell( + child: const Icon(Icons.qr_code_sharp, size: 18), + onTap: () => controller.openQrScanner(context), + ), ), helperText: 'pages.printer_edit.general.moonraker_api_desc'.tr(), helperMaxLines: 3, @@ -204,12 +212,7 @@ class _Body extends ConsumerWidget { FormBuilderValidators.integer(), ]), ), - FormBuilderCheckbox( - name: 'trustSelfSigned', - title: const Text('pages.printer_edit.general.self_signed').tr(), - controlAffinity: ListTileControlAffinity.trailing, - initialValue: machine.trustUntrustedCertificate, - ), + SslSettings(machine: machine), HttpHeaders(initialValue: machine.httpHeaders), const Divider(), // if (machine.hasRemoteConnection) @@ -333,8 +336,8 @@ class _WebCamItem extends HookConsumerWidget { keyboardType: TextInputType.text, decoration: InputDecoration( labelText: 'pages.printer_edit.general.displayname'.tr(), - suffix: IconButton( - icon: const Icon(Icons.delete), + suffix: DecoratorSuffixIconButton( + icon: Icons.delete, onPressed: ref.watch(printerEditControllerProvider) ? null : () => ref.read(webcamListControllerProvider.notifier).removeWebcam(cam), @@ -737,8 +740,8 @@ class _TempPresetItem extends HookConsumerWidget { keyboardType: TextInputType.text, decoration: InputDecoration( labelText: 'pages.printer_edit.general.displayname'.tr(), - suffix: IconButton( - icon: const Icon(Icons.delete), + suffix: DecoratorSuffixIconButton( + icon: Icons.delete, onPressed: ref.watch(printerEditControllerProvider) ? null : () => ref @@ -999,7 +1002,7 @@ class _ThemeSelector extends ConsumerWidget { decoration: InputDecoration( labelStyle: Theme.of(context).textTheme.labelLarge, labelText: tr('pages.printer_edit.general.theme'), - helperText: tr('pages.printer_edit.general.theme_helper'), + helperText: isSupporter ? tr('pages.printer_edit.general.theme_helper') : null, suffix: isSupporter ? null : IconButton( diff --git a/pubspec.yaml b/pubspec.yaml index 9026ef2f..60acf9d1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -73,6 +73,11 @@ dependencies: permission_handler: ^10.4.3 collection: ^1.17.0 + #crypto + hashlib_codecs: ^2.2.0 + hashlib: ^1.12.0 + pem: ^2.0.4 + #persisstent hive: ^2.2.3 hive_flutter: ^1.1.0 @@ -138,6 +143,7 @@ dependencies: loader_overlay: ^2.2.0 appinio_video_player: ^1.2.2 flutter_echarts: ^2.5.0 + file_picker: ^6.1.1 # ditredi: ^1.0.3