diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index ac6b9b389..e5ac80b1c 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -85,7 +85,7 @@ "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.", - "importAegisGuide": "Use the \"Export the vault\" option in Aegis's Settings.\n\nDeselect 'Encrypt the vault` option while exporting from Aegis", + "importAegisGuide": "Use the \"Export the vault\" option in Aegis's Settings.\n\nIf your vault is encrypted, you will need to enter vault password to decrypt the vault.", "exportCodes": "Export codes", "importLabel": "Import", "importInstruction": "Please select a file that contains a list of your codes in the following format", diff --git a/lib/ui/settings/data/import/aegis_import.dart b/lib/ui/settings/data/import/aegis_import.dart index 2e46425dd..46f18b8eb 100644 --- a/lib/ui/settings/data/import/aegis_import.dart +++ b/lib/ui/settings/data/import/aegis_import.dart @@ -1,20 +1,28 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; +import 'dart:typed_data'; +import 'package:convert/convert.dart'; import 'package:ente_auth/l10n/l10n.dart'; import 'package:ente_auth/models/code.dart'; import 'package:ente_auth/services/authenticator_service.dart'; import 'package:ente_auth/store/code_store.dart'; +import 'package:ente_auth/ui/common/progress_dialog.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/ui/settings/data/import/import_success.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/material.dart'; import 'package:logging/logging.dart'; +import 'package:pointycastle/block/aes.dart'; +import 'package:pointycastle/block/modes/gcm.dart'; +import 'package:pointycastle/key_derivators/scrypt.dart'; +import 'package:pointycastle/pointycastle.dart'; Future showAegisImportInstruction(BuildContext context) async { final l10n = context.l10n; @@ -41,22 +49,24 @@ Future showAegisImportInstruction(BuildContext context) async { ); if (result?.action != null && result!.action != ButtonAction.cancel) { if (result.action == ButtonAction.first) { - await _pickRaivoJsonFile(context); + await _pickAegisJsonFile(context); } else {} } } -Future _pickRaivoJsonFile(BuildContext context) async { +Future _pickAegisJsonFile(BuildContext context) async { final l10n = context.l10n; - FilePickerResult? result = await FilePicker.platform.pickFiles(); + FilePickerResult? result = await FilePicker.platform + .pickFiles(dialogTitle: l10n.importSelectJsonFile); if (result == null) { return; } - final progressDialog = createProgressDialog(context, l10n.pleaseWait); + final ProgressDialog progressDialog = + createProgressDialog(context, l10n.pleaseWait); await progressDialog.show(); try { String path = result.files.single.path!; - int? count = await _processRaivoExportFile(context, path); + int? count = await _processAegisExportFile(context, path, progressDialog); await progressDialog.hide(); if (count != null) { await importSuccessDialog(context, count); @@ -72,21 +82,51 @@ Future _pickRaivoJsonFile(BuildContext context) async { } } -Future _processRaivoExportFile(BuildContext context, String path) async { +Future _processAegisExportFile( + BuildContext context, String path, final ProgressDialog dialog) async { File file = File(path); - if (path.endsWith('.zip')) { - await showErrorDialog( - context, - context.l10n.sorry, - "We don't support zip files yet. Please unzip the file and try again.", - ); - return null; - } + final jsonString = await file.readAsString(); final decodedJson = jsonDecode(jsonString); - int version = decodedJson['db']['version']; + final isEncrypted = decodedJson['header']['slots'] != null; + var aegisDB; + if (isEncrypted) { + String? password; + try { + await showTextInputDialog( + context, + title: "Enter password to aegis vault", + submitButtonLabel: "Submit", + isPasswordInput: true, + onSubmit: (value) async { + password = value; + }, + ); + if (password == null) { + await dialog.hide(); + return null; + } + final content = decryptAegisVault(decodedJson, password: password!); + aegisDB = jsonDecode(content); + } catch (e, s) { + Logger("AegisImport") + .warning("exception while decrypting aegis vault", e, s); + await dialog.hide(); + if (password != null) { + await showErrorDialog( + context, + "Failed to decrypt aegis vault", + "Please check your password and try again.", + ); + } + return null; + } + } else { + aegisDB = decodedJson['db']; + } + int dbVersion = aegisDB['version']; final parsedCodes = []; - for (var item in decodedJson['db']['entries']) { + for (var item in aegisDB['entries']) { var kind = item['type']; var account = item['name']; var issuer = item['issuer']; @@ -119,3 +159,77 @@ Future _processRaivoExportFile(BuildContext context, String path) async { int count = parsedCodes.length; return count; } + +String decryptAegisVault(dynamic data, {required String password}) { + final header = data["header"]; + final slots = + (header["slots"] as List).where((slot) => slot["type"] == 1).toList(); + + Uint8List? masterKey; + for (final slot in slots) { + final salt = Uint8List.fromList(hex.decode(slot["salt"])); + final int iterations = slot["n"]; + final int r = slot["r"]; + final int p = slot["p"]; + const int derivedKeyLength = 32; + final script = Scrypt() + ..init( + ScryptParameters( + iterations, + r, + p, + derivedKeyLength, + salt, + ), + ); + + final key = script.process(Uint8List.fromList(utf8.encode(password))); + + final params = slot["key_params"]; + final nonce = Uint8List.fromList(hex.decode(params["nonce"])); + final encryptedKeyWithTag = + Uint8List.fromList(hex.decode(slot["key"]) + hex.decode(params["tag"])); + + final cipher = GCMBlockCipher(AESEngine()) + ..init( + false, + AEADParameters( + KeyParameter(key), + 128, + nonce, + Uint8List.fromList([]), + ), + ); + + try { + masterKey = cipher.process(encryptedKeyWithTag); + break; + } catch (e) { + // Ignore decryption failure and continue to next slot + } + } + + if (masterKey == null) { + throw Exception("Unable to decrypt the master key with the given password"); + } + + final content = base64.decode(data["db"]); + final params = header["params"]; + final nonce = Uint8List.fromList(hex.decode(params["nonce"])); + final tag = Uint8List.fromList(hex.decode(params["tag"])); + final cipherTextWithTag = Uint8List.fromList(content + tag); + + final cipher = GCMBlockCipher(AESEngine()) + ..init( + false, + AEADParameters( + KeyParameter(masterKey), + 128, + nonce, + Uint8List.fromList([]), + ), + ); + + final dbBytes = cipher.process(cipherTextWithTag); + return utf8.decode(dbBytes); +}