mirror of
https://github.com/cake-tech/cake_wallet.git
synced 2025-06-28 04:19:50 +00:00
* implement v3 backup system - Add new BackupServiceV3 with chunked backup support and checksums - Implement json-based metadata in backup system - Instead of binary file export a .zip file that user can open and see that it is a cake backup (should also prevent 3rd party software from corrupting binary data inside of the file, in case it doesn't checksum will fail, and user will know for sure that backup got corrupted) - Update flutter to 3.27.4 to use archive ^4.x.x (it offers in memory archive operations) * fix wallets not getting restored properly * prevent out of memory errors on files that are way too big during restore * Update lib/view_model/backup_view_model.dart [skip ci] * Update lib/core/backup_service.dart [skip ci] * Update lib/core/backup_service.dart --------- Co-authored-by: Omar Hatem <omarh.ismail1@gmail.com>
470 lines
No EOL
16 KiB
Dart
470 lines
No EOL
16 KiB
Dart
import 'dart:convert';
|
|
import 'dart:io';
|
|
import 'dart:typed_data';
|
|
|
|
import 'package:archive/archive_io.dart';
|
|
import 'package:cake_wallet/core/backup_service.dart';
|
|
import 'package:cake_wallet/.secrets.g.dart' as secrets;
|
|
import 'package:cake_backup/backup.dart' as cake_backup;
|
|
import 'package:cake_wallet/utils/package_info.dart';
|
|
import 'package:crypto/crypto.dart';
|
|
import 'package:cw_core/root_dir.dart';
|
|
import 'package:cw_core/utils/print_verbose.dart';
|
|
import 'package:flutter/foundation.dart';
|
|
|
|
enum BackupVersion {
|
|
unknown, // index 0
|
|
v1,
|
|
v2,
|
|
v3,
|
|
}
|
|
|
|
class ChunkChecksum {
|
|
ChunkChecksum({
|
|
required this.encrypted,
|
|
required this.plain,
|
|
});
|
|
|
|
final String encrypted;
|
|
final String plain;
|
|
|
|
factory ChunkChecksum.fromJson(Map<String, dynamic> json) {
|
|
return ChunkChecksum(
|
|
encrypted: json['encrypted'] as String,
|
|
plain: json['plain'] as String,
|
|
);
|
|
}
|
|
|
|
Map<String, dynamic> toJson() {
|
|
return {
|
|
'encrypted': encrypted,
|
|
'plain': plain,
|
|
};
|
|
}
|
|
|
|
@override
|
|
String toString() {
|
|
return 'ChunkChecksum(encrypted: $encrypted, plain: $plain)';
|
|
}
|
|
}
|
|
|
|
class ChunkLength {
|
|
ChunkLength({
|
|
required this.encrypted,
|
|
required this.plain,
|
|
});
|
|
|
|
final int encrypted;
|
|
final int plain;
|
|
|
|
factory ChunkLength.fromJson(Map<String, dynamic> json) {
|
|
return ChunkLength(
|
|
encrypted: json['encrypted'] as int,
|
|
plain: json['plain'] as int,
|
|
);
|
|
}
|
|
|
|
Map<String, dynamic> toJson() {
|
|
return {
|
|
'encrypted': encrypted,
|
|
'plain': plain,
|
|
};
|
|
}
|
|
@override
|
|
String toString() {
|
|
return 'ChunkLength(encrypted: $encrypted, plain: $plain)';
|
|
}
|
|
}
|
|
|
|
class ChunkDetails {
|
|
ChunkDetails({
|
|
required this.sha512sum,
|
|
required this.length,
|
|
});
|
|
|
|
final ChunkChecksum sha512sum;
|
|
final ChunkLength length;
|
|
|
|
factory ChunkDetails.fromJson(Map<String, dynamic> json) {
|
|
return ChunkDetails(
|
|
sha512sum: ChunkChecksum.fromJson(json['sha512sum'] as Map<String, dynamic>),
|
|
length: ChunkLength.fromJson(json['length'] as Map<String, dynamic>),
|
|
);
|
|
}
|
|
|
|
Map<String, dynamic> toJson() {
|
|
return {
|
|
'sha512sum': sha512sum,
|
|
'length': length,
|
|
};
|
|
}
|
|
|
|
@override
|
|
String toString() {
|
|
return 'ChunkDetails(sha512sum: $sha512sum, length: $length)';
|
|
}
|
|
}
|
|
|
|
class BackupMetadata {
|
|
BackupMetadata({
|
|
required this.version,
|
|
required this.sha512sum,
|
|
required this.chunks,
|
|
required this.cakeVersion,
|
|
});
|
|
|
|
final BackupVersion version;
|
|
String sha512sum;
|
|
final List<ChunkDetails> chunks;
|
|
String cakeVersion;
|
|
factory BackupMetadata.fromJson(Map<String, dynamic> json) {
|
|
return BackupMetadata(
|
|
version: BackupVersion.values[json['version'] as int],
|
|
sha512sum: json['sha512sum'] as String,
|
|
chunks: (json['chunks'] as List<dynamic>).map((chunk) => ChunkDetails.fromJson(chunk as Map<String, dynamic>)).toList(),
|
|
cakeVersion: json['cakeVersion'] as String,
|
|
);
|
|
}
|
|
|
|
Map<String, dynamic> toJson() {
|
|
return {
|
|
'version': version.index,
|
|
'sha512sum': sha512sum,
|
|
'chunks': chunks.map((chunk) => chunk.toJson()).toList(),
|
|
'cakeVersion': cakeVersion,
|
|
};
|
|
}
|
|
|
|
@override
|
|
String toString() {
|
|
return 'BackupMetadata(version: $version, sha512sum: $sha512sum, chunks: $chunks)';
|
|
}
|
|
}
|
|
|
|
class BackupServiceV3 extends $BackupService {
|
|
BackupServiceV3(super.secureStorage, super.walletInfoSource, super.transactionDescriptionBox, super.keyService, super.sharedPreferences);
|
|
|
|
static BackupVersion get currentVersion => BackupVersion.v3;
|
|
|
|
Future<File> exportBackupFile(String password, {String nonce = secrets.backupSalt}) {
|
|
return exportBackupFileV3(password, nonce: nonce);
|
|
}
|
|
|
|
BackupVersion getVersionFile(File data) {
|
|
final raf = data.openSync(mode: FileMode.read);
|
|
|
|
try {
|
|
// Read first 4 bytes to check both version and zip signature
|
|
final buffer = Uint8List(1);
|
|
final bytesRead = raf.readIntoSync(buffer);
|
|
|
|
if (bytesRead == 0) {
|
|
throw Exception('Invalid backup file: empty file');
|
|
}
|
|
|
|
// Check if first byte is version 1 or 2
|
|
if (buffer[0] == 1) {
|
|
return BackupVersion.v1;
|
|
} else if (buffer[0] == 2) {
|
|
return BackupVersion.v2;
|
|
} else if (buffer[0] == 0x50) {
|
|
// $ head -c 64 test-archive.zip | hexdump -C
|
|
// 00000000 50 4b 03 04 ....
|
|
// Here we just check if the first byte is the zip signature
|
|
// Inside of v3 backup we have multiple files.
|
|
// Check metadata.json for version in v3 backup
|
|
final inputStream = InputFileStream(data.path);
|
|
final archive = ZipDecoder().decodeStream(inputStream);
|
|
final metadataFile = archive.findFile('metadata.json');
|
|
if (metadataFile == null) {
|
|
throw Exception('Invalid v3 backup: missing metadata.json');
|
|
}
|
|
final metadataBytes = metadataFile.rawContent!.readBytes();
|
|
final metadataString = utf8.decode(metadataBytes);
|
|
final metadataJsonRaw = json.decode(metadataString) as Map<String, dynamic>;
|
|
final metadata = BackupMetadata.fromJson(metadataJsonRaw);
|
|
if (metadata.version == BackupVersion.v3) {
|
|
return BackupVersion.v3;
|
|
}
|
|
}
|
|
|
|
throw Exception('Invalid backup file: unknown version');
|
|
} finally {
|
|
raf.closeSync();
|
|
}
|
|
}
|
|
|
|
Future<void> importBackupFile(File file, String password, {String nonce = secrets.backupSalt}) {
|
|
final version = getVersionFile(file);
|
|
switch (version) {
|
|
case BackupVersion.unknown:
|
|
throw Exception('Invalid backup file: unknown version');
|
|
case BackupVersion.v1:
|
|
final data = file.readAsBytesSync();
|
|
final backupBytes = data.toList()..removeAt(0);
|
|
final backupData = Uint8List.fromList(backupBytes);
|
|
return super.importBackupV1(backupData, password, nonce: nonce);
|
|
case BackupVersion.v2:
|
|
return super.importBackupV2(file.readAsBytesSync(), password);
|
|
case BackupVersion.v3:
|
|
return importBackupFileV3(file, password, nonce: nonce);
|
|
}
|
|
}
|
|
|
|
Future<void> importBackupFileV3(File file, String password, {String nonce = secrets.backupSalt}) 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
|
|
// 3. backup.zip/metadata.json - json file with metadata about backup.
|
|
// 4. backup.zip/data.bin - v2 backup file
|
|
|
|
final inputStream = InputFileStream(file.path);
|
|
final archive = ZipDecoder().decodeStream(inputStream);
|
|
final metadataFile = archive.findFile('metadata.json');
|
|
if (metadataFile == null) {
|
|
throw Exception('Invalid v3 backup: missing metadata.json');
|
|
}
|
|
final metadataBytes = metadataFile.rawContent!.readBytes();
|
|
final metadataString = utf8.decode(metadataBytes);
|
|
final metadataJsonRaw = json.decode(metadataString) as Map<String, dynamic>;
|
|
final metadata = BackupMetadata.fromJson(metadataJsonRaw);
|
|
|
|
final dataFile = archive.findFile('data.bin');
|
|
if (dataFile == null) {
|
|
throw Exception('Invalid v3 backup: missing data.bin');
|
|
}
|
|
final dataStream = dataFile.rawContent!.getStream();
|
|
|
|
final decryptedData = File('${file.path}_decrypted'); // decrypted zip file
|
|
if (decryptedData.existsSync()) {
|
|
decryptedData.deleteSync();
|
|
}
|
|
decryptedData.createSync(recursive: true);
|
|
decryptedData.writeAsBytesSync(Uint8List(0), mode: FileMode.write, flush: true);
|
|
|
|
int chunkIndex = 0;
|
|
for (var chunk in metadata.chunks) {
|
|
chunkIndex++;
|
|
final chunkBytes = dataStream.readBytes(chunk.length.encrypted).toUint8List();
|
|
final chunkChecksum = (await sha512.bind(Stream.fromIterable([chunkBytes])).first).toString();
|
|
|
|
// readBytes stores position internally, so we don't need to think about it.
|
|
if (chunk.sha512sum.encrypted != chunkChecksum) {
|
|
throw Exception('Invalid v3 backup: chunk (${chunk.length.encrypted} bytes) checksum mismatch at index $chunkIndex\n'
|
|
'expected: ${chunk.sha512sum.encrypted}\n'
|
|
'got: $chunkChecksum');
|
|
}
|
|
final decryptedChunk = await cake_backup.decrypt(password, chunkBytes);
|
|
decryptedData.writeAsBytesSync(decryptedChunk, mode: FileMode.append, flush: true);
|
|
}
|
|
|
|
|
|
final sha512sum = (await sha512.bind(decryptedData.openRead()).first).toString();
|
|
if (sha512sum.toString() != metadata.sha512sum) {
|
|
throw Exception('Invalid v3 backup: SHA512 checksum mismatch\n'
|
|
'expected: ${metadata.sha512sum}\n'
|
|
'got: $sha512sum');
|
|
}
|
|
|
|
// Decryption done, now we can import the backup (that is, unzip app data)
|
|
|
|
// archive is **NOT** backup, it is just a zip file that contains data.bin inside.
|
|
// We need to unzip it to get the backup.
|
|
// data.bin after decryption is available in decryptedData.
|
|
|
|
final zip = ZipDecoder();
|
|
final decryptedDataStream = InputFileStream(decryptedData.path);
|
|
final backupArchive = zip.decodeStream(decryptedDataStream);
|
|
|
|
|
|
final appDir = await getAppDir();
|
|
|
|
outer:
|
|
for (var file in backupArchive.files) {
|
|
final filename = file.name;
|
|
for (var ignore in $BackupService.ignoreFiles) {
|
|
if (filename.endsWith(ignore) && !filename.contains("wallets/")) {
|
|
printV("ignoring backup file: $filename");
|
|
continue outer;
|
|
}
|
|
}
|
|
printV("restoring: $filename");
|
|
if (file.isFile) {
|
|
final output = File('${appDir.path}/' + filename)
|
|
..createSync(recursive: true);
|
|
final outputStream = OutputFileStream(output.path);
|
|
file.writeContent(outputStream);
|
|
outputStream.flush();
|
|
} else {
|
|
final dir = Directory('${appDir.path}/' + filename);
|
|
if (!dir.existsSync()) {
|
|
dir.createSync(recursive: true);
|
|
}
|
|
}
|
|
};
|
|
|
|
// Continue importing the backup the old way
|
|
await super.verifyWallets();
|
|
await super.importKeychainDumpV2(password);
|
|
await super.importPreferencesDump();
|
|
await super.importTransactionDescriptionDump();
|
|
|
|
// Delete decrypted data file
|
|
decryptedData.deleteSync();
|
|
}
|
|
|
|
Future<File> exportBackupFileV3(String password, {String nonce = secrets.backupSalt}) async {
|
|
final metadata = BackupMetadata(
|
|
version: BackupVersion.v3,
|
|
sha512sum: 'tbd',
|
|
chunks: [],
|
|
cakeVersion: 'tbd',
|
|
);
|
|
final zipEncoder = ZipFileEncoder();
|
|
final appDir = await getAppDir();
|
|
final now = DateTime.now().toIso8601String().replaceAll(':', '-');
|
|
final tmpDir = Directory('${appDir.path}/~_BACKUP_TMP');
|
|
final archivePath = '${tmpDir.path}/backup_${now}.tmp.zip';
|
|
final archivePathExport = '${tmpDir.path}/backup_${now}.zip';
|
|
final fileEntities = appDir.listSync(recursive: false);
|
|
final keychainDump = await super.exportKeychainDumpV2(password);
|
|
final preferencesDump = await super.exportPreferencesJSON();
|
|
final preferencesDumpFile = File('${tmpDir.path}/~_preferences_dump_TMP');
|
|
final keychainDumpFile = File('${tmpDir.path}/~_keychain_dump_TMP');
|
|
final transactionDescriptionDumpFile =
|
|
File('${tmpDir.path}/~_transaction_descriptions_dump_TMP');
|
|
|
|
final transactionDescriptionData = super.transactionDescriptionBox
|
|
.toMap()
|
|
.map((key, value) => MapEntry(key.toString(), value.toJson()));
|
|
final transactionDescriptionDump = jsonEncode(transactionDescriptionData);
|
|
|
|
if (tmpDir.existsSync()) {
|
|
tmpDir.deleteSync(recursive: true);
|
|
}
|
|
|
|
tmpDir.createSync();
|
|
zipEncoder.create(archivePath);
|
|
outer:
|
|
for (var entity in fileEntities) {
|
|
if (entity.path == archivePath || entity.path == tmpDir.path) {
|
|
continue;
|
|
}
|
|
for (var ignore in $BackupService.ignoreFiles) {
|
|
final filename = entity.absolute.path;
|
|
if (filename.endsWith(ignore) && !filename.contains("wallets/")) {
|
|
printV("ignoring backup file: $filename");
|
|
continue outer;
|
|
}
|
|
}
|
|
|
|
if (entity.statSync().type == FileSystemEntityType.directory) {
|
|
await zipEncoder.addDirectory(Directory(entity.path));
|
|
} else {
|
|
await zipEncoder.addFile(File(entity.path));
|
|
}
|
|
}
|
|
await keychainDumpFile.writeAsBytes(keychainDump.toList());
|
|
await preferencesDumpFile.writeAsString(preferencesDump);
|
|
await transactionDescriptionDumpFile.writeAsString(transactionDescriptionDump);
|
|
await zipEncoder.addFile(preferencesDumpFile, '~_preferences_dump');
|
|
await zipEncoder.addFile(keychainDumpFile, '~_keychain_dump');
|
|
await zipEncoder.addFile(transactionDescriptionDumpFile, '~_transaction_descriptions_dump');
|
|
await zipEncoder.close();
|
|
|
|
final dataBinUnencrypted = File(archivePath);
|
|
|
|
final dataBin = File('${tmpDir.path}/data.bin');
|
|
dataBin.writeAsBytesSync(Uint8List(0), mode: FileMode.write, flush: true);
|
|
final dataBinWriter = dataBin.openWrite();
|
|
|
|
printV("------ Backup stats ------");
|
|
printV("Backup version: ${metadata.version}");
|
|
printV("Backup size: ${await dataBinUnencrypted.length()}");
|
|
printV("Backup chunks: ${(await dataBinUnencrypted.length()) / chunkSize}");
|
|
printV("------ Backup stats ------");
|
|
|
|
int chunkIndex = 0;
|
|
final stopwatch = Stopwatch()..start();
|
|
printV("Starting backup encryption...");
|
|
|
|
metadata.sha512sum = (await sha512.bind(dataBinUnencrypted.openRead()).first).toString();
|
|
|
|
final raf = await dataBinUnencrypted.open();
|
|
|
|
|
|
while (true) {
|
|
printV("Reading chunk ${chunkIndex++}");
|
|
|
|
stopwatch.reset();
|
|
final chunk = await raf.read(chunkSize);
|
|
printV("Chunk read completed in ${stopwatch.elapsed}");
|
|
printV("Chunk length: ${chunk.length} expected: $chunkSize");
|
|
if (chunk.length == 0) {
|
|
break;
|
|
}
|
|
|
|
stopwatch.reset();
|
|
final encryptedChunk = await cake_backup.encrypt(password, chunk);
|
|
printV("Encryption completed in ${stopwatch.elapsed}");
|
|
|
|
stopwatch.reset();
|
|
final sha512sumEncryptedChunk = await sha512.bind(Stream.fromIterable([encryptedChunk])).first;
|
|
final sha512sumUnencryptedChunk = await sha512.bind(Stream.fromIterable([chunk])).first;
|
|
printV("Hashing completed in ${stopwatch.elapsed}");
|
|
|
|
stopwatch.reset();
|
|
dataBinWriter.add(encryptedChunk);
|
|
metadata.chunks.add(ChunkDetails(
|
|
sha512sum: ChunkChecksum(
|
|
encrypted: sha512sumEncryptedChunk.toString(),
|
|
plain: sha512sumUnencryptedChunk.toString(),
|
|
),
|
|
length: ChunkLength(
|
|
encrypted: encryptedChunk.length,
|
|
plain: chunk.length,
|
|
),
|
|
));
|
|
|
|
await dataBinWriter.flush();
|
|
printV("Writing completed in ${stopwatch.elapsed}");
|
|
}
|
|
await raf.close();
|
|
|
|
// Give the file to the user
|
|
|
|
final metadataFile = File('${tmpDir.path}/metadata.json');
|
|
final packageInfo = await PackageInfo.fromPlatform();
|
|
metadata.cakeVersion = packageInfo.version;
|
|
|
|
metadataFile.writeAsStringSync(JsonEncoder.withIndent(' ').convert(metadata.toJson()));
|
|
final readmeFile = File('${tmpDir.path}/README.txt');
|
|
readmeFile.writeAsStringSync('''This is a ${packageInfo.appName} backup. Do not modify this archive.
|
|
|
|
App version: ${packageInfo.version}
|
|
|
|
If you have any issues with this backup, please contact our in-app support.
|
|
This backup was created on ${DateTime.now().toIso8601String()}
|
|
''');
|
|
final zip = ZipFileEncoder();
|
|
zip.create(archivePathExport, level: 9);
|
|
await zip.addFile(dataBin, 'data.bin');
|
|
await zip.addFile(metadataFile, 'metadata.json');
|
|
await zip.addFile(readmeFile, 'README.txt');
|
|
await zip.close();
|
|
// tmpDir.deleteSync(recursive: true);
|
|
final file = File(archivePathExport);
|
|
return file;
|
|
}
|
|
|
|
static const chunkSize = 24 * 1024 * 1024; // 24MiB
|
|
|
|
File setVersionFile(File file, BackupVersion version) {
|
|
if (version == BackupVersion.v3) return file; // v3 uses
|
|
// helper function to call super.setVersion();
|
|
final data = file.readAsBytesSync();
|
|
super.setVersion(data, version.index);
|
|
file.writeAsBytesSync(data);
|
|
return file;
|
|
}
|
|
} |