fix: unify encryption across platforms (#3470)
What changed, and why it matters
This commit changes how Cake Wallet encrypts and decrypts wallet backup files. It removes an older, weaker encryption method (Salsa20) and switches to a stronger one (XChaCha20) for all platforms. It also adds automatic migration: when the app opens an old Salsa20 wallet file, it re-encrypts it with the stronger method. The change touches code that handles wallet seeds/private keys, so a mistake here could put users' funds at risk, but the patch itself appears to be a hardening fix rather than an introduction of a new vulnerability.
Review the cake_backup library change at ref b5d86a2a21e1c186cd0baa4df1f3a4f3f9413056 to confirm XChaCha20 implementation, key derivation, and authentication tag handling are correct. Verify that the Salsa20 migration fallback cannot be abused to downgrade or corrupt wallet files, and that the '{"' prefix check is an acceptable risk given the documented 1/2^16 false-positive rate. Run the new test suite and perform additional fuzzing on legacy file parsing.
Security signals we found
Removal of Salsa20 stream cipher for wallet file encryption
Adoption of XChaCha20 via cake_backup library
Automatic on-read migration from legacy Salsa20 to XChaCha20
Salsa20 fallback guarded by '{"' prefix and JSON validity checks
Pinning of cake_backup dependency to a specific git ref
Addition of 380-line encryption test suite including wrong-password and collision tests
Comment acknowledging Salsa20 is unauthenticated and a wrong password decrypts to garbage
Evidence from the diff
The patch unifies file encryption by replacing the platform-specific Salsa20 and raw XChaCha20 helpers with a single XChaCha20MigratingEncryptionFileUtils class backed by the cake_backup library. Reads now attempt XChaCha20 first, then fall back to legacy Salsa20, validate the decrypted plaintext starts with ‘{“’ and is valid JSON, and only then rewrite the file with XChaCha20. Writes use a high-entropy passphrase for generated keys and a low-entropy passphrase for direct user passwords. The commit also pins cake_backup to a specific git ref (b5d86a2a…) instead of a floating main branch. A comprehensive test suite is added covering round-trips, migration, wrong-password handling, and Latin-1 legacy decoding.
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
diff --git a/cw_core/lib/encryption_file_utils.dart b/cw_core/lib/encryption_file_utils.dart
index 72a601bc..54729b73 100644
--- a/cw_core/lib/encryption_file_utils.dart
+++ b/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,
+ );
}
}
diff --git a/cw_core/lib/key.dart b/cw_core/lib/key.dart
index 383cbfff..99570bc7 100644
--- a/cw_core/lib/key.dart
+++ b/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;
-}
diff --git a/cw_core/lib/utils/file.dart b/cw_core/lib/utils/file.dart
index 72fc9485..3ad3165d 100644
--- a/cw_core/lib/utils/file.dart
+++ b/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);
}
diff --git a/cw_core/lib/wallet_service.dart b/cw_core/lib/wallet_service.dart
index 72ea98a9..d69300d1 100644
--- a/cw_core/lib/wallet_service.dart
+++ b/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? ?? "";
diff --git a/cw_core/pubspec.yaml b/cw_core/pubspec.yaml
index 38a4e9a4..b7843f4b 100644
--- a/cw_core/pubspec.yaml
+++ b/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
diff --git a/cw_core/test/encryption_file_utils_test.dart b/cw_core/test/encryption_file_utils_test.dart
new file mode 100644
index 00000000..bfd840f0
--- /dev/null
+++ b/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');
+}
diff --git a/lib/view_model/dashboard/dashboard_view_model.dart b/lib/view_model/dashboard/dashboard_view_model.dart
index b19bf123..763c60e5 100644
--- a/lib/view_model/dashboard/dashboard_view_model.dart
+++ b/lib/view_model/dashboard/dashboard_view_model.dart
@@ -55,7 +55,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';
@@ -1486,7 +1486,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?;
diff --git a/pubspec.lock b/pubspec.lock
index f37ff20e..4cd50bf7 100644
--- a/pubspec.lock
+++ b/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"
diff --git a/pubspec_base.yaml b/pubspec_base.yaml
index e8c75f95..52a41ad6 100644
--- a/pubspec_base.yaml
+++ b/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"
diff --git a/pubspec_overrides.yaml b/pubspec_overrides.yaml
index 15910a4f..2bf9d2ce 100644
--- a/pubspec_overrides.yaml
+++ b/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/core
Why this scored 61/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.