dev
dart 556 lines 18.7 KB
Raw
1 import 'dart:convert';
2 import 'dart:io';
3 import 'dart:typed_data';
4
5 import 'package:archive/archive_io.dart';
6 import 'package:cake_wallet/core/backup_service.dart';
7 import 'package:cake_wallet/.secrets.g.dart' as secrets;
8 import 'package:cake_backup/backup.dart' as cake_backup;
9 import 'package:cake_wallet/utils/package_info.dart';
10 import 'package:crypto/crypto.dart';
11 import 'package:cw_core/db/sqlite.dart';
12 import 'package:cw_core/root_dir.dart';
13 import 'package:cw_core/utils/print_verbose.dart';
14 import 'package:cw_core/wallet_info.dart';
15 import 'package:cw_core/wallet_type.dart';
16 import 'package:flutter/foundation.dart';
17
18 enum BackupVersion {
19 unknown, // index 0
20 v1,
21 v2,
22 v3,
23 }
24
25 class ChunkChecksum {
26 ChunkChecksum({
27 required this.encrypted,
28 required this.plain,
29 });
30
31 final String encrypted;
32 final String plain;
33
34 factory ChunkChecksum.fromJson(Map<String, dynamic> json) {
35 return ChunkChecksum(
36 encrypted: json['encrypted'] as String,
37 plain: json['plain'] as String,
38 );
39 }
40
41 Map<String, dynamic> toJson() {
42 return {
43 'encrypted': encrypted,
44 'plain': plain,
45 };
46 }
47
48 @override
49 String toString() {
50 return 'ChunkChecksum(encrypted: $encrypted, plain: $plain)';
51 }
52 }
53
54 class ChunkLength {
55 ChunkLength({
56 required this.encrypted,
57 required this.plain,
58 });
59
60 final int encrypted;
61 final int plain;
62
63 factory ChunkLength.fromJson(Map<String, dynamic> json) {
64 return ChunkLength(
65 encrypted: json['encrypted'] as int,
66 plain: json['plain'] as int,
67 );
68 }
69
70 Map<String, dynamic> toJson() {
71 return {
72 'encrypted': encrypted,
73 'plain': plain,
74 };
75 }
76
77 @override
78 String toString() {
79 return 'ChunkLength(encrypted: $encrypted, plain: $plain)';
80 }
81 }
82
83 class ChunkDetails {
84 ChunkDetails({
85 required this.sha512sum,
86 required this.length,
87 });
88
89 final ChunkChecksum sha512sum;
90 final ChunkLength length;
91
92 factory ChunkDetails.fromJson(Map<String, dynamic> json) {
93 return ChunkDetails(
94 sha512sum: ChunkChecksum.fromJson(json['sha512sum'] as Map<String, dynamic>),
95 length: ChunkLength.fromJson(json['length'] as Map<String, dynamic>),
96 );
97 }
98
99 Map<String, dynamic> toJson() {
100 return {
101 'sha512sum': sha512sum,
102 'length': length,
103 };
104 }
105
106 @override
107 String toString() {
108 return 'ChunkDetails(sha512sum: $sha512sum, length: $length)';
109 }
110 }
111
112 class BackupMetadata {
113 BackupMetadata({
114 required this.version,
115 required this.sha512sum,
116 required this.chunks,
117 required this.cakeVersion,
118 });
119
120 final BackupVersion version;
121 String sha512sum;
122 final List<ChunkDetails> chunks;
123 String cakeVersion;
124 factory BackupMetadata.fromJson(Map<String, dynamic> json) {
125 return BackupMetadata(
126 version: BackupVersion.values[json['version'] as int],
127 sha512sum: json['sha512sum'] as String,
128 chunks: (json['chunks'] as List<dynamic>)
129 .map((chunk) => ChunkDetails.fromJson(chunk as Map<String, dynamic>))
130 .toList(),
131 cakeVersion: json['cakeVersion'] as String,
132 );
133 }
134
135 Map<String, dynamic> toJson() {
136 return {
137 'version': version.index,
138 'sha512sum': sha512sum,
139 'chunks': chunks.map((chunk) => chunk.toJson()).toList(),
140 'cakeVersion': cakeVersion,
141 };
142 }
143
144 @override
145 String toString() {
146 return 'BackupMetadata(version: $version, sha512sum: $sha512sum, chunks: $chunks)';
147 }
148 }
149
150 class IncompatibleBackupAppException implements Exception {
151 IncompatibleBackupAppException({
152 required this.sourceAppName,
153 required this.currentAppName,
154 });
155
156 final String sourceAppName;
157 final String currentAppName;
158
159 @override
160 String toString() {
161 return 'This backup was created in $sourceAppName and cannot be restored in $currentAppName.';
162 }
163 }
164
165 class BackupServiceV3 extends $BackupService {
166 BackupServiceV3(super.secureStorage, super.transactionDescriptionBox, super.keyService,
167 super.sharedPreferences);
168
169 static BackupVersion get currentVersion => BackupVersion.v3;
170
171 Future<File> exportBackupFile(String password, {String nonce = secrets.backupSalt}) {
172 return exportBackupFileV3(password, nonce: nonce);
173 }
174
175 BackupVersion getVersionFile(File data) {
176 final raf = data.openSync(mode: FileMode.read);
177
178 try {
179 // Read first 4 bytes to check both version and zip signature
180 final buffer = Uint8List(1);
181 final bytesRead = raf.readIntoSync(buffer);
182
183 if (bytesRead == 0) {
184 throw Exception('Invalid backup file: empty file');
185 }
186
187 // Check if first byte is version 1 or 2
188 if (buffer[0] == 1) {
189 return BackupVersion.v1;
190 } else if (buffer[0] == 2) {
191 return BackupVersion.v2;
192 } else if (buffer[0] == 0x50) {
193 // $ head -c 64 test-archive.zip | hexdump -C
194 // 00000000 50 4b 03 04 ....
195 // Here we just check if the first byte is the zip signature
196 // Inside of v3 backup we have multiple files.
197 // Check metadata.json for version in v3 backup
198 final inputStream = InputFileStream(data.path);
199 final archive = ZipDecoder().decodeStream(inputStream);
200 final metadataFile = archive.findFile('metadata.json');
201 if (metadataFile == null) {
202 return BackupVersion.unknown;
203 }
204 final metadataBytes = metadataFile.rawContent!.readBytes();
205 final metadataString = utf8.decode(metadataBytes);
206 final metadataJsonRaw = json.decode(metadataString) as Map<String, dynamic>;
207 final metadata = BackupMetadata.fromJson(metadataJsonRaw);
208 if (metadata.version == BackupVersion.v3) {
209 return BackupVersion.v3;
210 }
211 }
212
213 return BackupVersion.unknown;
214 } finally {
215 raf.closeSync();
216 }
217 }
218
219 Future<void> importBackupFile(File file, String password,
220 {String nonce = secrets.backupSalt, bool checkBackupApp = true}) {
221 final version = getVersionFile(file);
222 switch (version) {
223 case BackupVersion.unknown:
224 throw Exception('unknown_backup_version');
225 case BackupVersion.v1:
226 final data = file.readAsBytesSync();
227 final backupBytes = data.toList()..removeAt(0);
228 final backupData = Uint8List.fromList(backupBytes);
229 return super.importBackupV1(backupData, password, nonce: nonce);
230 case BackupVersion.v2:
231 return super.importBackupV2(file.readAsBytesSync(), password);
232 case BackupVersion.v3:
233 return importBackupFileV3(file, password, nonce: nonce, checkBackupApp: checkBackupApp);
234 }
235 }
236
237 Future<void> importBackupFileV3(File file, String password,
238 {String nonce = secrets.backupSalt, bool checkBackupApp = true}) async {
239 // Overall design of v3 backup is the following:
240 // 1. backup.zip - plaintext zip file that user can open with any archive manager
241 // 2. backup.zip/README.txt - text file to let user know what is inside of this file
242 // 3. backup.zip/metadata.json - json file with metadata about backup.
243 // 4. backup.zip/data.bin - v2 backup file
244
245 final inputStream = InputFileStream(file.path);
246 final archive = ZipDecoder().decodeStream(inputStream);
247
248 if (checkBackupApp) {
249 await _throwIfBackupWasCreatedInAnotherApp(archive);
250 }
251
252 final metadataFile = archive.findFile('metadata.json');
253 if (metadataFile == null) {
254 throw Exception('Invalid v3 backup: missing metadata.json');
255 }
256 final metadataBytes = metadataFile.rawContent!.readBytes();
257 final metadataString = utf8.decode(metadataBytes);
258 final metadataJsonRaw = json.decode(metadataString) as Map<String, dynamic>;
259 final metadata = BackupMetadata.fromJson(metadataJsonRaw);
260
261 final dataFile = archive.findFile('data.bin');
262 if (dataFile == null) {
263 throw Exception('Invalid v3 backup: missing data.bin');
264 }
265 final dataStream = dataFile.rawContent!.getStream();
266
267 final decryptedData = File('${file.path}_decrypted'); // decrypted zip file
268 if (decryptedData.existsSync()) {
269 decryptedData.deleteSync();
270 }
271 decryptedData.createSync(recursive: true);
272 decryptedData.writeAsBytesSync(Uint8List(0), mode: FileMode.write, flush: true);
273
274 int chunkIndex = 0;
275 for (var chunk in metadata.chunks) {
276 chunkIndex++;
277 final chunkBytes = dataStream.readBytes(chunk.length.encrypted).toUint8List();
278 final chunkChecksum = (await sha512.bind(Stream.fromIterable([chunkBytes])).first).toString();
279
280 // readBytes stores position internally, so we don't need to think about it.
281 if (chunk.sha512sum.encrypted != chunkChecksum) {
282 throw Exception(
283 'Invalid v3 backup: chunk (${chunk.length.encrypted} bytes) checksum mismatch at index $chunkIndex\n'
284 'expected: ${chunk.sha512sum.encrypted}\n'
285 'got: $chunkChecksum');
286 }
287 final decryptedChunk = await cake_backup.decrypt(password, chunkBytes);
288 decryptedData.writeAsBytesSync(decryptedChunk, mode: FileMode.append, flush: true);
289 }
290
291 final sha512sum = (await sha512.bind(decryptedData.openRead()).first).toString();
292 if (sha512sum.toString() != metadata.sha512sum) {
293 throw Exception('Invalid v3 backup: SHA512 checksum mismatch\n'
294 'expected: ${metadata.sha512sum}\n'
295 'got: $sha512sum');
296 }
297
298 // Decryption done, now we can import the backup (that is, unzip app data)
299
300 // archive is **NOT** backup, it is just a zip file that contains data.bin inside.
301 // We need to unzip it to get the backup.
302 // data.bin after decryption is available in decryptedData.
303
304 final zip = ZipDecoder();
305 final decryptedDataStream = InputFileStream(decryptedData.path);
306 final backupArchive = zip.decodeStream(decryptedDataStream);
307
308 final appDir = await getAppDir();
309
310 outer:
311 for (var file in backupArchive.files) {
312 final filename = file.name;
313 for (var ignore in $BackupService.ignoreFiles) {
314 if (filename.endsWith(ignore) && !filename.contains("wallets/")) {
315 printV("ignoring backup file: $filename");
316 continue outer;
317 }
318 }
319 printV("restoring: $filename");
320 if (file.isFile) {
321 final output = File('${appDir.path}/' + filename)..createSync(recursive: true);
322 final outputStream = OutputFileStream(output.path);
323 file.writeContent(outputStream);
324 outputStream.flush();
325 } else {
326 final dir = Directory('${appDir.path}/' + filename);
327 if (!dir.existsSync()) {
328 dir.createSync(recursive: true);
329 }
330 }
331 }
332 ;
333
334 // Continue importing the backup the old way
335 await super.verifyWallets();
336 await verifyHardwareWallets(password);
337 await super.importKeychainDumpV2(password);
338 await super.importPreferencesDump();
339 await super.importTransactionDescriptionDump();
340
341 // Delete decrypted data file
342 decryptedData.deleteSync();
343 await initDb();
344 }
345
346 Future<void> verifyHardwareWallets(String password,
347 {String keychainSalt = secrets.backupKeychainSalt}) async {
348 final appDir = await getAppDir();
349 final keychainDumpFile = File('${appDir.path}/~_keychain_dump');
350 final decryptedKeychainDumpFileData =
351 await decryptV2(keychainDumpFile.readAsBytesSync(), '$keychainSalt$password');
352 final keychainJSON =
353 json.decode(utf8.decode(decryptedKeychainDumpFileData)) as Map<String, dynamic>;
354 final keychainWalletsInfo = keychainJSON['wallets'] as List;
355
356 final expectedHardwareWallets = keychainWalletsInfo
357 .where((e) =>
358 (e as Map<String, dynamic>).containsKey("hardwareWalletType") &&
359 e["hardwareWalletType"] != null)
360 .toList();
361
362 for (final expectedHardwareWallet in expectedHardwareWallets) {
363 final info = expectedHardwareWallet as Map<String, dynamic>;
364 final actualWalletInfo = await WalletInfo.get(info['name'] as String,
365 WalletType.values.firstWhere((e) => e.toString() == info['type'] as String));
366 if (actualWalletInfo != null &&
367 info["hardwareWalletType"] != actualWalletInfo.hardwareWalletType?.index) {
368 actualWalletInfo.hardwareWalletType =
369 HardwareWalletType.values[info["hardwareWalletType"] as int];
370 await actualWalletInfo.save();
371 }
372 }
373 }
374
375 Future<File> exportBackupFileV3(String password, {String nonce = secrets.backupSalt}) async {
376 final metadata = BackupMetadata(
377 version: BackupVersion.v3,
378 sha512sum: 'tbd',
379 chunks: [],
380 cakeVersion: 'tbd',
381 );
382 final zipEncoder = ZipFileEncoder();
383 final appDir = await getAppDir();
384 final now = DateTime.now().toIso8601String().replaceAll(':', '-');
385 final tmpDir = Directory('${appDir.path}/~_BACKUP_TMP');
386 final archivePath = '${tmpDir.path}/backup_${now}.tmp.zip';
387 final archivePathExport = '${tmpDir.path}/backup_${now}.zip';
388 final fileEntities = appDir.listSync(recursive: false);
389 final keychainDump = await super.exportKeychainDumpV2(password);
390 final preferencesDump = await super.exportPreferencesJSON();
391 final preferencesDumpFile = File('${tmpDir.path}/~_preferences_dump_TMP');
392 final keychainDumpFile = File('${tmpDir.path}/~_keychain_dump_TMP');
393 final transactionDescriptionDumpFile =
394 File('${tmpDir.path}/~_transaction_descriptions_dump_TMP');
395
396 final transactionDescriptionData = super
397 .transactionDescriptionBox
398 .toMap()
399 .map((key, value) => MapEntry(key.toString(), value.toJson()));
400 final transactionDescriptionDump = jsonEncode(transactionDescriptionData);
401
402 if (tmpDir.existsSync()) {
403 tmpDir.deleteSync(recursive: true);
404 }
405
406 tmpDir.createSync();
407 zipEncoder.create(archivePath);
408 outer:
409 for (var entity in fileEntities) {
410 if (entity.path == archivePath || entity.path == tmpDir.path) {
411 continue;
412 }
413 for (var ignore in $BackupService.ignoreFiles) {
414 final filename = entity.absolute.path;
415 if (filename.endsWith(ignore) && !filename.contains("wallets/")) {
416 printV("ignoring backup file: $filename");
417 continue outer;
418 }
419 }
420
421 if (entity.statSync().type == FileSystemEntityType.directory) {
422 await zipEncoder.addDirectory(Directory(entity.path));
423 } else {
424 await zipEncoder.addFile(File(entity.path));
425 }
426 }
427 await keychainDumpFile.writeAsBytes(keychainDump.toList());
428 await preferencesDumpFile.writeAsString(preferencesDump);
429 await transactionDescriptionDumpFile.writeAsString(transactionDescriptionDump);
430 await zipEncoder.addFile(preferencesDumpFile, '~_preferences_dump');
431 await zipEncoder.addFile(keychainDumpFile, '~_keychain_dump');
432 await zipEncoder.addFile(transactionDescriptionDumpFile, '~_transaction_descriptions_dump');
433 await zipEncoder.close();
434
435 final dataBinUnencrypted = File(archivePath);
436
437 final dataBin = File('${tmpDir.path}/data.bin');
438 dataBin.writeAsBytesSync(Uint8List(0), mode: FileMode.write, flush: true);
439 final dataBinWriter = dataBin.openWrite();
440
441 printV("------ Backup stats ------");
442 printV("Backup version: ${metadata.version}");
443 printV("Backup size: ${await dataBinUnencrypted.length()}");
444 printV("Backup chunks: ${(await dataBinUnencrypted.length()) / chunkSize}");
445 printV("------ Backup stats ------");
446
447 int chunkIndex = 0;
448 final stopwatch = Stopwatch()..start();
449 printV("Starting backup encryption...");
450
451 metadata.sha512sum = (await sha512.bind(dataBinUnencrypted.openRead()).first).toString();
452
453 final raf = await dataBinUnencrypted.open();
454
455 while (true) {
456 printV("Reading chunk ${chunkIndex++}");
457
458 stopwatch.reset();
459 final chunk = await raf.read(chunkSize);
460 printV("Chunk read completed in ${stopwatch.elapsed}");
461 printV("Chunk length: ${chunk.length} expected: $chunkSize");
462 if (chunk.length == 0) {
463 break;
464 }
465
466 stopwatch.reset();
467 final encryptedChunk = await cake_backup.encrypt(password, chunk);
468 printV("Encryption completed in ${stopwatch.elapsed}");
469
470 stopwatch.reset();
471 final sha512sumEncryptedChunk =
472 await sha512.bind(Stream.fromIterable([encryptedChunk])).first;
473 final sha512sumUnencryptedChunk = await sha512.bind(Stream.fromIterable([chunk])).first;
474 printV("Hashing completed in ${stopwatch.elapsed}");
475
476 stopwatch.reset();
477 dataBinWriter.add(encryptedChunk);
478 metadata.chunks.add(ChunkDetails(
479 sha512sum: ChunkChecksum(
480 encrypted: sha512sumEncryptedChunk.toString(),
481 plain: sha512sumUnencryptedChunk.toString(),
482 ),
483 length: ChunkLength(
484 encrypted: encryptedChunk.length,
485 plain: chunk.length,
486 ),
487 ));
488
489 await dataBinWriter.flush();
490 printV("Writing completed in ${stopwatch.elapsed}");
491 }
492 await raf.close();
493
494 // Give the file to the user
495
496 final metadataFile = File('${tmpDir.path}/metadata.json');
497 final packageInfo = await PackageInfo.fromPlatform();
498 metadata.cakeVersion = packageInfo.version;
499
500 metadataFile.writeAsStringSync(JsonEncoder.withIndent(' ').convert(metadata.toJson()));
501 final readmeFile = File('${tmpDir.path}/README.txt');
502 readmeFile
503 .writeAsStringSync('''This is a ${packageInfo.appName} backup. Do not modify this archive.
504
505 App version: ${packageInfo.version}
506
507 If you have any issues with this backup, please contact our in-app support.
508 This backup was created on ${DateTime.now().toIso8601String()}
509 ''');
510 final zip = ZipFileEncoder();
511 zip.create(archivePathExport, level: 9);
512 await zip.addFile(dataBin, 'data.bin');
513 await zip.addFile(metadataFile, 'metadata.json');
514 await zip.addFile(readmeFile, 'README.txt');
515 await zip.close();
516 // tmpDir.deleteSync(recursive: true);
517 final file = File(archivePathExport);
518 return file;
519 }
520
521 Future<void> _throwIfBackupWasCreatedInAnotherApp(Archive archive) async {
522 final readmeFile = archive.findFile('README.txt');
523 if (readmeFile == null) return;
524
525 final readmeBytes = readmeFile.rawContent?.readBytes();
526 if (readmeBytes == null) return;
527
528 final readmeString = utf8.decode(readmeBytes, allowMalformed: true);
529 final sourceAppName = _extractBackupAppName(readmeString);
530 if (sourceAppName == null) return;
531
532 final currentAppName = (await PackageInfo.fromPlatform()).appName;
533 if (sourceAppName == currentAppName) return;
534
535 throw IncompatibleBackupAppException(
536 sourceAppName: sourceAppName,
537 currentAppName: currentAppName,
538 );
539 }
540
541 String? _extractBackupAppName(String readme) {
542 final match = RegExp(r'^This is a (.+) backup\.', multiLine: true).firstMatch(readme);
543 return match?.group(1)?.trim();
544 }
545
546 static const chunkSize = 24 * 1024 * 1024; // 24MiB
547
548 File setVersionFile(File file, BackupVersion version) {
549 if (version == BackupVersion.v3) return file; // v3 uses
550 // helper function to call super.setVersion();
551 final data = file.readAsBytesSync();
552 super.setVersion(data, version.index);
553 file.writeAsBytesSync(data);
554 return file;
555 }
556 }