commit ed0a98d9235fca4531ab45477c7a71177e39e353
parent aabf5a41af9c9f2f30ba663bb6194f596ec67208
Author: Joris Hartog <jorishartog@hotmail.com>
Date: Wed, 19 Aug 2026 21:16:58 +0200
Add wallet config fuzz target
Diffstat:
12 files changed, 130 insertions(+), 9 deletions(-)
diff --git a/PLAN.md b/PLAN.md
@@ -5,7 +5,9 @@ candidate chain is treated as promotable to mainnet.
## 1. Fuzzing Harnesses
-Status: initial repository harnesses and deployment smoke runs are in place.
+Status: initial repository harnesses and deployment smoke runs are in place for
+P2P gossip, compact snapshots, domain JSON, Stratum requests, and wallet/config
+persistence metadata.
Goal: continuously throw malformed and semi-valid input at every external data
boundary and every compact persistence format.
diff --git a/ROADMAP.md b/ROADMAP.md
@@ -120,7 +120,8 @@ A release intended for deployment must pass:
- `cargo test --locked`
- `cargo check --locked --manifest-path fuzz/Cargo.toml`
-- fuzz smoke runs for `p2p_envelope`, `compact_snapshot`, `domain_json`, and `stratum_request`
+- fuzz smoke runs for `p2p_envelope`, `compact_snapshot`, `domain_json`,
+ `stratum_request`, and `wallet_config`
- `cargo test --locked --release --test properties -- --ignored`
Normal local development may skip ignored long-running property tests and long fuzzing sessions, but deployment must run the release gate smoke checks.
diff --git a/deployment.sh b/deployment.sh
@@ -80,6 +80,7 @@ run_release_tests() {
cargo run --locked --manifest-path fuzz/Cargo.toml --bin compact_snapshot -- -runs=1 fuzz/corpus/compact_snapshot
cargo run --locked --manifest-path fuzz/Cargo.toml --bin domain_json -- -runs=1 fuzz/corpus/domain_json
cargo run --locked --manifest-path fuzz/Cargo.toml --bin stratum_request -- -runs=1 fuzz/corpus/stratum_request
+ cargo run --locked --manifest-path fuzz/Cargo.toml --bin wallet_config -- -runs=1 fuzz/corpus/wallet_config
cargo test --locked --release --test properties -- --ignored
}
diff --git a/docs/security-review.md b/docs/security-review.md
@@ -111,6 +111,7 @@ Evidence already in the tree:
- HTTP/auth abuse tests for CSRF, lockout/backoff, session expiry, and
forwarded-header spoofing;
- wallet/config/chain crash-consistency tests;
+- wallet/config persistence metadata fuzz target;
- local-only UI guidance in `README.md`.
### Stratum
@@ -148,6 +149,7 @@ cargo fuzz run p2p_envelope -- -runs=256
cargo fuzz run compact_snapshot -- -runs=256
cargo fuzz run domain_json -- -runs=256
cargo fuzz run stratum_request -- -runs=256
+cargo fuzz run wallet_config -- -runs=256
cargo test --locked --release --test properties -- --ignored
```
diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml
@@ -35,3 +35,9 @@ name = "stratum_request"
path = "fuzz_targets/stratum_request.rs"
test = false
doc = false
+
+[[bin]]
+name = "wallet_config"
+path = "fuzz_targets/wallet_config.rs"
+test = false
+doc = false
diff --git a/fuzz/README.md b/fuzz/README.md
@@ -15,6 +15,7 @@ cargo fuzz run p2p_envelope
cargo fuzz run compact_snapshot
cargo fuzz run domain_json
cargo fuzz run stratum_request
+cargo fuzz run wallet_config
```
Short smoke run without installing `cargo-fuzz`:
@@ -24,6 +25,7 @@ cargo run --manifest-path fuzz/Cargo.toml --bin p2p_envelope -- -runs=1 fuzz/cor
cargo run --manifest-path fuzz/Cargo.toml --bin compact_snapshot -- -runs=1 fuzz/corpus/compact_snapshot
cargo run --manifest-path fuzz/Cargo.toml --bin domain_json -- -runs=1 fuzz/corpus/domain_json
cargo run --manifest-path fuzz/Cargo.toml --bin stratum_request -- -runs=1 fuzz/corpus/stratum_request
+cargo run --manifest-path fuzz/Cargo.toml --bin wallet_config -- -runs=1 fuzz/corpus/wallet_config
```
Targets intentionally accept malformed input. A parse error is fine; panics,
diff --git a/fuzz/corpus/wallet_config/config.json b/fuzz/corpus/wallet_config/config.json
@@ -0,0 +1,22 @@
+{
+ "version": 1,
+ "amount_unit": "microiuna",
+ "setup_complete": true,
+ "auth_password_hash": "argon2id:test",
+ "mining_enabled": true,
+ "pow_mining_enabled": true,
+ "pow_mining_workers": 4,
+ "burn_per_block": 100,
+ "burn_fee": 100,
+ "recovery_vdf_top_rank_percent": 50,
+ "keep_track_of_metrics": true,
+ "p2p_accept_inbound": true,
+ "p2p_bind_port": 9444,
+ "p2p_announce_addr": "203.0.113.10:9444",
+ "stratum_enabled": false,
+ "stratum_bind_port": 3333,
+ "peers": ["iuna.jhx.app:9444"],
+ "address_book": {
+ "0000000000000000000000000000000000000000000000000000000000000000": "Test"
+ }
+}
diff --git a/fuzz/corpus/wallet_config/wallet_encrypted.json b/fuzz/corpus/wallet_config/wallet_encrypted.json
@@ -0,0 +1,12 @@
+{
+ "version": 3,
+ "address": "0000000000000000000000000000000000000000000000000000000000000000",
+ "encryption": {
+ "algorithm": "chacha20poly1305",
+ "kdf": "pbkdf2-sha256",
+ "kdf_iterations": 210000,
+ "salt": "000102030405060708090a0b0c0d0e0f",
+ "nonce": "000102030405060708090a0b",
+ "ciphertext": "000102030405060708090a0b0c0d0e0f"
+ }
+}
diff --git a/fuzz/corpus/wallet_config/wallet_plain.json b/fuzz/corpus/wallet_config/wallet_plain.json
@@ -0,0 +1,5 @@
+{
+ "version": 2,
+ "seed": "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art",
+ "address": "8fcb9ff51abf624ef8ccd78cecf59be5c8cb3789668c32916fefc08c1864a4b0"
+}
diff --git a/fuzz/fuzz_targets/wallet_config.rs b/fuzz/fuzz_targets/wallet_config.rs
@@ -0,0 +1,13 @@
+#![no_main]
+
+use iuna::adapters::{config_store, wallet_store};
+use libfuzzer_sys::fuzz_target;
+
+fuzz_target!(|data: &[u8]| {
+ if data.len() > 1024 * 1024 {
+ return;
+ }
+
+ let _ = config_store::fuzz_parse_config(data);
+ let _ = wallet_store::fuzz_parse_wallet_metadata(data);
+});
diff --git a/src/adapters/config_store.rs b/src/adapters/config_store.rs
@@ -151,14 +151,17 @@ pub fn save(path: &Path, config: &UiConfig) -> Result<()> {
fn load(path: &Path) -> Result<UiConfig> {
let bytes =
fs::read(path).with_context(|| format!("failed to read config file {}", path.display()))?;
- let stored: ConfigFile = serde_json::from_slice(&bytes)
- .with_context(|| format!("failed to parse config file {}", path.display()))?;
+ parse_config_bytes(&bytes, &path.display().to_string())
+}
+
+fn parse_config_bytes(bytes: &[u8], source: &str) -> Result<UiConfig> {
+ let stored: ConfigFile = serde_json::from_slice(bytes)
+ .with_context(|| format!("failed to parse config file {source}"))?;
if stored.version != CONFIG_FILE_VERSION {
bail!(
- "unsupported config file version {} in {}",
- stored.version,
- path.display()
+ "unsupported config file version {} in {source}",
+ stored.version
);
}
@@ -201,6 +204,11 @@ fn load(path: &Path) -> Result<UiConfig> {
})
}
+#[cfg(feature = "fuzzing")]
+pub fn fuzz_parse_config(bytes: &[u8]) -> Result<UiConfig> {
+ parse_config_bytes(bytes, "<fuzz>")
+}
+
pub fn clamp_pow_mining_workers(workers: u8) -> u8 {
workers.clamp(1, MAX_POW_MINING_WORKERS)
}
diff --git a/src/adapters/wallet_store.rs b/src/adapters/wallet_store.rs
@@ -16,6 +16,8 @@ use serde::{Deserialize, Serialize};
use sha2::Sha256;
use crate::domain::Wallet;
+#[cfg(feature = "fuzzing")]
+use crate::domain::validate_address;
const WALLET_FILE_VERSION: u32 = 3;
const PLAINTEXT_WALLET_FILE_VERSION: u32 = 2;
@@ -250,8 +252,11 @@ fn wallet_seed(stored: &WalletFile, password: Option<&str>) -> Result<String> {
fn read_wallet_file(path: &Path) -> Result<WalletFile> {
let bytes =
fs::read(path).with_context(|| format!("failed to read wallet file {}", path.display()))?;
- serde_json::from_slice(&bytes)
- .with_context(|| format!("failed to parse wallet file {}", path.display()))
+ parse_wallet_file_bytes(&bytes, &path.display().to_string())
+}
+
+fn parse_wallet_file_bytes(bytes: &[u8], source: &str) -> Result<WalletFile> {
+ serde_json::from_slice(bytes).with_context(|| format!("failed to parse wallet file {source}"))
}
enum WalletFileMode {
@@ -464,6 +469,48 @@ fn decode_hex_nibble(byte: u8) -> Result<u8> {
}
}
+#[cfg(feature = "fuzzing")]
+pub fn fuzz_parse_wallet_metadata(bytes: &[u8]) -> Result<WalletMetadata> {
+ let stored = parse_wallet_file_bytes(bytes, "<fuzz>")?;
+ validate_wallet_file_metadata(&stored)?;
+ Ok(WalletMetadata {
+ address: stored.address,
+ encrypted: stored.encryption.is_some(),
+ })
+}
+
+#[cfg(feature = "fuzzing")]
+fn validate_wallet_file_metadata(stored: &WalletFile) -> Result<()> {
+ if stored.version != WALLET_FILE_VERSION
+ && stored.version != PLAINTEXT_WALLET_FILE_VERSION
+ && stored.version != 1
+ {
+ bail!("unsupported wallet file version {}", stored.version);
+ }
+ validate_address(&stored.address, "wallet address")?;
+ if let Some(encryption) = &stored.encryption {
+ if encryption.algorithm != WALLET_ENCRYPTION_ALGORITHM {
+ bail!("unsupported wallet encryption algorithm");
+ }
+ if encryption.kdf != WALLET_ENCRYPTION_KDF {
+ bail!("unsupported wallet encryption kdf");
+ }
+ let _ = decode_hex(&encryption.salt).context("invalid wallet encryption salt")?;
+ let nonce = decode_hex(&encryption.nonce).context("invalid wallet encryption nonce")?;
+ if nonce.len() != 12 {
+ bail!("invalid wallet encryption nonce length");
+ }
+ let _ = decode_hex(&encryption.ciphertext).context("invalid wallet encrypted seed")?;
+ } else {
+ let seed = stored
+ .seed
+ .as_deref()
+ .context("wallet file does not contain a seed")?;
+ let _ = normalize_seed_phrase(seed)?;
+ }
+ Ok(())
+}
+
fn normalize_seed_phrase(seed_phrase: &str) -> Result<String> {
let normalized = seed_phrase
.split_whitespace()