From 46d96ef779a5c469e8889c7ee83d834ac67647b3 Mon Sep 17 00:00:00 2001 From: Neeraj Gupta <254676+ua741@users.noreply.github.com> Date: Mon, 31 Jul 2023 17:26:56 +0530 Subject: [PATCH] Support for importing ente encrypted export --- lib/l10n/arb/app_en.arb | 4 + .../data/import/encrypted_ente_import.dart | 166 ++++++++++++++++++ .../settings/data/import/import_service.dart | 6 +- ...port.dart => raivo_plain_text_import.dart} | 2 +- lib/ui/settings/data/import_page.dart | 123 ++++++------- lib/utils/dialog_util.dart | 43 ++--- 6 files changed, 260 insertions(+), 84 deletions(-) create mode 100644 lib/ui/settings/data/import/encrypted_ente_import.dart rename lib/ui/settings/data/import/{ravio_plain_text_import.dart => raivo_plain_text_import.dart} (99%) diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 196691d50..4503c06c0 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -75,10 +75,14 @@ "importCodes": "Import codes", "importTypePlainText": "Plain text", "importTypeEnteEncrypted": "ente Encrypted export", + "passwordForDecryptingExport" : "Password to decrypt export", + "passwordEmptyError": "Password can not be empty", "importFromApp": "Import codes from {appName}", "importSelectJsonFile": "Select JSON file", + "importEnteEncGuide": "Select the encrypted JSON file exported from ente", "importRaivoGuide": "Use the \"Export OTPs to Zip archive\" option in Raivo's Settings.\n\nExtract the zip file and import the JSON file.", "exportCodes": "Export codes", + "importLabel": "Import", "importInstruction": "Please select a file that contains a list of your codes in the following format", "importCodeDelimiterInfo": "The codes can be separated by a comma or a new line", "selectFile": "Select file", diff --git a/lib/ui/settings/data/import/encrypted_ente_import.dart b/lib/ui/settings/data/import/encrypted_ente_import.dart new file mode 100644 index 000000000..50616ef6c --- /dev/null +++ b/lib/ui/settings/data/import/encrypted_ente_import.dart @@ -0,0 +1,166 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:ente_auth/l10n/l10n.dart'; +import 'package:ente_auth/models/code.dart'; +import 'package:ente_auth/models/export/ente.dart'; +import 'package:ente_auth/services/authenticator_service.dart'; +import 'package:ente_auth/store/code_store.dart'; +import 'package:ente_auth/ui/components/buttons/button_widget.dart'; +import 'package:ente_auth/ui/components/dialog_widget.dart'; +import 'package:ente_auth/ui/components/models/button_type.dart'; +import 'package:ente_auth/utils/crypto_util.dart'; +import 'package:ente_auth/utils/dialog_util.dart'; +import 'package:ente_auth/utils/toast_util.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_sodium/flutter_sodium.dart'; +import 'package:logging/logging.dart'; + +Future showEncryptedImportInstruction(BuildContext context) async { + final l10n = context.l10n; + final result = await showDialogWidget( + context: context, + title: l10n.importFromApp("ente Auth"), + body: l10n.importEnteEncGuide, + buttons: [ + ButtonWidget( + buttonType: ButtonType.primary, + labelText: l10n.importSelectJsonFile, + isInAlert: true, + buttonSize: ButtonSize.large, + buttonAction: ButtonAction.first, + ), + ButtonWidget( + buttonType: ButtonType.secondary, + labelText: context.l10n.cancel, + buttonSize: ButtonSize.large, + isInAlert: true, + buttonAction: ButtonAction.second, + ), + ], + ); + if (result?.action != null && result!.action != ButtonAction.cancel) { + if (result.action == ButtonAction.first) { + await _pickEnteJsonFile(context); + } else {} + } +} + +Future _decryptExportData( + BuildContext context, + EnteAuthExport enteAuthExport, { + String? password, +}) async { + final l10n = context.l10n; + bool isPasswordIncorrect = false; + int? importedCodeCount; + await showTextInputDialog( + context, + title: l10n.passwordForDecryptingExport, + submitButtonLabel: l10n.importLabel, + hintText: l10n.enterYourPasswordHint, + isPasswordInput: true, + alwaysShowSuccessState: false, + showOnlyLoadingState: true, + onSubmit: (String password) async { + if (password.isEmpty) { + showToast(context, l10n.passwordEmptyError); + Future.delayed(const Duration(seconds: 0), () { + _decryptExportData(context, enteAuthExport, password: password); + }); + return; + } + if (password.isNotEmpty) { + final progressDialog = createProgressDialog(context, l10n.pleaseWait); + try { + await progressDialog.show(); + final derivedKey = await CryptoUtil.deriveKey( + utf8.encode(password) as Uint8List, + Sodium.base642bin(enteAuthExport.kdfParams.salt), + enteAuthExport.kdfParams.memLimit, + enteAuthExport.kdfParams.opsLimit, + ); + Uint8List? decryptedContent; + // Encrypt the key with this derived key + try { + decryptedContent = await CryptoUtil.decryptChaCha( + Sodium.base642bin(enteAuthExport.encryptedData), + derivedKey, + Sodium.base642bin(enteAuthExport.encryptionNonce), + ); + } catch (e) { + showToast(context, l10n.incorrectPasswordTitle); + isPasswordIncorrect = true; + } + if (isPasswordIncorrect) { + await progressDialog.hide(); + + Future.delayed(const Duration(seconds: 0), () { + _decryptExportData(context, enteAuthExport, password: password); + }); + return; + } + String content = utf8.decode(decryptedContent!); + List splitCodes = content.split("\n"); + final parsedCodes = []; + for (final code in splitCodes) { + try { + parsedCodes.add(Code.fromRawData(code)); + } catch (e) { + Logger('EncryptedText').severe("Could not parse code", e); + } + } + for (final code in parsedCodes) { + await CodeStore.instance.addCode(code, shouldSync: false); + } + unawaited(AuthenticatorService.instance.sync()); + importedCodeCount = parsedCodes.length; + await progressDialog.hide(); + } catch (e, s) { + await progressDialog.hide(); + Logger("ExportWidget").severe(e, s); + showToast(context, "Error while exporting codes."); + } + } + }, + ); + if (importedCodeCount != null) { + final DialogWidget dialog = choiceDialog( + title: context.l10n.importSuccessTitle, + body: context.l10n.importSuccessDesc(importedCodeCount!), + firstButtonLabel: l10n.ok, + firstButtonType: ButtonType.primary, + ); + await showConfettiDialog( + context: context, + dialogBuilder: (BuildContext context) { + return dialog; + }, + ); + } +} + +Future _pickEnteJsonFile(BuildContext context) async { + FilePickerResult? result = await FilePicker.platform.pickFiles(); + if (result == null) { + return; + } + + try { + File file = File(result.files.single.path!); + final jsonString = await file.readAsString(); + EnteAuthExport exportedData = + EnteAuthExport.fromJson(jsonDecode(jsonString)); + await _decryptExportData(context, exportedData); + } catch (e) { + await showErrorDialog( + context, + context.l10n.sorry, + context.l10n.importFailureDesc, + ); + } +} diff --git a/lib/ui/settings/data/import/import_service.dart b/lib/ui/settings/data/import/import_service.dart index 53fede50e..dd2616579 100644 --- a/lib/ui/settings/data/import/import_service.dart +++ b/lib/ui/settings/data/import/import_service.dart @@ -1,8 +1,8 @@ +import 'package:ente_auth/ui/settings/data/import/encrypted_ente_import.dart'; import 'package:ente_auth/ui/settings/data/import/plain_text_import.dart'; -import 'package:ente_auth/ui/settings/data/import/ravio_plain_text_import.dart'; +import 'package:ente_auth/ui/settings/data/import/raivo_plain_text_import.dart'; import 'package:ente_auth/ui/settings/data/import_page.dart'; -import 'package:ente_auth/utils/toast_util.dart'; import 'package:flutter/cupertino.dart'; class ImportService { @@ -17,7 +17,7 @@ class ImportService { } else if(type == ImportType.ravio) { showRaivoImportInstruction(context); } else { - showToast(context, 'Coming soon!'); + showEncryptedImportInstruction(context); } } } \ No newline at end of file diff --git a/lib/ui/settings/data/import/ravio_plain_text_import.dart b/lib/ui/settings/data/import/raivo_plain_text_import.dart similarity index 99% rename from lib/ui/settings/data/import/ravio_plain_text_import.dart rename to lib/ui/settings/data/import/raivo_plain_text_import.dart index f425c89e0..5664d0e43 100644 --- a/lib/ui/settings/data/import/ravio_plain_text_import.dart +++ b/lib/ui/settings/data/import/raivo_plain_text_import.dart @@ -21,7 +21,7 @@ Future showRaivoImportInstruction(BuildContext context) async { title: l10n.importFromApp("Raivo OTP"), body: l10n.importRaivoGuide, buttons: [ - ButtonWidget( + ButtonWidget( buttonType: ButtonType.primary, labelText: l10n.importSelectJsonFile, isInAlert: true, diff --git a/lib/ui/settings/data/import_page.dart b/lib/ui/settings/data/import_page.dart index 0b28feb55..e08c8776f 100644 --- a/lib/ui/settings/data/import_page.dart +++ b/lib/ui/settings/data/import_page.dart @@ -38,68 +38,71 @@ class ImportCodePage extends StatelessWidget { @override Widget build(BuildContext context) { - return Scaffold( - body: CustomScrollView( - primary: false, - slivers: [ - TitleBarWidget( - flexibleSpaceTitle: TitleBarTitleWidget( - title: context.l10n.importCodes, - ), - flexibleSpaceCaption: "Import source", - actionIcons: [ - IconButtonWidget( - icon: Icons.close_outlined, - iconButtonType: IconButtonType.secondary, - onTap: () { - Navigator.pop(context); - Navigator.pop(context); - }, + return Material( + color: Colors.transparent, + child: Scaffold( + body: CustomScrollView( + primary: false, + slivers: [ + TitleBarWidget( + flexibleSpaceTitle: TitleBarTitleWidget( + title: context.l10n.importCodes, ), - ], - ), - SliverList( - delegate: SliverChildBuilderDelegate( - (delegateBuildContext, index) { - final type = importOptions[index]; - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: Column( - children: [ - if (index == 0) - const SizedBox( - height: 24, - ), - MenuItemWidget( - captionedTextWidget: CaptionedTextWidget( - title: getTitle(context, type), - ), - alignCaptionedTextToLeft: true, - menuItemColor: getEnteColorScheme(context).fillFaint, - pressedColor: getEnteColorScheme(context).fillFaint, - trailingIcon: Icons.chevron_right_outlined, - isBottomBorderRadiusRemoved: - index != importOptions.length - 1, - isTopBorderRadiusRemoved: index != 0, - onTap: () async { - ImportService().initiateImport(context, type); - // routeToPage(context, ImportCodePage()); - // _showImportInstructionDialog(context); - }, - ), - if (index != importOptions.length - 1) - DividerWidget( - dividerType: DividerType.menu, - bgColor: getEnteColorScheme(context).fillFaint, - ), - ], - ), - ); - }, - childCount: importOptions.length, + flexibleSpaceCaption: "Import source", + actionIcons: [ + IconButtonWidget( + icon: Icons.close_outlined, + iconButtonType: IconButtonType.secondary, + onTap: () { + Navigator.pop(context); + Navigator.pop(context); + }, + ), + ], ), - ), - ], + SliverList( + delegate: SliverChildBuilderDelegate( + (delegateBuildContext, index) { + final type = importOptions[index]; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Column( + children: [ + if (index == 0) + const SizedBox( + height: 24, + ), + MenuItemWidget( + captionedTextWidget: CaptionedTextWidget( + title: getTitle(context, type), + ), + alignCaptionedTextToLeft: true, + menuItemColor: getEnteColorScheme(context).fillFaint, + pressedColor: getEnteColorScheme(context).fillFaint, + trailingIcon: Icons.chevron_right_outlined, + isBottomBorderRadiusRemoved: + index != importOptions.length - 1, + isTopBorderRadiusRemoved: index != 0, + onTap: () async { + ImportService().initiateImport(context, type); + // routeToPage(context, ImportCodePage()); + // _showImportInstructionDialog(context); + }, + ), + if (index != importOptions.length - 1) + DividerWidget( + dividerType: DividerType.menu, + bgColor: getEnteColorScheme(context).fillFaint, + ), + ], + ), + ); + }, + childCount: importOptions.length, + ), + ), + ], + ), ), ); } diff --git a/lib/utils/dialog_util.dart b/lib/utils/dialog_util.dart index e37beb831..12184bc78 100644 --- a/lib/utils/dialog_util.dart +++ b/lib/utils/dialog_util.dart @@ -310,26 +310,29 @@ Future showTextInputDialog( builder: (context) { final bottomInset = MediaQuery.of(context).viewInsets.bottom; final isKeyboardUp = bottomInset > 100; - return Center( - child: Padding( - padding: EdgeInsets.only(bottom: isKeyboardUp ? bottomInset : 0), - child: TextInputDialog( - title: title, - message: message, - label: label, - body: body, - icon: icon, - submitButtonLabel: submitButtonLabel, - onSubmit: onSubmit, - hintText: hintText, - prefixIcon: prefixIcon, - initialValue: initialValue, - alignMessage: alignMessage, - maxLength: maxLength, - showOnlyLoadingState: showOnlyLoadingState, - textCapitalization: textCapitalization, - alwaysShowSuccessState: alwaysShowSuccessState, - isPasswordInput: isPasswordInput, + return Material( + color: Colors.transparent, + child: Center( + child: Padding( + padding: EdgeInsets.only(bottom: isKeyboardUp ? bottomInset : 0), + child: TextInputDialog( + title: title, + message: message, + label: label, + body: body, + icon: icon, + submitButtonLabel: submitButtonLabel, + onSubmit: onSubmit, + hintText: hintText, + prefixIcon: prefixIcon, + initialValue: initialValue, + alignMessage: alignMessage, + maxLength: maxLength, + showOnlyLoadingState: showOnlyLoadingState, + textCapitalization: textCapitalization, + alwaysShowSuccessState: alwaysShowSuccessState, + isPasswordInput: isPasswordInput, + ), ), ), );