iuna

iuna

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

commit 22aca6b73da3d3f687db476e91abc0ffba8377a3
parent 3f9e3c72334786cb7ac6f90e680dba01f8626702
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Wed,  9 Sep 2026 21:29:40 +0200

fix(security): harden cheap denial of service paths

Diffstat:
Adocs/security-audit-2026-09-09.md | 155+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/adapters/stratum.rs | 137+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Msrc/domain/ledger_apply.rs | 2++
Msrc/domain/ledger_chain.rs | 2++
Msrc/domain/ledger_consensus.rs | 133+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Msrc/domain/ledger_state.rs | 4++++
6 files changed, 406 insertions(+), 27 deletions(-)

diff --git a/docs/security-audit-2026-09-09.md b/docs/security-audit-2026-09-09.md @@ -0,0 +1,155 @@ +# Pre-mainnet security audit — 2026-09-09 + +Scope: repository revision `3f9e3c7` before the changes in this report. Testing +was local-only. The review prioritized attacks with an estimated cash cost below +EUR 10,000 and covered consensus validation, transaction admission, P2P, +Stratum, bootstrap trust, wallet storage, and HTTP authentication. + +This is a focused implementation audit, not a proof of cryptographic or +economic security. The custom VDF and the burn/finality mechanism still require +independent specialist review before promotion. + +## Executive result + +Two remotely reachable denial-of-service weaknesses were confirmed and patched. +Two cheap admission/bootstrap risks remain promotion blockers or hardening work. +No confirmed supply-creation, signature-bypass, or consensus-split defect was +found in the reviewed validation paths. + +| ID | Severity | Estimated attacker cost | Result | +| --- | --- | ---: | --- | +| IUNA-2026-001 | High | Effectively EUR 0 from one public IP | Patched | +| IUNA-2026-002 | High over chain lifetime | A basic host and bandwidth; well below EUR 100/month | Patched | +| IUNA-2026-003 | High during first sync | Below EUR 10,000 when DNS/routing/bootstrap access is available | Open promotion blocker | +| IUNA-2026-004 | Medium | 10,000 minimum-fee transactions; protocol value likely far below EUR 10,000 | Open hardening item | + +## IUNA-2026-001 — one source could exhaust every Stratum session + +The public Stratum listener used a global 64-permit semaphore but no per-source +limit. `mining.authorize` is intentionally permissionless, and even an entirely +silent TCP client retained a permit for up to the 120-second idle timeout. A +single machine could therefore open 64 connections and prevent all miners from +connecting. A client that stopped reading responses could retain a task longer +because response writes had no timeout. + +Impact: loss of public mining availability and a practical reduction in PoW +issuance participation. This does not directly create coins or alter consensus. + +Reproduction before the patch: instantiate `StratumSessionLimiter::new(64)` and +acquire all permits using the same source address; all 64 acquisitions succeed. +The regression tests now show that only four sessions are accepted for one IPv4 +address or IPv6 /64 while other sources retain capacity: + +```sh +cargo test --locked stratum_session_limiter +``` + +Patch: + +- enforce four active sessions per IPv4 address or IPv6 /64 in addition to the + global limit; +- release both counters through an RAII permit; +- bound response writes to ten seconds. + +Residual risk: sixteen independently routed source groups can still consume the +global limit. Public deployments should add upstream connection/rate limiting. + +## IUNA-2026-002 — invalid mine shares amplified into historical chain scans + +Each mine transaction validation called +`mine_difficulty_bits_for_anchor_height`. That function iterated over every +retarget window and scanned the complete chain again for each window. At chain +height `H` the work was approximately `H * floor(H / 10)` block visits per +share. At height 100,000 that is about one billion predicate visits. Stratum +performed this work while holding the global node mutex, before rejecting an +invalid share. Any remote client could authorize with a syntactically valid +wallet address and repeatedly submit arbitrary nonces. + +Impact: growing CPU exhaustion and starvation of block, gossip, wallet, and UI +operations. Exploit cost is only a network connection and request bandwidth. + +Regression test: + +```sh +cargo test --locked applied_window_cache_preserves_retarget_results_for_constant_time_lookup +``` + +Patch: maintain the derived difficulty after every completed ten-block window. +Normal mine validation is now an indexed lookup. Cache updates examine only the +latest ten blocks and snapshot restoration reconstructs the same cache while +replaying blocks. A linear compatibility fallback exists only for synthetic or +migration-created ledgers whose cache is absent. + +The patch changes no consensus value or serialized chain format. + +## IUNA-2026-003 — first sync trusts an unpinned bootstrap genesis + +A setup-placeholder node accepts any self-consistent genesis and launch profile +served by its selected bootstrap peer. The network ID is checked, but the live +candidate genesis is not pinned in code or required through an independently +supplied checkpoint. P2P node identity proves continuity of the peer's +self-generated key; it does not establish that the key is an authorized +candidate bootstrap identity. + +Impact: a new operator whose DNS, route, configuration, or only bootstrap peer +is controlled can be placed on a valid but attacker-created chain. Subsequent +same-genesis validation will keep that node isolated from the real candidate. + +The repository manifest records genesis +`3d677cd7ced1c04d3a276cbee7ea38076e34ac65f18a2c9b8286a4872d986a9a`, but the +manifest still describes v0.4.18 while this audit targets v0.4.28. Hard-coding +that value without first reconciling and signing the release manifest could +lock in stale governance data, so no automatic patch was applied. + +Required before promotion: + +1. Publish and sign an updated candidate manifest. +2. Pin the approved genesis hash (and preferably one finalized checkpoint) in + the mainnet-candidate binary. +3. Require fresh nodes to match it before downloading or adopting block pages. +4. Pin bootstrap node identities or obtain the checkpoint from at least two + independently operated sources. + +## IUNA-2026-004 — full mempool rejects higher-fee transactions + +The mempool accepts any non-zero fee. At 10,000 entries it rejects every new +transaction before comparing fee rate or evicting lower-value entries. An +attacker with confirmed funds can create a long sequence of minimum-fee +transactions and pin admission until blocks drain the pool. The 8 MiB byte cap +bounds memory, but not admission fairness or repeated validation cost. + +Impact: delayed transaction propagation and local CPU load. Consensus remains +valid and directly connected block producers can still include transactions. + +Recommended patch: introduce a minimum relay fee rate and dependency-aware +replacement of the lowest-fee transaction package. This should be implemented +as mempool policy, leaving block consensus compatible with already signed +low-fee transactions. Add tests proving that a full pool accepts a higher-fee +independent transaction, removes descendants of an evicted parent, and keeps +byte/count counters exact. + +## Verification evidence + +The focused regression tests passed: + +```text +stratum_session_limiter: 3 passed +applied_window_cache_preserves_retarget_results_for_constant_time_lookup: 1 passed +cargo clippy --locked --all-targets --all-features -- -D warnings: passed +``` + +The initial sandboxed run completed 330 tests successfully; its 15 socket-based +P2P tests could not call `bind(2)`. The suite was then repeated locally with +loopback permission and passed completely: 427 passed, 0 failed, and 36 +long-running tests were ignored by their existing configuration. + +`scripts/check-dependencies.sh` could not refresh RustSec because the sandbox +made the Cargo advisory database lock path read-only. Dependency audit status is +therefore not claimed by this report. + +## Promotion recommendation + +Do not promote to mainnet until IUNA-2026-003 is resolved, the complete release +gate is run on the exact final revision, and the custom VDF plus economic +finality assumptions receive independent review. IUNA-2026-004 should be fixed +before exposing a high-value public transaction network. diff --git a/src/adapters/stratum.rs b/src/adapters/stratum.rs @@ -1,8 +1,8 @@ use std::{ collections::BTreeMap, - net::SocketAddr, + net::{IpAddr, Ipv6Addr, SocketAddr}, sync::{ - Arc, + Arc, Mutex as StdMutex, atomic::{AtomicU64, Ordering}, }, time::Duration, @@ -26,7 +26,9 @@ use crate::{ const STRATUM_MAX_LINE_BYTES: usize = 16 * 1024; const STRATUM_MAX_JOBS_PER_SESSION: usize = 128; const STRATUM_MAX_SESSIONS: usize = 64; +const STRATUM_MAX_SESSIONS_PER_SOURCE: usize = 4; const STRATUM_IDLE_TIMEOUT: Duration = Duration::from_secs(120); +const STRATUM_WRITE_TIMEOUT: Duration = Duration::from_secs(10); #[cfg(feature = "fuzzing")] pub fn fuzz_parse_stratum_request(line: &str) -> Result<Value> { @@ -57,17 +59,72 @@ struct StratumJob { #[derive(Clone)] struct StratumSessionLimiter { permits: Arc<Semaphore>, + active_by_source: Arc<StdMutex<BTreeMap<IpAddr, usize>>>, +} + +struct StratumSessionPermit { + _global: OwnedSemaphorePermit, + source: IpAddr, + active_by_source: Arc<StdMutex<BTreeMap<IpAddr, usize>>>, } impl StratumSessionLimiter { fn new(max_sessions: usize) -> Self { Self { permits: Arc::new(Semaphore::new(max_sessions)), + active_by_source: Arc::new(StdMutex::new(BTreeMap::new())), } } - fn try_acquire(&self) -> Option<OwnedSemaphorePermit> { - self.permits.clone().try_acquire_owned().ok() + fn try_acquire(&self, remote_ip: IpAddr) -> Option<StratumSessionPermit> { + let global = self.permits.clone().try_acquire_owned().ok()?; + let source = stratum_source_key(remote_ip); + let mut active_by_source = self.active_by_source.lock().ok()?; + let active = active_by_source.entry(source).or_default(); + if *active >= STRATUM_MAX_SESSIONS_PER_SOURCE { + return None; + } + *active += 1; + drop(active_by_source); + Some(StratumSessionPermit { + _global: global, + source, + active_by_source: Arc::clone(&self.active_by_source), + }) + } +} + +impl Drop for StratumSessionPermit { + fn drop(&mut self) { + let Ok(mut active_by_source) = self.active_by_source.lock() else { + return; + }; + let Some(active) = active_by_source.get_mut(&self.source) else { + return; + }; + *active = active.saturating_sub(1); + if *active == 0 { + active_by_source.remove(&self.source); + } + } +} + +fn stratum_source_key(ip: IpAddr) -> IpAddr { + match ip { + IpAddr::V4(_) => ip, + IpAddr::V6(ip) => { + let segments = ip.segments(); + IpAddr::V6(Ipv6Addr::new( + segments[0], + segments[1], + segments[2], + segments[3], + 0, + 0, + 0, + 0, + )) + } } } @@ -101,7 +158,7 @@ async fn run_listener(server: StratumServer, listener: TcpListener) { loop { match listener.accept().await { Ok((stream, remote)) => { - let Some(permit) = server.session_limiter.try_acquire() else { + let Some(permit) = server.session_limiter.try_acquire(remote.ip()) else { if debug_logging_enabled() { eprintln!("stratum session with {remote} rejected: session limit reached"); } @@ -373,11 +430,12 @@ impl StratumSession { } async fn send(&self, value: Value) -> Result<()> { + let mut payload = serde_json::to_vec(&value)?; + payload.push(b'\n'); let mut writer = self.writer.lock().await; - writer - .write_all(serde_json::to_string(&value)?.as_bytes()) - .await?; - writer.write_all(b"\n").await?; + timeout(STRATUM_WRITE_TIMEOUT, writer.write_all(&payload)) + .await + .context("Stratum response write timeout")??; Ok(()) } } @@ -458,8 +516,9 @@ mod tests { use crate::{app::ExternalMineJob, domain::StratumMineTemplate}; use super::{ - STRATUM_MAX_JOBS_PER_SESSION, STRATUM_MAX_LINE_BYTES, STRATUM_MAX_SESSIONS, StratumJob, - StratumLineReader, StratumSessionLimiter, insert_bounded_job, parse_stratum_request, + STRATUM_MAX_JOBS_PER_SESSION, STRATUM_MAX_LINE_BYTES, STRATUM_MAX_SESSIONS, + STRATUM_MAX_SESSIONS_PER_SOURCE, StratumJob, StratumLineReader, StratumSessionLimiter, + insert_bounded_job, parse_stratum_request, }; fn dummy_job() -> StratumJob { @@ -523,12 +582,62 @@ mod tests { fn stratum_session_limiter_enforces_global_cap() { let limiter = StratumSessionLimiter::new(STRATUM_MAX_SESSIONS); let permits = (0..STRATUM_MAX_SESSIONS) - .map(|_| limiter.try_acquire().expect("permit should be available")) + .map(|index| { + limiter + .try_acquire(format!("192.0.2.{}", index + 1).parse().unwrap()) + .expect("permit should be available") + }) + .collect::<Vec<_>>(); + + assert!( + limiter + .try_acquire("198.51.100.1".parse().unwrap()) + .is_none() + ); + drop(permits); + assert!( + limiter + .try_acquire("198.51.100.1".parse().unwrap()) + .is_some() + ); + } + + #[test] + fn stratum_session_limiter_prevents_one_source_from_exhausting_global_slots() { + let limiter = StratumSessionLimiter::new(STRATUM_MAX_SESSIONS); + let source = "192.0.2.10".parse().unwrap(); + let permits = (0..STRATUM_MAX_SESSIONS_PER_SOURCE) + .map(|_| limiter.try_acquire(source).unwrap()) .collect::<Vec<_>>(); - assert!(limiter.try_acquire().is_none()); + assert!(limiter.try_acquire(source).is_none()); + assert!(limiter.try_acquire("192.0.2.11".parse().unwrap()).is_some()); + drop(permits); + assert!(limiter.try_acquire(source).is_some()); + } + + #[test] + fn stratum_session_limiter_groups_ipv6_clients_by_prefix() { + let limiter = StratumSessionLimiter::new(STRATUM_MAX_SESSIONS); + let permits = (1..=STRATUM_MAX_SESSIONS_PER_SOURCE) + .map(|index| { + limiter + .try_acquire(format!("2001:db8:1:2::{index}").parse().unwrap()) + .unwrap() + }) + .collect::<Vec<_>>(); + + assert!( + limiter + .try_acquire("2001:db8:1:2::ffff".parse().unwrap()) + .is_none() + ); + assert!( + limiter + .try_acquire("2001:db8:1:3::1".parse().unwrap()) + .is_some() + ); drop(permits); - assert!(limiter.try_acquire().is_some()); } #[test] diff --git a/src/domain/ledger_apply.rs b/src/domain/ledger_apply.rs @@ -121,6 +121,7 @@ impl Ledger { self.mined_transaction_ids .extend(mined_signatures.iter().cloned()); self.chain.push(block); + self.update_mine_difficulty_cache_after_tip(); if let Some(checkpoint) = certified_parent { self.objective_finality_checkpoint = Some(checkpoint); } @@ -194,6 +195,7 @@ impl Ledger { self.compact_block_context.append_trusted_block(&block)?; self.mined_transaction_ids.extend(mined_signatures); self.chain.push(block); + self.update_mine_difficulty_cache_after_tip(); if let Some(checkpoint) = certified_parent { self.objective_finality_checkpoint = Some(checkpoint); } diff --git a/src/domain/ledger_chain.rs b/src/domain/ledger_chain.rs @@ -95,6 +95,7 @@ impl Ledger { pending_bytes: 0, orphan_bytes: 0, mine_reward: MINE_REWARD, + mine_difficulty_windows: vec![launch_profile.mine_difficulty_bits], initial_vdf_rounds: vdf_rounds, vdf_rounds, launch_profile, @@ -210,6 +211,7 @@ impl Ledger { pending_bytes: 0, orphan_bytes: 0, mine_reward: MINE_REWARD, + mine_difficulty_windows: vec![launch_profile.mine_difficulty_bits], initial_vdf_rounds: vdf_rounds, vdf_rounds, launch_profile, diff --git a/src/domain/ledger_consensus.rs b/src/domain/ledger_consensus.rs @@ -80,20 +80,51 @@ impl Ledger { } pub(super) fn mine_difficulty_bits_for_anchor_height(&self, anchor_height: u64) -> u32 { - let mut difficulty = self.launch_profile.mine_difficulty_bits; - let mut window_end = MINE_RETARGET_WINDOW_BLOCKS; - while window_end <= anchor_height { - let window_start = window_end + 1 - MINE_RETARGET_WINDOW_BLOCKS; - let mine_actions = self - .chain - .iter() - .filter(|block| window_start <= block.height && block.height <= window_end) - .map(mine_action_count) - .sum::<u64>(); - difficulty = retarget_mine_difficulty_bits(difficulty, mine_actions); - window_end = window_end.saturating_add(MINE_RETARGET_WINDOW_BLOCKS); + let completed_windows = anchor_height / MINE_RETARGET_WINDOW_BLOCKS; + if let Ok(index) = usize::try_from(completed_windows) + && let Some(difficulty) = self.mine_difficulty_windows.get(index) + { + return *difficulty; } - difficulty + + // Tests and migration helpers may construct synthetic chains directly. + // Keep a linear fallback for those callers; production ledgers update + // the cache as each block is applied. + mine_difficulty_windows_for_chain( + &self.chain, + self.launch_profile.mine_difficulty_bits, + anchor_height, + ) + .last() + .copied() + .unwrap_or(self.launch_profile.mine_difficulty_bits) + } + + pub(super) fn update_mine_difficulty_cache_after_tip(&mut self) { + let height = self.tip().height; + if height == 0 || !height.is_multiple_of(MINE_RETARGET_WINDOW_BLOCKS) { + return; + } + let expected_len = usize::try_from(height / MINE_RETARGET_WINDOW_BLOCKS) + .unwrap_or(usize::MAX) + .saturating_add(1); + if self.mine_difficulty_windows.len() >= expected_len { + return; + } + let mine_actions = self + .chain + .iter() + .rev() + .take(MINE_RETARGET_WINDOW_BLOCKS as usize) + .map(mine_action_count) + .sum(); + let previous = self + .mine_difficulty_windows + .last() + .copied() + .unwrap_or(self.launch_profile.mine_difficulty_bits); + self.mine_difficulty_windows + .push(retarget_mine_difficulty_bits(previous, mine_actions)); } pub(super) fn tip(&self) -> &Block { @@ -102,3 +133,79 @@ impl Ledger { .expect("ledger is always initialized with genesis") } } + +fn mine_difficulty_windows_for_chain( + chain: &[Block], + initial_difficulty: u32, + anchor_height: u64, +) -> Vec<u32> { + let completed_windows = anchor_height / MINE_RETARGET_WINDOW_BLOCKS; + let mut difficulties = Vec::with_capacity( + usize::try_from(completed_windows) + .unwrap_or_default() + .saturating_add(1), + ); + difficulties.push(initial_difficulty); + let mut difficulty = initial_difficulty; + for window in 1..=completed_windows { + let window_end = window.saturating_mul(MINE_RETARGET_WINDOW_BLOCKS); + let window_start = window_end + 1 - MINE_RETARGET_WINDOW_BLOCKS; + let mine_actions = chain + .iter() + .filter(|block| window_start <= block.height && block.height <= window_end) + .map(mine_action_count) + .sum(); + difficulty = retarget_mine_difficulty_bits(difficulty, mine_actions); + difficulties.push(difficulty); + } + difficulties +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use super::*; + use crate::domain::{BurnBundleSection, Transaction}; + + fn mine(anchor: &str, nonce: u64) -> Transaction { + Transaction::Mine { + recipient: "1".repeat(64), + anchor: anchor.to_string(), + salt: 1, + nonce, + difficulty_bits: 12, + proof_header: None, + signature: format!("{nonce:064x}"), + } + } + + #[test] + fn applied_window_cache_preserves_retarget_results_for_constant_time_lookup() { + let mut ledger = Ledger::new(BTreeMap::new(), 1); + for height in 1..=20 { + let parent = ledger.tip().clone(); + let mut block = parent.clone(); + block.height = height; + block.prev_hash = parent.hash.clone(); + block.hash = format!("{height:064x}"); + block.burn_bundle_section = BurnBundleSection::default(); + block.transactions = if height <= 10 { + vec![mine(&parent.hash, height)] + } else { + Vec::new() + }; + ledger.chain.push(block); + ledger.update_mine_difficulty_cache_after_tip(); + } + + assert_eq!(ledger.mine_difficulty_windows, vec![12, 12, 10]); + assert_eq!(ledger.mine_difficulty_bits_for_anchor_height(9), 12); + assert_eq!(ledger.mine_difficulty_bits_for_anchor_height(10), 12); + assert_eq!(ledger.mine_difficulty_bits_for_anchor_height(20), 10); + + let mut uncached = ledger.clone(); + uncached.mine_difficulty_windows.truncate(1); + assert_eq!(uncached.mine_difficulty_bits_for_anchor_height(20), 10); + } +} diff --git a/src/domain/ledger_state.rs b/src/domain/ledger_state.rs @@ -24,6 +24,10 @@ pub struct Ledger { pub(super) pending_bytes: usize, pub(super) orphan_bytes: usize, pub(super) mine_reward: Amount, + /// PoW difficulty after each completed retarget window. Index zero is the + /// launch difficulty; index `n` is the difficulty at anchor height + /// `n * MINE_RETARGET_WINDOW_BLOCKS`. + pub(super) mine_difficulty_windows: Vec<u32>, pub(super) initial_vdf_rounds: u64, pub(super) vdf_rounds: u64, pub(super) launch_profile: LaunchProfile,