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:
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,