Support importing 2FAS export (v3) (#370)
Tested locally. Planning to extract the strings separately while merging common strings from other import sections.
This commit is contained in:
parent
cdc27e061b
commit
4de3287f55
6 changed files with 229 additions and 6 deletions
|
@ -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",
|
||||
|
|
|
@ -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()) {
|
||||
|
|
209
lib/ui/settings/data/import/2fas_import.dart
Normal file
209
lib/ui/settings/data/import/2fas_import.dart
Normal file
|
@ -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<void> 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<void> _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<int?> _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(<int>[]),
|
||||
),
|
||||
);
|
||||
|
||||
final dbBytes = cipher.process(data);
|
||||
return dbBytes;
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -15,6 +15,7 @@ enum ImportType {
|
|||
ravio,
|
||||
googleAuthenticator,
|
||||
aegis,
|
||||
twoFas,
|
||||
bitwarden,
|
||||
}
|
||||
|
||||
|
@ -22,10 +23,11 @@ class ImportCodePage extends StatelessWidget {
|
|||
late List<ImportType> 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);
|
||||
}
|
||||
},
|
||||
|
|
|
@ -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:
|
||||
|
|
Loading…
Add table
Reference in a new issue