|
@@ -0,0 +1,236 @@
|
|
|
|
+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: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<void> showAegisImportInstruction(BuildContext context) async {
|
|
|
|
+ final l10n = context.l10n;
|
|
|
|
+ final result = await showDialogWidget(
|
|
|
|
+ context: context,
|
|
|
|
+ title: l10n.importFromApp("Aegis Authenticator"),
|
|
|
|
+ body: l10n.importAegisGuide,
|
|
|
|
+ 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 _pickAegisJsonFile(context);
|
|
|
|
+ } else {}
|
|
|
|
+ }
|
|
|
|
+}
|
|
|
|
+
|
|
|
|
+Future<void> _pickAegisJsonFile(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 _processAegisExportFile(context, path, progressDialog);
|
|
|
|
+ await progressDialog.hide();
|
|
|
|
+ if (count != null) {
|
|
|
|
+ await importSuccessDialog(context, count);
|
|
|
|
+ }
|
|
|
|
+ } catch (e, s) {
|
|
|
|
+ Logger('AegisImport').severe('exception while processing for aegis', e, s);
|
|
|
|
+ await progressDialog.hide();
|
|
|
|
+ await showErrorDialog(
|
|
|
|
+ context,
|
|
|
|
+ context.l10n.sorry,
|
|
|
|
+ context.l10n.importFailureDesc,
|
|
|
|
+ );
|
|
|
|
+ }
|
|
|
|
+}
|
|
|
|
+
|
|
|
|
+Future<int?> _processAegisExportFile(
|
|
|
|
+ BuildContext context,
|
|
|
|
+ String path,
|
|
|
|
+ final ProgressDialog dialog,
|
|
|
|
+) async {
|
|
|
|
+ File file = File(path);
|
|
|
|
+
|
|
|
|
+ final jsonString = await file.readAsString();
|
|
|
|
+ final decodedJson = jsonDecode(jsonString);
|
|
|
|
+ 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'];
|
|
|
|
+ }
|
|
|
|
+ final parsedCodes = [];
|
|
|
|
+ for (var item in aegisDB['entries']) {
|
|
|
|
+ var kind = item['type'];
|
|
|
|
+ var account = item['name'];
|
|
|
|
+ var issuer = item['issuer'];
|
|
|
|
+ var algorithm = item['info']['algo'];
|
|
|
|
+ var secret = item['info']['secret'];
|
|
|
|
+ var timer = item['info']['period'];
|
|
|
|
+ var digits = item['info']['digits'];
|
|
|
|
+
|
|
|
|
+ var counter = item['info']['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.sync());
|
|
|
|
+ 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(<int>[]),
|
|
|
|
+ ),
|
|
|
|
+ );
|
|
|
|
+
|
|
|
|
+ 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(<int>[]),
|
|
|
|
+ ),
|
|
|
|
+ );
|
|
|
|
+
|
|
|
|
+ final dbBytes = cipher.process(cipherTextWithTag);
|
|
|
|
+ return utf8.decode(dbBytes);
|
|
|
|
+}
|