瀏覽代碼

Add support for importing from Google Authenticator

Neeraj Gupta 1 年之前
父節點
當前提交
25b10efec4

+ 1 - 0
lib/l10n/arb/app_en.arb

@@ -78,6 +78,7 @@
   "passwordForDecryptingExport" : "Password to decrypt export",
   "passwordEmptyError": "Password can not be empty",
   "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.",
   "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.",

+ 97 - 0
lib/ui/scanner_gauth_page.dart

@@ -0,0 +1,97 @@
+import 'dart:io';
+
+import 'package:ente_auth/l10n/l10n.dart';
+import 'package:ente_auth/models/code.dart';
+import 'package:ente_auth/theme/ente_theme.dart';
+import 'package:ente_auth/ui/settings/data/import/google_auth_import.dart';
+import 'package:ente_auth/utils/toast_util.dart';
+import 'package:flutter/material.dart';
+import 'package:qr_code_scanner/qr_code_scanner.dart';
+
+class ScannerGoogleAuthPage extends StatefulWidget {
+  const ScannerGoogleAuthPage({Key? key}) : super(key: key);
+
+  @override
+  State<ScannerGoogleAuthPage> createState() => ScannerGoogleAuthPageState();
+}
+
+class ScannerGoogleAuthPageState extends State<ScannerGoogleAuthPage> {
+  final GlobalKey qrKey = GlobalKey(debugLabel: 'QR');
+  QRViewController? controller;
+  String? totp;
+
+  // In order to get hot reload to work we need to pause the camera if the platform
+  // is android, or resume the camera if the platform is iOS.
+  @override
+  void reassemble() {
+    super.reassemble();
+    if (Platform.isAndroid) {
+      controller!.pauseCamera();
+    } else if (Platform.isIOS) {
+      controller!.resumeCamera();
+    }
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    final l10n = context.l10n;
+    return Scaffold(
+      appBar: AppBar(
+        title: Text(l10n.scan),
+      ),
+      body: Column(
+        children: <Widget>[
+          Expanded(
+            flex: 5,
+            child: QRView(
+              key: qrKey,
+              overlay: QrScannerOverlayShape(
+                  borderColor: getEnteColorScheme(context).primary700),
+              onQRViewCreated: _onQRViewCreated,
+              formatsAllowed: const [BarcodeFormat.qrcode],
+            ),
+          ),
+          Expanded(
+            flex: 1,
+            child: Center(
+              child: (totp != null) ? Text(totp!) : Text(l10n.scanACode),
+            ),
+          )
+        ],
+      ),
+    );
+  }
+
+  void _onQRViewCreated(QRViewController controller) {
+    this.controller = controller;
+    // h4ck to remove black screen on Android scanners: https://github.com/juliuscanute/qr_code_scanner/issues/560#issuecomment-1159611301
+    if (Platform.isAndroid) {
+      controller.pauseCamera();
+      controller.resumeCamera();
+    }
+    controller.scannedDataStream.listen((scanData) {
+      try {
+        if (scanData.code == null) {
+          return;
+        }
+        if (scanData.code!.startsWith(kGoogleAuthExportPrefix)) {
+          List<Code> codes = parseGoogleAuth(scanData.code!);
+          controller.dispose();
+          Navigator.of(context).pop(codes);
+        } else {
+          showToast(context, "Invalid QR code");
+        }
+      } catch (e) {
+        controller.dispose();
+        Navigator.of(context).pop();
+        showToast(context, "Error " + e.toString());
+      }
+    });
+  }
+
+  @override
+  void dispose() {
+    controller?.dispose();
+    super.dispose();
+  }
+}

+ 145 - 0
lib/ui/settings/data/import/google_auth_import.dart

