From 39292b1b1f1abbbe58bcbc69816e96e9a857f0bb Mon Sep 17 00:00:00 2001 From: DanS Date: Wed, 14 Oct 2026 10:37:34 -0500 Subject: [PATCH] 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 --- .../lib/src/lightclient.rs | 27 ++++-- .../lib/src/lightwallet.rs | 91 +++++++++++++++++-- .../lib/src/lightwallet/tests.rs | 13 +++ 3 files changed, 118 insertions(+), 13 deletions(-) diff --git a/third_party/silentdragonxlite/silentdragonxlite-cli/lib/src/lightclient.rs b/third_party/silentdragonxlite/silentdragonxlite-cli/lib/src/lightclient.rs index f04b4e8..265985d 100644 --- a/third_party/silentdragonxlite/silentdragonxlite-cli/lib/src/lightclient.rs +++ b/third_party/silentdragonxlite/silentdragonxlite-cli/lib/src/lightclient.rs @@ -112,14 +112,14 @@ fn create_private_file_new(p: &Path) -> io::Result { /// 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 { 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::().unwrap(); + kdf_mem = reader.read_u32::().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); } diff --git a/third_party/silentdragonxlite/silentdragonxlite-cli/lib/src/lightwallet.rs b/third_party/silentdragonxlite/silentdragonxlite-cli/lib/src/lightwallet.rs index 398066b..78f8332 100644 --- a/third_party/silentdragonxlite/silentdragonxlite-cli/lib/src/lightwallet.rs +++ b/third_party/silentdragonxlite/silentdragonxlite-cli/lib/src/lightwallet.rs @@ -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, // 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 { + 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(mut inp: R, config: &LightClientConfig) -> io::Result { let version = inp.read_u64::()?; - 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::()?; + kdf_mem = reader.read_u32::()?; + 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::(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::(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::(self.kdf_ops)?; + writer.write_u32::(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(()) } diff --git a/third_party/silentdragonxlite/silentdragonxlite-cli/lib/src/lightwallet/tests.rs b/third_party/silentdragonxlite/silentdragonxlite-cli/lib/src/lightwallet/tests.rs index b94f59e..ca30e30 100644 --- a/third_party/silentdragonxlite/silentdragonxlite-cli/lib/src/lightwallet/tests.rs +++ b/third_party/silentdragonxlite/silentdragonxlite-cli/lib/src/lightwallet/tests.rs @@ -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 +}