| 1 | import 'dart:convert'; |
| 2 | import 'dart:io'; |
| 3 | import 'package:cake_wallet/core/secure_storage.dart'; |
| 4 | import 'package:cake_wallet/entities/get_encryption_key.dart'; |
| 5 | import 'package:cake_wallet/entities/transaction_description.dart'; |
| 6 | import 'package:cake_wallet/themes/utils/theme_list.dart'; |
| 7 | import 'package:cw_core/root_dir.dart'; |
| 8 | import 'package:cake_wallet/utils/device_info.dart'; |
| 9 | import 'package:cw_core/utils/print_verbose.dart'; |
| 10 | import 'package:cw_core/wallet_type.dart'; |
| 11 | import 'package:flutter/foundation.dart'; |
| 12 | import 'package:hive/hive.dart'; |
| 13 | import 'package:cryptography/cryptography.dart'; |
| 14 | import 'package:shared_preferences/shared_preferences.dart'; |
| 15 | import 'package:archive/archive_io.dart' hide Mac; |
| 16 | import 'package:cw_core/cake_hive.dart'; |
| 17 | import 'package:cake_wallet/core/key_service.dart'; |
| 18 | import 'package:cake_wallet/entities/encrypt.dart'; |
| 19 | import 'package:cake_wallet/entities/preferences_key.dart'; |
| 20 | import 'package:cake_wallet/entities/secret_store_key.dart'; |
| 21 | import 'package:cw_core/erc20_token_legacy.dart' show performErc20TokenHiveMigration; |
| 22 | import 'package:cw_core/spl_token_legacy.dart' show performSplTokenHiveMigration; |
| 23 | import 'package:cw_core/tron_token_legacy.dart' show performTronTokenHiveMigration; |
| 24 | import 'package:cw_core/wallet_info.dart'; |
| 25 | import 'package:cake_wallet/exchange/trade_legacy.dart'; |
| 26 | import 'package:cake_wallet/.secrets.g.dart' as secrets; |
| 27 | import 'package:cake_wallet/wallet_types.g.dart'; |
| 28 | import 'package:cake_backup/backup.dart' as cake_backup; |
| 29 | |
| 30 | class $BackupService { |
| 31 | $BackupService( |
| 32 | this._secureStorage, this.transactionDescriptionBox, this.keyService, this.sharedPreferences) |
| 33 | : cipher = Cryptography.instance.chacha20Poly1305Aead(), |
| 34 | correctWallets = <WalletInfo>[]; |
| 35 | |
| 36 | static const currentVersion = _v3; |
| 37 | |
| 38 | static const _v2 = 2; |
| 39 | static const _v3 = 3; |
| 40 | |
| 41 | final Cipher cipher; |
| 42 | final SecureStorage _secureStorage; |
| 43 | final SharedPreferences sharedPreferences; |
| 44 | final Box<TransactionDescription> transactionDescriptionBox; |
| 45 | final KeyService keyService; |
| 46 | List<WalletInfo> correctWallets; |
| 47 | |
| 48 | Future<void> importBackupV1(Uint8List data, String password, {required String nonce}) async { |
| 49 | final appDir = await getAppDir(); |
| 50 | final decryptedData = await _decryptV1(data, password, nonce); |
| 51 | final zip = ZipDecoder().decodeBytes(decryptedData); |
| 52 | |
| 53 | for (var file in zip.files) { |
| 54 | final filename = file.name; |
| 55 | |
| 56 | if (file.isFile) { |
| 57 | final content = file.content as List<int>; |
| 58 | File('${appDir.path}/' + filename) |
| 59 | ..createSync(recursive: true) |
| 60 | ..writeAsBytesSync(content, flush: true); |
| 61 | } else { |
| 62 | Directory('${appDir.path}/' + filename)..create(recursive: true); |
| 63 | } |
| 64 | } |
| 65 | ; |
| 66 | |
| 67 | await verifyWallets(); |
| 68 | await _importKeychainDumpV1(password, nonce: nonce); |
| 69 | await importPreferencesDump(); |
| 70 | } |
| 71 | |
| 72 | // checked with .endsWith - so this should be the last part of the filename |
| 73 | static const ignoreFiles = [ |
| 74 | "flutter_assets/kernel_blob.bin", |
| 75 | "flutter_assets/vm_snapshot_data", |
| 76 | "flutter_assets/isolate_snapshot_data", |
| 77 | "README.txt", |
| 78 | ".lock", |
| 79 | ]; |
| 80 | |
| 81 | Future<void> importBackupV2(Uint8List data, String password) async { |
| 82 | final appDir = await getAppDir(); |
| 83 | final decryptedData = await decryptV2(data, password); |
| 84 | final zip = ZipDecoder().decodeBytes(decryptedData); |
| 85 | |
| 86 | outer: |
| 87 | for (var file in zip.files) { |
| 88 | final filename = file.name; |
| 89 | for (var ignore in ignoreFiles) { |
| 90 | if (filename.endsWith(ignore) && !filename.contains("wallets/")) { |
| 91 | printV("ignoring backup file: $filename"); |
| 92 | continue outer; |
| 93 | } |
| 94 | } |
| 95 | printV("restoring: $filename"); |
| 96 | if (file.isFile) { |
| 97 | final content = file.content as List<int>; |
| 98 | File('${appDir.path}/' + filename) |
| 99 | ..createSync(recursive: true) |
| 100 | ..writeAsBytesSync(content, flush: true); |
| 101 | } else { |
| 102 | final dir = Directory('${appDir.path}/' + filename); |
| 103 | if (!dir.existsSync()) { |
| 104 | dir.createSync(recursive: true); |
| 105 | } |
| 106 | } |
| 107 | } |
| 108 | ; |
| 109 | |
| 110 | await verifyWallets(); |
| 111 | await importKeychainDumpV2(password); |
| 112 | await importPreferencesDump(); |
| 113 | await importTransactionDescriptionDump(); // HiveError: Box has already been closed |
| 114 | } |
| 115 | |
| 116 | Future<void> verifyWallets() async { |
| 117 | await performHiveMigration(); // for backups made before sqlite migration |
| 118 | await performTradeHiveMigration(_secureStorage); |
| 119 | await performErc20TokenHiveMigration(); |
| 120 | await performSplTokenHiveMigration(); |
| 121 | await performTronTokenHiveMigration(); |
| 122 | |
| 123 | correctWallets = (await WalletInfo.getAll()) |
| 124 | .where((info) => availableWalletTypes.contains(info.type)) |
| 125 | .toList(); |
| 126 | |
| 127 | if (correctWallets.isEmpty) { |
| 128 | printV('Correct wallets not detected'); |
| 129 | } |
| 130 | } |
| 131 | |
| 132 | Future<void> importTransactionDescriptionDump() async { |
| 133 | final appDir = await getAppDir(); |
| 134 | final transactionDescriptionFile = File('${appDir.path}/~_transaction_descriptions_dump'); |
| 135 | |
| 136 | if (!transactionDescriptionFile.existsSync()) { |
| 137 | return; |
| 138 | } |
| 139 | |
| 140 | final jsonData = |
| 141 | json.decode(transactionDescriptionFile.readAsStringSync()) as Map<String, dynamic>; |
| 142 | final descriptionsMap = jsonData.map((key, value) => |
| 143 | MapEntry(key, TransactionDescription.fromJson(value as Map<String, dynamic>))); |
| 144 | var box = transactionDescriptionBox; |
| 145 | if (!box.isOpen) { |
| 146 | final transactionDescriptionsBoxKey = await getEncryptionKey( |
| 147 | secureStorage: _secureStorage, forKey: TransactionDescription.boxKey); |
| 148 | box = await CakeHive.openBox<TransactionDescription>(TransactionDescription.boxName, |
| 149 | encryptionKey: transactionDescriptionsBoxKey); |
| 150 | } |
| 151 | await box.putAll(descriptionsMap); |
| 152 | } |
| 153 | |
| 154 | Future<void> importPreferencesDump() async { |
| 155 | final appDir = await getAppDir(); |
| 156 | final preferencesFile = File('${appDir.path}/~_preferences_dump'); |
| 157 | |
| 158 | if (!preferencesFile.existsSync()) { |
| 159 | return; |
| 160 | } |
| 161 | |
| 162 | final data = json.decode(preferencesFile.readAsStringSync()) as Map<String, dynamic>; |
| 163 | |
| 164 | try { |
| 165 | // shouldn't throw an error but just in case, so it doesn't stop the backup restore |
| 166 | for (var entry in data.entries) { |
| 167 | String key = entry.key; |
| 168 | dynamic value = entry.value; |
| 169 | |
| 170 | // Check the type of the value and save accordingly |
| 171 | if (value is String) { |
| 172 | await sharedPreferences.setString(key, value); |
| 173 | } else if (value is int) { |
| 174 | await sharedPreferences.setInt(key, value); |
| 175 | } else if (value is double) { |
| 176 | await sharedPreferences.setDouble(key, value); |
| 177 | } else if (value is bool) { |
| 178 | await sharedPreferences.setBool(key, value); |
| 179 | } else if (value is List<String>) { |
| 180 | await sharedPreferences.setStringList(key, value); |
| 181 | } else { |
| 182 | if (kDebugMode) { |
| 183 | printV( |
| 184 | 'Skipping individual save for key "$key": Unsupported type (${value.runtimeType}). Value: $value'); |
| 185 | } |
| 186 | } |
| 187 | } |
| 188 | } catch (_) {} |
| 189 | |
| 190 | String currentWalletName = data[PreferencesKey.currentWalletName] as String; |
| 191 | int currentWalletType = data[PreferencesKey.currentWalletType] as int; |
| 192 | |
| 193 | final isCorrectCurrentWallet = correctWallets |
| 194 | .any((info) => info.name == currentWalletName && info.type.index == currentWalletType); |
| 195 | |
| 196 | try { |
| 197 | if (!isCorrectCurrentWallet) { |
| 198 | currentWalletName = correctWallets.first.name; |
| 199 | currentWalletType = serializeToInt(correctWallets.first.type); |
| 200 | } |
| 201 | } catch (e) {} |
| 202 | |
| 203 | if (DeviceInfo.instance.isDesktop) { |
| 204 | await sharedPreferences.setInt(PreferencesKey.currentTheme, ThemeList.darkTheme.raw); |
| 205 | } |
| 206 | |
| 207 | await preferencesFile.delete(); |
| 208 | } |
| 209 | |
| 210 | Future<void> _importKeychainDumpV1(String password, |
| 211 | {required String nonce, String keychainSalt = secrets.backupKeychainSalt}) async { |
| 212 | final appDir = await getAppDir(); |
| 213 | final keychainDumpFile = File('${appDir.path}/~_keychain_dump'); |
| 214 | final decryptedKeychainDumpFileData = |
| 215 | await _decryptV1(keychainDumpFile.readAsBytesSync(), '$keychainSalt$password', nonce); |
| 216 | final keychainJSON = |
| 217 | json.decode(utf8.decode(decryptedKeychainDumpFileData)) as Map<String, dynamic>; |
| 218 | final keychainWalletsInfo = keychainJSON['wallets'] as List; |
| 219 | final decodedPin = keychainJSON['pin'] as String; |
| 220 | final pinCodeKey = generateStoreKeyFor(key: SecretStoreKey.pinCodePassword); |
| 221 | final backupPasswordKey = generateStoreKeyFor(key: SecretStoreKey.backupPassword); |
| 222 | final backupPassword = keychainJSON[backupPasswordKey] as String; |
| 223 | |
| 224 | await _secureStorage.write(key: backupPasswordKey, value: backupPassword); |
| 225 | |
| 226 | keychainWalletsInfo.forEach((dynamic rawInfo) async { |
| 227 | final info = rawInfo as Map<String, dynamic>; |
| 228 | await importWalletKeychainInfo(info); |
| 229 | }); |
| 230 | |
| 231 | await _secureStorage.write(key: pinCodeKey, value: encodedPinCode(pin: decodedPin)); |
| 232 | |
| 233 | keychainDumpFile.deleteSync(); |
| 234 | } |
| 235 | |
| 236 | Future<void> importKeychainDumpV2(String password, |
| 237 | {String keychainSalt = secrets.backupKeychainSalt}) async { |
| 238 | final appDir = await getAppDir(); |
| 239 | final keychainDumpFile = File('${appDir.path}/~_keychain_dump'); |
| 240 | final decryptedKeychainDumpFileData = |
| 241 | await decryptV2(keychainDumpFile.readAsBytesSync(), '$keychainSalt$password'); |
| 242 | final keychainJSON = |
| 243 | json.decode(utf8.decode(decryptedKeychainDumpFileData)) as Map<String, dynamic>; |
| 244 | final keychainWalletsInfo = keychainJSON['wallets'] as List; |
| 245 | final backupPasswordKey = generateStoreKeyFor(key: SecretStoreKey.backupPassword); |
| 246 | final backupPassword = keychainJSON[backupPasswordKey] as String; |
| 247 | |
| 248 | await _secureStorage.write(key: backupPasswordKey, value: backupPassword); |
| 249 | |
| 250 | keychainWalletsInfo.forEach((dynamic rawInfo) async { |
| 251 | final info = rawInfo as Map<String, dynamic>; |
| 252 | await importWalletKeychainInfo(info); |
| 253 | }); |
| 254 | |
| 255 | if (keychainJSON['_all'] is Map<String, dynamic>) { |
| 256 | for (var key in (keychainJSON['_all'] as Map<String, dynamic>).keys) { |
| 257 | try { |
| 258 | if (!key.startsWith('MONERO_WALLET_')) continue; |
| 259 | final decodedPassword = |
| 260 | decodeWalletPassword(password: keychainJSON['_all'][key].toString()); |
| 261 | final walletName = key.split('_WALLET_')[1]; |
| 262 | final walletType = key.split('_WALLET_')[0].toLowerCase(); |
| 263 | await importWalletKeychainInfo({ |
| 264 | 'name': walletName, |
| 265 | 'type': "WalletType.$walletType", |
| 266 | 'password': decodedPassword, |
| 267 | }); |
| 268 | } catch (e) { |
| 269 | printV('Error importing wallet ($key) password: $e'); |
| 270 | } |
| 271 | } |
| 272 | } |
| 273 | |
| 274 | keychainDumpFile.deleteSync(); |
| 275 | } |
| 276 | |
| 277 | Future<void> importWalletKeychainInfo(Map<String, dynamic> info) async { |
| 278 | final name = info['name'] as String; |
| 279 | final password = info['password'] as String; |
| 280 | |
| 281 | await keyService.saveWalletPassword(walletName: name, password: password); |
| 282 | } |
| 283 | |
| 284 | Future<Uint8List> exportKeychainDumpV2(String password, |
| 285 | {String keychainSalt = secrets.backupKeychainSalt}) async { |
| 286 | final key = generateStoreKeyFor(key: SecretStoreKey.pinCodePassword); |
| 287 | final wallets = await Future.wait((await WalletInfo.getAll()).map((walletInfo) async { |
| 288 | try { |
| 289 | return { |
| 290 | 'name': walletInfo.name, |
| 291 | 'type': walletInfo.type.toString(), |
| 292 | 'password': await keyService.getWalletPassword(walletName: walletInfo.name), |
| 293 | 'hardwareWalletType': walletInfo.hardwareWalletType?.index, |
| 294 | }; |
| 295 | } catch (e) { |
| 296 | return { |
| 297 | 'name': walletInfo.name, |
| 298 | 'type': walletInfo.type.toString(), |
| 299 | 'password': '', |
| 300 | 'hardwareWalletType': walletInfo.hardwareWalletType?.index, |
| 301 | }; |
| 302 | } |
| 303 | })); |
| 304 | final backupPasswordKey = generateStoreKeyFor(key: SecretStoreKey.backupPassword); |
| 305 | final backupPassword = await _secureStorage.read(key: backupPasswordKey); |
| 306 | final data = utf8.encode(json.encode({ |
| 307 | 'wallets': wallets, |
| 308 | backupPasswordKey: backupPassword, |
| 309 | '_all': await _secureStorage.readAll() |
| 310 | })); |
| 311 | final encrypted = await _encryptV2(Uint8List.fromList(data), '$keychainSalt$password'); |
| 312 | |
| 313 | return encrypted; |
| 314 | } |
| 315 | |
| 316 | static const List<String> _excludedPrefsKeys = [ |
| 317 | PreferencesKey.currentPinLength, |
| 318 | PreferencesKey.showCameraConsent, |
| 319 | PreferencesKey.lastSeenAppVersion, |
| 320 | PreferencesKey.failedTotpTokenTrials, |
| 321 | ]; |
| 322 | |
| 323 | Future<String> exportPreferencesJSON() async { |
| 324 | final preferences = <String, dynamic>{}; |
| 325 | sharedPreferences.getKeys().forEach((key) => preferences[key] = sharedPreferences.get(key)); |
| 326 | |
| 327 | _excludedPrefsKeys.forEach((key) => preferences.remove(key)); |
| 328 | |
| 329 | return json.encode(preferences); |
| 330 | } |
| 331 | |
| 332 | int getVersion(Uint8List data) => data.toList().first; |
| 333 | |
| 334 | Uint8List setVersion(Uint8List data, int version) { |
| 335 | final bytes = data.toList()..insert(0, version); |
| 336 | return Uint8List.fromList(bytes); |
| 337 | } |
| 338 | |
| 339 | Future<Uint8List> _decryptV1(Uint8List data, String secretKeySource, String nonceBase64, |
| 340 | {int macLength = 16}) async { |
| 341 | final secretKeyHash = await Cryptography.instance.sha256().hash(utf8.encode(secretKeySource)); |
| 342 | final secretKey = SecretKey(secretKeyHash.bytes); |
| 343 | final nonce = base64.decode(nonceBase64).toList(); |
| 344 | final box = SecretBox(Uint8List.sublistView(data, 0, data.lengthInBytes - macLength).toList(), |
| 345 | nonce: nonce, mac: Mac(Uint8List.sublistView(data, data.lengthInBytes - macLength))); |
| 346 | final plainData = await cipher.decrypt(box, secretKey: secretKey); |
| 347 | return Uint8List.fromList(plainData); |
| 348 | } |
| 349 | |
| 350 | Future<Uint8List> _encryptV2(Uint8List data, String passphrase) async => |
| 351 | cake_backup.encrypt(passphrase, data, version: _v2); |
| 352 | |
| 353 | Future<Uint8List> decryptV2(Uint8List data, String passphrase) async => |
| 354 | cake_backup.decrypt(passphrase, data); |
| 355 | } |