iuna

iuna

iuna - experimental mainnet-candidate protocol
git clone https://getiuna.org/git/iuna.git
Log | Files | Refs | README | LICENSE

commit 2b68b202ece64305fa177c674cff5b4360076c3d
parent d1bd82718b86d90f9d9b3d64462a7aa7f6a06f54
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Fri, 21 Aug 2026 14:41:51 +0200

Review wallet security for candidate

Diffstat:
Mdocs/genesis.md | 8++++++--
Mdocs/security-review.md | 45++++++++++++++++++++++++++++++++++-----------
Msrc/adapters/http/auth.rs | 29+++++++++++++++++++++++++++++
Msrc/adapters/wallet_store.rs | 67+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
4 files changed, 134 insertions(+), 15 deletions(-)

diff --git a/docs/genesis.md b/docs/genesis.md @@ -48,13 +48,17 @@ cargo run --locked --manifest-path fuzz/Cargo.toml --bin compact_snapshot -- -ru cargo run --locked --manifest-path fuzz/Cargo.toml --bin domain_json -- -runs=256 fuzz/corpus/domain_json cargo run --locked --manifest-path fuzz/Cargo.toml --bin stratum_request -- -runs=256 fuzz/corpus/stratum_request cargo run --locked --manifest-path fuzz/Cargo.toml --bin wallet_config -- -runs=256 fuzz/corpus/wallet_config +cargo run --locked --manifest-path fuzz/Cargo.toml --bin vdf_proof -- -runs=16 fuzz/corpus/vdf_proof +cargo test --locked domain::adversarial_tests:: -- --ignored cargo test --locked --release --test properties -- --ignored ./deployment.sh <version> ``` The deployment script writes release packages to `downloads/` and creates `downloads/SHA256SUMS`. -It runs `256` iterations per fuzz target by default; set `IUNA_FUZZ_RUNS` -to a positive integer only for an explicitly documented emergency redeploy. +It runs `256` iterations per general fuzz target and `16` iterations for the +slower VDF proof fuzz target by default; set `IUNA_FUZZ_RUNS` or +`IUNA_VDF_FUZZ_RUNS` to a positive integer only for an explicitly documented +emergency redeploy. Verify the files before publishing them: diff --git a/docs/security-review.md b/docs/security-review.md @@ -150,9 +150,18 @@ cargo run --locked --manifest-path fuzz/Cargo.toml --bin compact_snapshot -- -ru cargo run --locked --manifest-path fuzz/Cargo.toml --bin domain_json -- -runs=256 fuzz/corpus/domain_json cargo run --locked --manifest-path fuzz/Cargo.toml --bin stratum_request -- -runs=256 fuzz/corpus/stratum_request cargo run --locked --manifest-path fuzz/Cargo.toml --bin wallet_config -- -runs=256 fuzz/corpus/wallet_config +cargo run --locked --manifest-path fuzz/Cargo.toml --bin vdf_proof -- -runs=16 fuzz/corpus/vdf_proof +cargo test --locked domain::adversarial_tests:: -- --ignored cargo test --locked --release --test properties -- --ignored ``` +On macOS hosts with the optional Python/C++ `chiavdf` package installed, also +run the byte-for-byte compatibility test: + +```sh +IUNA_CHIAVDF_PYTHON=/path/to/python cargo test --locked --release domain::vdf::wesolowski::tests::prover_matches_chiavdf_python_binding -- --ignored --nocapture +``` + The deployment script runs these gates for release builds. Keep the exact command output with the candidate release notes so independent operators can see which revision was tested. @@ -189,19 +198,33 @@ see which revision was tested. construction. The limb backend covers signed limb arithmetic, division, full and partial XGCD, production NUDUPL/NUCOMP, checkpoint bucket selection, and class-group exponentiation. - Before promotion, review the - limb backend's canonical encoding and division behavior, add deeper in-place - arithmetic for multiplication intermediates where profiling justifies it, and run - fixed vectors, differential VDF tests, fuzz targets, release benchmarks, and - Windows MSVC builds on every release platform. -- Wallet/key handling: review the encrypted wallet format, PBKDF2 iteration - count, password UX, recovery phrase exposure, and backup guidance. + Before promotion, keep running fixed vectors, differential VDF tests, fuzz + targets, release benchmarks, and the optional `chiavdf` compatibility test on + at least one macOS host. Windows MSVC release benchmarking remains a platform + readiness item, not a wire-compatibility requirement. +- Wallet/key handling: reviewed for the mainnet-candidate run. Wallet files use + versioned JSON. Plaintext seed files are still supported for legacy/setup + flows, but setting a management password encrypts existing or newly generated + wallets before normal authenticated use. Encrypted wallets store no plaintext + seed, use `chacha20poly1305` with a random 16-byte salt, random 12-byte nonce, + PBKDF2-SHA256 at 210,000 iterations, and bind the ciphertext to the wallet + address as AEAD associated data. Unlock rejects unsupported algorithms, KDFs, + unreasonable PBKDF2 iteration counts, wrong salt/nonce lengths, wrong + passwords, and address/seed mismatches. Wallet writes are atomic and use + `0600` temporary files on Unix. Management UI password hashes also use + PBKDF2-SHA256 with bounded iteration counts, login backoff, session expiry, + `HttpOnly`/`SameSite=Strict` cookies, CSRF same-origin checks, and trusted + forwarded headers only from loopback proxies. Residual accepted risk for the + candidate: PBKDF2 is CPU-hard rather than memory-hard, so operators must use + strong unique passwords and keep the management UI local or otherwise + protected. - Public exposure: verify bootnodes expose only the intended P2P and optional Stratum ports, and that the management UI remains bound to a local or otherwise protected address. -- Candidate manifest: verify genesis hash, network ID, bootnodes, checksums, - promotion policy, and rollback instructions before the stability window - starts. +- Candidate manifest: `docs/genesis.md` contains the manifest template and + operating procedure. For a specific candidate, verify and publish the real + genesis hash, network ID, bootnodes, checksums, promotion policy, rollback + instructions, release tag, and git commit before the stability window starts. - Release evidence: keep successful release-gate logs from the exact tagged candidate revision. @@ -215,7 +238,7 @@ filled in for the candidate release. | Consensus validation | | | | | | Transaction and mempool validation | | | | | | P2P input handling | | | | | -| Wallet, key storage, and HTTP auth | | | | | +| Wallet, key storage, and HTTP auth | Codex | 2026-08-21 | Candidate accepted with residual operational risk | Reviewed encryption/auth paths; added PBKDF2 iteration and salt-length hardening. | | Stratum | | | | | | Release evidence | | | | | | Candidate manifest | | | | | diff --git a/src/adapters/http/auth.rs b/src/adapters/http/auth.rs @@ -5,6 +5,8 @@ use sha2::{Digest, Sha256}; const PASSWORD_KDF_ALGORITHM: &str = "pbkdf2-sha256"; const PASSWORD_KDF_ITERATIONS: u32 = 210_000; +const MIN_PASSWORD_KDF_ITERATIONS: u32 = 100_000; +const MAX_PASSWORD_KDF_ITERATIONS: u32 = 1_000_000; pub(super) fn validate_password(password: &str) -> Result<()> { if password.len() < 12 { @@ -34,12 +36,20 @@ pub(super) fn verify_password(password: &str, encoded: &str) -> Result<bool> { let iterations = parts[1] .parse::<u32>() .context("invalid password hash iterations")?; + validate_password_kdf_iterations(iterations)?; let salt = decode_hex(parts[2]).context("invalid password hash salt")?; let expected = decode_hex(parts[3]).context("invalid password hash")?; let actual = pbkdf2_sha256(password.as_bytes(), &salt, iterations); Ok(constant_time_eq(&actual, &expected)) } +fn validate_password_kdf_iterations(iterations: u32) -> Result<()> { + if !(MIN_PASSWORD_KDF_ITERATIONS..=MAX_PASSWORD_KDF_ITERATIONS).contains(&iterations) { + bail!("unsupported password hash iteration count"); + } + Ok(()) +} + pub(super) fn session_token_hash(token: &str) -> String { hex_encode(Sha256::digest(format!("iuna-session:{token}").as_bytes())) } @@ -105,3 +115,22 @@ fn decode_hex_nibble(byte: u8) -> Result<u8> { _ => bail!("invalid hex character"), } } + +#[cfg(test)] +mod tests { + use super::{hash_password, verify_password}; + + #[test] + fn password_hash_rejects_unreasonable_kdf_iterations() { + let encoded = hash_password("password-123456").unwrap(); + let excessive = encoded.replacen("$210000$", "$1000000000$", 1); + + let error = verify_password("password-123456", &excessive).unwrap_err(); + + assert!( + error + .to_string() + .contains("unsupported password hash iteration count") + ); + } +} diff --git a/src/adapters/wallet_store.rs b/src/adapters/wallet_store.rs @@ -24,6 +24,8 @@ const PLAINTEXT_WALLET_FILE_VERSION: u32 = 2; const WALLET_ENCRYPTION_ALGORITHM: &str = "chacha20poly1305"; const WALLET_ENCRYPTION_KDF: &str = "pbkdf2-sha256"; const WALLET_ENCRYPTION_ITERATIONS: u32 = 210_000; +const MIN_WALLET_ENCRYPTION_ITERATIONS: u32 = 100_000; +const MAX_WALLET_ENCRYPTION_ITERATIONS: u32 = 1_000_000; const GENERATED_SEED_WORDS: usize = 24; const BIP39_SEED_ENTROPY_BYTES: usize = 32; @@ -390,9 +392,13 @@ fn decrypt_wallet_data( if encryption.kdf != WALLET_ENCRYPTION_KDF { bail!("unsupported wallet encryption kdf"); } + validate_wallet_encryption_iterations(encryption.kdf_iterations)?; let salt = decode_hex(&encryption.salt).context("invalid wallet encryption salt")?; let nonce = decode_hex(&encryption.nonce).context("invalid wallet encryption nonce")?; let ciphertext = decode_hex(&encryption.ciphertext).context("invalid wallet encrypted seed")?; + if salt.len() != 16 { + bail!("invalid wallet encryption salt length"); + } if nonce.len() != 12 { bail!("invalid wallet encryption nonce length"); } @@ -421,6 +427,14 @@ fn wallet_encryption_key(password: &str, salt: &[u8], iterations: u32) -> [u8; 3 key } +fn validate_wallet_encryption_iterations(iterations: u32) -> Result<()> { + if !(MIN_WALLET_ENCRYPTION_ITERATIONS..=MAX_WALLET_ENCRYPTION_ITERATIONS).contains(&iterations) + { + bail!("unsupported wallet encryption iteration count"); + } + Ok(()) +} + fn random_bytes<const N: usize>() -> Result<[u8; N]> { let mut bytes = [0_u8; N]; getrandom::getrandom(&mut bytes) @@ -495,7 +509,11 @@ fn validate_wallet_file_metadata(stored: &WalletFile) -> Result<()> { if encryption.kdf != WALLET_ENCRYPTION_KDF { bail!("unsupported wallet encryption kdf"); } - let _ = decode_hex(&encryption.salt).context("invalid wallet encryption salt")?; + validate_wallet_encryption_iterations(encryption.kdf_iterations)?; + let salt = decode_hex(&encryption.salt).context("invalid wallet encryption salt")?; + if salt.len() != 16 { + bail!("invalid wallet encryption salt length"); + } let nonce = decode_hex(&encryption.nonce).context("invalid wallet encryption nonce")?; if nonce.len() != 12 { bail!("invalid wallet encryption nonce length"); @@ -638,7 +656,10 @@ mod tests { use tempfile::tempdir; - use super::{load_or_create, replace_with_imported_seed_phrase}; + use super::{ + load_or_create, load_with_password, read_wallet_file, replace_with_imported_seed_phrase, + replace_with_imported_seed_phrase_encrypted, + }; const TEST_SEED: &str = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art"; @@ -655,4 +676,46 @@ mod tests { assert_eq!(loaded.address(), wallet.address()); assert!(stale_temp.exists()); } + + #[test] + fn encrypted_wallet_rejects_unreasonable_kdf_iterations_before_unlock() { + let dir = tempdir().unwrap(); + let path = dir.path().join("wallet.json"); + replace_with_imported_seed_phrase_encrypted(&path, TEST_SEED, "password-123456").unwrap(); + let wallet_json = fs::read_to_string(&path).unwrap(); + fs::write( + &path, + wallet_json.replace( + "\"kdf_iterations\": 210000", + "\"kdf_iterations\": 1000000000", + ), + ) + .unwrap(); + + let error = load_with_password(&path, "password-123456").unwrap_err(); + + assert!( + error + .to_string() + .contains("unsupported wallet encryption iteration count") + ); + } + + #[test] + fn encrypted_wallet_metadata_rejects_short_salt() { + let dir = tempdir().unwrap(); + let path = dir.path().join("wallet.json"); + replace_with_imported_seed_phrase_encrypted(&path, TEST_SEED, "password-123456").unwrap(); + let mut stored = read_wallet_file(&path).unwrap(); + stored.encryption.as_mut().unwrap().salt = "abcd".to_string(); + fs::write(&path, serde_json::to_vec_pretty(&stored).unwrap()).unwrap(); + + let error = load_with_password(&path, "password-123456").unwrap_err(); + + assert!( + error + .to_string() + .contains("invalid wallet encryption salt length") + ); + } }