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

* reject backups from other apps

* make compatibility check optional
This commit is contained in:
Serhii
2026-06-25 12:25:57 +03:00
committed by GitHub
parent cda9d09ead
commit 7a9a585543
3 changed files with 106 additions and 22 deletions
+53 -4
View File
@@ -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<void> importBackupFile(File file, String password, {String nonce = secrets.backupSalt}) {
Future<void> 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<void> importBackupFileV3(File file, String password, {String nonce = secrets.backupSalt}) async{
Future<void> 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<void> _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) {
@@ -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<void>(
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<void>(
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<bool>(
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<void>(
context: context,
@@ -32,7 +32,7 @@ abstract class RestoreFromBackupViewModelBase with Store {
void reset() => filePath = '';
@action
Future<void> import(String password) async {
Future<void> 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();