feat(litebe): salted Argon2id KDF for new encrypted wallets

New Lite wallets now encrypt their seed and keys with a salted Argon2id
key instead of the unsalted double-SHA256 of the passphrase, which was
cheap to brute-force from a stolen wallet file.

- A shared derive_wallet_key() replaces double-SHA256 across encrypt,
  unlock, remove-encryption and seed recovery. KDF 0 is the old
  double-SHA256 (unchanged); KDF 1 is Argon2id (moderate: 3 passes,
  256 MiB), using sodiumoxide's existing pwhash -- no new dependency.
- The KDF id, salt and parameters are stored in the wallet. A wallet is
  written at version 10 only while it is actually Argon2id-encrypted;
  every pre-existing wallet (plaintext or double-SHA256) is read and
  written byte-for-byte as version 9.
- New-wallets-only: a freshly created or restored wallet encrypts with
  Argon2id; a wallet read from disk never changes its KDF, and removing
  encryption never changes it. Set DRAGONX_LITE_KDF=legacy to keep new
  wallets on double-SHA256.
- An older build (or SilentDragonXLite) opening a version-10 wallet
  fails cleanly with "don't know how to read wallet version 10" before
  parsing anything, never a silent misread; the seed phrase stays the
  cross-wallet recovery path.

Compatibility break (by design, opt-in): a wallet newly encrypted with
Argon2id cannot be opened by older ObsidianDragon or SilentDragonXLite.

