Skip to content

Commit

Permalink
feat: Now allowing to decrypt and save more than one TOTP on home page.
Browse files Browse the repository at this point in the history
  • Loading branch information
Skyost committed Jan 11, 2025
1 parent b29d48a commit a73b9db
Show file tree
Hide file tree
Showing 8 changed files with 170 additions and 44 deletions.
16 changes: 13 additions & 3 deletions lib/i18n/en/totp.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,21 @@
},
"totpKeyDialog": {
"title": "Decrypted with success",
"message": "Do you want to change your master password or to encrypt this TOTP with your current encryption key ?",
"message": {
"one": "Do you want to change your master password or to encrypt this TOTP with your current encryption key ?",
"other": "This TOTP has been decrypted with success, and the password has successfully decrypted more TOTPs. What do you want to do ?"
},
"choices": {
"changeAllDecryptedTotpsKey": {
"title": "Encrypt TOTPs with your current key",
"subtitle": "Change this TOTP encryption key as well as those that have been decrypted, and use yours instead."
},
"changeTotpKey": {
"title": "Encrypt this TOTP with your current key",
"subtitle": "Change this TOTP encryption key, and use yours instead."
"title": {
"one": "Encrypt this TOTP with your current key",
"other": "Encrypt only this TOTP with your current key"
},
"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."
},
"changeMasterPassword": {
"title": "Change your current master password",
Expand Down
16 changes: 13 additions & 3 deletions lib/i18n/fr/totp.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,21 @@
},
"totpKeyDialog": {
"title": "Déchiffrement réussi",
"message": "Souhaitez-vous changer votre mot de passe maître ou chiffrer ce TOTP avec votre clé de chiffrement actuelle ?",
"message": {
"one": "Souhaitez-vous changer votre mot de passe maître ou chiffrer ce TOTP avec votre clé de chiffrement actuelle ?",
"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 ?"
},
"choices": {
"changeAllDecryptedTotpsKey": {
"title": "Chiffrer les TOTPs avec votre clé actuelle",
"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."
},
"changeTotpKey": {
"title": "Chiffrer ce TOTP avec votre clé actuelle",
"subtitle": "Changer la clé de chiffrement utilisée pour ce TOTP, et utiliser la votre à la place."
"title": {
"one": "Chiffrer ce TOTP avec votre clé actuelle",
"other": "Chiffrer seulement ce TOTP avec votre clé actuelle"
},
"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."
},
"changeMasterPassword": {
"title": "Changer votre mot de passe maître",
Expand Down
14 changes: 13 additions & 1 deletion lib/model/storage/local.dart
Original file line number Diff line number Diff line change
Expand Up @@ -102,10 +102,22 @@ class LocalStorage extends _$LocalStorage with Storage {
}

@override
Future<void> updateTotp(String uuid, Totp totp) async {
Future<void> updateTotp(Totp totp) async {
await update(totps).replace(totp.asDriftTotp);
}

@override
Future<void> updateTotps(List<Totp> totps) async {
await batch((batch) {
batch.replaceAll(
this.totps,
[
for (Totp totp in totps) totp.asDriftTotp,
],
);
});
}

@override
Future<Totp?> getTotp(String uuid) async {
_DriftTotp? totp = await (select(totps)
Expand Down
14 changes: 12 additions & 2 deletions lib/model/storage/online.dart
Original file line number Diff line number Diff line change
Expand Up @@ -87,9 +87,19 @@ class OnlineStorage with Storage {
}

@override
Future<void> updateTotp(String uuid, Totp totp) async {
Future<void> updateTotp(Totp totp) async {
CollectionReference? collection = _totpsCollection;
await collection.doc(uuid).set(totp.toFirestore());
await collection.doc(totp.uuid).set(totp.toFirestore());
}

@override
Future<void> updateTotps(List<Totp> totps) async {
CollectionReference? collection = _totpsCollection;
WriteBatch batch = _firestore.batch();
for (Totp totp in totps) {
batch.set(collection.doc(totp.uuid), totp.toFirestore());
}
await batch.commit();
}

@override
Expand Down
7 changes: 5 additions & 2 deletions lib/model/storage/storage.dart
Original file line number Diff line number Diff line change
Expand Up @@ -218,8 +218,11 @@ mixin Storage {
/// Stores the given [totps].
Future<void> addTotps(List<Totp> totps);

/// Updates the TOTP associated with the specified [uuid].
Future<void> updateTotp(String uuid, Totp totp);
/// Updates the [totp].
Future<void> updateTotp(Totp totp);

/// Updates all [totps].
Future<void> updateTotps(List<Totp> totps);

/// Deletes the TOTP associated to the given [uuid].
Future<void> deleteTotp(String uuid);
Expand Down
43 changes: 34 additions & 9 deletions lib/model/totp/repository.dart
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,17 @@ class TotpRepository extends AutoDisposeAsyncNotifier<TotpList> {
}

/// Tries to decrypt all TOTPs with the given [cryptoStore].
Future<void> tryDecryptAll(CryptoStore? cryptoStore) async {
/// Returns all newly decrypted TOTPs.
Future<Set<DecryptedTotp>> tryDecryptAll(CryptoStore? cryptoStore) async {
TotpList totpList = await future;
state = const AsyncLoading();
state = AsyncData(TotpList._(
TotpList newTotpList = TotpList._(
list: await totpList._list.decrypt(cryptoStore),
operationThreshold: totpList.operationThreshold,
));
);
Set<DecryptedTotp> difference = newTotpList.decryptedTotps.toSet().difference(totpList.decryptedTotps.toSet());
state = AsyncData(newTotpList);
return difference;
}

/// Refreshes the current state.
Expand Down Expand Up @@ -133,18 +137,33 @@ class TotpRepository extends AutoDisposeAsyncNotifier<TotpList> {
}
}

/// Updates the TOTP associated with the specified [uuid].
Future<Result<Totp>> updateTotp(String uuid, DecryptedTotp totp) async {
/// Updates the [totp].
Future<Result<Totp>> updateTotp(DecryptedTotp totp) async => await _updateTotps([totp]);

/// Updates the [totps].
Future<Result<Totp>> updateTotps(List<Totp> totps) async => await _updateTotps(totps);

/// Updates the [totps].
Future<Result<Totp>> _updateTotps(List<Totp> totps) async {
try {
if (totps.isEmpty) {
return const ResultSuccess();
}
TotpList totpList = await future;
await totpList.waitBeforeNextOperation();
Storage storage = await ref.read(storageProvider.future);
await storage.updateTotp(uuid, totp);
if (totps.length > 1) {
await storage.updateTotps(totps);
} else {
await storage.updateTotp(totps.first);
}
TotpImageCacheManager totpImageCacheManager = ref.read(totpImageCacheManagerProvider.notifier);
await totpImageCacheManager.cacheImage(totp);
for (Totp totp in totps) {
await totpImageCacheManager.cacheImage(totp);
}
state = AsyncData(
TotpList._fromListAndStorage(
list: _mergeToCurrentList(totpList, totp: totp),
list: _mergeToCurrentList(totpList, totps: totps),
storage: storage,
),
);
Expand Down Expand Up @@ -188,7 +207,7 @@ class TotpRepository extends AutoDisposeAsyncNotifier<TotpList> {
String password, {
String? backupPassword,
Uint8List? salt,
updateTotps = true,
bool updateTotps = true,
}) async {
try {
TotpList totpList = await future;
Expand Down Expand Up @@ -309,6 +328,12 @@ class TotpList extends Iterable<Totp> {
}
return Future.delayed(nextPossibleOperationTime.difference(now));
}

/// Returns the decrypted TOTPs list.
List<DecryptedTotp> get decryptedTotps => [
for (Totp totp in _list)
if (totp.isDecrypted) totp as DecryptedTotp,
];
}

/// The TOTP limit provider.
Expand Down
102 changes: 79 additions & 23 deletions lib/pages/home.dart
Original file line number Diff line number Diff line change
Expand Up @@ -311,55 +311,93 @@ class _HomePageBody extends ConsumerWidget {
return;
}

late CryptoStore previousCryptoStore;
TotpRepository repository = ref.read(totpRepositoryProvider.notifier);
Totp decrypted = await showWaitingOverlay(
(CryptoStore, List<DecryptedTotp>) decryptedTotps = await showWaitingOverlay(
context,
future: () async {
previousCryptoStore = await CryptoStore.fromPassword(password, totp.encryptedData.encryptionSalt);
return await totp.decrypt(previousCryptoStore);
CryptoStore previousCryptoStore = await CryptoStore.fromPassword(password, totp.encryptedData.encryptionSalt);
Totp targetTotp = await totp.decrypt(previousCryptoStore);
if (!targetTotp.isDecrypted) {
return (previousCryptoStore, <DecryptedTotp>[]);
}
Set<DecryptedTotp> decryptedTotps = await repository.tryDecryptAll(previousCryptoStore);
return (
previousCryptoStore,
[
targetTotp as DecryptedTotp,
for (DecryptedTotp decryptedTotp in decryptedTotps)
if (targetTotp.uuid != decryptedTotp.uuid) decryptedTotp,
],
);
}(),
);
if (!context.mounted) {
return;
}
if (!decrypted.isDecrypted) {
if (decryptedTotps.$2.isEmpty) {
SnackBarIcon.showErrorSnackBar(context, text: translations.error.totpDecrypt);
return;
}

_TotpKeyDialogResult? choice = await showDialog<_TotpKeyDialogResult>(
context: context,
builder: (context) => _TotpKeyDialog(),
builder: (context) => _TotpKeyDialog(
decryptedTotps: decryptedTotps.$2,
),
);

switch (choice) {
case _TotpKeyDialogResult.changeTotpKey:
if (!context.mounted) {
return;
}

Future<Result> changeTotpsKey(CryptoStore oldCryptoStore, List<DecryptedTotp> totps) async {
try {
CryptoStore? currentCryptoStore = await ref.read(cryptoStoreProvider.future);
if (currentCryptoStore == null) {
if (context.mounted) {
SnackBarIcon.showErrorSnackBar(context, text: translations.error.generic.tryAgain);
}
break;
throw Exception('Unable to get current crypto store.');
}
DecryptedTotp? decryptedTotpWithNewKey = await totp.changeEncryptionKey(previousCryptoStore, currentCryptoStore);
if (decryptedTotpWithNewKey == null || !decryptedTotpWithNewKey.isDecrypted) {
if (context.mounted) {
SnackBarIcon.showErrorSnackBar(context, text: translations.error.generic.tryAgain);
List<DecryptedTotp> toUpdate = [];
for (DecryptedTotp totp in totps) {
DecryptedTotp? decryptedTotpWithNewKey = await totp.changeEncryptionKey(oldCryptoStore, currentCryptoStore);
if (decryptedTotpWithNewKey == null || !decryptedTotpWithNewKey.isDecrypted) {
throw Exception('Failed to encrypt TOTP with current crypto store.');
}
break;
toUpdate.add(decryptedTotpWithNewKey);
}
return await repository.updateTotps(toUpdate);
} catch (ex, stacktrace) {
return ResultError(
exception: ex,
stacktrace: stacktrace,
);
}
}

switch (choice) {
case _TotpKeyDialogResult.changeTotpKey:
Result result = await showWaitingOverlay(
context,
future: changeTotpsKey(decryptedTotps.$1, [decryptedTotps.$2.first]),
);
if (context.mounted) {
context.showSnackBarForResult(result, retryIfError: true);
}
await repository.updateTotp(totp.uuid, decryptedTotpWithNewKey);
break;
case _TotpKeyDialogResult.changeMasterPassword:
case _TotpKeyDialogResult.changeAllTotpsKey:
Result result = await showWaitingOverlay(
context,
future: changeTotpsKey(decryptedTotps.$1, decryptedTotps.$2),
);
if (context.mounted) {
await MasterPasswordUtils.changeMasterPassword(context, ref, password: password);
context.showSnackBarForResult(result, retryIfError: true);
}
break;
case _TotpKeyDialogResult.changeMasterPassword:
await MasterPasswordUtils.changeMasterPassword(context, ref, password: password);
break;
default:
break;
}
await repository.tryDecryptAll(previousCryptoStore);
}
}

Expand Down Expand Up @@ -508,6 +546,14 @@ enum _AddTotpDialogResult {

/// Allows the user to choose an action to execute when a TOTP decryption has been done with success.
class _TotpKeyDialog extends StatelessWidget {
/// Contains all decrypted TOTPs.
final List<DecryptedTotp> decryptedTotps;

/// Creates a new TOTP key dialog instance.
const _TotpKeyDialog({
this.decryptedTotps = const [],
});

@override
Widget build(BuildContext context) => AlertDialog(
title: Text(translations.totp.totpKeyDialog.title),
Expand All @@ -516,12 +562,19 @@ class _TotpKeyDialog extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
children: [
Text(
translations.totp.totpKeyDialog.message,
translations.totp.totpKeyDialog.message(n: decryptedTotps.length),
),
if (decryptedTotps.length > 1)
ListTile(
leading: const Icon(Icons.done_all),
onTap: () => Navigator.pop(context, _TotpKeyDialogResult.changeAllTotpsKey),
title: Text(translations.totp.totpKeyDialog.choices.changeAllDecryptedTotpsKey.title),
subtitle: Text(translations.totp.totpKeyDialog.choices.changeAllDecryptedTotpsKey.subtitle),
),
ListTile(
leading: const Icon(Icons.key),
onTap: () => Navigator.pop(context, _TotpKeyDialogResult.changeTotpKey),
title: Text(translations.totp.totpKeyDialog.choices.changeTotpKey.title),
title: Text(translations.totp.totpKeyDialog.choices.changeTotpKey.title(n: decryptedTotps.length)),
subtitle: Text(translations.totp.totpKeyDialog.choices.changeTotpKey.subtitle),
),
ListTile(
Expand Down Expand Up @@ -552,6 +605,9 @@ enum _TotpKeyDialogResult {
/// Allows to change the TOTP key.
changeTotpKey,

/// Allows to change all TOTPs key (the current one and those that have been decrypted additionally).
changeAllTotpsKey,

/// Allows to change the current master password.
changeMasterPassword;
}
2 changes: 1 addition & 1 deletion lib/pages/totp.dart
Original file line number Diff line number Diff line change
Expand Up @@ -442,7 +442,7 @@ class _TotpPageState extends ConsumerState<TotpPage> with BrightnessListener {
if (totp == null) {
return ResultError();
}
return await ref.read(totpRepositoryProvider.notifier).updateTotp(widget.totp!.uuid, totp);
return await ref.read(totpRepositoryProvider.notifier).updateTotp(totp);
}

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

0 comments on commit a73b9db

Please sign in to comment.