diff --git a/CHANGELOG.md b/CHANGELOG.md index 256fddcc1..d2e7ba516 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- GUI + CLI: When Tor is disabled, onion addresses are no longer dialed. Previously a maker's advertised onion address could be attempted without Tor, producing failed dials and pointless redial churn; such addresses are now filtered out before the Swarm attempts to dial them. - ASB: The Hermes protocol is now enabled by default (`hermes_enabled` defaults to `true`), and the default `hermes_min_swap_amount` was lowered from `0.01` to `0.001` BTC (~50 USD at a reference price of 50,000 USD/BTC). ## [4.11.4] - 2026-06-30 diff --git a/swap-p2p/src/protocols.rs b/swap-p2p/src/protocols.rs index a8ff498b0..68dde861f 100644 --- a/swap-p2p/src/protocols.rs +++ b/swap-p2p/src/protocols.rs @@ -8,5 +8,6 @@ pub mod quotes_cached; pub mod redial; pub mod rendezvous; pub mod swap_setup; +pub mod tor_dial_filter; pub mod transfer_proof; pub mod wormhole; diff --git a/swap-p2p/src/protocols/tor_dial_filter.rs b/swap-p2p/src/protocols/tor_dial_filter.rs new file mode 100644 index 000000000..6dbd0fd09 --- /dev/null +++ b/swap-p2p/src/protocols/tor_dial_filter.rs @@ -0,0 +1,160 @@ +//! A single choke point that prevents dialing onion addresses without Tor. +//! +//! [`TorDialFilter`] wraps any [`NetworkBehaviour`] and, when Tor is disabled, +//! strips `/onion3` (and `/onion`) addresses from every dial-candidate list the +//! inner behaviour produces via [`NetworkBehaviour::handle_pending_outbound_connection`]. +//! +//! Why a wrapper instead of per-behaviour filters? +//! +//! libp2p assembles the address list for an address-less `DialOpts` by calling +//! `handle_pending_outbound_connection` on *every* behaviour and concatenating +//! the results. Many behaviours contribute cached peer addresses here: the +//! `redial` behaviours, `identify`, and — crucially — every libp2p +//! `request_response` behaviour keeps a per-peer address book that it populates +//! from `FromSwarm::NewExternalAddrOfPeer` and hands back as dial candidates. +//! Filtering each of them individually is fragile whack-a-mole. Wrapping the +//! top-level behaviour filters the *combined* result once, so no matter which +//! inner behaviour contributed an onion address it is dropped before the Swarm +//! ever hands it to the transport. +//! +//! It delegates everything else to the inner behaviour (including, via +//! [`Deref`], its inherent API and derived field access). + +use std::ops::{Deref, DerefMut}; +use std::task::{Context, Poll}; + +use libp2p::PeerId; +use libp2p::core::{Endpoint, Multiaddr}; +use libp2p::multiaddr::Protocol; +use libp2p::swarm::{ + ConnectionDenied, ConnectionId, FromSwarm, NetworkBehaviour, THandler, THandlerInEvent, + THandlerOutEvent, ToSwarm, +}; + +/// Returns true if the multiaddr contains a Tor onion (v2 or v3) component. +fn is_onion(addr: &Multiaddr) -> bool { + addr.iter() + .any(|proto| matches!(proto, Protocol::Onion(_, _) | Protocol::Onion3(_))) +} + +/// Wraps a [`NetworkBehaviour`] and drops onion dial candidates when Tor is off. +/// +/// When `tor_enabled` is `true` this is a transparent passthrough. +#[allow(missing_debug_implementations)] +pub struct TorDialFilter { + inner: B, + tor_enabled: bool, +} + +impl TorDialFilter { + pub fn new(inner: B, tor_enabled: bool) -> Self { + Self { inner, tor_enabled } + } +} + +impl Deref for TorDialFilter { + type Target = B; + + fn deref(&self) -> &Self::Target { + &self.inner + } +} + +impl DerefMut for TorDialFilter { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.inner + } +} + +impl NetworkBehaviour for TorDialFilter +where + B: NetworkBehaviour, +{ + type ConnectionHandler = B::ConnectionHandler; + type ToSwarm = B::ToSwarm; + + fn handle_pending_inbound_connection( + &mut self, + connection_id: ConnectionId, + local_addr: &Multiaddr, + remote_addr: &Multiaddr, + ) -> Result<(), ConnectionDenied> { + self.inner + .handle_pending_inbound_connection(connection_id, local_addr, remote_addr) + } + + fn handle_established_inbound_connection( + &mut self, + connection_id: ConnectionId, + peer: PeerId, + local_addr: &Multiaddr, + remote_addr: &Multiaddr, + ) -> Result, ConnectionDenied> { + self.inner.handle_established_inbound_connection( + connection_id, + peer, + local_addr, + remote_addr, + ) + } + + fn handle_pending_outbound_connection( + &mut self, + connection_id: ConnectionId, + maybe_peer: Option, + addresses: &[Multiaddr], + effective_role: Endpoint, + ) -> Result, ConnectionDenied> { + let candidates = self.inner.handle_pending_outbound_connection( + connection_id, + maybe_peer, + addresses, + effective_role, + )?; + + // With Tor enabled every address is dialable, so don't touch anything. + if self.tor_enabled { + return Ok(candidates); + } + + // Tor is off: drop onion addresses so the Swarm never even attempts to + // dial them (an onion dial without Tor can only fail with + // `MultiaddrNotSupported` and trigger pointless redial churn). + Ok(candidates + .into_iter() + .filter(|addr| !is_onion(addr)) + .collect()) + } + + fn handle_established_outbound_connection( + &mut self, + connection_id: ConnectionId, + peer: PeerId, + addr: &Multiaddr, + role_override: Endpoint, + ) -> Result, ConnectionDenied> { + self.inner + .handle_established_outbound_connection(connection_id, peer, addr, role_override) + } + + fn on_swarm_event(&mut self, event: FromSwarm) { + self.inner.on_swarm_event(event) + } + + fn on_connection_handler_event( + &mut self, + peer_id: PeerId, + connection_id: ConnectionId, + event: THandlerOutEvent, + ) { + self.inner + .on_connection_handler_event(peer_id, connection_id, event) + } + + fn poll( + &mut self, + cx: &mut Context<'_>, + ) -> Poll>> { + self.inner.poll(cx) + } +} diff --git a/swap/src/cli/event_loop.rs b/swap/src/cli/event_loop.rs index a50fcce02..43ab774c9 100644 --- a/swap/src/cli/event_loop.rs +++ b/swap/src/cli/event_loop.rs @@ -22,6 +22,7 @@ use std::sync::Arc; use std::time::Duration; use swap_core::bitcoin::EncryptedSignature; use swap_p2p::protocols::redial; +use swap_p2p::protocols::tor_dial_filter::TorDialFilter; use uuid::Uuid; // Timeout for the execution setup protocol within the event loop. @@ -41,7 +42,7 @@ static RETRY_MAX_INTERVAL: Duration = Duration::from_secs(5); #[allow(missing_debug_implementations)] pub struct EventLoop { - swarm: libp2p::Swarm, + swarm: libp2p::Swarm>, db: Arc, // When a new `SwapEventLoopHandle` is created: @@ -153,7 +154,7 @@ pub struct EventLoop { impl EventLoop { pub fn new( - swarm: Swarm, + swarm: Swarm>, db: Arc, tauri_handle: Option, tor_priority_tracker: Option, diff --git a/swap/src/network/swarm.rs b/swap/src/network/swarm.rs index 76ba4e8d5..46e53160b 100644 --- a/swap/src/network/swarm.rs +++ b/swap/src/network/swarm.rs @@ -19,6 +19,7 @@ use swap_core::bitcoin; use swap_env::env; use swap_p2p::libp2p_ext::MultiAddrExt; use swap_p2p::protocols::metered::RequestResponseMetrics; +use swap_p2p::protocols::tor_dial_filter::TorDialFilter; use tor_hsservice::RunningOnionService; use tor_rtcompat::tokio::TokioRustlsRuntime; @@ -136,12 +137,22 @@ pub async fn cli( identity: identity::Keypair, maybe_tor_client: Option>>, behaviour: T, -) -> Result<(Swarm, Option)> +) -> Result<(Swarm>, Option)> where T: NetworkBehaviour, { + // Onion addresses can only be dialed through Tor. Derive the flag from the + // very same `maybe_tor_client` that decides whether the transport can dial + // onion addresses, so the dial filter and the transport can never disagree. + let tor_enabled = maybe_tor_client.is_some(); + let (transport, tor_priority_tracker) = cli::transport::new(&identity, maybe_tor_client)?; + // Single choke point: with Tor disabled, strip onion addresses from every + // dial-candidate list before the Swarm ever attempts to dial them, + // regardless of which inner behaviour contributed the address. + let behaviour = TorDialFilter::new(behaviour, tor_enabled); + let swarm = SwarmBuilder::with_existing_identity(identity) .with_tokio() .with_other_transport(|_| transport)?