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:
+85
-6
@@ -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(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user