fix(lite-backend): validate WIFs, refuse key import while encrypted
import_taddr decoded a WIF with bs58::decode(..).unwrap(), a [2..66] slice of its hex and SecretKey::from_slice(..).unwrap(), all under the wallet write guard that do_import_tk holds. A z-key, a typo or any short string panicked there and poisoned the wallet until restart, and the checksum was never verified, so a mistyped WIF that was still valid base58 imported a different key. A private key imported into an encrypted wallet was stored without an enc_key. The import then failed in do_save's lock(), which had already zeroed the seed while the wallet stayed "unlocked": seed export showed the all-zero-entropy phrase. - decode_taddr_wif replaces the old decode: Base58Check with the checksum verified, version 0xBC (the config prefix) or 0x80, and the 0x01 compressed flag. It decodes into a buffer it owns and wipes it with sodiumoxide::utils::memzero on every path, including a failed checksum. Errors are fixed strings that never contain the key. - import_taddr and add_imported_sk refuse while the wallet is encrypted, as add_taddr and add_zaddr already do. - do_import_tk and do_import_sk check is_encrypted() before the "locked" check, so a locked wallet does not send the user to unlock and retry. Flagged behaviour changes: - Base58Check, version 0xBC/0x80 and the compressed flag are required; - uncompressed and foreign-network WIFs are rejected with fixed messages (an uncompressed key used to import under the compressed address, where its funds are not); - no panic and no poisoned lock; - private-key import (t and z) is refused while the wallet is encrypted, with a reason that says to remove encryption, import, then encrypt again; viewing keys still import; - the key is never echoed. No ABI, symbol or wallet format change. Tests: garbage, z-keys, bad checksums, a transposed or flipped character, a zero scalar, a wrong flag, uncompressed, 0x05 and 0xEF WIFs are rejected with their message and leave tkeys usable; the wallet's own export, a 0x80 WIF and the published key-1 vector still import; the encrypted refusal in the wallet and the client, locked and unlocked, leaves lock, save and seed export intact; a viewing key still imports into an encrypted wallet; a bad timport leaves the wallet lock unpoisoned. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+210
@@ -3292,3 +3292,213 @@ fn test_g4_transaction_too_large_refused() {
|
||||
let txs = wallet.txs.read().unwrap();
|
||||
assert!(txs.values().flat_map(|t| t.notes.iter()).all(|nd| nd.unconfirmed_spent.is_none()));
|
||||
}
|
||||
|
||||
// ---- R3: transparent key import (C251) and private-key import while encrypted (C252) ----
|
||||
// A WIF is Base58Check(version || key(32) || 0x01); the version is the config prefix (0xBC on main) or 0x80.
|
||||
// Bad input returns "Error: ..." (never a panic under the wallet lock), and no error contains the key.
|
||||
|
||||
fn r3_wif(version: u8, payload: &[u8], suffix: &[u8]) -> String {
|
||||
use super::ToBase58Check;
|
||||
payload.to_base58check(&[version], suffix)
|
||||
}
|
||||
|
||||
fn r3_key_one() -> SecretKey {
|
||||
let mut k = [0u8; 32];
|
||||
k[31] = 1;
|
||||
SecretKey::from_slice(&k).unwrap()
|
||||
}
|
||||
|
||||
// Swap the first pair of adjacent, different characters at or after `from` (a one-character typo class).
|
||||
fn r3_transpose(s: &str, from: usize) -> String {
|
||||
let mut c: Vec<char> = s.chars().collect();
|
||||
let i = (from..c.len() - 1).find(|&i| c[i] != c[i + 1]).unwrap();
|
||||
c.swap(i, i + 1);
|
||||
c.into_iter().collect()
|
||||
}
|
||||
|
||||
// Replace the character at `at` with a different base58 character.
|
||||
fn r3_flip(s: &str, at: usize) -> String {
|
||||
let mut c: Vec<char> = s.chars().collect();
|
||||
c[at] = if c[at] == 'z' { 'y' } else { 'z' };
|
||||
c.into_iter().collect()
|
||||
}
|
||||
|
||||
fn r3_import_rejected(wallet: &mut LightWallet, input: &str) -> String {
|
||||
let n = wallet.tkeys.read().unwrap().len();
|
||||
let r = wallet.import_taddr(input.to_string(), 0);
|
||||
assert!(r.starts_with("Error"), "{:?} was imported as {}", input, r);
|
||||
assert!(input.trim().len() < 8 || !r.contains(input.trim()), "the error echoes the key: {}", r);
|
||||
assert!(wallet.tkeys.read().is_ok(), "tkeys lock poisoned");
|
||||
assert_eq!(wallet.tkeys.read().unwrap().len(), n);
|
||||
r
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_import_taddr_bad_input_never_panics() {
|
||||
// s2 C251 test 1 (+ the 0xEF case). Before: panics at the bs58 unwrap, the [2..66] slice or the key unwrap.
|
||||
let config = get_test_config();
|
||||
let mut wallet = LightWallet::new(None, &config, 0, 0).unwrap();
|
||||
let other = LightWallet::new(None, &config, 0, 0).unwrap();
|
||||
let good = other.get_t_secret_keys()[0].1.clone();
|
||||
let (zaddr, zsk, zvk) = other.get_z_private_keys()[0].clone();
|
||||
let key = [7u8; 32];
|
||||
|
||||
let mut bad_checksum = good.clone();
|
||||
let last = bad_checksum.pop().unwrap();
|
||||
bad_checksum.push(if last == '2' { '3' } else { '2' });
|
||||
|
||||
let inputs = vec![
|
||||
"".to_string(), "x".to_string(), "0OIl".to_string(), " ".to_string(),
|
||||
zaddr, zsk, zvk,
|
||||
"zivks1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq".to_string(),
|
||||
bs58::encode(&[1u8, 2, 3, 4, 5]).into_string(), // plain base58, 5 bytes
|
||||
bad_checksum.clone(),
|
||||
r3_wif(0xBC, &[9u8; 9], &[]), // checksum-valid, 10-byte payload
|
||||
r3_wif(0xBC, &[0u8; 32], &[0x01]), // zero scalar
|
||||
r3_wif(0xBC, &key, &[0x02]), // 34 bytes, wrong compressed flag
|
||||
r3_wif(0x80, &key, &[]), // uncompressed
|
||||
r3_wif(0x05, &key, &[0x01]), // foreign version byte
|
||||
r3_wif(0xEF, &key, &[0x01]), // testnet WIF on main
|
||||
"5HpHagT65TZzG1PH3CSu63k8DbpvD8s5ip4nEB3kEsreAnchuDf".to_string(), // key 1, uncompressed (0x80)
|
||||
];
|
||||
for input in &inputs {
|
||||
r3_import_rejected(&mut wallet, input);
|
||||
}
|
||||
|
||||
assert_eq!(r3_import_rejected(&mut wallet, &bad_checksum), "Error: Couldn't decode the transparent private key (not valid Base58Check)");
|
||||
assert_eq!(r3_import_rejected(&mut wallet, "0OIl"), "Error: Couldn't decode the transparent private key (not valid Base58Check)");
|
||||
assert_eq!(r3_import_rejected(&mut wallet, &r3_wif(0xBC, &[9u8; 9], &[])),
|
||||
"Error: Couldn't decode the transparent private key (unrecognized format)");
|
||||
assert_eq!(r3_import_rejected(&mut wallet, &r3_wif(0xBC, &key, &[0x02])),
|
||||
"Error: Couldn't decode the transparent private key (unrecognized format)");
|
||||
assert_eq!(r3_import_rejected(&mut wallet, &r3_wif(0xBC, &[0u8; 32], &[0x01])), "Error: Invalid transparent private key");
|
||||
for unc in &[r3_wif(0x80, &key, &[]), "5HpHagT65TZzG1PH3CSu63k8DbpvD8s5ip4nEB3kEsreAnchuDf".to_string()] {
|
||||
assert_eq!(r3_import_rejected(&mut wallet, unc),
|
||||
"Error: Uncompressed transparent private keys are not supported; import the compressed form");
|
||||
}
|
||||
for foreign in &[r3_wif(0xEF, &key, &[0x01]), r3_wif(0x05, &key, &[0x01])] {
|
||||
assert_eq!(r3_import_rejected(&mut wallet, foreign), "Error: The transparent private key is for a different network");
|
||||
}
|
||||
|
||||
// The wallet still works: the good key imports afterwards.
|
||||
assert_eq!(wallet.import_taddr(good, 0), other.get_t_secret_keys()[0].0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_import_taddr_accepts_own_export_and_btc_wif() {
|
||||
// s2 C251 test 2 (passes before and after; guards compatibility): the wallet's own export (0xBC) and a
|
||||
// standard compressed 0x80 WIF both import, under the compressed-key address.
|
||||
let config = get_test_config();
|
||||
let other = LightWallet::new(None, &config, 0, 0).unwrap();
|
||||
let (taddr, wif) = other.get_t_secret_keys()[0].clone();
|
||||
assert_eq!(bs58::decode(&wif).with_check(None).into_vec().unwrap()[0], 0xBC);
|
||||
|
||||
let mut wallet = LightWallet::new(None, &config, 0, 0).unwrap();
|
||||
let n = wallet.tkeys.read().unwrap().len();
|
||||
assert_eq!(wallet.import_taddr(wif.clone(), 0), taddr);
|
||||
assert!(wallet.get_t_secret_keys().iter().any(|(a, k)| a == &taddr && k == &wif), "export round trip");
|
||||
|
||||
// 0x80 WIF of another key, built here, and the published key-1 vector (Bitcoin 1BgGZ9tc...; on DragonX
|
||||
// the same hash160 with prefix 0x3C is RKxTdfmt...).
|
||||
let k42 = SecretKey::from_slice(&[0x42u8; 32]).unwrap();
|
||||
let wif80 = r3_wif(0x80, &k42[..], &[0x01]);
|
||||
assert!(wif80.starts_with('K') || wif80.starts_with('L'), "{}", wif80);
|
||||
assert_eq!(wallet.import_taddr(wif80, 0), wallet.address_from_sk(&k42));
|
||||
assert_eq!(wallet.import_taddr("KwDiBf89QgGbjEhKnhXJuH7LrciVrZi3qYjgd9M7rFU73sVHnoWn".to_string(), 0),
|
||||
"RKxTdfmtxtfLDKZBgx6SvNkBtNu9jRYnLh");
|
||||
assert_eq!(wallet.address_from_sk(&r3_key_one()), "RKxTdfmtxtfLDKZBgx6SvNkBtNu9jRYnLh");
|
||||
assert_eq!(wallet.tkeys.read().unwrap().len(), n + 3);
|
||||
|
||||
// A key that is already there is refused.
|
||||
assert_eq!(wallet.import_taddr(wif, 0), "Error: Key already exists");
|
||||
assert_eq!(wallet.tkeys.read().unwrap().len(), n + 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decode_taddr_wif_checksum_typo_is_rejected() {
|
||||
// s2 C251 test 3. Before: no checksum check, so a transposed WIF silently imported a different key.
|
||||
let config = get_test_config();
|
||||
let other = LightWallet::new(None, &config, 0, 0).unwrap();
|
||||
let (taddr, wif) = other.get_t_secret_keys()[0].clone();
|
||||
let mut wallet = LightWallet::new(None, &config, 0, 0).unwrap();
|
||||
for from in &[1usize, 20, 40] {
|
||||
let typo = r3_transpose(&wif, *from);
|
||||
assert_ne!(typo, wif);
|
||||
assert_eq!(r3_import_rejected(&mut wallet, &typo),
|
||||
"Error: Couldn't decode the transparent private key (not valid Base58Check)");
|
||||
assert!(LightWallet::decode_taddr_wif(&typo, 0xBC).is_err());
|
||||
}
|
||||
assert!(LightWallet::decode_taddr_wif(&wif, 0xBC).is_ok());
|
||||
// The untouched WIF imports, also as pasted with surrounding whitespace (it is trimmed).
|
||||
assert_eq!(wallet.import_taddr(format!(" {}\n", wif), 0), taddr);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_import_taddr_garbage_no_panic() {
|
||||
// s1 test 3 (+ D3: one flipped character). Before: panic, and the wallet lock poisoned behind it.
|
||||
let config = get_test_config();
|
||||
let mut wallet = LightWallet::new(None, &config, 0, 0).unwrap();
|
||||
let other = LightWallet::new(None, &config, 0, 0).unwrap();
|
||||
let wif = other.get_t_secret_keys()[0].1.clone();
|
||||
for g in &["secret-extended-key-main1qqqqqqqqqqqqqqqqqqqqqqqq", "", "1", "0OIl",
|
||||
"3mJr7AoUXx2Wqd"] { // valid base58, far shorter than 33 bytes
|
||||
r3_import_rejected(&mut wallet, g);
|
||||
}
|
||||
for at in &[5usize, 25, 45] {
|
||||
let typo = r3_flip(&wif, *at);
|
||||
assert_ne!(typo, wif);
|
||||
assert_eq!(r3_import_rejected(&mut wallet, &typo),
|
||||
"Error: Couldn't decode the transparent private key (not valid Base58Check)");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_import_refused_when_encrypted() {
|
||||
// s1 test 1. Before: both imports succeeded with no enc_key, then lock() failed after zeroing the seed.
|
||||
let config = get_test_config();
|
||||
let mut wallet = LightWallet::new(None, &config, 0, 0).unwrap();
|
||||
let other = LightWallet::new(None, &config, 0, 0).unwrap();
|
||||
let wif = other.get_t_secret_keys()[0].1.clone();
|
||||
let sk = other.get_z_private_keys()[0].1.clone();
|
||||
let phrase = wallet.get_seed_phrase();
|
||||
let counts = |w: &LightWallet| (w.tkeys.read().unwrap().len(), w.zkeys.read().unwrap().len());
|
||||
let before = counts(&wallet);
|
||||
|
||||
wallet.encrypt("pw".to_string()).unwrap();
|
||||
wallet.unlock("pw".to_string()).unwrap();
|
||||
for r in vec![wallet.add_imported_sk(sk.clone(), 0), wallet.import_taddr(wif.clone(), 0)] {
|
||||
assert_eq!(r, "Error: Can't import a private key while the wallet is encrypted - remove encryption, import, then encrypt again");
|
||||
// the C++ reason mapper keys on these words; this reason must not be mistaken for them
|
||||
assert!(!r.contains("locked") && !r.contains("decode") && !r.contains("already exists"), "{}", r);
|
||||
}
|
||||
assert_eq!(counts(&wallet), before);
|
||||
|
||||
wallet.lock().unwrap();
|
||||
wallet.unlock("pw".to_string()).unwrap();
|
||||
assert_eq!(wallet.get_seed_phrase(), phrase);
|
||||
assert!(wallet.tkeys.read().unwrap().iter().all(|tk| tk.tkey.is_some()));
|
||||
assert!(wallet.zkeys.read().unwrap().iter().all(|zk| zk.extsk.is_some()));
|
||||
|
||||
// Once encryption is removed, the same keys import.
|
||||
wallet.remove_encryption("pw".to_string()).unwrap();
|
||||
assert_eq!(wallet.import_taddr(wif, 0), other.get_t_secret_keys()[0].0);
|
||||
assert!(!wallet.add_imported_sk(sk, 0).starts_with("Error"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_import_vk_ok_when_encrypted_unlocked() {
|
||||
// s1 test 4: a regression guard (passes before and after). A viewing key needs no enc_key, so it still
|
||||
// imports into an encrypted, unlocked wallet, and the wallet still locks and unlocks.
|
||||
let config = get_test_config();
|
||||
let mut wallet = LightWallet::new(None, &config, 0, 0).unwrap();
|
||||
let other = LightWallet::new(None, &config, 0, 0).unwrap();
|
||||
let (zaddr, _, vk) = other.get_z_private_keys()[0].clone();
|
||||
let phrase = wallet.get_seed_phrase();
|
||||
|
||||
wallet.encrypt("pw".to_string()).unwrap();
|
||||
wallet.unlock("pw".to_string()).unwrap();
|
||||
assert_eq!(wallet.add_imported_vk(vk, 0), zaddr);
|
||||
wallet.lock().unwrap();
|
||||
wallet.unlock("pw".to_string()).unwrap();
|
||||
assert_eq!(wallet.get_seed_phrase(), phrase);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user