fix(lite-backend): make lock/unlock atomic, check passphrase to decrypt

lock() zeroed the seed first and then locked the keys one by one. A key
with no encrypted copy (an import into an encrypted wallet by an older
build) failed halfway: the seed was all zeros, the earlier keys were
wiped, and the wallet still reported unlocked. Seed export then showed
the all-zero-entropy phrase ("abandon ... art") as the backup.

unlock() had the mirror problem. When the passphrase was right but a
later key failed (a zaddress/fvk mismatch, a bad sealed key), the keys
decrypted so far stayed in memory while the wallet reported locked, and
write() saves a locked wallet as is, so the next save put them in
plaintext into the encrypted file. unlock also unwrapped the nonce and
the sealed key, so a malformed or foreign file panicked under the wallet
write lock and poisoned it.

remove_encryption() on an unlocked wallet never looked at the
passphrase: any string, even "", removed the encryption for good, and
the next save wrote the seed and every key in plaintext.

- WalletTKey/WalletZKey get can_lock(), which mirrors lock()'s
  precondition. lock() checks every key first and changes nothing when
  one cannot be locked; the keys are locked next and the seed is zeroed
  last.
- unlock() runs the t-key loop, then the z-key loop; on any error it
  re-locks every key that can be locked before it returns. This drops
  only plaintext copies that can be re-derived or re-decrypted.
- The nonce and enc_key unwraps in LightWallet::unlock and in
  WalletTKey/WalletZKey::unlock return "Wallet encryption data is
  corrupt" (InvalidData) instead.
- get_seed_phrase() returns "" when the wallet is encrypted and its seed
  is all zeros.
- remove_encryption() opens the sealed seed with the given passphrase
  first, locked or unlocked, before it changes anything.

Flagged behaviour changes:
- lock() checks every key before it changes anything;
- a failed unlock re-wipes;
- corrupt encryption data returns an error, not a panic;
- seed export never returns the all-zero phrase;
- remove_encryption verifies the passphrase in both lock states (a wrong
  passphrase now fails with "Decryption failed. Is your password
  correct?").
Wallet format v9 is unchanged; no ABI or symbol change. The KDF is
unchanged; upgrading it is left for later.

Tests: lock is atomic for an unsealed t-key and an unsealed imported
z-key (seed, unlocked flag and keys unchanged, then a clean lock and
unlock); a failed unlock leaves no t-key or z-key secret in memory or in
the written file, and with the z-key's real derivation index restored
the same passphrase unlocks every key; a key with no sealed copy, a
missing nonce or a nonce of the wrong length is an error in WalletTKey,
WalletZKey and LightWallet, and remove_encryption refuses it too;
remove_encryption with a wrong or empty passphrase fails locked and
unlocked, and the right one round-trips an unencrypted v9 file; an
encrypted wallet with a zero seed exports "".

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 20b63bf45d
commit 55431cd606
3 changed files with 309 additions and 12 deletions
@@ -975,7 +975,9 @@ impl LightWallet {
}
pub fn get_seed_phrase(&self) -> String {
if !self.unlocked {
// An encrypted wallet with a zero seed is locked, or was left half-locked by an older build: never
// return the all-zero-entropy phrase ("abandon ... art") as if it were the backup.
if !self.unlocked || (self.encrypted && self.seed == [0u8; 32]) {
return "".to_string();
}
@@ -1025,8 +1027,14 @@ impl LightWallet {
return Err(io::Error::new(ErrorKind::AlreadyExists, "Wallet is already locked"));
}
// Empty the seed and the secret keys
self.seed.copy_from_slice(&[0u8; 32]);
// Check every key before changing anything. A key with no encrypted copy can't be locked, and
// failing halfway used to leave the seed zeroed while the wallet still reported unlocked.
let t_ok = self.tkeys.read().unwrap().iter().all(|tk| tk.can_lock());
let z_ok = self.zkeys.read().unwrap().iter().all(|zk| zk.can_lock());
if !t_ok || !z_ok {
return Err(io::Error::new(ErrorKind::InvalidInput,
"Can't lock: the wallet holds a private key that is not encrypted"));
}
// Remove all the private key from the tkeys
self.tkeys.write().unwrap().iter_mut().map(|tk| {
@@ -1038,6 +1046,9 @@ impl LightWallet {
zk.lock()
}).collect::<io::Result<Vec<_>>>()?;
// Empty the seed last
self.seed.copy_from_slice(&[0u8; 32]);
self.unlocked = false;
Ok(())
@@ -1056,7 +1067,10 @@ impl LightWallet {
// Get the doublesha256 of the password, which is the right length
let key = secretbox::Key::from_slice(&double_sha256(passwd.as_bytes())).unwrap();
let nonce = secretbox::Nonce::from_slice(&self.nonce).unwrap();
let nonce = match secretbox::Nonce::from_slice(&self.nonce) {
Some(n) => n,
None => return Err(io::Error::new(ErrorKind::InvalidData, "Wallet encryption data is corrupt")),
};
let seed = match secretbox::open(&self.enc_seed, &nonce, &key) {
Ok(s) => s,
@@ -1071,14 +1085,23 @@ impl LightWallet {
let bip39_seed = bip39::Seed::new(&Mnemonic::from_entropy(&seed, Language::English).unwrap(), "");
// Go over the tkeys, and add the keys again
self.tkeys.write().unwrap().iter_mut().map(|tk| {
let t = self.tkeys.write().unwrap().iter_mut().map(|tk| {
tk.unlock(&key)
}).collect::<io::Result<Vec<()>>>()?;
}).collect::<io::Result<Vec<()>>>();
// Go over the zkeys, and add the spending keys again
self.zkeys.write().unwrap().iter_mut().map(|zk| {
let z = t.and_then(|_| self.zkeys.write().unwrap().iter_mut().map(|zk| {
zk.unlock(&self.config, bip39_seed.as_bytes(), &key)
}).collect::<io::Result<Vec<()>>>()?;
}).collect::<io::Result<Vec<()>>>());
// A key failed (a zaddress/fvk mismatch, corrupt encryption data): wipe the keys decrypted so far
// before returning. The wallet stays locked, and write() saves a locked wallet, so they would
// otherwise reach the encrypted file in plaintext.
if let Err(e) = z {
for tk in self.tkeys.write().unwrap().iter_mut() { if tk.can_lock() { let _ = tk.lock(); } }
for zk in self.zkeys.write().unwrap().iter_mut() { if zk.can_lock() { let _ = zk.lock(); } }
return Err(e);
}
// Everything checks out, so we'll update our wallet with the decrypted values
self.seed.copy_from_slice(&seed);
@@ -1096,6 +1119,19 @@ impl LightWallet {
return Err(Error::new(ErrorKind::AlreadyExists, "Wallet is not encrypted"));
}
// Always prove the passphrase, also when the wallet is already unlocked: from the next save on, the
// seed and every key are written in plaintext.
{
let key = secretbox::Key::from_slice(&double_sha256(passwd.as_bytes())).unwrap();
let nonce = match secretbox::Nonce::from_slice(&self.nonce) {
Some(n) => n,
None => return Err(io::Error::new(ErrorKind::InvalidData, "Wallet encryption data is corrupt")),
};
if secretbox::open(&self.enc_seed, &nonce, &key).is_err() {
return Err(io::Error::new(ErrorKind::InvalidData, "Decryption failed. Is your password correct?"));
}
}
// Unlock the wallet if it's locked
if !self.unlocked {
self.unlock(passwd)?;