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);
}