Skip to content

Commit 8b4944b

Browse files
Support winauth and totp authenticator
1 parent 087ff0e commit 8b4944b

16 files changed

+1023
-220
lines changed

lib/Screens/Token/import_export_token_screen.dart

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -299,7 +299,6 @@ class _ImportExportTokenScreenState extends State<ImportExportTokenScreen>
299299
ItemBuilder.buildEntryItem(
300300
context: context,
301301
title: S.current.exportUriFile,
302-
bottomRadius: true,
303302
description: S.current.exportUriFileHint,
304303
onTap: () async {
305304
DialogBuilder.showConfirmDialog(
@@ -326,9 +325,9 @@ class _ImportExportTokenScreenState extends State<ImportExportTokenScreen>
326325
);
327326
},
328327
),
329-
const SizedBox(height: 10),
330-
ItemBuilder.buildCaptionItem(
331-
context: context, title: S.current.exportToThirdParty),
328+
// const SizedBox(height: 10),
329+
// ItemBuilder.buildCaptionItem(
330+
// context: context, title: S.current.exportToThirdParty),
332331
ItemBuilder.buildEntryItem(
333332
context: context,
334333
bottomRadius: true,

lib/Screens/Token/token_layout.dart

Lines changed: 197 additions & 189 deletions
Large diffs are not rendered by default.

lib/Screens/home_screen.dart

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -744,6 +744,7 @@ class HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
744744
);
745745
Widget body = tokens.isEmpty
746746
? ListView(
747+
padding: const EdgeInsets.symmetric(vertical: 50),
747748
children: [
748749
ItemBuilder.buildEmptyPlaceholder(
749750
context: context,
@@ -1088,11 +1089,11 @@ enum LayoutType {
10881089
double getHeight([bool hideProgressBar = false]) {
10891090
switch (this) {
10901091
case LayoutType.Simple:
1091-
return hideProgressBar ? 94 : 110;
1092+
return 108;
10921093
case LayoutType.Compact:
1093-
return hideProgressBar ? 97 : 111;
1094+
return 108;
10941095
case LayoutType.Tile:
1095-
return hideProgressBar ? 105 : 118;
1096+
return 114;
10961097
case LayoutType.List:
10971098
return 60;
10981099
case LayoutType.Spotlight:
Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
import 'dart:io';
2+
3+
import 'package:cloudotp/Database/database_manager.dart';
4+
import 'package:cloudotp/Models/opt_token.dart';
5+
import 'package:cloudotp/Models/token_category.dart';
6+
import 'package:cloudotp/Models/token_category_binding.dart';
7+
import 'package:cloudotp/TokenUtils/ThirdParty/base_token_importer.dart';
8+
import 'package:cloudotp/Utils/file_util.dart';
9+
import 'package:cloudotp/Utils/utils.dart';
10+
import 'package:cloudotp/Widgets/Dialog/progress_dialog.dart';
11+
import 'package:convert/convert.dart';
12+
import 'package:flutter/foundation.dart';
13+
import 'package:path/path.dart';
14+
import 'package:sqflite_sqlcipher/sqflite.dart';
15+
16+
import '../../Utils/Base32/base32.dart';
17+
import '../../Utils/ilogger.dart';
18+
import '../../Utils/itoast.dart';
19+
import '../../generated/l10n.dart';
20+
21+
enum AuthenticatorType {
22+
Totp,
23+
Hotp,
24+
Blizzard;
25+
26+
OtpTokenType get tokenType {
27+
switch (this) {
28+
case AuthenticatorType.Totp:
29+
return OtpTokenType.TOTP;
30+
case AuthenticatorType.Hotp:
31+
return OtpTokenType.HOTP;
32+
case AuthenticatorType.Blizzard:
33+
return OtpTokenType.TOTP;
34+
}
35+
}
36+
}
37+
38+
class AuthenticatorPlusToken {
39+
static const String _blizzardIssuer = "Blizzard";
40+
static const OtpDigits _blizzardDigits = OtpDigits.D8;
41+
42+
final String uid;
43+
final String email;
44+
final String secret;
45+
final int counter;
46+
final AuthenticatorType type;
47+
final String issuer;
48+
final String originalName;
49+
final String categoryName;
50+
51+
AuthenticatorPlusToken({
52+
required this.email,
53+
required this.secret,
54+
required this.counter,
55+
required this.type,
56+
required this.issuer,
57+
required this.originalName,
58+
required this.categoryName,
59+
}) : uid = Utils.generateUid();
60+
61+
factory AuthenticatorPlusToken.fromMap(Map<String, dynamic> map) {
62+
return AuthenticatorPlusToken(
63+
email: map['email'] as String,
64+
secret: map['secret'] as String,
65+
counter: map['counter'] as int,
66+
type: AuthenticatorType.values[map['type'] as int],
67+
issuer: map['issuer'] as String,
68+
originalName: map['original_name'] as String,
69+
categoryName: map['category'] as String,
70+
);
71+
}
72+
73+
String _convertSecret(AuthenticatorType type) {
74+
if (this.type == AuthenticatorType.Blizzard) {
75+
final bytes = hex.decode(secret);
76+
final base32Secret = base32.encode(Uint8List.fromList(bytes));
77+
return base32Secret;
78+
}
79+
return secret;
80+
}
81+
82+
OtpToken toOtpToken() {
83+
String issuer = "";
84+
String? username;
85+
if (issuer.isNotEmpty) {
86+
issuer =
87+
type == AuthenticatorType.Blizzard ? _blizzardIssuer : this.issuer;
88+
if (email.isNotEmpty) {
89+
username = email;
90+
}
91+
} else {
92+
final originalNameParts = originalName.split(':');
93+
if (originalNameParts.length == 2) {
94+
issuer = originalNameParts[0];
95+
if (issuer.isEmpty) {
96+
issuer = email;
97+
} else {
98+
username = email;
99+
}
100+
} else {
101+
issuer = email;
102+
}
103+
}
104+
final secret = _convertSecret(type);
105+
OtpToken token = OtpToken.init();
106+
token.uid = uid;
107+
token.issuer = issuer;
108+
token.account = username ?? "";
109+
token.secret = secret;
110+
token.tokenType = type.tokenType;
111+
token.counterString = counter > 0
112+
? counter.toString()
113+
: token.tokenType.defaultDigits.toString();
114+
token.digits = type == AuthenticatorType.Blizzard
115+
? _blizzardDigits
116+
: token.tokenType.defaultDigits;
117+
token.algorithm = OtpAlgorithm.fromString("SHA1");
118+
token.periodString = token.tokenType.defaultPeriod.toString();
119+
return token;
120+
}
121+
}
122+
123+
class AuthenticatorPlusGroup {
124+
String id;
125+
String name;
126+
127+
AuthenticatorPlusGroup({
128+
required this.id,
129+
required this.name,
130+
});
131+
132+
TokenCategory toTokenCategory() {
133+
return TokenCategory.title(
134+
tUid: id,
135+
title: name,
136+
);
137+
}
138+
139+
factory AuthenticatorPlusGroup.fromJson(Map<String, dynamic> json) {
140+
return AuthenticatorPlusGroup(
141+
id: Utils.generateUid(),
142+
name: json['name'],
143+
);
144+
}
145+
}
146+
147+
class AuthenticatorPlusTokenImporter implements BaseTokenImporter {
148+
static const String baseAlgorithm = 'AES';
149+
static const String mode = 'GCM';
150+
static const String padding = 'NoPadding';
151+
static const String algorithmDescription = '$baseAlgorithm/$mode/$padding';
152+
153+
static const int iterations = 10000;
154+
static const int keyLength = 32;
155+
156+
Future<ImporterResult> _convertFromConnectionAsync(Database database) async {
157+
final sourceAccounts = await database.query('accounts');
158+
final sourceCategories = await database.query('category');
159+
160+
final authenticators = <AuthenticatorPlusToken>[];
161+
final categories = sourceCategories
162+
.map((row) => AuthenticatorPlusGroup.fromJson(row))
163+
.toList();
164+
final bindings = <TokenCategoryBinding>[];
165+
166+
for (final accountRow in sourceAccounts) {
167+
try {
168+
final account = AuthenticatorPlusToken.fromMap(accountRow);
169+
170+
if (account.categoryName != "All Accounts") {
171+
late final AuthenticatorPlusGroup? category;
172+
try {
173+
categories.firstWhere((c) => c.name == account.categoryName);
174+
} catch (e) {
175+
category = null;
176+
}
177+
if (category == null) continue;
178+
179+
final binding = TokenCategoryBinding(
180+
tokenUid: account.uid,
181+
categoryUid: category.id,
182+
);
183+
184+
bindings.add(binding);
185+
}
186+
} catch (e, t) {
187+
debugPrint("Failed to convert account: $e\n$t");
188+
}
189+
}
190+
191+
final backup = ImporterResult(
192+
authenticators.map((e) => e.toOtpToken()).toList(),
193+
categories.map((e) => e.toTokenCategory()).toList(),
194+
bindings,
195+
);
196+
197+
return backup;
198+
}
199+
200+
@override
201+
Future<void> importFromPath(
202+
String path, {
203+
bool showLoading = true,
204+
}) async {
205+
late ProgressDialog dialog;
206+
if (showLoading) {
207+
dialog =
208+
showProgressDialog(msg: S.current.importing, showProgress: false);
209+
}
210+
try {
211+
File file = File(path);
212+
if (!file.existsSync()) {
213+
IToast.showTop(S.current.fileNotExist);
214+
} else {
215+
try {
216+
final path = join(await FileUtil.getDatabaseDir(),
217+
'${DateTime.now().millisecondsSinceEpoch}.db');
218+
await file.copy(path);
219+
String password = "";
220+
final database = await DatabaseManager.cipherDbFactory.openDatabase(
221+
path,
222+
options: OpenDatabaseOptions(
223+
version: 1,
224+
singleInstance: true,
225+
onConfigure: (db) async {
226+
await db.execute('PRAGMA cipher_compatibility = 3');
227+
await db.rawQuery("PRAGMA KEY='$password'");
228+
},
229+
),
230+
);
231+
try {
232+
ImporterResult result = await _convertFromConnectionAsync(database);
233+
await BaseTokenImporter.importResult(result);
234+
} catch (e) {
235+
IToast.showTop(S.current.importFailed);
236+
} finally {
237+
await database.close();
238+
}
239+
} finally {
240+
File(path).deleteSync();
241+
}
242+
}
243+
} catch (e, t) {
244+
ILogger.error("Failed to import from 2FAS", e, t);
245+
IToast.showTop(S.current.importFailed);
246+
} finally {
247+
if (showLoading) {
248+
dialog.dismiss();
249+
}
250+
}
251+
}
252+
}

lib/TokenUtils/ThirdParty/base_token_importer.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import '../import_token_util.dart';
77

88
enum DecryptResult {
99
success,
10+
noFileInZip,
1011
invalidPasswordOrDataCorrupted,
1112
}
1213

0 commit comments

Comments
 (0)