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:
2026-10-12 13:22:00 -05:00
co-authored by Claude Opus 5.5
parent 026d0e92a6
commit 20b63bf45d
3 changed files with 324 additions and 12 deletions
@@ -675,19 +675,17 @@ impl LightWallet {
return "Error: Can't add key while wallet is locked".to_string();
}
//// Decode Wif to base58 to hex
let sk_to_bs58 = bs58::decode(sk).into_vec().unwrap();
// An imported key would be stored with no enc_key; lock() could then never succeed (see lock()).
if self.encrypted {
return "Error: Can't import a private key while the wallet is encrypted - remove encryption, import, then encrypt again".to_string();
}
let bs58_to_hex = hex::encode(sk_to_bs58);
//// Manipulate string, to exclude last 4 bytes (checksum bytes), first 2 bytes (secretkey prefix) and the compressed flag (works only for compressed Wifs!)
let slice_sk = &bs58_to_hex[2..66];
//// Get the SecretKey from slice
let secret_key = SecretKey::from_slice(&hex::decode(slice_sk).unwrap());
let sk_raw = secret_key.unwrap();
//// Decode the WIF: Base58Check (checksum verified), version byte, 32-byte key, 0x01 compressed flag.
//// Never panics, and never echoes the key in an error.
let sk_raw = match LightWallet::decode_taddr_wif(&sk, self.config.base58_secretkey_prefix()[0]) {
Ok(k) => k,
Err(e) => return format!("Error: {}", e),
};
//// Make sure the key doesn't already exist
if self.tkeys.read().unwrap().iter().find(|&wk| wk.tkey.is_some() && wk.tkey.as_ref().unwrap() == &sk_raw.clone()).is_some() {
@@ -714,6 +712,11 @@ impl LightWallet {
return "Error: Can't add key while wallet is locked".to_string();
}
// An imported key would be stored with no enc_key; lock() could then never succeed (see lock()).
if self.encrypted {
return "Error: Can't import a private key while the wallet is encrypted - remove encryption, import, then encrypt again".to_string();
}
// First, try to interpret the key
let extsk = match decode_extended_spending_key(self.config.hrp_sapling_private_key(), &sk) {
Ok(Some(k)) => k,
@@ -934,6 +937,27 @@ impl LightWallet {
hash160.result().to_base58check(prefix, &[])
}
/// WIF = version(1) || key(32) || 0x01 (compressed). `bs58 ... with_check(None)` verifies the double-SHA256
/// checksum and strips it (bs58 0.2.5 decode.rs:284-310), keeping the version byte. Do NOT pass
/// `with_check(Some(v))`: it would pin one version and reject the 0x80 (K/L) WIFs the help text advertises.
/// Accepts the config prefix (0xBC on main) and 0x80. The decoded bytes are wiped on every path (s2-D5),
/// and no error contains the key.
pub(crate) fn decode_taddr_wif(wif: &str, network_prefix: u8) -> Result<SecretKey, String> {
let wif = wif.trim();
// Decode into a buffer we own (bs58's into_vec allocates the same size), so it is wiped even when the
// checksum fails; into_vec would drop its buffer, which then holds the decoded bytes, unwiped.
let mut buf = vec![0u8; wif.len()];
let r = match bs58::decode(wif).with_check(None).into(&mut buf[..]) {
Err(_) => Err("Couldn't decode the transparent private key (not valid Base58Check)".to_string()),
Ok(33) => Err("Uncompressed transparent private keys are not supported; import the compressed form".to_string()),
Ok(n) if n != 34 || buf[33] != 0x01 => Err("Couldn't decode the transparent private key (unrecognized format)".to_string()),
Ok(_) if buf[0] != network_prefix && buf[0] != 0x80 => Err("The transparent private key is for a different network".to_string()),
Ok(_) => SecretKey::from_slice(&buf[1..33]).map_err(|_| "Invalid transparent private key".to_string()),
};
sodiumoxide::utils::memzero(&mut buf[..]); // already a dependency; no new crate
r
}
pub fn address_from_sk(&self, sk: &secp256k1::SecretKey) -> String {
LightWallet::address_from_prefix_sk(&self.config.base58_pubkey_address(), sk)
}