Skip to content

Commit

Permalink
Support winauth and totp authenticator
Browse files Browse the repository at this point in the history
  • Loading branch information
Robert-Stackflow committed Sep 5, 2024
1 parent 087ff0e commit 8b4944b
Show file tree
Hide file tree
Showing 16 changed files with 1,023 additions and 220 deletions.
7 changes: 3 additions & 4 deletions lib/Screens/Token/import_export_token_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -299,7 +299,6 @@ class _ImportExportTokenScreenState extends State<ImportExportTokenScreen>
ItemBuilder.buildEntryItem(
context: context,
title: S.current.exportUriFile,
bottomRadius: true,
description: S.current.exportUriFileHint,
onTap: () async {
DialogBuilder.showConfirmDialog(
Expand All @@ -326,9 +325,9 @@ class _ImportExportTokenScreenState extends State<ImportExportTokenScreen>
);
},
),
const SizedBox(height: 10),
ItemBuilder.buildCaptionItem(
context: context, title: S.current.exportToThirdParty),
// const SizedBox(height: 10),
// ItemBuilder.buildCaptionItem(
// context: context, title: S.current.exportToThirdParty),
ItemBuilder.buildEntryItem(
context: context,
bottomRadius: true,
Expand Down
386 changes: 197 additions & 189 deletions lib/Screens/Token/token_layout.dart

Large diffs are not rendered by default.

7 changes: 4 additions & 3 deletions lib/Screens/home_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -744,6 +744,7 @@ class HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
);
Widget body = tokens.isEmpty
? ListView(
padding: const EdgeInsets.symmetric(vertical: 50),
children: [
ItemBuilder.buildEmptyPlaceholder(
context: context,
Expand Down Expand Up @@ -1088,11 +1089,11 @@ enum LayoutType {
double getHeight([bool hideProgressBar = false]) {
switch (this) {
case LayoutType.Simple:
return hideProgressBar ? 94 : 110;
return 108;
case LayoutType.Compact:
return hideProgressBar ? 97 : 111;
return 108;
case LayoutType.Tile:
return hideProgressBar ? 105 : 118;
return 114;
case LayoutType.List:
return 60;
case LayoutType.Spotlight:
Expand Down
252 changes: 252 additions & 0 deletions lib/TokenUtils/ThirdParty/authenticatorplus_importer.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,252 @@
import 'dart:io';

import 'package:cloudotp/Database/database_manager.dart';
import 'package:cloudotp/Models/opt_token.dart';
import 'package:cloudotp/Models/token_category.dart';
import 'package:cloudotp/Models/token_category_binding.dart';
import 'package:cloudotp/TokenUtils/ThirdParty/base_token_importer.dart';
import 'package:cloudotp/Utils/file_util.dart';
import 'package:cloudotp/Utils/utils.dart';
import 'package:cloudotp/Widgets/Dialog/progress_dialog.dart';
import 'package:convert/convert.dart';
import 'package:flutter/foundation.dart';
import 'package:path/path.dart';
import 'package:sqflite_sqlcipher/sqflite.dart';

import '../../Utils/Base32/base32.dart';
import '../../Utils/ilogger.dart';
import '../../Utils/itoast.dart';
import '../../generated/l10n.dart';

enum AuthenticatorType {
Totp,
Hotp,
Blizzard;

OtpTokenType get tokenType {
switch (this) {
case AuthenticatorType.Totp:
return OtpTokenType.TOTP;
case AuthenticatorType.Hotp:
return OtpTokenType.HOTP;
case AuthenticatorType.Blizzard:
return OtpTokenType.TOTP;
}
}
}

class AuthenticatorPlusToken {
static const String _blizzardIssuer = "Blizzard";
static const OtpDigits _blizzardDigits = OtpDigits.D8;

final String uid;
final String email;
final String secret;
final int counter;
final AuthenticatorType type;
final String issuer;
final String originalName;
final String categoryName;

AuthenticatorPlusToken({
required this.email,
required this.secret,
required this.counter,
required this.type,
required this.issuer,
required this.originalName,
required this.categoryName,
}) : uid = Utils.generateUid();

factory AuthenticatorPlusToken.fromMap(Map<String, dynamic> map) {
return AuthenticatorPlusToken(
email: map['email'] as String,
secret: map['secret'] as String,
counter: map['counter'] as int,
type: AuthenticatorType.values[map['type'] as int],
issuer: map['issuer'] as String,
originalName: map['original_name'] as String,
categoryName: map['category'] as String,
);
}

String _convertSecret(AuthenticatorType type) {
if (this.type == AuthenticatorType.Blizzard) {
final bytes = hex.decode(secret);
final base32Secret = base32.encode(Uint8List.fromList(bytes));
return base32Secret;
}
return secret;
}

OtpToken toOtpToken() {
String issuer = "";
String? username;
if (issuer.isNotEmpty) {
issuer =
type == AuthenticatorType.Blizzard ? _blizzardIssuer : this.issuer;
if (email.isNotEmpty) {
username = email;
}
} else {
final originalNameParts = originalName.split(':');
if (originalNameParts.length == 2) {
issuer = originalNameParts[0];
if (issuer.isEmpty) {
issuer = email;
} else {
username = email;
}
} else {
issuer = email;
}
}
final secret = _convertSecret(type);
OtpToken token = OtpToken.init();
token.uid = uid;
token.issuer = issuer;
token.account = username ?? "";
token.secret = secret;
token.tokenType = type.tokenType;
token.counterString = counter > 0
? counter.toString()
: token.tokenType.defaultDigits.toString();
token.digits = type == AuthenticatorType.Blizzard
? _blizzardDigits
: token.tokenType.defaultDigits;
token.algorithm = OtpAlgorithm.fromString("SHA1");
token.periodString = token.tokenType.defaultPeriod.toString();
return token;
}
}

class AuthenticatorPlusGroup {
String id;
String name;

AuthenticatorPlusGroup({
required this.id,
required this.name,
});

TokenCategory toTokenCategory() {
return TokenCategory.title(
tUid: id,
title: name,
);
}

factory AuthenticatorPlusGroup.fromJson(Map<String, dynamic> json) {
return AuthenticatorPlusGroup(
id: Utils.generateUid(),
name: json['name'],
);
}
}

class AuthenticatorPlusTokenImporter implements BaseTokenImporter {
static const String baseAlgorithm = 'AES';
static const String mode = 'GCM';
static const String padding = 'NoPadding';
static const String algorithmDescription = '$baseAlgorithm/$mode/$padding';

static const int iterations = 10000;
static const int keyLength = 32;

Future<ImporterResult> _convertFromConnectionAsync(Database database) async {
final sourceAccounts = await database.query('accounts');
final sourceCategories = await database.query('category');

final authenticators = <AuthenticatorPlusToken>[];
final categories = sourceCategories
.map((row) => AuthenticatorPlusGroup.fromJson(row))
.toList();
final bindings = <TokenCategoryBinding>[];

for (final accountRow in sourceAccounts) {
try {
final account = AuthenticatorPlusToken.fromMap(accountRow);

if (account.categoryName != "All Accounts") {
late final AuthenticatorPlusGroup? category;
try {
categories.firstWhere((c) => c.name == account.categoryName);
} catch (e) {
category = null;
}
if (category == null) continue;

final binding = TokenCategoryBinding(
tokenUid: account.uid,
categoryUid: category.id,
);

bindings.add(binding);
}
} catch (e, t) {
debugPrint("Failed to convert account: $e\n$t");
}
}

final backup = ImporterResult(
authenticators.map((e) => e.toOtpToken()).toList(),
categories.map((e) => e.toTokenCategory()).toList(),
bindings,
);

return backup;
}

@override
Future<void> importFromPath(
String path, {
bool showLoading = true,
}) async {
late ProgressDialog dialog;
if (showLoading) {
dialog =
showProgressDialog(msg: S.current.importing, showProgress: false);
}
try {
File file = File(path);
if (!file.existsSync()) {
IToast.showTop(S.current.fileNotExist);
} else {
try {
final path = join(await FileUtil.getDatabaseDir(),
'${DateTime.now().millisecondsSinceEpoch}.db');
await file.copy(path);
String password = "";
final database = await DatabaseManager.cipherDbFactory.openDatabase(
path,
options: OpenDatabaseOptions(
version: 1,
singleInstance: true,
onConfigure: (db) async {
await db.execute('PRAGMA cipher_compatibility = 3');
await db.rawQuery("PRAGMA KEY='$password'");
},
),
);
try {
ImporterResult result = await _convertFromConnectionAsync(database);
await BaseTokenImporter.importResult(result);
} catch (e) {
IToast.showTop(S.current.importFailed);
} finally {
await database.close();
}
} finally {
File(path).deleteSync();
}
}
} catch (e, t) {
ILogger.error("Failed to import from 2FAS", e, t);
IToast.showTop(S.current.importFailed);
} finally {
if (showLoading) {
dialog.dismiss();
}
}
}
}
1 change: 1 addition & 0 deletions lib/TokenUtils/ThirdParty/base_token_importer.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import '../import_token_util.dart';

enum DecryptResult {
success,
noFileInZip,
invalidPasswordOrDataCorrupted,
}

Expand Down
Loading

0 comments on commit 8b4944b

Please sign in to comment.