CakeWallet/lib/core/backup_service_v3.dart

506 lines
17 KiB
Dart
Raw Permalink Normal View History

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:cw_core/wallet_info.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) {
return BackupVersion.unknown;
}
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;
}
}
return BackupVersion.unknown;
} finally {
raf.closeSync();
}
}
Future<void> importBackupFile(File file, String password, {String nonce = secrets.backupSalt}) {
final version = getVersionFile(file);
switch (version) {
case BackupVersion.unknown:
2025-03-28 16:17:38 +02:00
throw Exception('unknown_backup_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 verifyHardwareWallets(password);
await super.importKeychainDumpV2(password);
await super.importPreferencesDump();
await super.importTransactionDescriptionDump();
// Delete decrypted data file
decryptedData.deleteSync();
}
Future<void> verifyHardwareWallets(String password,
{String keychainSalt = secrets.backupKeychainSalt}) async {
final walletInfoSource = await reloadHiveWalletInfoBox();
final appDir = await getAppDir();
final keychainDumpFile = File('${appDir.path}/~_keychain_dump');
final decryptedKeychainDumpFileData = await decryptV2(
keychainDumpFile.readAsBytesSync(), '$keychainSalt$password');
final keychainJSON = json.decode(utf8.decode(decryptedKeychainDumpFileData))
as Map<String, dynamic>;
final keychainWalletsInfo = keychainJSON['wallets'] as List;
final expectedHardwareWallets = keychainWalletsInfo
.where((e) =>
(e as Map<String, dynamic>).containsKey("hardwareWalletType") &&
e["hardwareWalletType"] != null)
.toList();
for (final expectedHardwareWallet in expectedHardwareWallets) {
final info = expectedHardwareWallet as Map<String, dynamic>;
final actualWalletInfo = walletInfoSource.values
.where((e) =>
e.name == info['name'] && e.type.toString() == info['type'])
.firstOrNull;
if (actualWalletInfo != null &&
info["hardwareWalletType"] !=
actualWalletInfo.hardwareWalletType?.index) {
actualWalletInfo.hardwareWalletType =
HardwareWalletType.values[info["hardwareWalletType"] as int];
await actualWalletInfo.save();
}
}
}
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;
}
}