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:
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")
+ );
+ }
}