diff --git a/lib/entities/encrypt.dart b/lib/entities/encrypt.dart index cf5bec168..03399a5f9 100644 --- a/lib/entities/encrypt.dart +++ b/lib/entities/encrypt.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:encrypt/encrypt.dart'; // import 'package:password/password.dart'; import 'package:hash_wallet/.secrets.g.dart' as secrets; @@ -108,12 +110,28 @@ String? _tryDecrypt({required String source, required String key}) { } } +// A wallet password is generateKey() output: base64(512-byte key) + +// base64(8-byte iv) = 696 base64 chars. Validate against that exact shape so a +// wrong AES key (which could unpad to arbitrary printable bytes) cannot be +// re-saved as a bogus password. base64.decode round-trips to the exact byte +// lengths, which is astronomically unlikely for a false PKCS7 hit. +bool _looksLikeWalletPassword(String s) { + if (s.length != 696) return false; + try { + final key = base64.decode(s.substring(0, s.length - 12)); + final iv = base64.decode(s.substring(s.length - 12)); + return key.length == 512 && iv.length == 8; + } catch (_) { + return false; + } +} + /// Decode a stored wallet password written by a build with rotated secrets. /// Returns null when no legacy key fits. String? decodeWalletPasswordLegacy({required String password}) { for (final key in _legacyWalletPasswordKeys) { final decrypted = _tryDecrypt(source: password, key: key); - if (decrypted != null) return decrypted; + if (decrypted != null && _looksLikeWalletPassword(decrypted)) return decrypted; } return null; } diff --git a/test/legacy_decode_test.dart b/test/legacy_decode_test.dart new file mode 100644 index 000000000..8b48bc952 --- /dev/null +++ b/test/legacy_decode_test.dart @@ -0,0 +1,74 @@ +import 'dart:convert'; +import 'package:encrypt/encrypt.dart'; +import 'package:flutter_test/flutter_test.dart'; + +// Mirror of the shipped encode + generateKey (lib/entities/encrypt.dart, cw_core/key.dart) +String enc(String s, String key) => + Encrypter(AES(Key.fromUtf8(key))).encrypt(s, iv: IV.allZerosOfLength(16)).base64; +String dec(String s, String key) => + Encrypter(AES(Key.fromUtf8(key))).decrypt64(s, iv: IV.allZerosOfLength(16)); +String generateKey() => Key.fromSecureRandom(512).base64 + IV.fromSecureRandom(8).base64; +String rnd(int nHexChars) => Key.fromSecureRandom(nHexChars ~/ 2) + .bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); + +// Logic under test (identical to lib/entities/encrypt.dart 1.0.2) +bool looksLikeWalletPassword(String s) { + if (s.length != 696) return false; + try { + return base64.decode(s.substring(0, s.length - 12)).length == 512 && + base64.decode(s.substring(s.length - 12)).length == 8; + } catch (_) { return false; } +} +String? tryDecrypt(String source, String key) { + try { + final d = dec(source, key); + return (d.isNotEmpty && d.codeUnits.every((c) => c >= 0x20 && c < 0x7f)) ? d : null; + } catch (_) { return null; } +} +String? decodePinLegacy(String pin, List keys) { + for (final key in keys) { + final d = tryDecrypt(pin, key); + if (d == null || d.length <= 32) continue; + final cand = d.substring(32); + if (cand.isNotEmpty && cand.codeUnits.every((c) => c >= 0x30 && c <= 0x39)) return cand; + } + return null; +} +String? decodeWalletLegacy(String pw, List keys) { + for (final key in keys) { + final d = tryDecrypt(pw, key); + if (d != null && looksLikeWalletPassword(d)) return d; + } + return null; +} + +void main() { + test('1.0.0 -> 1.0.2 upgrade recovers PIN and wallet password', () { + final vSalt = rnd(32), vKey = rnd(32), vShort = rnd(24), vWsalt = rnd(8); + const realPin = '4913'; + final realPw = generateKey(); + final storedPin = enc(vSalt + realPin, vKey); + final storedPw = enc(realPw, vShort + vWsalt); + + // 1.0.2 legacy lists include the old values among decoys + final pinKeys = ['deadbeef' * 4, vKey, 'cafef00d' * 4]; + final pwKeys = ['00112233445566778899aabb01020304', vShort + vWsalt]; + + expect(decodePinLegacy(storedPin, pinKeys), realPin); + expect(decodeWalletLegacy(storedPw, pwKeys), realPw); + }); + + test('never false-accepts when the real key is absent', () { + final vSalt = rnd(32), vKey = rnd(32), vShort = rnd(24), vWsalt = rnd(8); + final storedPin = enc(vSalt + '271828', vKey); + final storedPw = enc(generateKey(), vShort + vWsalt); + + var pinFalse = 0, pwFalse = 0; + for (var i = 0; i < 8000; i++) { + if (decodePinLegacy(storedPin, [rnd(32)]) != null) pinFalse++; + if (decodeWalletLegacy(storedPw, [rnd(24) + rnd(8)]) != null) pwFalse++; + } + expect(pinFalse, 0, reason: 'PIN false-accepts'); + expect(pwFalse, 0, reason: 'wallet-password false-accepts'); + }); +}