fix-monero-com-backup-restore-bug (#3339)

* reject backups from other apps * make compatibility check optional

Serhii committed Jun 25, 2026 at 12:25 UTC 7a9a58554326bd3b4d53b5889d75084fa3e8bd00
3 files changed +106 -22
lib/core/backup_service_v3.dart
+53 -4
@@ -144,6 +144,21 @@ class BackupMetadata {
144 }
145 }
146
147 +class IncompatibleBackupAppException implements Exception {
148 + IncompatibleBackupAppException({
149 + required this.sourceAppName,
150 + required this.currentAppName,
151 + });
152 +
153 + final String sourceAppName;
154 + final String currentAppName;
155 +
156 + @override
157 + String toString() {
158 + return 'This backup was created in $sourceAppName and cannot be restored in $currentAppName.';
159 + }
160 +}
161 +
162 class BackupServiceV3 extends $BackupService {
163 BackupServiceV3(super.secureStorage, super.transactionDescriptionBox, super.keyService, super.sharedPreferences);
164
@@ -197,7 +212,8 @@ class BackupServiceV3 extends $BackupService {
212 }
213 }
214
200 - Future<void> importBackupFile(File file, String password, {String nonce = secrets.backupSalt}) {
215 + Future<void> importBackupFile(File file, String password,
216 + {String nonce = secrets.backupSalt, bool checkBackupApp = true}) {
217 final version = getVersionFile(file);
218 switch (version) {
219 case BackupVersion.unknown:
@@ -210,11 +226,12 @@ class BackupServiceV3 extends $BackupService {
226 case BackupVersion.v2:
227 return super.importBackupV2(file.readAsBytesSync(), password);
228 case BackupVersion.v3:
213 - return importBackupFileV3(file, password, nonce: nonce);
229 + return importBackupFileV3(file, password, nonce: nonce, checkBackupApp: checkBackupApp);
230 }
231 }
232
217 - Future<void> importBackupFileV3(File file, String password, {String nonce = secrets.backupSalt}) async{
233 + Future<void> importBackupFileV3(File file, String password,
234 + {String nonce = secrets.backupSalt,bool checkBackupApp = true}) async{
235 // Overall design of v3 backup is the following:
236 // 1. backup.zip - plaintext zip file that user can open with any archive manager
237 // 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 {
240
241 final inputStream = InputFileStream(file.path);
242 final archive = ZipDecoder().decodeStream(inputStream);
243 +
244 + if (checkBackupApp) {
245 + await _throwIfBackupWasCreatedInAnotherApp(archive);
246 + }
247 +
248 final metadataFile = archive.findFile('metadata.json');
249 if (metadataFile == null) {
250 throw Exception('Invalid v3 backup: missing metadata.json');
@@ -425,7 +447,7 @@ class BackupServiceV3 extends $BackupService {
447 metadata.sha512sum = (await sha512.bind(dataBinUnencrypted.openRead()).first).toString();
448
449 final raf = await dataBinUnencrypted.open();
428 -
450 +
451
452 while (true) {
453 printV("Reading chunk ${chunkIndex++}");
@@ -491,6 +513,33 @@ This backup was created on ${DateTime.now().toIso8601String()}
513 return file;
514 }
515
516 + Future<void> _throwIfBackupWasCreatedInAnotherApp(Archive archive) async {
517 + final readmeFile = archive.findFile('README.txt');
518 + if (readmeFile == null) return;
519 +
520 + final readmeBytes = readmeFile.rawContent?.readBytes();
521 + if (readmeBytes == null) return;
522 +
523 + final readmeString = utf8.decode(readmeBytes, allowMalformed: true);
524 + final sourceAppName = _extractBackupAppName(readmeString);
525 + if (sourceAppName == null) return;
526 +
527 +
528 + final currentAppName = (await PackageInfo.fromPlatform()).appName;
529 + if (sourceAppName == currentAppName) return;
530 +
531 +
532 + throw IncompatibleBackupAppException(
533 + sourceAppName: sourceAppName,
534 + currentAppName: currentAppName,
535 + );
536 + }
537 +
538 + String? _extractBackupAppName(String readme) {
539 + final match = RegExp(r'^This is a (.+) backup\.', multiLine: true).firstMatch(readme);
540 + return match?.group(1)?.trim();
541 + }
542 +
543 static const chunkSize = 24 * 1024 * 1024; // 24MiB
544
545 File setVersionFile(File file, BackupVersion version) {
lib/src/screens/restore/restore_from_backup_page.dart
+46 -16
@@ -1,6 +1,8 @@
1 +import 'package:cake_wallet/core/backup_service_v3.dart';
2 import 'package:cake_wallet/core/execution_state.dart';
3 import 'package:cake_wallet/generated/i18n.dart';
4 import 'package:cake_wallet/src/widgets/alert_with_one_action.dart';
5 +import 'package:cake_wallet/src/widgets/alert_with_two_actions.dart';
6 import 'package:cake_wallet/src/widgets/base_text_form_field.dart';
7 import 'package:cake_wallet/utils/responsive_layout_util.dart';
8 import 'package:cake_wallet/utils/show_pop_up.dart';
@@ -18,6 +20,7 @@ class RestoreFromBackupPage extends BasePage {
20
21 final RestoreFromBackupViewModel restoreFromBackupViewModel;
22 final TextEditingController textEditingController;
23 + bool _isStateReactionSet = false;
24
25 @override
26 String get title => S.current.restore_title_from_backup;
@@ -32,22 +35,26 @@ class RestoreFromBackupPage extends BasePage {
35
36 @override
37 Widget body(BuildContext context) {
35 - reaction((_) => restoreFromBackupViewModel.state, (ExecutionState state) {
36 - if (state is FailureState) {
37 - WidgetsBinding.instance.addPostFrameCallback((_) {
38 - showPopUp<void>(
39 - context: context,
40 - builder: (BuildContext context) {
41 - return AlertWithOneAction(
42 - alertTitle: S.of(context).error,
43 - alertContent: state.error,
44 - buttonText: S.of(context).ok,
45 - buttonAction: () => Navigator.of(context).pop(),
46 - );
47 - });
48 - });
49 - }
50 - });
38 + if (!_isStateReactionSet) {
39 + _isStateReactionSet = true;
40 +
41 + reaction((_) => restoreFromBackupViewModel.state, (ExecutionState state) {
42 + if (state is FailureState) {
43 + WidgetsBinding.instance.addPostFrameCallback((_) {
44 + showPopUp<void>(
45 + context: context,
46 + builder: (BuildContext context) {
47 + return AlertWithOneAction(
48 + alertTitle: S.of(context).error,
49 + alertContent: state.error,
50 + buttonText: S.of(context).ok,
51 + buttonAction: () => Navigator.of(context).pop(),
52 + );
53 + });
54 + });
55 + }
56 + });
57 + }
58
59 return Center(
60 child: ConstrainedBox(
@@ -175,6 +182,29 @@ class RestoreFromBackupPage extends BasePage {
182 }
183 try {
184 await restoreFromBackupViewModel.import(textEditingController.text);
185 + } on IncompatibleBackupAppException catch (e) {
186 + final isConfirmed = await showPopUp<bool>(
187 + context: context,
188 + builder: (_) {
189 + return AlertWithTwoActions(
190 + alertTitle: S.current.warning,
191 + alertContent:
192 + 'This backup was created in ${e.sourceAppName}, but you are restoring it in ${e.currentAppName}. '
193 + 'Please make sure this is the correct backup file before continuing.',
194 + leftButtonText: S.of(context).cancel,
195 + rightButtonText: S.of(context).ok,
196 + actionLeftButton: () => Navigator.of(context).pop(false),
197 + actionRightButton: () => Navigator.of(context).pop(true),
198 + );
199 + },
200 + );
201 +
202 + if (isConfirmed ?? false) {
203 + await restoreFromBackupViewModel.import(
204 + textEditingController.text,
205 + checkBackupApp: false,
206 + );
207 + }
208 } catch (e) {
209 await showPopUp<void>(
210 context: context,
lib/view_model/restore_from_backup_view_model.dart
+7 -2
@@ -32,7 +32,7 @@ abstract class RestoreFromBackupViewModelBase with Store {
32 void reset() => filePath = '';
33
34 @action
35 - Future<void> import(String password) async {
35 + Future<void> import(String password,{bool checkBackupApp = true}) async {
36 try {
37 state = IsExecutingState();
38
@@ -44,7 +44,9 @@ abstract class RestoreFromBackupViewModelBase with Store {
44 final file = File(filePath);
45
46 try {
47 - await backupService.importBackupFile(file, password);
47 + await backupService.importBackupFile(file, password, checkBackupApp: checkBackupApp);
48 + } on IncompatibleBackupAppException {
49 + rethrow;
50 } catch (e, s) {
51 if (e.toString().contains("unknown_backup_version")) {
52 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 {
76 });
77
78 state = ExecutedSuccessfullyState();
79 + } on IncompatibleBackupAppException {
80 + state = InitialExecutionState();
81 + rethrow;
82 } catch (e, s) {
83 var msg = e.toString().toLowerCase();
84