Support for importing ente encrypted export
This commit is contained in:
parent
c666bbb6e8
commit
46d96ef779
6 changed files with 260 additions and 84 deletions
|
@ -75,10 +75,14 @@
|
||||||
"importCodes": "Import codes",
|
"importCodes": "Import codes",
|
||||||
"importTypePlainText": "Plain text",
|
"importTypePlainText": "Plain text",
|
||||||
"importTypeEnteEncrypted": "ente Encrypted export",
|
"importTypeEnteEncrypted": "ente Encrypted export",
|
||||||
|
"passwordForDecryptingExport" : "Password to decrypt export",
|
||||||
|
"passwordEmptyError": "Password can not be empty",
|
||||||
"importFromApp": "Import codes from {appName}",
|
"importFromApp": "Import codes from {appName}",
|
||||||
"importSelectJsonFile": "Select JSON file",
|
"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.",
|
"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",
|
"exportCodes": "Export codes",
|
||||||
|
"importLabel": "Import",
|
||||||
"importInstruction": "Please select a file that contains a list of your codes in the following format",
|
"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",
|
"importCodeDelimiterInfo": "The codes can be separated by a comma or a new line",
|
||||||
"selectFile": "Select file",
|
"selectFile": "Select file",
|
||||||
|
|
166
lib/ui/settings/data/import/encrypted_ente_import.dart
Normal file
166
lib/ui/settings/data/import/encrypted_ente_import.dart
Normal file
|
@ -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<void> 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<void> _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<String> 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<void> _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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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/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/ui/settings/data/import_page.dart';
|
||||||
import 'package:ente_auth/utils/toast_util.dart';
|
|
||||||
import 'package:flutter/cupertino.dart';
|
import 'package:flutter/cupertino.dart';
|
||||||
|
|
||||||
class ImportService {
|
class ImportService {
|
||||||
|
@ -17,7 +17,7 @@ class ImportService {
|
||||||
} else if(type == ImportType.ravio) {
|
} else if(type == ImportType.ravio) {
|
||||||
showRaivoImportInstruction(context);
|
showRaivoImportInstruction(context);
|
||||||
} else {
|
} else {
|
||||||
showToast(context, 'Coming soon!');
|
showEncryptedImportInstruction(context);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -21,7 +21,7 @@ Future<void> showRaivoImportInstruction(BuildContext context) async {
|
||||||
title: l10n.importFromApp("Raivo OTP"),
|
title: l10n.importFromApp("Raivo OTP"),
|
||||||
body: l10n.importRaivoGuide,
|
body: l10n.importRaivoGuide,
|
||||||
buttons: [
|
buttons: [
|
||||||
ButtonWidget(
|
ButtonWidget(
|
||||||
buttonType: ButtonType.primary,
|
buttonType: ButtonType.primary,
|
||||||
labelText: l10n.importSelectJsonFile,
|
labelText: l10n.importSelectJsonFile,
|
||||||
isInAlert: true,
|
isInAlert: true,
|
|
@ -38,68 +38,71 @@ class ImportCodePage extends StatelessWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Material(
|
||||||
body: CustomScrollView(
|
color: Colors.transparent,
|
||||||
primary: false,
|
child: Scaffold(
|
||||||
slivers: <Widget>[
|
body: CustomScrollView(
|
||||||
TitleBarWidget(
|
primary: false,
|
||||||
flexibleSpaceTitle: TitleBarTitleWidget(
|
slivers: <Widget>[
|
||||||
title: context.l10n.importCodes,
|
TitleBarWidget(
|
||||||
),
|
flexibleSpaceTitle: TitleBarTitleWidget(
|
||||||
flexibleSpaceCaption: "Import source",
|
title: context.l10n.importCodes,
|
||||||
actionIcons: [
|
|
||||||
IconButtonWidget(
|
|
||||||
icon: Icons.close_outlined,
|
|
||||||
iconButtonType: IconButtonType.secondary,
|
|
||||||
onTap: () {
|
|
||||||
Navigator.pop(context);
|
|
||||||
Navigator.pop(context);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
],
|
flexibleSpaceCaption: "Import source",
|
||||||
),
|
actionIcons: [
|
||||||
SliverList(
|
IconButtonWidget(
|
||||||
delegate: SliverChildBuilderDelegate(
|
icon: Icons.close_outlined,
|
||||||
(delegateBuildContext, index) {
|
iconButtonType: IconButtonType.secondary,
|
||||||
final type = importOptions[index];
|
onTap: () {
|
||||||
return Padding(
|
Navigator.pop(context);
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
Navigator.pop(context);
|
||||||
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,
|
|
||||||
),
|
),
|
||||||
),
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -310,26 +310,29 @@ Future<dynamic> showTextInputDialog(
|
||||||
builder: (context) {
|
builder: (context) {
|
||||||
final bottomInset = MediaQuery.of(context).viewInsets.bottom;
|
final bottomInset = MediaQuery.of(context).viewInsets.bottom;
|
||||||
final isKeyboardUp = bottomInset > 100;
|
final isKeyboardUp = bottomInset > 100;
|
||||||
return Center(
|
return Material(
|
||||||
child: Padding(
|
color: Colors.transparent,
|
||||||
padding: EdgeInsets.only(bottom: isKeyboardUp ? bottomInset : 0),
|
child: Center(
|
||||||
child: TextInputDialog(
|
child: Padding(
|
||||||
title: title,
|
padding: EdgeInsets.only(bottom: isKeyboardUp ? bottomInset : 0),
|
||||||
message: message,
|
child: TextInputDialog(
|
||||||
label: label,
|
title: title,
|
||||||
body: body,
|
message: message,
|
||||||
icon: icon,
|
label: label,
|
||||||
submitButtonLabel: submitButtonLabel,
|
body: body,
|
||||||
onSubmit: onSubmit,
|
icon: icon,
|
||||||
hintText: hintText,
|
submitButtonLabel: submitButtonLabel,
|
||||||
prefixIcon: prefixIcon,
|
onSubmit: onSubmit,
|
||||||
initialValue: initialValue,
|
hintText: hintText,
|
||||||
alignMessage: alignMessage,
|
prefixIcon: prefixIcon,
|
||||||
maxLength: maxLength,
|
initialValue: initialValue,
|
||||||
showOnlyLoadingState: showOnlyLoadingState,
|
alignMessage: alignMessage,
|
||||||
textCapitalization: textCapitalization,
|
maxLength: maxLength,
|
||||||
alwaysShowSuccessState: alwaysShowSuccessState,
|
showOnlyLoadingState: showOnlyLoadingState,
|
||||||
isPasswordInput: isPasswordInput,
|
textCapitalization: textCapitalization,
|
||||||
|
alwaysShowSuccessState: alwaysShowSuccessState,
|
||||||
|
isPasswordInput: isPasswordInput,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
Loading…
Add table
Reference in a new issue