mirror of
https://github.com/Such-Software/hash-wallet.git
synced 2026-10-17 05:41:29 +02:00
fix: harden legacy wallet-password validation to exact generateKey() shape + test
- validate legacy-decoded wallet passwords are real generateKey() output (696 chars, base64 of 512-byte key + 8-byte iv) so a wrong AES key that unpads to printable bytes can't be re-saved as a bogus password - test/legacy_decode_test.dart: proves 1.0.0->1.0.2 recovers PIN+password and 16k wrong keys yield zero false-accepts
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<String> 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<String> 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');
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user