From 7a9a58554326bd3b4d53b5889d75084fa3e8bd00 Mon Sep 17 00:00:00 2001 From: Serhii <17529954+serhii-bor@users.noreply.github.com> Date: Thu, 25 Jun 2026 12:25:57 +0300 Subject: [PATCH] fix-monero-com-backup-restore-bug (#3339) * reject backups from other apps * make compatibility check optional --- lib/core/backup_service_v3.dart | 57 +++++++++++++++-- .../restore/restore_from_backup_page.dart | 62 ++++++++++++++----- .../restore_from_backup_view_model.dart | 9 ++- 3 files changed, 106 insertions(+), 22 deletions(-) diff --git a/lib/core/backup_service_v3.dart b/lib/core/backup_service_v3.dart index 9e9b5b667..7d746ff42 100644 --- a/lib/core/backup_service_v3.dart +++ b/lib/core/backup_service_v3.dart @@ -144,6 +144,21 @@ class BackupMetadata { } } +class IncompatibleBackupAppException implements Exception { + IncompatibleBackupAppException({ + required this.sourceAppName, + required this.currentAppName, + }); + + final String sourceAppName; + final String currentAppName; + + @override + String toString() { + return 'This backup was created in $sourceAppName and cannot be restored in $currentAppName.'; + } +} + class BackupServiceV3 extends $BackupService { BackupServiceV3(super.secureStorage, super.transactionDescriptionBox, super.keyService, super.sharedPreferences); @@ -197,7 +212,8 @@ class BackupServiceV3 extends $BackupService { } } - Future importBackupFile(File file, String password, {String nonce = secrets.backupSalt}) { + Future importBackupFile(File file, String password, + {String nonce = secrets.backupSalt, bool checkBackupApp = true}) { final version = getVersionFile(file); switch (version) { case BackupVersion.unknown: @@ -210,11 +226,12 @@ class BackupServiceV3 extends $BackupService { case BackupVersion.v2: return super.importBackupV2(file.readAsBytesSync(), password); case BackupVersion.v3: - return importBackupFileV3(file, password, nonce: nonce); + return importBackupFileV3(file, password, nonce: nonce, checkBackupApp: checkBackupApp); } } - Future importBackupFileV3(File file, String password, {String nonce = secrets.backupSalt}) async{ + Future importBackupFileV3(File file, String password, + {String nonce = secrets.backupSalt,bool checkBackupApp = true}) async{ // Overall design of v3 backup is the following: // 1. backup.zip - plaintext zip file that user can open with any archive manager // 2. backup.zip/README.txt - text file to let user know what is inside of this file @@ -223,6 +240,11 @@ class BackupServiceV3 extends $BackupService { final inputStream = InputFileStream(file.path); final archive = ZipDecoder().decodeStream(inputStream); + + if (checkBackupApp) { + await _throwIfBackupWasCreatedInAnotherApp(archive); + } + final metadataFile = archive.findFile('metadata.json'); if (metadataFile == null) { throw Exception('Invalid v3 backup: missing metadata.json'); @@ -425,7 +447,7 @@ class BackupServiceV3 extends $BackupService { metadata.sha512sum = (await sha512.bind(dataBinUnencrypted.openRead()).first).toString(); final raf = await dataBinUnencrypted.open(); - + while (true) { printV("Reading chunk ${chunkIndex++}"); @@ -491,6 +513,33 @@ This backup was created on ${DateTime.now().toIso8601String()} return file; } + Future _throwIfBackupWasCreatedInAnotherApp(Archive archive) async { + final readmeFile = archive.findFile('README.txt'); + if (readmeFile == null) return; + + final readmeBytes = readmeFile.rawContent?.readBytes(); + if (readmeBytes == null) return; + + final readmeString = utf8.decode(readmeBytes, allowMalformed: true); + final sourceAppName = _extractBackupAppName(readmeString); + if (sourceAppName == null) return; + + + final currentAppName = (await PackageInfo.fromPlatform()).appName; + if (sourceAppName == currentAppName) return; + + + throw IncompatibleBackupAppException( + sourceAppName: sourceAppName, + currentAppName: currentAppName, + ); + } + + String? _extractBackupAppName(String readme) { + final match = RegExp(r'^This is a (.+) backup\.', multiLine: true).firstMatch(readme); + return match?.group(1)?.trim(); + } + static const chunkSize = 24 * 1024 * 1024; // 24MiB File setVersionFile(File file, BackupVersion version) { diff --git a/lib/src/screens/restore/restore_from_backup_page.dart b/lib/src/screens/restore/restore_from_backup_page.dart index 0f5bdd324..621a880ba 100644 --- a/lib/src/screens/restore/restore_from_backup_page.dart +++ b/lib/src/screens/restore/restore_from_backup_page.dart @@ -1,6 +1,8 @@ +import 'package:cake_wallet/core/backup_service_v3.dart'; import 'package:cake_wallet/core/execution_state.dart'; import 'package:cake_wallet/generated/i18n.dart'; import 'package:cake_wallet/src/widgets/alert_with_one_action.dart'; +import 'package:cake_wallet/src/widgets/alert_with_two_actions.dart'; import 'package:cake_wallet/src/widgets/base_text_form_field.dart'; import 'package:cake_wallet/utils/responsive_layout_util.dart'; import 'package:cake_wallet/utils/show_pop_up.dart'; @@ -18,6 +20,7 @@ class RestoreFromBackupPage extends BasePage { final RestoreFromBackupViewModel restoreFromBackupViewModel; final TextEditingController textEditingController; + bool _isStateReactionSet = false; @override String get title => S.current.restore_title_from_backup; @@ -32,22 +35,26 @@ class RestoreFromBackupPage extends BasePage { @override Widget body(BuildContext context) { - reaction((_) => restoreFromBackupViewModel.state, (ExecutionState state) { - if (state is FailureState) { - WidgetsBinding.instance.addPostFrameCallback((_) { - showPopUp( - context: context, - builder: (BuildContext context) { - return AlertWithOneAction( - alertTitle: S.of(context).error, - alertContent: state.error, - buttonText: S.of(context).ok, - buttonAction: () => Navigator.of(context).pop(), - ); - }); - }); - } - }); + if (!_isStateReactionSet) { + _isStateReactionSet = true; + + reaction((_) => restoreFromBackupViewModel.state, (ExecutionState state) { + if (state is FailureState) { + WidgetsBinding.instance.addPostFrameCallback((_) { + showPopUp( + context: context, + builder: (BuildContext context) { + return AlertWithOneAction( + alertTitle: S.of(context).error, + alertContent: state.error, + buttonText: S.of(context).ok, + buttonAction: () => Navigator.of(context).pop(), + ); + }); + }); + } + }); + } return Center( child: ConstrainedBox( @@ -175,6 +182,29 @@ class RestoreFromBackupPage extends BasePage { } try { await restoreFromBackupViewModel.import(textEditingController.text); + } on IncompatibleBackupAppException catch (e) { + final isConfirmed = await showPopUp( + context: context, + builder: (_) { + return AlertWithTwoActions( + alertTitle: S.current.warning, + alertContent: + 'This backup was created in ${e.sourceAppName}, but you are restoring it in ${e.currentAppName}. ' + 'Please make sure this is the correct backup file before continuing.', + leftButtonText: S.of(context).cancel, + rightButtonText: S.of(context).ok, + actionLeftButton: () => Navigator.of(context).pop(false), + actionRightButton: () => Navigator.of(context).pop(true), + ); + }, + ); + + if (isConfirmed ?? false) { + await restoreFromBackupViewModel.import( + textEditingController.text, + checkBackupApp: false, + ); + } } catch (e) { await showPopUp( context: context, diff --git a/lib/view_model/restore_from_backup_view_model.dart b/lib/view_model/restore_from_backup_view_model.dart index 6a54cf4cc..40e2891f7 100644 --- a/lib/view_model/restore_from_backup_view_model.dart +++ b/lib/view_model/restore_from_backup_view_model.dart @@ -32,7 +32,7 @@ abstract class RestoreFromBackupViewModelBase with Store { void reset() => filePath = ''; @action - Future import(String password) async { + Future import(String password,{bool checkBackupApp = true}) async { try { state = IsExecutingState(); @@ -44,7 +44,9 @@ abstract class RestoreFromBackupViewModelBase with Store { final file = File(filePath); try { - await backupService.importBackupFile(file, password); + await backupService.importBackupFile(file, password, checkBackupApp: checkBackupApp); + } on IncompatibleBackupAppException { + rethrow; } catch (e, s) { if (e.toString().contains("unknown_backup_version")) { state = FailureState('This is not a valid backup file, please make sure you have selected the correct one'); @@ -74,6 +76,9 @@ abstract class RestoreFromBackupViewModelBase with Store { }); state = ExecutedSuccessfullyState(); + } on IncompatibleBackupAppException { + state = InitialExecutionState(); + rethrow; } catch (e, s) { var msg = e.toString().toLowerCase();