Revert "Revert "fix: unify encryption across platforms (#3470)" (#3634)" (#3635)
What changed, and why it matters
This commit re-applies a change that makes wallet file encryption consistent across all platforms. It replaces an older, weaker encryption method (Salsa20) with a stronger one (XChaCha20) and adds automatic migration of old wallet files. The change also fixes how non-English characters are stored and how user-chosen versus app-generated passwords are handled. Because this is a security-hardening change that was previously reverted and then re-applied, it is relevant to security, but the commit itself does not describe a specific vulnerability or incident.
Review the pinned cake_backup commit (b5d86a2a21e1c186cd0baa4df1f3a4f3f9413056) for its own security properties, since the wallet security now depends entirely on that library. Verify that the Salsa20-to-XChaCha20 migration does not introduce downgrade or oracle risks, and confirm the JSON-prefix heuristic for legacy files is acceptable for the threat model. Ensure the test suite passes and that no other code paths still call the removed Salsa20 helpers directly.
Security signals we found
Replaces Salsa20 with XChaCha20 for wallet file encryption
Adds transparent migration path from legacy Salsa20 files
Pins cake_backup dependency to a specific git commit instead of floating branch
Adds 380-line test suite covering encryption, migration, and failure modes
Previously reverted and now re-applied, indicating security relevance
Handles unauthenticated Salsa20 legacy decryption with a JSON-prefix heuristic
Evidence from the diff
The commit reverts a prior revert, re-integrating PR #3470. It unifies encryption by routing all reads/writes through XChaCha20MigratingEncryptionFileUtils, which uses the cake_backup library’s XChaCha20 implementation. Legacy Salsa20 files are detected and transparently re-encrypted on read. The code distinguishes ‘direct’ (user-chosen, low-entropy) passwords from generated (high-entropy) passwords, selecting the appropriate cake_backup version byte. It also handles a legacy Linux encoding quirk (UTF-16 code units stored as bytes) and adds extensive tests covering migration, wrong-password behavior, and corruption handling. The cake_backup dependency is pinned to a specific commit (b5d86a2a21e1c186cd0baa4df1f3a4f3f9413056).
Changed components
cw_core/lib/encryption_file_utils.dartcw_core/lib/key.dartcw_core/lib/utils/file.dartcw_core/lib/wallet_service.dartlib/view_model/dashboard/dashboard_view_model.dartcw_core/pubspec.yamlpubspec_base.yamlpubspec_overrides.yamlpubspec.lockcw_core/test/encryption_file_utils_test.dartInspect captured patch +555 / −86
### cw_core/lib/encryption_file_utils.dart
@@ -1,41 +1,34 @@
-import 'dart:io';
-import 'dart:typed_data';
import 'package:cw_core/utils/file.dart' as file;
-import 'package:cake_backup/backup.dart' as cwb;
-EncryptionFileUtils encryptionFileUtilsFor(bool direct) =>
- direct ? XChaCha20EncryptionFileUtils() : Salsa20EncryhptionFileUtils();
+EncryptionFileUtils encryptionFileUtilsFor(bool isDirect) =>
+ XChaCha20MigratingEncryptionFileUtils(isDirect: isDirect);
abstract class EncryptionFileUtils {
Future<void> write({required String path, required String password, required String data});
Future<String> read({required String path, required String password});
}
-class Salsa20EncryhptionFileUtils extends EncryptionFileUtils {
- // Requires legacy complex key + iv as password
- @override
- Future<void> write(
- {required String path, required String password, required String data}) async =>
- await file.write(path: path, password: password, data: data);
+class XChaCha20MigratingEncryptionFileUtils extends EncryptionFileUtils {
+ XChaCha20MigratingEncryptionFileUtils({required this.isDirect});
- // Requires legacy complex key + iv as password
- @override
- Future<String> read({required String path, required String password}) async =>
- await file.read(path: path, password: password);
-}
+ final bool isDirect;
-class XChaCha20EncryptionFileUtils extends EncryptionFileUtils {
@override
- Future<void> write({required String path, required String password, required String data}) async {
- final encrypted = await cwb.encrypt(password, Uint8List.fromList(data.codeUnits));
- await File(path).writeAsBytes(encrypted);
+ Future<void> write({required String path, required String password, required String data}) {
+ return file.write(
+ path: path,
+ password: password,
+ data: data,
+ highEntropyPassphrase: !isDirect,
+ );
}
@override
- Future<String> read({required String path, required String password}) async {
- final file = File(path);
- final encrypted = await file.readAsBytes();
- final bytes = await cwb.decrypt(password, encrypted);
- return String.fromCharCodes(bytes);
+ Future<String> read({required String path, required String password}) {
+ return file.read(
+ path: path,
+ password: password,
+ highEntropyPassphrase: !isDirect,
+ );
}
}
### cw_core/lib/key.dart
@@ -1,35 +1,8 @@
import 'package:encrypt/encrypt.dart' as encrypt;
-const ivEncodedStringLength = 12;
-
String generateKey() {
final key = encrypt.Key.fromSecureRandom(512);
final iv = encrypt.IV.fromSecureRandom(8);
return key.base64 + iv.base64;
}
-
-List<String> extractKeys(String key) {
- final _key = key.substring(0, key.length - ivEncodedStringLength);
- final iv = key.substring(key.length - ivEncodedStringLength);
-
- return [_key, iv];
-}
-
-Future<String> encode(
- {required encrypt.Key key, required encrypt.IV iv, required String data}) async {
- final encrypter = encrypt.Encrypter(encrypt.Salsa20(key));
- final encrypted = encrypter.encrypt(data, iv: iv);
-
- return encrypted.base64;
-}
-
-Future<String> decode({required String password, required String data}) async {
- final keys = extractKeys(password);
- final key = encrypt.Key.fromBase64(keys.first);
- final iv = encrypt.IV.fromBase64(keys.last);
- final encrypter = encrypt.Encrypter(encrypt.Salsa20(key));
- final encrypted = encrypter.decrypt64(data, iv: iv);
-
- return encrypted;
-}
### cw_core/lib/utils/file.dart
@@ -1,21 +1,84 @@
+import 'dart:convert';
import 'dart:io';
-import 'package:cw_core/key.dart';
+import 'dart:typed_data';
+
+import 'package:cake_backup/backup.dart' as cwb;
import 'package:encrypt/encrypt.dart' as encrypt;
-Future<void> write({required String path, required String password, required String data}) async =>
- writeData(path: path, password: password, data: data);
+const _ivEncodedStringLength = 12;
-Future<void> writeData(
- {required String path, required String password, required String data}) async {
- final keys = extractKeys(password);
- final key = encrypt.Key.fromBase64(keys.first);
- final iv = encrypt.IV.fromBase64(keys.last);
- final encrypted = await encode(key: key, iv: iv, data: data);
- final f = File(path);
- f.writeAsStringSync(encrypted);
+Future<void> write({
+ required String path,
+ required String password,
+ required String data,
+ bool? highEntropyPassphrase,
+}) async {
+ await _writeXChaCha20(
+ path: path,
+ password: password,
+ data: data,
+ highEntropyPassphrase: highEntropyPassphrase ?? !Platform.isLinux,
+ );
}
-Future<String> read({required String path, required String password}) async {
+Future<String> read({
+ required String path,
+ required String password,
+ bool? highEntropyPassphrase,
+}) async {
+ final useHighEntropy = highEntropyPassphrase ?? !Platform.isLinux;
+ try {
+ return await _readXChaCha20(path: path, password: password);
+ } catch (e) {
+ final encrypted = await File(path).readAsBytes();
+ if (encrypted.isNotEmpty &&
+ (encrypted[0] == cwb.lowEntropyVersion || encrypted[0] == cwb.highEntropyVersion)) {
+ rethrow;
+ }
+
+ final String data;
+ try {
+ data = await _readLegacy(path: path, password: password);
+ } catch (_) {
+ throw Exception('Failed to decrypt legacy file: invalid password or corrupted data');
+ }
+ if (data.isEmpty) {
+ throw Exception('Failed to read data');
+ }
+
+ // Salsa20 is unauthenticated, so a wrong password decrypts to garbage instead of
+ // failing. Every payload written here is a JSON object with at least one key, so a
+ // correctly decrypted file always starts with '{"' and anything else is a bad
+ // password.
+ // There's 1 in 2^16 chance that this will be a false positive, but check later
+ // prevents any damage to the file caused by re-encryption.
+ if (!data.startsWith('{"')) {
+ throw Exception('Failed to decrypt legacy file: invalid password or corrupted data');
+ }
+
+ if (_isJson(data)) {
+ await _writeXChaCha20(
+ path: path,
+ password: password,
+ data: data,
+ highEntropyPassphrase: useHighEntropy,
+ );
+ }
+
+ return data;
+ }
+}
+
+bool _isJson(String data) {
+ try {
+ json.decode(data);
+ return true;
+ } catch (_) {
+ return false;
+ }
+}
+
+Future<String> _readLegacy({required String path, required String password}) async {
final file = File(path);
if (!file.existsSync()) {
@@ -24,5 +87,57 @@ Future<String> read({required String path, required String password}) async {
final encrypted = file.readAsStringSync();
- return decode(password: password, data: encrypted);
+ return _decode(password: password, data: encrypted);
+}
+
+Future<void> _writeXChaCha20({
+ required String path,
+ required String password,
+ required String data,
+ required bool highEntropyPassphrase,
+}) async {
+ final encrypted = await cwb.encrypt(
+ password,
+ Uint8List.fromList(utf8.encode(data)),
+ highEntropyPassphrase: highEntropyPassphrase,
+ );
+
+ final tmpFile = File('$path.tmp');
+ tmpFile.writeAsBytesSync(encrypted, flush: true);
+ tmpFile.renameSync(path);
+}
+
+Future<String> _readXChaCha20({
+ required String path,
+ required String password,
+}) async {
+ final encrypted = await File(path).readAsBytes();
+ final bytes = await cwb.decrypt(password, encrypted);
+ return _decodeUtf8(bytes);
+}
+
+// Linux wallets written by the old XChaCha20EncryptionFileUtils stored one byte per
+// UTF-16 code unit, so bytes above 0x7F in those files are Latin-1 rather than UTF-8.
+String _decodeUtf8(Uint8List bytes) {
+ try {
+ return utf8.decode(bytes);
+ } on FormatException {
+ return String.fromCharCodes(bytes);
+ }
+}
+
+List<String> _extractKeys(String key) {
+ final k = key.substring(0, key.length - _ivEncodedStringLength);
+ final iv = key.substring(key.length - _ivEncodedStringLength);
+
+ return [k, iv];
+}
+
+Future<String> _decode({required String password, required String data}) async {
+ final keys = _extractKeys(password);
+ final key = encrypt.Key.fromBase64(keys.first);
+ final iv = encrypt.IV.fromBase64(keys.last);
+ final encrypter = encrypt.Encrypter(encrypt.Salsa20(key));
+
+ return encrypter.decrypt64(data, iv: iv);
}
### cw_core/lib/wallet_service.dart
@@ -1,17 +1,18 @@
import "dart:convert";
import "dart:io";
-import "package:cw_core/imported_nft.dart";
-import "package:cw_core/pathForWallet.dart";
-import "package:cw_core/spl_token.dart";
-import "package:cw_core/tron_token.dart";
-import "package:cw_core/utils/file.dart";
-import "package:cw_core/utils/print_verbose.dart";
-import "package:cw_core/wallet_base.dart";
-import "package:cw_core/wallet_credentials.dart";
-import "package:cw_core/wallet_info.dart";
-import "package:cw_core/wallet_type.dart";
-import "package:path/path.dart" as p;
+import 'package:cw_core/encryption_file_utils.dart';
+import 'package:cw_core/imported_nft.dart';
+import 'package:cw_core/pathForWallet.dart';
+import 'package:cw_core/spl_token.dart';
+import 'package:cw_core/tron_token.dart';
+import 'package:cw_core/utils/print_verbose.dart';
+import 'package:cw_core/wallet_keys_file.dart';
+import 'package:cw_core/wallet_base.dart';
+import 'package:cw_core/wallet_credentials.dart';
+import 'package:cw_core/wallet_info.dart';
+import 'package:cw_core/wallet_type.dart';
+import 'package:path/path.dart' as p;
abstract class WalletService<N extends WalletCredentials, RFS extends WalletCredentials,
RFK extends WalletCredentials, RFH extends WalletCredentials> {
@@ -95,8 +96,15 @@ abstract class WalletService<N extends WalletCredentials, RFS extends WalletCred
Future<String> getSeeds(String name, String password, WalletType type) async {
try {
+ final encryption = encryptionFileUtilsFor(Platform.isLinux);
+
+ if (await WalletKeysFile.hasKeysFile(name, type)) {
+ final keysData = await WalletKeysFile.readKeysFile(name, type, password, encryption);
+ return keysData.mnemonic ?? keysData.altMnemonic ?? keysData.privateKey ?? '';
+ }
+
final path = await pathForWallet(name: name, type: type);
- final jsonSource = await read(path: path, password: password);
+ final jsonSource = await encryption.read(path: path, password: password);
try {
final data = json.decode(jsonSource) as Map;
return data["mnemonic"] as String? ?? "";
### cw_core/pubspec.yaml
@@ -22,8 +22,7 @@ dependencies:
cake_backup:
git:
url: https://github.com/cake-tech/cake_backup.git
- ref: main
- version: 1.0.0
+ ref: b5d86a2a21e1c186cd0baa4df1f3a4f3f9413056
socks5_proxy:
git:
url: https://github.com/LacticWhale/socks_dart
### cw_core/test/encryption_file_utils_test.dart
@@ -0,0 +1,380 @@
+import 'dart:convert';
+import 'dart:io';
+import 'dart:typed_data';
+
+import 'package:cake_backup/backup.dart' as cwb;
+import 'package:cw_core/encryption_file_utils.dart';
+import 'package:cw_core/key.dart';
+import 'package:cw_core/utils/file.dart' as encrypted_file;
+import 'package:cw_core/wallet_keys_file.dart';
+import 'package:encrypt/encrypt.dart' as encrypt;
+import 'package:flutter_test/flutter_test.dart';
+
+void main() {
+ late Directory tmpDir;
+
+ setUp(() {
+ tmpDir = Directory.systemTemp.createTempSync('cw_enc_');
+ });
+
+ tearDown(() {
+ if (tmpDir.existsSync()) {
+ tmpDir.deleteSync(recursive: true);
+ }
+ });
+
+ String path([String name = 'wallet.json']) => '${tmpDir.path}/$name';
+
+ const walletJson = '{"mnemonic":"abandon ability able","privateKey":null}';
+
+ group('XChaCha20 round-trip', () {
+ test('writes and reads JSON with a generated high-entropy password', () async {
+ final password = generateKey();
+ await encrypted_file.write(
+ path: path(),
+ password: password,
+ data: walletJson,
+ highEntropyPassphrase: true,
+ );
+
+ expect(await encrypted_file.read(path: path(), password: password), walletJson);
+ expect(File('${path()}.tmp').existsSync(), isFalse);
+ });
+
+ test('uses high-entropy (v3) vs low-entropy (v2) version bytes', () async {
+ final password = generateKey();
+
+ await encrypted_file.write(
+ path: path('high'),
+ password: password,
+ data: walletJson,
+ highEntropyPassphrase: true,
+ );
+ expect(File(path('high')).readAsBytesSync().first, cwb.highEntropyVersion);
+
+ await encrypted_file.write(
+ path: path('low'),
+ password: password,
+ data: walletJson,
+ highEntropyPassphrase: false,
+ );
+ expect(File(path('low')).readAsBytesSync().first, cwb.lowEntropyVersion);
+
+ expect(await encrypted_file.read(path: path('high'), password: password), walletJson);
+ expect(await encrypted_file.read(path: path('low'), password: password), walletJson);
+ });
+
+ test('read ignores highEntropyPassphrase because version is in the file', () async {
+ final password = generateKey();
+ await encrypted_file.write(
+ path: path(),
+ password: password,
+ data: walletJson,
+ highEntropyPassphrase: true,
+ );
+
+ expect(
+ await encrypted_file.read(
+ path: path(),
+ password: password,
+ highEntropyPassphrase: false,
+ ),
+ walletJson,
+ );
+ });
+
+ test('round-trips UTF-8 JSON with non-ASCII characters', () async {
+ final password = generateKey();
+ const data = '{"name":"Café","mnemonic":"abandon"}';
+ await encrypted_file.write(
+ path: path(),
+ password: password,
+ data: data,
+ highEntropyPassphrase: true,
+ );
+
+ expect(await encrypted_file.read(path: path(), password: password), data);
+ });
+
+ test('wrong password does not fall back to Salsa20 or rewrite the file', () async {
+ final password = generateKey();
+ await encrypted_file.write(
+ path: path(),
+ password: password,
+ data: walletJson,
+ highEntropyPassphrase: true,
+ );
+ final before = File(path()).readAsBytesSync();
+
+ await expectLater(
+ encrypted_file.read(path: path(), password: generateKey()),
+ throwsA(anything),
+ );
+ expect(File(path()).readAsBytesSync(), before);
+ });
+
+ test('corrupt XChaCha20 blob is not treated as a legacy Salsa20 file', () async {
+ final password = generateKey();
+ await encrypted_file.write(
+ path: path(),
+ password: password,
+ data: walletJson,
+ highEntropyPassphrase: true,
+ );
+ final bytes = File(path()).readAsBytesSync();
+ bytes[bytes.length - 1] = bytes[bytes.length - 1] ^ 0xFF;
+ File(path()).writeAsBytesSync(bytes);
+
+ await expectLater(
+ encrypted_file.read(path: path(), password: password),
+ throwsA(anything),
+ );
+ expect(File(path()).readAsBytesSync().first, cwb.highEntropyVersion);
+ });
+ });
+
+ group('encryptionFileUtilsFor', () {
+ test('isDirect=false (generated password) writes high-entropy v3', () async {
+ final password = generateKey();
+ final encryption = encryptionFileUtilsFor(false);
+ await encryption.write(path: path(), password: password, data: walletJson);
+
+ expect(File(path()).readAsBytesSync().first, cwb.highEntropyVersion);
+ expect(await encryption.read(path: path(), password: password), walletJson);
+ });
+
+ test('isDirect=true (user password) writes low-entropy v2', () async {
+ const password = 'user-chosen-passphrase';
+ final encryption = encryptionFileUtilsFor(true);
+ await encryption.write(path: path(), password: password, data: walletJson);
+
+ expect(File(path()).readAsBytesSync().first, cwb.lowEntropyVersion);
+ expect(await encryption.read(path: path(), password: password), walletJson);
+ });
+
+ test('WalletKeysData JSON survives a write/read used by getSeeds', () async {
+ final password = generateKey();
+ final keys = WalletKeysData(
+ mnemonic: 'abandon ability able about above absent absorb abstract',
+ privateKey: 'deadbeef',
+ );
+ final encryption = encryptionFileUtilsFor(false);
+ await encryption.write(path: path(), password: password, data: keys.toJSON());
+
+ final decoded = json.decode(await encryption.read(path: path(), password: password))
+ as Map<String, dynamic>;
+ final restored = WalletKeysData.fromJSON(decoded);
+ expect(restored.mnemonic, keys.mnemonic);
+ expect(restored.privateKey, keys.privateKey);
+ });
+ });
+
+ group('legacy Salsa20 migration', () {
+ test('reads a Salsa20 wallet file and re-encrypts it as XChaCha20', () async {
+ final password = generateKey();
+ File(path()).writeAsStringSync(_salsa20Encrypt(password, walletJson));
+ expect(File(path()).readAsBytesSync().first, isNot(cwb.lowEntropyVersion));
+ expect(File(path()).readAsBytesSync().first, isNot(cwb.highEntropyVersion));
+
+ final result = await encrypted_file.read(
+ path: path(),
+ password: password,
+ highEntropyPassphrase: true,
+ );
+
+ expect(result, walletJson);
+ expect(File(path()).readAsBytesSync().first, cwb.highEntropyVersion);
+ expect(await encrypted_file.read(path: path(), password: password), walletJson);
+ });
+
+ test('migrated file can be opened with EncryptionFileUtils', () async {
+ final password = generateKey();
+ File(path()).writeAsStringSync(_salsa20Encrypt(password, walletJson));
+
+ final encryption = encryptionFileUtilsFor(false);
+ expect(await encryption.read(path: path(), password: password), walletJson);
+ expect(await encryption.read(path: path(), password: password), walletJson);
+ });
+
+ test('wrong password does not rewrite the Salsa20 file', () async {
+ final password = generateKey();
+ final salsa = _salsa20Encrypt(password, walletJson);
+ File(path()).writeAsStringSync(salsa);
+
+ await expectLater(
+ encrypted_file.read(path: path(), password: generateKey()),
+ throwsLegacyDecryptError,
+ );
+ expect(File(path()).readAsStringSync(), salsa);
+ });
+
+ test('short or malformed password does not leak _readLegacy errors', () async {
+ final password = generateKey();
+ final salsa = _salsa20Encrypt(password, walletJson);
+ File(path()).writeAsStringSync(salsa);
+
+ await expectLater(
+ encrypted_file.read(path: path(), password: 'user-pass'),
+ throwsLegacyDecryptError,
+ );
+ expect(File(path()).readAsStringSync(), salsa);
+ });
+
+ test('corrupt non-base64 file does not leak _readLegacy errors', () async {
+ File(path()).writeAsStringSync('not-valid-base64!!!');
+
+ await expectLater(
+ encrypted_file.read(path: path(), password: generateKey()),
+ throwsLegacyDecryptError,
+ );
+ expect(File(path()).readAsStringSync(), 'not-valid-base64!!!');
+ });
+
+ test('invalid UTF-8 legacy bytes do not leak _readLegacy errors', () async {
+ File(path()).writeAsBytesSync(const [0x00, 0xFF, 0xFE, 0x01]);
+
+ await expectLater(
+ encrypted_file.read(path: path(), password: generateKey()),
+ throwsLegacyDecryptError,
+ );
+ expect(File(path()).readAsBytesSync(), const [0x00, 0xFF, 0xFE, 0x01]);
+ });
+
+ test('rejects Salsa20 plaintext that does not start with {"', () async {
+ final password = generateKey();
+ File(path()).writeAsStringSync(_salsa20Encrypt(password, 'not-json-payload'));
+
+ await expectLater(
+ encrypted_file.read(path: path(), password: password),
+ throwsLegacyDecryptError,
+ );
+ expect(File(path()).readAsBytesSync().first, isNot(cwb.highEntropyVersion));
+ expect(File(path()).readAsBytesSync().first, isNot(cwb.lowEntropyVersion));
+ });
+
+ test('does not re-encrypt Salsa20 data that starts with {" but is not JSON', () async {
+ final password = generateKey();
+ const garbage = '{"not valid json';
+ File(path()).writeAsStringSync(_salsa20Encrypt(password, garbage));
+
+ expect(await encrypted_file.read(path: path(), password: password), garbage);
+ expect(File(path()).readAsBytesSync().first, isNot(cwb.highEntropyVersion));
+ expect(File(path()).readAsBytesSync().first, isNot(cwb.lowEntropyVersion));
+ });
+
+ test(
+ 'wrong password that decrypts to {" does not rewrite the file',
+ () async {
+ final password = generateKey();
+ final salsa = _salsa20Encrypt(password, walletJson);
+ File(path()).writeAsStringSync(salsa);
+
+ final collision = _findWrongPasswordWithJsonObjectPrefix(salsa);
+ expect(collision, isNot(password));
+ expect(_salsa20Decrypt(collision, salsa).startsWith('{"'), isTrue);
+ expect(() => json.decode(_salsa20Decrypt(collision, salsa)), throwsA(anything));
+
+ final result = await encrypted_file.read(path: path(), password: collision);
+
+ expect(result.startsWith('{"'), isTrue);
+ expect(result, isNot(walletJson));
+ expect(File(path()).readAsStringSync(), salsa);
+ },
+ );
+ });
+
+ group('legacy Linux XChaCha20 Latin-1 payloads', () {
+ test('decodes files stored as one byte per UTF-16 code unit', () async {
+ final password = generateKey();
+ const data = '{"name":"Café","mnemonic":"abandon"}';
+ // Old XChaCha20EncryptionFileUtils used String.codeUnits instead of utf8.encode.
+ final encrypted = await cwb.encrypt(
+ password,
+ Uint8List.fromList(data.codeUnits),
+ highEntropyPassphrase: true,
+ );
+ File(path()).writeAsBytesSync(encrypted);
+
+ expect(await encrypted_file.read(path: path(), password: password), data);
+ });
+ });
+
+ group('generateKey', () {
+ test('produces unique passwords that still encrypt and decrypt', () async {
+ final a = generateKey();
+ final b = generateKey();
+ expect(a, isNot(b));
+ expect(a.length, greaterThan(12));
+
+ await encrypted_file.write(
+ path: path(),
+ password: a,
+ data: walletJson,
+ highEntropyPassphrase: true,
+ );
+ expect(await encrypted_file.read(path: path(), password: a), walletJson);
+ });
+ });
+}
+
+final throwsLegacyDecryptError = throwsA(
+ isA<Exception>().having(
+ (e) => e.toString(),
+ 'message',
+ contains('Failed to decrypt legacy file'),
+ ),
+);
+
+({encrypt.Key key, encrypt.IV iv}) _salsa20Keys(String password) {
+ const ivEncodedStringLength = 12;
+ return (
+ key: encrypt.Key.fromBase64(password.substring(0, password.length - ivEncodedStringLength)),
+ iv: encrypt.IV.fromBase64(password.substring(password.length - ivEncodedStringLength)),
+ );
+}
+
+encrypt.Encrypter _salsa20(String password) {
+ final keys = _salsa20Keys(password);
+ return encrypt.Encrypter(encrypt.Salsa20(keys.key));
+}
+
+String _salsa20Encrypt(String password, String data) {
+ final keys = _salsa20Keys(password);
+ return _salsa20(password).encrypt(data, iv: keys.iv).base64;
+}
+
+String _salsa20Decrypt(String password, String data) {
+ final keys = _salsa20Keys(password);
+ return _salsa20(password).decrypt64(data, iv: keys.iv);
+}
+
+List<int> _salsa20DecryptBytes(String password, String data) {
+ final keys = _salsa20Keys(password);
+ return _salsa20(password).decryptBytes(encrypt.Encrypted.fromBase64(data), iv: keys.iv);
+}
+
+String _passwordFromNonce(int nonce) {
+ final ivBytes = Uint8List(8);
+ ivBytes.buffer.asByteData().setUint64(0, nonce, Endian.little);
+ return encrypt.Key(Uint8List(32)).base64 + encrypt.IV(ivBytes).base64;
+}
+
+/// Salsa20 has no MAC, so a wrong key decrypts to garbage. The first two bytes
+/// are `{"` about once in 2^16 tries; keep going until that happens, then skip
+/// the much rarer case where the garbage is also valid JSON (that would rewrite).
+String _findWrongPasswordWithJsonObjectPrefix(String ciphertext) {
+ const maxAttempts = 1 << 20;
+ for (var nonce = 0; nonce < maxAttempts; nonce++) {
+ final candidate = _passwordFromNonce(nonce);
+ final bytes = _salsa20DecryptBytes(candidate, ciphertext);
+ if (bytes.length < 2 || bytes[0] != 0x7b || bytes[1] != 0x22) {
+ continue;
+ }
+ try {
+ json.decode(_salsa20Decrypt(candidate, ciphertext));
+ } catch (_) {
+ return candidate;
+ }
+ }
+ fail('no Salsa20 {" prefix collision in $maxAttempts attempts');
+}
### lib/view_model/dashboard/dashboard_view_model.dart
@@ -54,7 +54,7 @@ import 'package:cw_core/pathForWallet.dart';
import 'package:cw_core/sync_status.dart';
import 'package:cw_core/transaction_history.dart';
import 'package:cw_core/transaction_info.dart';
-import 'package:cw_core/utils/file.dart';
+import 'package:cw_core/encryption_file_utils.dart';
import 'package:cw_core/utils/print_verbose.dart';
import 'package:cw_core/wallet_base.dart';
import 'package:cw_core/wallet_info.dart';
@@ -1512,7 +1512,8 @@ abstract class DashboardViewModelBase with Store {
if (walletInfo.type == WalletType.bitcoin) {
final password = await keyService.getWalletPassword(walletName: walletInfo.name);
final path = await pathForWallet(name: walletInfo.name, type: walletInfo.type);
- final jsonSource = await read(path: path, password: password);
+ final encryption = encryptionFileUtilsFor(SettingsStoreBase.walletPasswordDirectInput);
+ final jsonSource = await encryption.read(path: path, password: password);
final data = json.decode(jsonSource) as Map;
final mnemonic = data['mnemonic'] as String?;
### pubspec.lock
@@ -388,8 +388,8 @@ packages:
dependency: "direct main"
description:
path: "."
- ref: "3aba867dcab6737f6707782f5db15d71f303db38"
- resolved-ref: "3aba867dcab6737f6707782f5db15d71f303db38"
+ ref: b5d86a2a21e1c186cd0baa4df1f3a4f3f9413056
+ resolved-ref: b5d86a2a21e1c186cd0baa4df1f3a4f3f9413056
url: "https://github.com/cake-tech/cake_backup.git"
source: git
version: "1.0.0+1"
### pubspec_base.yaml
@@ -85,7 +85,7 @@ dependencies:
cake_backup:
git:
url: https://github.com/cake-tech/cake_backup.git
- ref: 3aba867dcab6737f6707782f5db15d71f303db38
+ ref: b5d86a2a21e1c186cd0baa4df1f3a4f3f9413056
flutter_plugin_android_lifecycle: 2.0.23
path_provider_android: ^2.2.1
shared_preferences_android: ^2.4.8
@@ -247,7 +247,7 @@ dev_dependencies:
# cake_backup:
# git:
# url: https://github.com/cake-tech/cake_backup.git
-# ref: 3aba867dcab6737f6707782f5db15d71f303db38
+# ref: b5d86a2a21e1c186cd0baa4df1f3a4f3f9413056
flutter_icons:
image_path: "assets/images/app_logo.png"
### pubspec_overrides.yaml
@@ -174,7 +174,7 @@ dependency_overrides:
cake_backup:
git:
url: https://github.com/cake-tech/cake_backup.git
- ref: 3aba867dcab6737f6707782f5db15d71f303db38
+ ref: b5d86a2a21e1c186cd0baa4df1f3a4f3f9413056
characters:
git:
url: https://github.com/dart-lang/coreWhy this scored 60/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.