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
@@ -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
}