mirror of
https://github.com/Such-Software/hash-wallet.git
synced 2026-10-26 06:08:40 +01:00
This reverts commit 90e25afc11.
This commit is contained in:
@@ -1,34 +1,41 @@
|
||||
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 isDirect) =>
|
||||
XChaCha20MigratingEncryptionFileUtils(isDirect: isDirect);
|
||||
EncryptionFileUtils encryptionFileUtilsFor(bool direct) =>
|
||||
direct ? XChaCha20EncryptionFileUtils() : Salsa20EncryhptionFileUtils();
|
||||
|
||||
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 XChaCha20MigratingEncryptionFileUtils extends EncryptionFileUtils {
|
||||
XChaCha20MigratingEncryptionFileUtils({required this.isDirect});
|
||||
|
||||
final bool isDirect;
|
||||
|
||||
class Salsa20EncryhptionFileUtils extends EncryptionFileUtils {
|
||||
// Requires legacy complex key + iv as password
|
||||
@override
|
||||
Future<void> write({required String path, required String password, required String data}) {
|
||||
return file.write(
|
||||
path: path,
|
||||
password: password,
|
||||
data: data,
|
||||
highEntropyPassphrase: !isDirect,
|
||||
);
|
||||
Future<void> write(
|
||||
{required String path, required String password, required String data}) async =>
|
||||
await file.write(path: path, password: password, data: data);
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> read({required String path, required String password}) {
|
||||
return file.read(
|
||||
path: path,
|
||||
password: password,
|
||||
highEntropyPassphrase: !isDirect,
|
||||
);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,35 @@
|
||||
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;
|
||||
}
|
||||
|
||||
+13
-128
@@ -1,84 +1,21 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:cake_backup/backup.dart' as cwb;
|
||||
import 'package:cw_core/key.dart';
|
||||
import 'package:encrypt/encrypt.dart' as encrypt;
|
||||
|
||||
const _ivEncodedStringLength = 12;
|
||||
Future<void> write({required String path, required String password, required String data}) async =>
|
||||
writeData(path: path, password: password, data: data);
|
||||
|
||||
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<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<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 {
|
||||
Future<String> read({required String path, required String password}) async {
|
||||
final file = File(path);
|
||||
|
||||
if (!file.existsSync()) {
|
||||
@@ -87,57 +24,5 @@ Future<String> _readLegacy({required String path, required String password}) asy
|
||||
|
||||
final encrypted = file.readAsStringSync();
|
||||
|
||||
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);
|
||||
return decode(password: password, data: encrypted);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
import "dart:convert";
|
||||
import "dart:io";
|
||||
|
||||
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;
|
||||
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;
|
||||
|
||||
abstract class WalletService<N extends WalletCredentials, RFS extends WalletCredentials,
|
||||
RFK extends WalletCredentials, RFH extends WalletCredentials> {
|
||||
@@ -96,15 +95,8 @@ 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 encryption.read(path: path, password: password);
|
||||
final jsonSource = await read(path: path, password: password);
|
||||
try {
|
||||
final data = json.decode(jsonSource) as Map;
|
||||
return data["mnemonic"] as String? ?? "";
|
||||
|
||||
@@ -22,7 +22,8 @@ dependencies:
|
||||
cake_backup:
|
||||
git:
|
||||
url: https://github.com/cake-tech/cake_backup.git
|
||||
ref: b5d86a2a21e1c186cd0baa4df1f3a4f3f9413056
|
||||
ref: main
|
||||
version: 1.0.0
|
||||
socks5_proxy:
|
||||
git:
|
||||
url: https://github.com/LacticWhale/socks_dart
|
||||
|
||||
@@ -1,380 +0,0 @@
|
||||
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');
|
||||
}
|
||||
@@ -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/encryption_file_utils.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_info.dart';
|
||||
@@ -1515,8 +1515,7 @@ 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 encryption = encryptionFileUtilsFor(SettingsStoreBase.walletPasswordDirectInput);
|
||||
final jsonSource = await encryption.read(path: path, password: password);
|
||||
final jsonSource = await read(path: path, password: password);
|
||||
final data = json.decode(jsonSource) as Map;
|
||||
final mnemonic = data['mnemonic'] as String?;
|
||||
|
||||
|
||||
+2
-2
@@ -388,8 +388,8 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "."
|
||||
ref: b5d86a2a21e1c186cd0baa4df1f3a4f3f9413056
|
||||
resolved-ref: b5d86a2a21e1c186cd0baa4df1f3a4f3f9413056
|
||||
ref: "3aba867dcab6737f6707782f5db15d71f303db38"
|
||||
resolved-ref: "3aba867dcab6737f6707782f5db15d71f303db38"
|
||||
url: "https://github.com/cake-tech/cake_backup.git"
|
||||
source: git
|
||||
version: "1.0.0+1"
|
||||
|
||||
+2
-2
@@ -85,7 +85,7 @@ dependencies:
|
||||
cake_backup:
|
||||
git:
|
||||
url: https://github.com/cake-tech/cake_backup.git
|
||||
ref: b5d86a2a21e1c186cd0baa4df1f3a4f3f9413056
|
||||
ref: 3aba867dcab6737f6707782f5db15d71f303db38
|
||||
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: b5d86a2a21e1c186cd0baa4df1f3a4f3f9413056
|
||||
# ref: 3aba867dcab6737f6707782f5db15d71f303db38
|
||||
|
||||
flutter_icons:
|
||||
image_path: "assets/images/app_logo.png"
|
||||
|
||||
@@ -174,7 +174,7 @@ dependency_overrides:
|
||||
cake_backup:
|
||||
git:
|
||||
url: https://github.com/cake-tech/cake_backup.git
|
||||
ref: b5d86a2a21e1c186cd0baa4df1f3a4f3f9413056
|
||||
ref: 3aba867dcab6737f6707782f5db15d71f303db38
|
||||
characters:
|
||||
git:
|
||||
url: https://github.com/dart-lang/core
|
||||
|
||||
Reference in New Issue
Block a user