Skip to content

Commit a73b9db

Browse files
committed
feat: Now allowing to decrypt and save more than one TOTP on home page.
1 parent b29d48a commit a73b9db

File tree

8 files changed

+170
-44
lines changed

8 files changed

+170
-44
lines changed

lib/i18n/en/totp.json

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,21 @@
2727
},
2828
"totpKeyDialog": {
2929
"title": "Decrypted with success",
30-
"message": "Do you want to change your master password or to encrypt this TOTP with your current encryption key ?",
30+
"message": {
31+
"one": "Do you want to change your master password or to encrypt this TOTP with your current encryption key ?",
32+
"other": "This TOTP has been decrypted with success, and the password has successfully decrypted more TOTPs. What do you want to do ?"
33+
},
3134
"choices": {
35+
"changeAllDecryptedTotpsKey": {
36+
"title": "Encrypt TOTPs with your current key",
37+
"subtitle": "Change this TOTP encryption key as well as those that have been decrypted, and use yours instead."
38+
},
3239
"changeTotpKey": {
33-
"title": "Encrypt this TOTP with your current key",
34-
"subtitle": "Change this TOTP encryption key, and use yours instead."
40+
"title": {
41+
"one": "Encrypt this TOTP with your current key",
42+
"other": "Encrypt only this TOTP with your current key"
43+
},
44+
"subtitle": "Change this TOTP encryption key, and use yours instead. The other decrypted TOTPs will be usable as long as you leave the app opened."
3545
},
3646
"changeMasterPassword": {
3747
"title": "Change your current master password",

lib/i18n/fr/totp.json

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,21 @@
2727
},
2828
"totpKeyDialog": {
2929
"title": "Déchiffrement réussi",
30-
"message": "Souhaitez-vous changer votre mot de passe maître ou chiffrer ce TOTP avec votre clé de chiffrement actuelle ?",
30+
"message": {
31+
"one": "Souhaitez-vous changer votre mot de passe maître ou chiffrer ce TOTP avec votre clé de chiffrement actuelle ?",
32+
"other": "Ce TOTP a été déchiffré avec succès, et le mot de passe a permis de déchiffrer plus de TOTPs. Que souhaitez-vous faire ?"
33+
},
3134
"choices": {
35+
"changeAllDecryptedTotpsKey": {
36+
"title": "Chiffrer les TOTPs avec votre clé actuelle",
37+
"subtitle": "Changer la clé de chiffrement utilisée pour ce TOTP ainsi que pour ceux qui ont pu être déchiffrés, et utiliser la votre à la place."
38+
},
3239
"changeTotpKey": {
33-
"title": "Chiffrer ce TOTP avec votre clé actuelle",
34-
"subtitle": "Changer la clé de chiffrement utilisée pour ce TOTP, et utiliser la votre à la place."
40+
"title": {
41+
"one": "Chiffrer ce TOTP avec votre clé actuelle",
42+
"other": "Chiffrer seulement ce TOTP avec votre clé actuelle"
43+
},
44+
"subtitle": "Changer la clé de chiffrement utilisée pour ce TOTP, et utiliser la votre à la place. Les autres TOTPs déchiffrés seront utilisables jusqu'à la fermeture de l'application."
3545
},
3646
"changeMasterPassword": {
3747
"title": "Changer votre mot de passe maître",

lib/model/storage/local.dart

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,10 +102,22 @@ class LocalStorage extends _$LocalStorage with Storage {
102102
}
103103

104104
@override
105-
Future<void> updateTotp(String uuid, Totp totp) async {
105+
Future<void> updateTotp(Totp totp) async {
106106
await update(totps).replace(totp.asDriftTotp);
107107
}
108108

109+
@override
110+
Future<void> updateTotps(List<Totp> totps) async {
111+
await batch((batch) {
112+
batch.replaceAll(
113+
this.totps,
114+
[
115+
for (Totp totp in totps) totp.asDriftTotp,
116+
],
117+
);
118+
});
119+
}
120+
109121
@override
110122
Future<Totp?> getTotp(String uuid) async {
111123
_DriftTotp? totp = await (select(totps)

lib/model/storage/online.dart

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,9 +87,19 @@ class OnlineStorage with Storage {
8787
}
8888

8989
@override
90-
Future<void> updateTotp(String uuid, Totp totp) async {
90+
Future<void> updateTotp(Totp totp) async {
9191
CollectionReference? collection = _totpsCollection;
92-
await collection.doc(uuid).set(totp.toFirestore());
92+
await collection.doc(totp.uuid).set(totp.toFirestore());
93+
}
94+
95+
@override
96+
Future<void> updateTotps(List<Totp> totps) async {
97+
CollectionReference? collection = _totpsCollection;
98+
WriteBatch batch = _firestore.batch();
99+
for (Totp totp in totps) {
100+
batch.set(collection.doc(totp.uuid), totp.toFirestore());
101+
}
102+
await batch.commit();
93103
}
94104

95105
@override

lib/model/storage/storage.dart

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -218,8 +218,11 @@ mixin Storage {
218218
/// Stores the given [totps].
219219
Future<void> addTotps(List<Totp> totps);
220220

221-
/// Updates the TOTP associated with the specified [uuid].
222-
Future<void> updateTotp(String uuid, Totp totp);
221+
/// Updates the [totp].
222+
Future<void> updateTotp(Totp totp);
223+
224+
/// Updates all [totps].
225+
Future<void> updateTotps(List<Totp> totps);
223226

224227
/// Deletes the TOTP associated to the given [uuid].
225228
Future<void> deleteTotp(String uuid);

lib/model/totp/repository.dart

Lines changed: 34 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,17 @@ class TotpRepository extends AutoDisposeAsyncNotifier<TotpList> {
3232
}
3333

3434
/// Tries to decrypt all TOTPs with the given [cryptoStore].
35-
Future<void> tryDecryptAll(CryptoStore? cryptoStore) async {
35+
/// Returns all newly decrypted TOTPs.
36+
Future<Set<DecryptedTotp>> tryDecryptAll(CryptoStore? cryptoStore) async {
3637
TotpList totpList = await future;
3738
state = const AsyncLoading();
38-
state = AsyncData(TotpList._(
39+
TotpList newTotpList = TotpList._(
3940
list: await totpList._list.decrypt(cryptoStore),
4041
operationThreshold: totpList.operationThreshold,
41-
));
42+
);
43+
Set<DecryptedTotp> difference = newTotpList.decryptedTotps.toSet().difference(totpList.decryptedTotps.toSet());
44+
state = AsyncData(newTotpList);
45+
return difference;
4246
}
4347

4448
/// Refreshes the current state.
@@ -133,18 +137,33 @@ class TotpRepository extends AutoDisposeAsyncNotifier<TotpList> {
133137
}
134138
}
135139

136-
/// Updates the TOTP associated with the specified [uuid].
137-
Future<Result<Totp>> updateTotp(String uuid, DecryptedTotp totp) async {
140+
/// Updates the [totp].
141+
Future<Result<Totp>> updateTotp(DecryptedTotp totp) async => await _updateTotps([totp]);
142+
143+
/// Updates the [totps].
144+
Future<Result<Totp>> updateTotps(List<Totp> totps) async => await _updateTotps(totps);
145+
146+
/// Updates the [totps].
147+
Future<Result<Totp>> _updateTotps(List<Totp> totps) async {
138148
try {
149+
if (totps.isEmpty) {
150+
return const ResultSuccess();
151+
}
139152
TotpList totpList = await future;
140153
await totpList.waitBeforeNextOperation();
141154
Storage storage = await ref.read(storageProvider.future);
142-
await storage.updateTotp(uuid, totp);
155+
if (totps.length > 1) {
156+
await storage.updateTotps(totps);
157+
} else {
158+
await storage.updateTotp(totps.first);
159+
}
143160
TotpImageCacheManager totpImageCacheManager = ref.read(totpImageCacheManagerProvider.notifier);
144-
await totpImageCacheManager.cacheImage(totp);
161+
for (Totp totp in totps) {
162+
await totpImageCacheManager.cacheImage(totp);
163+
}
145164
state = AsyncData(
146165
TotpList._fromListAndStorage(
147-
list: _mergeToCurrentList(totpList, totp: totp),
166+
list: _mergeToCurrentList(totpList, totps: totps),
148167
storage: storage,
149168
),
150169
);
@@ -188,7 +207,7 @@ class TotpRepository extends AutoDisposeAsyncNotifier<TotpList> {
188207
String password, {
189208
String? backupPassword,
190209
Uint8List? salt,
191-
updateTotps = true,
210+
bool updateTotps = true,
192211
}) async {
193212
try {
194213
TotpList totpList = await future;
@@ -309,6 +328,12 @@ class TotpList extends Iterable<Totp> {
309328
}
310329
return Future.delayed(nextPossibleOperationTime.difference(now));
311330
}
331+
332+
/// Returns the decrypted TOTPs list.
333+
List<DecryptedTotp> get decryptedTotps => [
334+
for (Totp totp in _list)
335+
if (totp.isDecrypted) totp as DecryptedTotp,
336+
];
312337
}
313338

314339
/// The TOTP limit provider.

lib/pages/home.dart

Lines changed: 79 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -311,55 +311,93 @@ class _HomePageBody extends ConsumerWidget {
311311
return;
312312
}
313313

314-
late CryptoStore previousCryptoStore;
315314
TotpRepository repository = ref.read(totpRepositoryProvider.notifier);
316-
Totp decrypted = await showWaitingOverlay(
315+
(CryptoStore, List<DecryptedTotp>) decryptedTotps = await showWaitingOverlay(
317316
context,
318317
future: () async {
319-
previousCryptoStore = await CryptoStore.fromPassword(password, totp.encryptedData.encryptionSalt);
320-
return await totp.decrypt(previousCryptoStore);
318+
CryptoStore previousCryptoStore = await CryptoStore.fromPassword(password, totp.encryptedData.encryptionSalt);
319+
Totp targetTotp = await totp.decrypt(previousCryptoStore);
320+
if (!targetTotp.isDecrypted) {
321+
return (previousCryptoStore, <DecryptedTotp>[]);
322+
}
323+
Set<DecryptedTotp> decryptedTotps = await repository.tryDecryptAll(previousCryptoStore);
324+
return (
325+
previousCryptoStore,
326+
[
327+
targetTotp as DecryptedTotp,
328+
for (DecryptedTotp decryptedTotp in decryptedTotps)
329+
if (targetTotp.uuid != decryptedTotp.uuid) decryptedTotp,
330+
],
331+
);
321332
}(),
322333
);
323334
if (!context.mounted) {
324335
return;
325336
}
326-
if (!decrypted.isDecrypted) {
337+
if (decryptedTotps.$2.isEmpty) {
327338
SnackBarIcon.showErrorSnackBar(context, text: translations.error.totpDecrypt);
328339
return;
329340
}
330341

331342
_TotpKeyDialogResult? choice = await showDialog<_TotpKeyDialogResult>(
332343
context: context,
333-
builder: (context) => _TotpKeyDialog(),
344+
builder: (context) => _TotpKeyDialog(
345+
decryptedTotps: decryptedTotps.$2,
346+
),
334347
);
335348

336-
switch (choice) {
337-
case _TotpKeyDialogResult.changeTotpKey:
349+
if (!context.mounted) {
350+
return;
351+
}
352+
353+
Future<Result> changeTotpsKey(CryptoStore oldCryptoStore, List<DecryptedTotp> totps) async {
354+
try {
338355
CryptoStore? currentCryptoStore = await ref.read(cryptoStoreProvider.future);
339356
if (currentCryptoStore == null) {
340-
if (context.mounted) {
341-
SnackBarIcon.showErrorSnackBar(context, text: translations.error.generic.tryAgain);
342-
}
343-
break;
357+
throw Exception('Unable to get current crypto store.');
344358
}
345-
DecryptedTotp? decryptedTotpWithNewKey = await totp.changeEncryptionKey(previousCryptoStore, currentCryptoStore);
346-
if (decryptedTotpWithNewKey == null || !decryptedTotpWithNewKey.isDecrypted) {
347-
if (context.mounted) {
348-
SnackBarIcon.showErrorSnackBar(context, text: translations.error.generic.tryAgain);
359+
List<DecryptedTotp> toUpdate = [];
360+
for (DecryptedTotp totp in totps) {
361+
DecryptedTotp? decryptedTotpWithNewKey = await totp.changeEncryptionKey(oldCryptoStore, currentCryptoStore);
362+
if (decryptedTotpWithNewKey == null || !decryptedTotpWithNewKey.isDecrypted) {
363+
throw Exception('Failed to encrypt TOTP with current crypto store.');
349364
}
350-
break;
365+
toUpdate.add(decryptedTotpWithNewKey);
366+
}
367+
return await repository.updateTotps(toUpdate);
368+
} catch (ex, stacktrace) {
369+
return ResultError(
370+
exception: ex,
371+
stacktrace: stacktrace,
372+
);
373+
}
374+
}
375+
376+
switch (choice) {
377+
case _TotpKeyDialogResult.changeTotpKey:
378+
Result result = await showWaitingOverlay(
379+
context,
380+
future: changeTotpsKey(decryptedTotps.$1, [decryptedTotps.$2.first]),
381+
);
382+
if (context.mounted) {
383+
context.showSnackBarForResult(result, retryIfError: true);
351384
}
352-
await repository.updateTotp(totp.uuid, decryptedTotpWithNewKey);
353385
break;
354-
case _TotpKeyDialogResult.changeMasterPassword:
386+
case _TotpKeyDialogResult.changeAllTotpsKey:
387+
Result result = await showWaitingOverlay(
388+
context,
389+
future: changeTotpsKey(decryptedTotps.$1, decryptedTotps.$2),
390+
);
355391
if (context.mounted) {
356-
await MasterPasswordUtils.changeMasterPassword(context, ref, password: password);
392+
context.showSnackBarForResult(result, retryIfError: true);
357393
}
358394
break;
395+
case _TotpKeyDialogResult.changeMasterPassword:
396+
await MasterPasswordUtils.changeMasterPassword(context, ref, password: password);
397+
break;
359398
default:
360399
break;
361400
}
362-
await repository.tryDecryptAll(previousCryptoStore);
363401
}
364402
}
365403

@@ -508,6 +546,14 @@ enum _AddTotpDialogResult {
508546

509547
/// Allows the user to choose an action to execute when a TOTP decryption has been done with success.
510548
class _TotpKeyDialog extends StatelessWidget {
549+
/// Contains all decrypted TOTPs.
550+
final List<DecryptedTotp> decryptedTotps;
551+
552+
/// Creates a new TOTP key dialog instance.
553+
const _TotpKeyDialog({
554+
this.decryptedTotps = const [],
555+
});
556+
511557
@override
512558
Widget build(BuildContext context) => AlertDialog(
513559
title: Text(translations.totp.totpKeyDialog.title),
@@ -516,12 +562,19 @@ class _TotpKeyDialog extends StatelessWidget {
516562
mainAxisSize: MainAxisSize.min,
517563
children: [
518564
Text(
519-
translations.totp.totpKeyDialog.message,
565+
translations.totp.totpKeyDialog.message(n: decryptedTotps.length),
520566
),
567+
if (decryptedTotps.length > 1)
568+
ListTile(
569+
leading: const Icon(Icons.done_all),
570+
onTap: () => Navigator.pop(context, _TotpKeyDialogResult.changeAllTotpsKey),
571+
title: Text(translations.totp.totpKeyDialog.choices.changeAllDecryptedTotpsKey.title),
572+
subtitle: Text(translations.totp.totpKeyDialog.choices.changeAllDecryptedTotpsKey.subtitle),
573+
),
521574
ListTile(
522575
leading: const Icon(Icons.key),
523576
onTap: () => Navigator.pop(context, _TotpKeyDialogResult.changeTotpKey),
524-
title: Text(translations.totp.totpKeyDialog.choices.changeTotpKey.title),
577+
title: Text(translations.totp.totpKeyDialog.choices.changeTotpKey.title(n: decryptedTotps.length)),
525578
subtitle: Text(translations.totp.totpKeyDialog.choices.changeTotpKey.subtitle),
526579
),
527580
ListTile(
@@ -552,6 +605,9 @@ enum _TotpKeyDialogResult {
552605
/// Allows to change the TOTP key.
553606
changeTotpKey,
554607

608+
/// Allows to change all TOTPs key (the current one and those that have been decrypted additionally).
609+
changeAllTotpsKey,
610+
555611
/// Allows to change the current master password.
556612
changeMasterPassword;
557613
}

lib/pages/totp.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -442,7 +442,7 @@ class _TotpPageState extends ConsumerState<TotpPage> with BrightnessListener {
442442
if (totp == null) {
443443
return ResultError();
444444
}
445-
return await ref.read(totpRepositoryProvider.notifier).updateTotp(widget.totp!.uuid, totp);
445+
return await ref.read(totpRepositoryProvider.notifier).updateTotp(totp);
446446
}
447447

448448
/// Creates a [DecryptedTotp] corresponding to the current fields.

0 commit comments

Comments
 (0)