Tests: the existing encryption tests now exercise the Argon2id path,
plus the version-10 header, legacy byte-identity and derive_wallet_key
parameter bounds. Validated by the lite oracle suite and the old-vs-new
serialization differential (legacy wallets byte-identical; version-10
round-trips).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-10-14 10:37:34 -05:00
co-authored by Claude Opus 4.8
parent 6d4ed96f6d
commit 39292b1b1f
3 changed files with 118 additions and 13 deletions
@@ -112,14 +112,14 @@ fn create_private_file_new(p: &Path) -> io::Result<File> {
/// X3: the encryption flag in a wallet file's header (u64 LE version, then the flag byte that LightWallet::read reads
/// for every version >= 4). None when the header cannot tell: a short read or an I/O error, v5 (gzip: its byte 8 is
/// not the flag), v<=3 (no flag) or v>9 (a format this build does not know).
/// not the flag), v<=3 (no flag) or v>10 (a format this build does not know).
fn wallet_header_encrypted(p: &Path) -> Option<bool> {
let mut h = [0u8; 9];
File::open(p).and_then(|mut f| f.read_exact(&mut h)).ok()?;
let mut v = [0u8; 8];
v.copy_from_slice(&h[..8]);
match u64::from_le_bytes(v) {
4 | 6 | 7 | 8 | 9 => Some(h[8] != 0),
4 | 6 | 7 | 8 | 9 | 10 => Some(h[8] != 0),
_ => None,
}
}
@@ -757,12 +757,24 @@ impl LightClient {
vec![]
};
// C253: a version-10 wallet carries the Argon2id descriptor here, between the nonce and the seed.
let mut kdf = 0u8;
let mut kdf_salt = [0u8; 16];
let mut kdf_ops = 0u32;
let mut kdf_mem = 0u32;
if version >= 10 {
kdf = reader.read_u8().unwrap();
reader.read_exact(&mut kdf_salt).unwrap();
kdf_ops = reader.read_u32::<LittleEndian>().unwrap();
kdf_mem = reader.read_u32::<LittleEndian>().unwrap();
}
let phrase = if encrypted {
use sodiumoxide::crypto::secretbox;
use crate::lightwallet::double_sha256;
// Get the doublesha256 of the password, which is the right length
let key = secretbox::Key::from_slice(&double_sha256(password.unwrap().as_bytes())).unwrap();
let key = crate::lightwallet::LightWallet::derive_wallet_key(kdf, password.unwrap().as_bytes(), &kdf_salt, kdf_ops, kdf_mem).map_err(|e| e.to_string())?;
let nonce = secretbox::Nonce::from_slice(&nonce).unwrap();
let seed = match secretbox::open(&enc_seed, &nonce, &key) {
@@ -2759,6 +2771,7 @@ pub fn test_bad_import() {
(b[..8].to_vec(), b[8])
};
let v9 = 9u64.to_le_bytes().to_vec();
let v10 = 10u64.to_le_bytes().to_vec();
let lc = LightClient::new_from_phrase(TEST_SEED.to_string(), &config, 0, 0, false).unwrap();
lc.do_save().unwrap();
assert_eq!(header(&bak), (v9.clone(), 0), "precondition: a plaintext v9 .bak");
@@ -2766,8 +2779,8 @@ pub fn test_bad_import() {
lc.wallet.write().unwrap().encrypt("password".to_string()).unwrap();
lc.do_save().unwrap();
assert_eq!(header(&wallet), (v9.clone(), 1), "the wallet is saved encrypted, as v9");
assert_eq!(header(&bak), (v9.clone(), 1), "the .bak still holds the plaintext wallet after the encrypt save");
assert_eq!(header(&wallet), (v10.clone(), 1), "the wallet is saved encrypted; a new wallet uses Argon2id (v10)");
assert_eq!(header(&bak), (v10.clone(), 1), "after the encrypt save the .bak is the encrypted copy too (v10)");
assert_eq!(wallet_header_encrypted(&bak), Some(true));
assert_eq!(std::fs::read(&bak).unwrap(), std::fs::read(&wallet).unwrap());
let mut from_bak = LightWallet::read(std::io::BufReader::new(std::fs::File::open(&bak).unwrap()), &config).unwrap();
@@ -2796,13 +2809,13 @@ pub fn test_bad_import() {
std::fs::write(&p, &b).unwrap();
wallet_header_encrypted(&p)
};
for &v in &[4u64, 6, 7, 8, 9] {
for &v in &[4u64, 6, 7, 8, 9, 10] {
assert_eq!(probe(v, &[0, 7, 7]), Some(false), "v{}", v);
assert_eq!(probe(v, &[1]), Some(true), "v{}", v);
assert_eq!(probe(v, &[2]), Some(true), "v{}: any non-zero flag, as LightWallet::read", v);
assert_eq!(probe(v, &[]), None, "v{} without a flag byte", v);
}
for &v in &[0u64, 1, 2, 3, 5, 10, u64::MAX] {
for &v in &[0u64, 1, 2, 3, 5, 11, u64::MAX] {
assert_eq!(probe(v, &[0]), None, "v{}", v);
assert_eq!(probe(v, &[1]), None, "v{}", v);
}
@@ -26,6 +26,7 @@ use pairing::bls12_381::{Bls12};
use sha2::{Sha256, Digest};
use sodiumoxide::crypto::secretbox;
use sodiumoxide::crypto::pwhash::argon2id13;
use zcash_client_backend::{
encoding::{encode_payment_address, encode_extended_spending_key, encode_extended_full_viewing_key, decode_extended_spending_key, decode_extended_full_viewing_key},
@@ -146,6 +147,12 @@ pub struct LightWallet {
enc_seed: [u8; 48], // If locked, this contains the encrypted seed
nonce: Vec<u8>, // Nonce used to encrypt the wallet.
// C253: passphrase KDF descriptor (non-secret; all zero unless kdf == 1 and encrypted).
kdf: u8,
kdf_salt: [u8; 16],
kdf_ops: u32,
kdf_mem: u32,
seed: [u8; 32], // Seed phrase for this wallet. If wallet is locked, this is 0
// List of keys, actually in this wallet. This is a combination of HD keys derived from the seed,
@@ -178,6 +185,35 @@ impl LightWallet {
return 9;
}
/// C253: the version written ONLY for a wallet actually protected by the salted Argon2id KDF.
pub const ARGON2_WALLET_VERSION: u64 = 10;
/// Highest wallet version this build can read.
pub fn max_read_version() -> u64 {
LightWallet::ARGON2_WALLET_VERSION
}
/// C253: derive the secretbox key from the passphrase. kdf 0 = legacy unsalted double SHA-256; kdf 1 = Argon2id13 with the stored salt/ops/mem.
pub(crate) fn derive_wallet_key(kdf: u8, passwd: &[u8], salt: &[u8; 16], ops: u32, mem: u32) -> io::Result<secretbox::Key> {
if kdf == 0 {
return secretbox::Key::from_slice(&double_sha256(passwd))
.ok_or_else(|| io::Error::new(ErrorKind::InvalidData, "bad key length"));
}
if ops < 1 || ops > 12 || mem < (8u32 << 20) || mem > (1u32 << 30) {
return Err(io::Error::new(ErrorKind::InvalidData, "Wallet key-derivation parameters are corrupt"));
}
let _ = sodiumoxide::init();
let mut kb = [0u8; secretbox::KEYBYTES];
let r = argon2id13::derive_key(&mut kb, passwd, &argon2id13::Salt(*salt),
argon2id13::OpsLimit(ops as usize), argon2id13::MemLimit(mem as usize));
let key = match r {
Ok(_) => secretbox::Key::from_slice(&kb),
Err(_) => None,
};
sodiumoxide::utils::memzero(&mut kb[..]);
key.ok_or_else(|| io::Error::new(ErrorKind::Other, "Argon2id key derivation failed"))
}
fn get_taddr_from_bip39seed(config: &LightClientConfig, bip39_seed: &[u8], pos: u32) -> SecretKey {
assert_eq!(bip39_seed.len(), 64);
@@ -279,6 +315,10 @@ impl LightWallet {
unlocked: true,
enc_seed: [0u8; 48],
nonce: vec![],
kdf: if std::env::var("DRAGONX_LITE_KDF").map(|v| v == "legacy").unwrap_or(false) { 0 } else { 1 },
kdf_salt: [0u8; 16],
kdf_ops: 0,
kdf_mem: 0,
seed: seed_bytes,
zkeys: Arc::new(RwLock::new(vec![WalletZKey::new_hdkey(hdkey_num, extsk)])),
tkeys: Arc::new(RwLock::new(vec![WalletTKey::new_hdkey(tpk, taddr)])),
@@ -306,7 +346,7 @@ impl LightWallet {
pub fn read<R: Read>(mut inp: R, config: &LightClientConfig) -> io::Result<Self> {
let version = inp.read_u64::<LittleEndian>()?;
if version > LightWallet::serialized_version() {
if version > LightWallet::max_read_version() {
let e = format!("Don't know how to read wallet version {}. Do you have the latest version?", version);
error!("{}", e);
return Err(io::Error::new(ErrorKind::Unsupported, e));
@@ -341,6 +381,21 @@ impl LightWallet {
vec![]
};
// C253: version 10 carries the passphrase KDF descriptor between the nonce and the seed. v<=9 is always legacy double SHA-256.
let mut kdf = 0u8;
let mut kdf_salt = [0u8; 16];
let mut kdf_ops = 0u32;
let mut kdf_mem = 0u32;
if version >= LightWallet::ARGON2_WALLET_VERSION {
kdf = reader.read_u8()?;
reader.read_exact(&mut kdf_salt)?;
kdf_ops = reader.read_u32::<LittleEndian>()?;
kdf_mem = reader.read_u32::<LittleEndian>()?;
if kdf != 1 || !encrypted || kdf_ops < 1 || kdf_ops > 12 || kdf_mem < (8u32 << 20) || kdf_mem > (1u32 << 30) {
return Err(io::Error::new(ErrorKind::InvalidData, "Wallet key-derivation data is corrupt"));
}
}
// Seed
let mut seed_bytes = [0u8; 32];
reader.read_exact(&mut seed_bytes)?;
@@ -449,6 +504,10 @@ impl LightWallet {
unlocked: !encrypted, // When reading from disk, if wallet is encrypted, it starts off locked.
enc_seed: enc_seed,
nonce: nonce,
kdf,
kdf_salt,
kdf_ops,
kdf_mem,
seed: seed_bytes,
zkeys: Arc::new(RwLock::new(zkeys)),
tkeys: Arc::new(RwLock::new(wallet_tkeys)),
@@ -478,7 +537,9 @@ impl LightWallet {
}
// Write the version
writer.write_u64::<LittleEndian>(LightWallet::serialized_version())?;
// C253: v10 only for a wallet actually protected by the Argon2id KDF; everything else stays v9 (readable by older builds and SDXL).
let argon = self.encrypted && self.kdf == 1;
writer.write_u64::<LittleEndian>(if argon { LightWallet::ARGON2_WALLET_VERSION } else { LightWallet::serialized_version() })?;
// Write if it is locked
writer.write_u8(if self.encrypted {1} else {0})?;
@@ -489,6 +550,13 @@ impl LightWallet {
// Write the nonce
Vector::write(&mut writer, &self.nonce, |w, b| w.write_u8(*b))?;
if argon {
writer.write_u8(1)?;
writer.write_all(&self.kdf_salt)?;
writer.write_u32::<LittleEndian>(self.kdf_ops)?;
writer.write_u32::<LittleEndian>(self.kdf_mem)?;
}
// Write the seed
writer.write_all(&self.seed)?;
@@ -995,14 +1063,22 @@ impl LightWallet {
return Err(io::Error::new(ErrorKind::AlreadyExists, "Wallet is already encrypted"));
}
// Get the doublesha256 of the password, which is the right length
let key = secretbox::Key::from_slice(&double_sha256(passwd.as_bytes())).unwrap();
// C253: legacy wallets keep the unsalted double SHA-256; new wallets use salted Argon2id (a fresh salt per encryption).
let (salt, ops, mem) = if self.kdf == 1 {
(argon2id13::gen_salt().0, argon2id13::OPSLIMIT_MODERATE.0 as u32, argon2id13::MEMLIMIT_MODERATE.0 as u32)
} else {
([0u8; 16], 0u32, 0u32)
};
let key = LightWallet::derive_wallet_key(self.kdf, passwd.as_bytes(), &salt, ops, mem)?;
let nonce = secretbox::gen_nonce();
let cipher = secretbox::seal(&self.seed, &nonce, &key);
self.enc_seed.copy_from_slice(&cipher);
self.nonce = nonce.as_ref().to_vec();
self.kdf_salt = salt;
self.kdf_ops = ops;
self.kdf_mem = mem;
// Encrypt the individual keys
@@ -1068,7 +1144,7 @@ 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 key = LightWallet::derive_wallet_key(self.kdf, passwd.as_bytes(), &self.kdf_salt, self.kdf_ops, self.kdf_mem)?;
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")),
@@ -1124,7 +1200,7 @@ impl LightWallet {
// 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 key = LightWallet::derive_wallet_key(self.kdf, passwd.as_bytes(), &self.kdf_salt, self.kdf_ops, self.kdf_mem)?;
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")),
@@ -1153,6 +1229,9 @@ impl LightWallet {
self.encrypted = false;
self.nonce = vec![];
self.enc_seed.copy_from_slice(&[0u8; 48]);
self.kdf_salt = [0u8; 16];
self.kdf_ops = 0;
self.kdf_mem = 0;
Ok(())
}
@@ -3983,3 +3983,16 @@ fn test_wallet_read_never_panics_on_corrupt_input() {
assert_eq!(kind("a u64::MAX string length", run("a u64::MAX string length", || utils::read_string(&s[..]))),
ErrorKind::UnexpectedEof);
}
#[test]
fn test_derive_wallet_key_param_bounds() {
// C253: kdf 0 is legacy double_sha256 (ignores ops/mem); kdf 1 rejects out-of-bounds Argon2id params
// BEFORE deriving, so this test never runs a heavy derivation.
let salt = [7u8; 16];
assert!(LightWallet::derive_wallet_key(0, b"pw", &salt, 0, 0).is_ok());
let bad = |ops: u32, mem: u32| LightWallet::derive_wallet_key(1, b"pw", &salt, ops, mem).unwrap_err().kind();
assert_eq!(bad(0, 256 * 1024 * 1024), std::io::ErrorKind::InvalidData); // ops too low
assert_eq!(bad(13, 256 * 1024 * 1024), std::io::ErrorKind::InvalidData); // ops too high
assert_eq!(bad(3, 4 * 1024 * 1024), std::io::ErrorKind::InvalidData); // mem below 8 MiB
assert_eq!(bad(3, (1u32 << 30) + 1), std::io::ErrorKind::InvalidData); // mem above 1 GiB
}