@@ -0,0 +1,145 @@
+import 'dart:async';
+import 'dart:convert';
+import 'dart:typed_data';
+import 'package:base32/base32.dart';
+import 'package:ente_auth/l10n/l10n.dart';
+import 'package:ente_auth/models/code.dart';
+import 'package:ente_auth/models/protos/googleauth.pb.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/ui/scanner_gauth_page.dart';
+import 'package:ente_auth/utils/dialog_util.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter/widgets.dart';
+import 'package:logging/logging.dart';
+
+const kGoogleAuthExportPrefix = 'otpauth-migration://offline?data=';
+
+Future<void> showGoogleAuthInstruction(BuildContext context) async {
+  final l10n = context.l10n;
+  final result = await showDialogWidget(
+    context: context,
+    title: l10n.importFromApp("Google Authenticator"),
+    body: l10n.importGoogleAuthGuide,
+    buttons: [
+      ButtonWidget(
+        buttonType: ButtonType.primary,
+        labelText: l10n.scanAQrCode,
+        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) {
+      final List<Code>? codes = await Navigator.of(context).push(
+        MaterialPageRoute(
+          builder: (BuildContext context) {
+            return const ScannerGoogleAuthPage();
+          },
+        ),
+      );
+      if (codes == null || codes.isEmpty) {
+        return;
+      }
+      for (final code in codes) {
+        await CodeStore.instance.addCode(code, shouldSync: false);
+      }
+      unawaited(AuthenticatorService.instance.sync());
+      final DialogWidget dialog = choiceDialog(
+        title: context.l10n.importSuccessTitle,
+        body: context.l10n.importSuccessDesc(codes.length ?? 0),
+        firstButtonLabel: l10n.ok,
+        firstButtonType: ButtonType.primary,
+      );
+      await showConfettiDialog(
+        context: context,
+        dialogBuilder: (BuildContext context) {
+          return dialog;
+        },
+      );
+    }
+  }
+}
+
+List<Code> parseGoogleAuth(String qrCodeData) {
+  try {
+    List<Code> codes = <Code>[];
+    final String payload = qrCodeData.substring(kGoogleAuthExportPrefix.length);
+    debugPrint("GoogleAuthImport: payload: $payload");
+    final Uint8List base64Decoded = base64Decode(Uri.decodeComponent(payload));
+    final MigrationPayload mPayload =
+        MigrationPayload.fromBuffer(base64Decoded);
+    for (var otpParameter in mPayload.otpParameters) {
+      // Build the OTP URL
+      String otpUrl;
+      String issuer = otpParameter.issuer;
+      String account = otpParameter.name;
+      var counter = otpParameter.counter;
+      // Create a list of bytes from the list of integers.
+      Uint8List bytes = Uint8List.fromList(otpParameter.secret);
+
+      // Encode the bytes to base 32.
+      String base32String = base32.encode(bytes);
+      String secret = base32String;
+      // identify digit count
+      int digits = 6;
+      int timer = 30; // default timer, no field in Google Auth
+      Algorithm algorithm = Algorithm.sha1;
+      switch (otpParameter.algorithm) {
+        case MigrationPayload_Algorithm.ALGORITHM_MD5:
+          throw Exception('GoogleAuthImport: MD5 is not supported');
+        case MigrationPayload_Algorithm.ALGORITHM_SHA1:
+          algorithm = Algorithm.sha1;
+          break;
+        case MigrationPayload_Algorithm.ALGORITHM_SHA256:
+          algorithm = Algorithm.sha256;
+          break;
+        case MigrationPayload_Algorithm.ALGORITHM_SHA512:
+          algorithm = Algorithm.sha512;
+          break;
+        case MigrationPayload_Algorithm.ALGORITHM_UNSPECIFIED:
+          algorithm = Algorithm.sha1;
+          break;
+      }
+      switch (otpParameter.digits) {
+        case MigrationPayload_DigitCount.DIGIT_COUNT_EIGHT:
+          digits = 8;
+          break;
+        case MigrationPayload_DigitCount.DIGIT_COUNT_SIX:
+          digits = 6;
+          break;
+        case MigrationPayload_DigitCount.DIGIT_COUNT_UNSPECIFIED:
+          digits = 6;
+      }
+
+      if (otpParameter.type == MigrationPayload_OtpType.OTP_TYPE_TOTP ||
+          otpParameter.type == MigrationPayload_OtpType.OTP_TYPE_UNSPECIFIED) {
+        otpUrl =
+            'otpauth://totp/$issuer:$account?secret=$secret&issuer=$issuer&algorithm=$algorithm&digits=$digits&period=$timer';
+      } else if (otpParameter.type == MigrationPayload_OtpType.OTP_TYPE_HOTP) {
+        otpUrl =
+            'otpauth://hotp/$issuer:$account?secret=$secret&issuer=$issuer&algorithm=$algorithm&digits=$digits&counter=$counter';
+      } else {
+        throw Exception('Invalid OTP type');
+      }
+      codes.add(Code.fromRawData(otpUrl));
+    }
+    return codes;
+  } catch (e, s) {
+    Logger("GoogleAuthImport")
+        .severe("Error while parsing Google Auth QR code", e, s);
+    throw Exception('Failed to parse Google Auth QR code \n ${e.toString()}');
+  }
+}

+ 17 - 6
lib/ui/settings/data/import/import_service.dart

@@ -1,5 +1,6 @@
 
 import 'package:ente_auth/ui/settings/data/import/encrypted_ente_import.dart';
+import 'package:ente_auth/ui/settings/data/import/google_auth_import.dart';
 import 'package:ente_auth/ui/settings/data/import/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';
@@ -12,12 +13,22 @@ class ImportService {
   ImportService._internal();
 
   Future<void> initiateImport(BuildContext context,ImportType type) async {
-    if(type == ImportType.plainText) {
-      showImportInstructionDialog(context);
-    } else if(type == ImportType.ravio) {
-      showRaivoImportInstruction(context);
-    } else {
-      showEncryptedImportInstruction(context);
+    switch(type) {
+
+      case ImportType.plainText:
+        showImportInstructionDialog(context);
+        break;
+      case ImportType.encrypted:
+        showEncryptedImportInstruction(context);
+        break;
+      case ImportType.ravio:
+        showRaivoImportInstruction(context);
+        break;
+      case ImportType.googleAuthenticator:
+        showGoogleAuthInstruction(context);
+
+        // showToast(context, 'coming soon');
+        break;
     }
   }
 }

+ 5 - 1
lib/ui/settings/data/import_page.dart

@@ -14,6 +14,7 @@ enum ImportType {
   plainText,
   encrypted,
   ravio,
+  googleAuthenticator,
 }
 
 class ImportCodePage extends StatelessWidget {
@@ -21,6 +22,7 @@ class ImportCodePage extends StatelessWidget {
     ImportType.plainText,
     ImportType.encrypted,
     ImportType.ravio,
+    ImportType.googleAuthenticator,
   ];
 
   ImportCodePage({super.key});
@@ -32,7 +34,9 @@ class ImportCodePage extends StatelessWidget {
       case ImportType.encrypted:
         return context.l10n.importTypeEnteEncrypted;
       case ImportType.ravio:
-        return 'Ravio OTP';
+        return 'Raivo OTP';
+      case ImportType.googleAuthenticator:
+        return 'Google Authenticator';
     }
   }