diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 26349b45b..299c53f97 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -84,10 +84,12 @@ "importFromApp": "Import codes from {appName}", "importGoogleAuthGuide": "Export your accounts from Google Authenticator to a QR code using the \"Transfer Accounts\" option. Then using another device, scan the QR code.\n\nTip: You can use your laptop's webcam to take a picture of the QR code.", "importSelectJsonFile": "Select JSON file", + "importSelectAppExport": "Select {appName} export 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.", "importBitwardenGuide": "Use the \"Export vault\" option within Bitwarden Tools and import the unencrypted JSON file.", "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.", + "import2FasGuide": "Use the \"Settings->Backup -Export\" option in 2FAS.\n\nIf your backup is encrypted, you will need to enter the password to decrypt the backup", "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/services/user_service.dart b/lib/services/user_service.dart index f9d9dad75..0efed7eca 100644 --- a/lib/services/user_service.dart +++ b/lib/services/user_service.dart @@ -146,9 +146,13 @@ class UserService { if (shouldCache) { if (userDetails.profileData != null) { _preferences.setBool( - kIsEmailMFAEnabled, userDetails.profileData!.isEmailMFAEnabled); + kIsEmailMFAEnabled, + userDetails.profileData!.isEmailMFAEnabled, + ); _preferences.setBool( - kCanDisableEmailMFA, userDetails.profileData!.canDisableEmailMFA); + kCanDisableEmailMFA, + userDetails.profileData!.canDisableEmailMFA, + ); } // handle email change from different client if (userDetails.email != _config.getEmail()) { diff --git a/lib/ui/settings/data/import/2fas_import.dart b/lib/ui/settings/data/import/2fas_import.dart new file mode 100644 index 000000000..e621b8387 --- /dev/null +++ b/lib/ui/settings/data/import/2fas_import.dart @@ -0,0 +1,209 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +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:file_picker/file_picker.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:logging/logging.dart'; +import 'package:pointycastle/export.dart'; + +Future show2FasImportInstruction(BuildContext context) async { + final l10n = context.l10n; + final result = await showDialogWidget( + context: context, + title: l10n.importFromApp("2FAS Authenticator"), + body: l10n.import2FasGuide, + buttons: [ + ButtonWidget( + buttonType: ButtonType.primary, + labelText: l10n.importSelectAppExport("2FAS Authenticator"), + 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 _pick2FasFile(context); + } else {} + } +} + +Future _pick2FasFile(BuildContext context) async { + final l10n = context.l10n; + FilePickerResult? result = await FilePicker.platform + .pickFiles(dialogTitle: l10n.importSelectJsonFile); + if (result == null) { + return; + } + final ProgressDialog progressDialog = + createProgressDialog(context, l10n.pleaseWait); + await progressDialog.show(); + try { + String path = result.files.single.path!; + int? count = await _process2FasExportFile(context, path, progressDialog); + await progressDialog.hide(); + if (count != null) { + await importSuccessDialog(context, count); + } + } catch (e, s) { + Logger('2FASImport').severe('exception while processing import', e, s); + await progressDialog.hide(); + await showErrorDialog( + context, + context.l10n.sorry, + context.l10n.importFailureDesc, + ); + } +} + +Future _process2FasExportFile( + BuildContext context, + String path, + final ProgressDialog dialog, +) async { + File file = File(path); + + final jsonString = await file.readAsString(); + final decodedJson = jsonDecode(jsonString); + int version = (decodedJson['schemaVersion'] ?? 0) as int; + if (version != 3) { + await dialog.hide(); + // todo: extract strings for l10n. Use same naming format as in aegis + // to avoid duplicate translation efforts. + await showErrorDialog( + context, + 'Unsupported format: $version', + version == 0 + ? "The selected file is not a valid 2FAS Authenticator export." + : "Sorry, the app doesn't support this version of 2FAS Authenticator export", + ); + return null; + } + + var decodedServices = decodedJson['services']; + // https://github.com/twofas/2fas-android/blob/e97f1a1040eafaed6d5284d54d33403dff215886/data/services/src/main/java/com/twofasapp/data/services/domain/BackupContent.kt#L39 + final isEncrypted = decodedJson['reference'] != null; + if (isEncrypted) { + String? password; + try { + await showTextInputDialog( + context, + title: "Enter password to decrypt 2FAS backup", + submitButtonLabel: "Submit", + isPasswordInput: true, + onSubmit: (value) async { + password = value; + }, + ); + if (password == null) { + await dialog.hide(); + return null; + } + final content = decrypt2FasVault(decodedJson, password: password!); + decodedServices = jsonDecode(content); + } catch (e, s) { + Logger("2FASImport").warning("exception while decrypting backup", e, s); + await dialog.hide(); + if (password != null) { + await showErrorDialog( + context, + "Failed to decrypt 2Fas export", + "Please check your password and try again.", + ); + } + return null; + } + } + final parsedCodes = []; + for (var item in decodedServices) { + var kind = item['otp']['tokenType']; + var account = item['otp']['account'] ?? ''; + var issuer = item['otp']['issuer'] ?? item['name'] ?? ''; + var algorithm = item['otp']['algorithm']; + var secret = item['secret']; + var timer = item['otp']['period']; + var digits = item['otp']['digits']; + var counter = item['otp']['counter']; + + // Build the OTP URL + String otpUrl; + + if (kind.toLowerCase() == 'totp') { + otpUrl = + 'otpauth://$kind/$issuer:$account?secret=$secret&issuer=$issuer&algorithm=$algorithm&digits=$digits&period=$timer'; + } else if (kind.toLowerCase() == 'hotp') { + otpUrl = + 'otpauth://$kind/$issuer:$account?secret=$secret&issuer=$issuer&algorithm=$algorithm&digits=$digits&counter=$counter'; + } else { + throw Exception('Invalid OTP type'); + } + parsedCodes.add(Code.fromRawData(otpUrl)); + } + + for (final code in parsedCodes) { + await CodeStore.instance.addCode(code, shouldSync: false); + } + unawaited(AuthenticatorService.instance.onlineSync()); + int count = parsedCodes.length; + return count; +} + +String decrypt2FasVault(dynamic data, {required String password}) { + int ITERATION_COUNT = 10000; + int KEY_SIZE = 256; + final String encryptedServices = data["servicesEncrypted"]; + var split = encryptedServices.split(":"); + final encryptedData = base64.decode(split[0]); + final salt = base64.decode(split[1]); + final iv = base64.decode(split[2]); + // derive 256 key using PBKDF2WithHmacSHA256 and 10000 iterations and above salt + final pbkdf2 = PBKDF2KeyDerivator(HMac(SHA256Digest(), 64)); + final params = Pbkdf2Parameters( + salt, + ITERATION_COUNT, + KEY_SIZE ~/ 8, + ); + pbkdf2.init(params); + Uint8List key = Uint8List(KEY_SIZE ~/ 8); + pbkdf2.deriveKey(Uint8List.fromList(utf8.encode(password)), 0, key, 0); + final decrypted = decrypt(key, iv, encryptedData); + final utf8Decode = utf8.decode(decrypted); + return utf8Decode; +} + +Uint8List decrypt(Uint8List key, Uint8List iv, Uint8List data) { + final cipher = GCMBlockCipher(AESEngine()) + ..init( + false, + AEADParameters( + KeyParameter(key), + 128, + iv, + Uint8List.fromList([]), + ), + ); + + final dbBytes = cipher.process(data); + return dbBytes; +} diff --git a/lib/ui/settings/data/import/import_service.dart b/lib/ui/settings/data/import/import_service.dart index 0be1f572e..b2ce9effd 100644 --- a/lib/ui/settings/data/import/import_service.dart +++ b/lib/ui/settings/data/import/import_service.dart @@ -1,3 +1,4 @@ +import 'package:ente_auth/ui/settings/data/import/2fas_import.dart'; import 'package:ente_auth/ui/settings/data/import/aegis_import.dart'; import 'package:ente_auth/ui/settings/data/import/bitwarden_import.dart'; import 'package:ente_auth/ui/settings/data/import/encrypted_ente_import.dart'; @@ -32,6 +33,9 @@ class ImportService { case ImportType.aegis: showAegisImportInstruction(context); break; + case ImportType.twoFas: + show2FasImportInstruction(context); + break; case ImportType.bitwarden: showBitwardenImportInstruction(context); break; diff --git a/lib/ui/settings/data/import_page.dart b/lib/ui/settings/data/import_page.dart index fb8c55d1c..1a14cf319 100644 --- a/lib/ui/settings/data/import_page.dart +++ b/lib/ui/settings/data/import_page.dart @@ -15,6 +15,7 @@ enum ImportType { ravio, googleAuthenticator, aegis, + twoFas, bitwarden, } @@ -22,10 +23,11 @@ class ImportCodePage extends StatelessWidget { late List importOptions = [ ImportType.plainText, ImportType.encrypted, - ImportType.ravio, + ImportType.twoFas, ImportType.aegis, - ImportType.googleAuthenticator, ImportType.bitwarden, + ImportType.googleAuthenticator, + ImportType.ravio, ]; ImportCodePage({super.key}); @@ -42,6 +44,8 @@ class ImportCodePage extends StatelessWidget { return 'Google Authenticator'; case ImportType.aegis: return 'Aegis Authenticator'; + case ImportType.twoFas: + return '2FAS Authenticator'; case ImportType.bitwarden: return 'Bitwarden'; } @@ -66,7 +70,7 @@ class ImportCodePage extends StatelessWidget { iconButtonType: IconButtonType.secondary, onTap: () { Navigator.pop(context); - if(Navigator.canPop(context)) { + if (Navigator.canPop(context)) { Navigator.pop(context); } }, diff --git a/pubspec.yaml b/pubspec.yaml index 046c89978..104c6b264 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: ente_auth description: ente two-factor authenticator -version: 2.0.22+222 +version: 2.0.24+224 publish_to: none environment: