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