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