From 323f1e0d076766749284fcf5d98c0bd6bc8f6f63 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Mon, 12 Jan 2026 05:15:49 +0000 Subject: [PATCH 1/5] net-tokio: add `fn socks5_connect_outbound` --- Cargo.toml | 1 + lightning-net-tokio/Cargo.toml | 2 +- lightning-net-tokio/src/lib.rs | 219 ++++++++++++++++++++++++++++++++- 3 files changed, 220 insertions(+), 2 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index a0895fe1641..389655668f8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -67,4 +67,5 @@ check-cfg = [ "cfg(require_route_graph_test)", "cfg(simple_close)", "cfg(peer_storage)", + "cfg(tor_socks5)", ] diff --git a/lightning-net-tokio/Cargo.toml b/lightning-net-tokio/Cargo.toml index 6c45f40e3c8..af4845b7397 100644 --- a/lightning-net-tokio/Cargo.toml +++ b/lightning-net-tokio/Cargo.toml @@ -19,7 +19,7 @@ rustdoc-args = ["--cfg", "docsrs"] [dependencies] bitcoin = "0.32.2" lightning = { version = "0.3.0", path = "../lightning" } -tokio = { version = "1.35", features = [ "rt", "sync", "net", "time" ] } +tokio = { version = "1.35", features = [ "rt", "sync", "net", "time", "io-util" ] } [dev-dependencies] tokio = { version = "1.35", features = [ "macros", "rt", "rt-multi-thread", "sync", "net", "time" ] } diff --git a/lightning-net-tokio/src/lib.rs b/lightning-net-tokio/src/lib.rs index 75886ebfb5f..ea859440d51 100644 --- a/lightning-net-tokio/src/lib.rs +++ b/lightning-net-tokio/src/lib.rs @@ -51,6 +51,9 @@ use std::time::Duration; static ID_COUNTER: AtomicU64 = AtomicU64::new(0); +const CONNECT_OUTBOUND_TIMEOUT: u64 = 10; +const SOCKS5_CONNECT_OUTBOUND_TIMEOUT: u64 = 30; + // We only need to select over multiple futures in one place, and taking on the full `tokio/macros` // dependency tree in order to do so (which has broken our MSRV before) is excessive. Instead, we // define a trivial two- and three- select macro with the specific types we need and just use that. @@ -462,13 +465,156 @@ where PM::Target: APeerManager, { let connect_fut = async { TcpStream::connect(&addr).await.map(|s| s.into_std().unwrap()) }; - if let Ok(Ok(stream)) = time::timeout(Duration::from_secs(10), connect_fut).await { + if let Ok(Ok(stream)) = + time::timeout(Duration::from_secs(CONNECT_OUTBOUND_TIMEOUT), connect_fut).await + { Some(setup_outbound(peer_manager, their_node_id, stream)) } else { None } } +/// Same as [`connect_outbound`], using a SOCKS5 proxy +pub async fn socks5_connect_outbound( + peer_manager: PM, their_node_id: PublicKey, socks5_proxy_addr: SocketAddr, addr: SocketAddress, + user_pass: Option<(&str, &str)>, +) -> Option> +where + PM::Target: APeerManager, +{ + let connect_fut = async { + socks5_connect(socks5_proxy_addr, addr, user_pass).await.map(|s| s.into_std().unwrap()) + }; + if let Ok(Ok(stream)) = + time::timeout(Duration::from_secs(SOCKS5_CONNECT_OUTBOUND_TIMEOUT), connect_fut).await + { + Some(setup_outbound(peer_manager, their_node_id, stream)) + } else { + None + } +} + +async fn socks5_connect( + socks5_proxy_addr: SocketAddr, addr: SocketAddress, user_pass: Option<(&str, &str)>, +) -> Result { + use std::io::{Cursor, Write}; + use tokio::io::AsyncReadExt; + + const IPV4_ADDR_LEN: usize = 4; + const IPV6_ADDR_LEN: usize = 16; + const HOSTNAME_MAX_LEN: usize = 255; + + // Constants defined in RFC 1928 and RFC 1929 + const VERSION: u8 = 5; + const NMETHODS: u8 = 1; + const NO_AUTH: u8 = 0; + const USERNAME_PASSWORD_AUTH: u8 = 2; + const METHOD_SELECT_REPLY_LEN: usize = 2; + const USERNAME_PASSWORD_VERSION: u8 = 1; + const USERNAME_PASSWORD_REPLY_LEN: usize = 2; + const CMD_CONNECT: u8 = 1; + const RSV: u8 = 0; + const ATYP_IPV4: u8 = 1; + const ATYP_DOMAINNAME: u8 = 3; + const ATYP_IPV6: u8 = 4; + const SUCCESS: u8 = 0; + const USERNAME_MAX_LEN: usize = 255; + const PASSWORD_MAX_LEN: usize = 255; + + const USERNAME_PASSWORD_REQUEST_MAX_LEN: usize = 1 /* VER */ + 1 /* ULEN */ + USERNAME_MAX_LEN /* UNAME max len */ + + 1 /* PLEN */ + PASSWORD_MAX_LEN /* PASSWD max len */; + const SOCKS5_REQUEST_MAX_LEN: usize = 1 /* VER */ + 1 /* CMD */ + 1 /* RSV */ + 1 /* ATYP */ + + 1 /* HOSTNAME len */ + HOSTNAME_MAX_LEN /* HOSTNAME */ + 2 /* PORT */; + + let selected_auth = if user_pass.is_some() { USERNAME_PASSWORD_AUTH } else { NO_AUTH }; + let method_selection_request = [VERSION, NMETHODS, selected_auth]; + let mut tcp_stream = TcpStream::connect(&socks5_proxy_addr).await.map_err(|_| ())?; + tokio::io::AsyncWriteExt::write_all(&mut tcp_stream, &method_selection_request) + .await + .map_err(|_| ())?; + + let mut method_selection_reply = [0u8; METHOD_SELECT_REPLY_LEN]; + tcp_stream.read_exact(&mut method_selection_reply).await.map_err(|_| ())?; + if method_selection_reply != [VERSION, selected_auth] { + return Err(()); + } + + if let Some((username, password)) = user_pass { + if username.len() > USERNAME_MAX_LEN || password.len() > PASSWORD_MAX_LEN { + return Err(()); + } + + let mut username_password_request = [0u8; USERNAME_PASSWORD_REQUEST_MAX_LEN]; + let mut writer = Cursor::new(&mut username_password_request[..]); + writer.write_all(&[USERNAME_PASSWORD_VERSION, username.len() as u8]).map_err(|_| ())?; + writer.write_all(username.as_bytes()).map_err(|_| ())?; + writer.write_all(&[password.len() as u8]).map_err(|_| ())?; + writer.write_all(password.as_bytes()).map_err(|_| ())?; + let pos = writer.position() as usize; + tokio::io::AsyncWriteExt::write_all(&mut tcp_stream, &username_password_request[..pos]) + .await + .map_err(|_| ())?; + + let mut username_password_reply = [0u8; USERNAME_PASSWORD_REPLY_LEN]; + tcp_stream.read_exact(&mut username_password_reply).await.map_err(|_| ())?; + if username_password_reply != [USERNAME_PASSWORD_VERSION, SUCCESS] { + return Err(()); + } + } + + let mut socks5_request = [0u8; SOCKS5_REQUEST_MAX_LEN]; + let mut writer = Cursor::new(&mut socks5_request[..]); + writer.write_all(&[VERSION, CMD_CONNECT, RSV]).map_err(|_| ())?; + match addr { + SocketAddress::TcpIpV4 { addr, port } => { + writer.write_all(&[ATYP_IPV4]).map_err(|_| ())?; + writer.write_all(&addr).map_err(|_| ())?; + writer.write_all(&port.to_be_bytes()).map_err(|_| ())?; + }, + SocketAddress::TcpIpV6 { addr, port } => { + writer.write_all(&[ATYP_IPV6]).map_err(|_| ())?; + writer.write_all(&addr).map_err(|_| ())?; + writer.write_all(&port.to_be_bytes()).map_err(|_| ())?; + }, + ref onion_v3 @ SocketAddress::OnionV3 { port, .. } => { + let onion_v3_url = onion_v3.to_string(); + let hostname = onion_v3_url.split_once(':').ok_or(())?.0.as_bytes(); + writer.write_all(&[ATYP_DOMAINNAME, hostname.len() as u8]).map_err(|_| ())?; + writer.write_all(hostname).map_err(|_| ())?; + writer.write_all(&port.to_be_bytes()).map_err(|_| ())?; + }, + SocketAddress::Hostname { hostname, port } => { + writer.write_all(&[ATYP_DOMAINNAME, hostname.len()]).map_err(|_| ())?; + writer.write_all(hostname.as_bytes()).map_err(|_| ())?; + writer.write_all(&port.to_be_bytes()).map_err(|_| ())?; + }, + SocketAddress::OnionV2 { .. } => return Err(()), + }; + let pos = writer.position() as usize; + tokio::io::AsyncWriteExt::write_all(&mut tcp_stream, &socks5_request[..pos]) + .await + .map_err(|_| ())?; + + let mut reply_buffer = [0u8; 4]; + tcp_stream.read_exact(&mut reply_buffer).await.map_err(|_| ())?; + if reply_buffer[..3] != [VERSION, SUCCESS, RSV] { + return Err(()); + } + match reply_buffer[3] { + ATYP_IPV4 => tcp_stream.read_exact(&mut [0u8; IPV4_ADDR_LEN]).await.map_err(|_| ())?, + ATYP_DOMAINNAME => { + let hostname_len = tcp_stream.read_u8().await.map_err(|_| ())? as usize; + let mut hostname_buffer = [0u8; HOSTNAME_MAX_LEN]; + tcp_stream.read_exact(&mut hostname_buffer[..hostname_len]).await.map_err(|_| ())? + }, + ATYP_IPV6 => tcp_stream.read_exact(&mut [0u8; IPV6_ADDR_LEN]).await.map_err(|_| ())?, + _ => return Err(()), + }; + tcp_stream.read_u16().await.map_err(|_| ())?; + + Ok(tcp_stream) +} + const SOCK_WAKER_VTABLE: task::RawWakerVTable = task::RawWakerVTable::new( clone_socket_waker, wake_socket_waker, @@ -608,6 +754,9 @@ impl Hash for SocketDescriptor { #[cfg(test)] mod tests { + #[cfg(tor_socks5)] + use super::socks5_connect; + use bitcoin::constants::ChainHash; use bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey}; use bitcoin::Network; @@ -621,6 +770,8 @@ mod tests { use tokio::sync::mpsc; use std::mem; + #[cfg(tor_socks5)] + use std::net::SocketAddr; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use std::time::Duration; @@ -941,4 +1092,70 @@ mod tests { async fn unthreaded_race_disconnect_accept() { race_disconnect_accept().await; } + + #[cfg(tor_socks5)] + #[tokio::test] + async fn test_socks5_connect() { + // Set TOR_SOCKS5_PROXY=127.0.0.1:9050 + let socks5_proxy_addr: SocketAddr = std::env!("TOR_SOCKS5_PROXY").parse().unwrap(); + + // Success cases + + for (addr_str, user_pass) in [ + // google.com + ("142.250.189.196:80", None), + // google.com + ("[2607:f8b0:4005:813::2004]:80", None), + // torproject.org + ("torproject.org:80", None), + // torproject.org + ("2gzyxa5ihm7nsggfxnu52rck2vv4rvmdlkiu3zzui5du4xyclen53wid.onion:80", None), + // Same vectors as above, with a username and password + ("142.250.189.196:80", Some(("0", ""))), + ("[2607:f8b0:4005:813::2004]:80", Some(("0", "123"))), + ("torproject.org:80", Some(("1abc", ""))), + ( + "2gzyxa5ihm7nsggfxnu52rck2vv4rvmdlkiu3zzui5du4xyclen53wid.onion:80", + Some(("1abc", "123")), + ), + ] { + let addr: SocketAddress = addr_str.parse().unwrap(); + let tcp_stream = socks5_connect(socks5_proxy_addr, addr, user_pass).await.unwrap(); + assert_eq!( + tcp_stream.try_read(&mut [0u8; 1]).unwrap_err().kind(), + std::io::ErrorKind::WouldBlock + ); + } + + // Failure cases + + for (addr_str, user_pass) in [ + // google.com, with some invalid port + ("142.250.189.196:1234", None), + // google.com, with some invalid port + ("[2607:f8b0:4005:813::2004]:1234", None), + // torproject.org, with some invalid port + ("torproject.org:1234", None), + // torproject.org, with a typo + ("3gzyxa5ihm7nsggfxnu52rck2vv4rvmdlkiu3zzui5du4xyclen53wid.onion:80", None), + // Same vectors as above, with a username and password + ("142.250.189.196:1234", Some(("0", ""))), + ("[2607:f8b0:4005:813::2004]:1234", Some(("0", "123"))), + ("torproject.org:1234", Some(("1abc", ""))), + ( + "3gzyxa5ihm7nsggfxnu52rck2vv4rvmdlkiu3zzui5du4xyclen53wid.onion:80", + Some(("1abc", "123")), + ), + /* TODO: Uncomment when format types 30 and 31 land in tor stable, see https://spec.torproject.org/socks-extensions.html, + these are invalid usernames according to those standards. + ("142.250.189.196:80", Some(("0abc", "123"))), + ("[2607:f8b0:4005:813::2004]:80", Some(("1", "123"))), + ("torproject.org:80", Some(("9", "123"))), + ("2gzyxa5ihm7nsggfxnu52rck2vv4rvmdlkiu3zzui5du4xyclen53wid.onion:80", Some(("", "123"))), + */ + ] { + let addr: SocketAddress = addr_str.parse().unwrap(); + assert!(socks5_connect(socks5_proxy_addr, addr, user_pass).await.is_err()); + } + } } From 051b8f2c399ec95596c4a1fc83b8b3ea7d51d241 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Fri, 16 Jan 2026 18:57:57 +0000 Subject: [PATCH 2/5] fixup: Add CI, hardcode the username to `0`, and... source the stream isolation parameter from `EntropySource::get_secure_random_bytes` --- .github/workflows/build.yml | 16 ++++ Cargo.toml | 2 +- lightning-net-tokio/src/lib.rs | 150 +++++++++++++++------------------ 3 files changed, 86 insertions(+), 82 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2658ff454e9..6ae6d83ddd3 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -320,3 +320,19 @@ jobs: run: cargo fmt --check - name: Run rustfmt checks on lightning-tests run: cd lightning-tests && cargo fmt --check + tor-connect: + runs-on: ubuntu-latest + env: + TOOLCHAIN: 1.75.0 + steps: + - name: Checkout source code + uses: actions/checkout@v4 + - name: Install tor + run: | + sudo apt install -y tor + - name: Install Rust ${{ env.TOOLCHAIN }} toolchain + run: | + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile=minimal --default-toolchain ${{ env.TOOLCHAIN }} + - name: Test tor connections using lightning-net-tokio + run: | + TOR_PROXY="127.0.0.1:9050" RUSTFLAGS="--cfg=tor" cargo test --verbose --color always -p lightning-net-tokio diff --git a/Cargo.toml b/Cargo.toml index 389655668f8..1eb7b572d8b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -67,5 +67,5 @@ check-cfg = [ "cfg(require_route_graph_test)", "cfg(simple_close)", "cfg(peer_storage)", - "cfg(tor_socks5)", + "cfg(tor)", ] diff --git a/lightning-net-tokio/src/lib.rs b/lightning-net-tokio/src/lib.rs index ea859440d51..0131929ddd6 100644 --- a/lightning-net-tokio/src/lib.rs +++ b/lightning-net-tokio/src/lib.rs @@ -37,6 +37,7 @@ use lightning::ln::msgs::SocketAddress; use lightning::ln::peer_handler; use lightning::ln::peer_handler::APeerManager; use lightning::ln::peer_handler::SocketDescriptor as LnSocketTrait; +use lightning::sign::EntropySource; use std::future::Future; use std::hash::Hash; @@ -474,16 +475,18 @@ where } } -/// Same as [`connect_outbound`], using a SOCKS5 proxy -pub async fn socks5_connect_outbound( - peer_manager: PM, their_node_id: PublicKey, socks5_proxy_addr: SocketAddr, addr: SocketAddress, - user_pass: Option<(&str, &str)>, +/// Routes [`connect_outbound`] through Tor. Implements stream isolation for each connection +/// using a stream isolation parameter sourced from [`EntropySource::get_secure_random_bytes`]. +pub async fn tor_connect_outbound( + peer_manager: PM, their_node_id: PublicKey, addr: SocketAddress, tor_proxy_addr: SocketAddr, + entropy_source: ES, ) -> Option> where PM::Target: APeerManager, + ES::Target: EntropySource, { let connect_fut = async { - socks5_connect(socks5_proxy_addr, addr, user_pass).await.map(|s| s.into_std().unwrap()) + tor_connect(addr, tor_proxy_addr, entropy_source).await.map(|s| s.into_std().unwrap()) }; if let Ok(Ok(stream)) = time::timeout(Duration::from_secs(SOCKS5_CONNECT_OUTBOUND_TIMEOUT), connect_fut).await @@ -494,9 +497,12 @@ where } } -async fn socks5_connect( - socks5_proxy_addr: SocketAddr, addr: SocketAddress, user_pass: Option<(&str, &str)>, -) -> Result { +async fn tor_connect( + addr: SocketAddress, tor_proxy_addr: SocketAddr, entropy_source: ES, +) -> Result +where + ES::Target: EntropySource, +{ use std::io::{Cursor, Write}; use tokio::io::AsyncReadExt; @@ -507,7 +513,6 @@ async fn socks5_connect( // Constants defined in RFC 1928 and RFC 1929 const VERSION: u8 = 5; const NMETHODS: u8 = 1; - const NO_AUTH: u8 = 0; const USERNAME_PASSWORD_AUTH: u8 = 2; const METHOD_SELECT_REPLY_LEN: usize = 2; const USERNAME_PASSWORD_VERSION: u8 = 1; @@ -518,48 +523,45 @@ async fn socks5_connect( const ATYP_DOMAINNAME: u8 = 3; const ATYP_IPV6: u8 = 4; const SUCCESS: u8 = 0; - const USERNAME_MAX_LEN: usize = 255; - const PASSWORD_MAX_LEN: usize = 255; - const USERNAME_PASSWORD_REQUEST_MAX_LEN: usize = 1 /* VER */ + 1 /* ULEN */ + USERNAME_MAX_LEN /* UNAME max len */ - + 1 /* PLEN */ + PASSWORD_MAX_LEN /* PASSWD max len */; + // Tor extensions, see https://spec.torproject.org/socks-extensions.html for further details + const USERNAME: &[u8] = b"0"; + const USERNAME_LEN: usize = USERNAME.len(); + const PASSWORD_LEN: usize = 32; + + const USERNAME_PASSWORD_REQUEST_LEN: usize = + 1 /* VER */ + 1 /* ULEN */ + USERNAME_LEN + 1 /* PLEN */ + PASSWORD_LEN; const SOCKS5_REQUEST_MAX_LEN: usize = 1 /* VER */ + 1 /* CMD */ + 1 /* RSV */ + 1 /* ATYP */ + 1 /* HOSTNAME len */ + HOSTNAME_MAX_LEN /* HOSTNAME */ + 2 /* PORT */; - let selected_auth = if user_pass.is_some() { USERNAME_PASSWORD_AUTH } else { NO_AUTH }; - let method_selection_request = [VERSION, NMETHODS, selected_auth]; - let mut tcp_stream = TcpStream::connect(&socks5_proxy_addr).await.map_err(|_| ())?; + let method_selection_request = [VERSION, NMETHODS, USERNAME_PASSWORD_AUTH]; + let mut tcp_stream = TcpStream::connect(&tor_proxy_addr).await.map_err(|_| ())?; tokio::io::AsyncWriteExt::write_all(&mut tcp_stream, &method_selection_request) .await .map_err(|_| ())?; let mut method_selection_reply = [0u8; METHOD_SELECT_REPLY_LEN]; tcp_stream.read_exact(&mut method_selection_reply).await.map_err(|_| ())?; - if method_selection_reply != [VERSION, selected_auth] { + if method_selection_reply != [VERSION, USERNAME_PASSWORD_AUTH] { return Err(()); } - if let Some((username, password)) = user_pass { - if username.len() > USERNAME_MAX_LEN || password.len() > PASSWORD_MAX_LEN { - return Err(()); - } + let password: [u8; PASSWORD_LEN] = entropy_source.get_secure_random_bytes(); + let mut username_password_request = [0u8; USERNAME_PASSWORD_REQUEST_LEN]; + let mut writer = Cursor::new(&mut username_password_request[..]); + writer.write_all(&[USERNAME_PASSWORD_VERSION, USERNAME_LEN as u8]).map_err(|_| ())?; + writer.write_all(USERNAME).map_err(|_| ())?; + writer.write_all(&[PASSWORD_LEN as u8]).map_err(|_| ())?; + writer.write_all(&password).map_err(|_| ())?; + debug_assert_eq!(writer.position() as usize, USERNAME_PASSWORD_REQUEST_LEN); + tokio::io::AsyncWriteExt::write_all(&mut tcp_stream, &username_password_request) + .await + .map_err(|_| ())?; - let mut username_password_request = [0u8; USERNAME_PASSWORD_REQUEST_MAX_LEN]; - let mut writer = Cursor::new(&mut username_password_request[..]); - writer.write_all(&[USERNAME_PASSWORD_VERSION, username.len() as u8]).map_err(|_| ())?; - writer.write_all(username.as_bytes()).map_err(|_| ())?; - writer.write_all(&[password.len() as u8]).map_err(|_| ())?; - writer.write_all(password.as_bytes()).map_err(|_| ())?; - let pos = writer.position() as usize; - tokio::io::AsyncWriteExt::write_all(&mut tcp_stream, &username_password_request[..pos]) - .await - .map_err(|_| ())?; - - let mut username_password_reply = [0u8; USERNAME_PASSWORD_REPLY_LEN]; - tcp_stream.read_exact(&mut username_password_reply).await.map_err(|_| ())?; - if username_password_reply != [USERNAME_PASSWORD_VERSION, SUCCESS] { - return Err(()); - } + let mut username_password_reply = [0u8; USERNAME_PASSWORD_REPLY_LEN]; + tcp_stream.read_exact(&mut username_password_reply).await.map_err(|_| ())?; + if username_password_reply != [USERNAME_PASSWORD_VERSION, SUCCESS] { + return Err(()); } let mut socks5_request = [0u8; SOCKS5_REQUEST_MAX_LEN]; @@ -754,9 +756,6 @@ impl Hash for SocketDescriptor { #[cfg(test)] mod tests { - #[cfg(tor_socks5)] - use super::socks5_connect; - use bitcoin::constants::ChainHash; use bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey}; use bitcoin::Network; @@ -770,8 +769,6 @@ mod tests { use tokio::sync::mpsc; use std::mem; - #[cfg(tor_socks5)] - use std::net::SocketAddr; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use std::time::Duration; @@ -1093,34 +1090,40 @@ mod tests { race_disconnect_accept().await; } - #[cfg(tor_socks5)] + #[cfg(tor)] #[tokio::test] - async fn test_socks5_connect() { - // Set TOR_SOCKS5_PROXY=127.0.0.1:9050 - let socks5_proxy_addr: SocketAddr = std::env!("TOR_SOCKS5_PROXY").parse().unwrap(); + async fn test_tor_connect() { + use super::tor_connect; + use lightning::sign::EntropySource; + use std::net::SocketAddr; + + // Set TOR_PROXY=127.0.0.1:9050 + let tor_proxy_addr: SocketAddr = std::env!("TOR_PROXY").parse().unwrap(); + + struct TestEntropySource; + + impl EntropySource for TestEntropySource { + fn get_secure_random_bytes(&self) -> [u8; 32] { + [0xffu8; 32] + } + } + + let entropy_source = TestEntropySource; // Success cases - for (addr_str, user_pass) in [ + for addr_str in [ // google.com - ("142.250.189.196:80", None), + "142.250.189.196:80", // google.com - ("[2607:f8b0:4005:813::2004]:80", None), + "[2607:f8b0:4005:813::2004]:80", // torproject.org - ("torproject.org:80", None), + "torproject.org:80", // torproject.org - ("2gzyxa5ihm7nsggfxnu52rck2vv4rvmdlkiu3zzui5du4xyclen53wid.onion:80", None), - // Same vectors as above, with a username and password - ("142.250.189.196:80", Some(("0", ""))), - ("[2607:f8b0:4005:813::2004]:80", Some(("0", "123"))), - ("torproject.org:80", Some(("1abc", ""))), - ( - "2gzyxa5ihm7nsggfxnu52rck2vv4rvmdlkiu3zzui5du4xyclen53wid.onion:80", - Some(("1abc", "123")), - ), + "2gzyxa5ihm7nsggfxnu52rck2vv4rvmdlkiu3zzui5du4xyclen53wid.onion:80", ] { let addr: SocketAddress = addr_str.parse().unwrap(); - let tcp_stream = socks5_connect(socks5_proxy_addr, addr, user_pass).await.unwrap(); + let tcp_stream = tor_connect(addr, tor_proxy_addr, &entropy_source).await.unwrap(); assert_eq!( tcp_stream.try_read(&mut [0u8; 1]).unwrap_err().kind(), std::io::ErrorKind::WouldBlock @@ -1129,33 +1132,18 @@ mod tests { // Failure cases - for (addr_str, user_pass) in [ + for addr_str in [ // google.com, with some invalid port - ("142.250.189.196:1234", None), + "142.250.189.196:1234", // google.com, with some invalid port - ("[2607:f8b0:4005:813::2004]:1234", None), + "[2607:f8b0:4005:813::2004]:1234", // torproject.org, with some invalid port - ("torproject.org:1234", None), + "torproject.org:1234", // torproject.org, with a typo - ("3gzyxa5ihm7nsggfxnu52rck2vv4rvmdlkiu3zzui5du4xyclen53wid.onion:80", None), - // Same vectors as above, with a username and password - ("142.250.189.196:1234", Some(("0", ""))), - ("[2607:f8b0:4005:813::2004]:1234", Some(("0", "123"))), - ("torproject.org:1234", Some(("1abc", ""))), - ( - "3gzyxa5ihm7nsggfxnu52rck2vv4rvmdlkiu3zzui5du4xyclen53wid.onion:80", - Some(("1abc", "123")), - ), - /* TODO: Uncomment when format types 30 and 31 land in tor stable, see https://spec.torproject.org/socks-extensions.html, - these are invalid usernames according to those standards. - ("142.250.189.196:80", Some(("0abc", "123"))), - ("[2607:f8b0:4005:813::2004]:80", Some(("1", "123"))), - ("torproject.org:80", Some(("9", "123"))), - ("2gzyxa5ihm7nsggfxnu52rck2vv4rvmdlkiu3zzui5du4xyclen53wid.onion:80", Some(("", "123"))), - */ + "3gzyxa5ihm7nsggfxnu52rck2vv4rvmdlkiu3zzui5du4xyclen53wid.onion:80", ] { let addr: SocketAddress = addr_str.parse().unwrap(); - assert!(socks5_connect(socks5_proxy_addr, addr, user_pass).await.is_err()); + assert!(tor_connect(addr, tor_proxy_addr, &entropy_source).await.is_err()); } } } From cf6ad204a249bf3011eba68519cc61829b5a25da Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Sun, 18 Jan 2026 05:34:31 +0000 Subject: [PATCH 3/5] fixup: delete `Cursor`, and unwrap writes to static buffers --- lightning-net-tokio/src/lib.rs | 70 ++++++++++++++++++---------------- 1 file changed, 37 insertions(+), 33 deletions(-) diff --git a/lightning-net-tokio/src/lib.rs b/lightning-net-tokio/src/lib.rs index 0131929ddd6..069704b52f2 100644 --- a/lightning-net-tokio/src/lib.rs +++ b/lightning-net-tokio/src/lib.rs @@ -53,7 +53,7 @@ use std::time::Duration; static ID_COUNTER: AtomicU64 = AtomicU64::new(0); const CONNECT_OUTBOUND_TIMEOUT: u64 = 10; -const SOCKS5_CONNECT_OUTBOUND_TIMEOUT: u64 = 30; +const TOR_CONNECT_OUTBOUND_TIMEOUT: u64 = 30; // We only need to select over multiple futures in one place, and taking on the full `tokio/macros` // dependency tree in order to do so (which has broken our MSRV before) is excessive. Instead, we @@ -489,7 +489,7 @@ where tor_connect(addr, tor_proxy_addr, entropy_source).await.map(|s| s.into_std().unwrap()) }; if let Ok(Ok(stream)) = - time::timeout(Duration::from_secs(SOCKS5_CONNECT_OUTBOUND_TIMEOUT), connect_fut).await + time::timeout(Duration::from_secs(TOR_CONNECT_OUTBOUND_TIMEOUT), connect_fut).await { Some(setup_outbound(peer_manager, their_node_id, stream)) } else { @@ -503,12 +503,12 @@ async fn tor_connect( where ES::Target: EntropySource, { - use std::io::{Cursor, Write}; + use std::io::Write; use tokio::io::AsyncReadExt; const IPV4_ADDR_LEN: usize = 4; const IPV6_ADDR_LEN: usize = 16; - const HOSTNAME_MAX_LEN: usize = 255; + const HOSTNAME_MAX_LEN: usize = u8::MAX as usize; // Constants defined in RFC 1928 and RFC 1929 const VERSION: u8 = 5; @@ -533,6 +533,7 @@ where 1 /* VER */ + 1 /* ULEN */ + USERNAME_LEN + 1 /* PLEN */ + PASSWORD_LEN; const SOCKS5_REQUEST_MAX_LEN: usize = 1 /* VER */ + 1 /* CMD */ + 1 /* RSV */ + 1 /* ATYP */ + 1 /* HOSTNAME len */ + HOSTNAME_MAX_LEN /* HOSTNAME */ + 2 /* PORT */; + const SOCKS5_REPLY_HEADER_LEN: usize = 1 /* VER */ + 1 /* REP */ + 1 /* RSV */ + 1 /* ATYP */; let method_selection_request = [VERSION, NMETHODS, USERNAME_PASSWORD_AUTH]; let mut tcp_stream = TcpStream::connect(&tor_proxy_addr).await.map_err(|_| ())?; @@ -548,12 +549,12 @@ where let password: [u8; PASSWORD_LEN] = entropy_source.get_secure_random_bytes(); let mut username_password_request = [0u8; USERNAME_PASSWORD_REQUEST_LEN]; - let mut writer = Cursor::new(&mut username_password_request[..]); - writer.write_all(&[USERNAME_PASSWORD_VERSION, USERNAME_LEN as u8]).map_err(|_| ())?; - writer.write_all(USERNAME).map_err(|_| ())?; - writer.write_all(&[PASSWORD_LEN as u8]).map_err(|_| ())?; - writer.write_all(&password).map_err(|_| ())?; - debug_assert_eq!(writer.position() as usize, USERNAME_PASSWORD_REQUEST_LEN); + let mut stream = &mut username_password_request[..]; + stream.write_all(&[USERNAME_PASSWORD_VERSION, USERNAME_LEN as u8]).unwrap(); + stream.write_all(USERNAME).unwrap(); + stream.write_all(&[PASSWORD_LEN as u8]).unwrap(); + stream.write_all(&password).unwrap(); + debug_assert!(stream.is_empty()); tokio::io::AsyncWriteExt::write_all(&mut tcp_stream, &username_password_request) .await .map_err(|_| ())?; @@ -565,44 +566,47 @@ where } let mut socks5_request = [0u8; SOCKS5_REQUEST_MAX_LEN]; - let mut writer = Cursor::new(&mut socks5_request[..]); - writer.write_all(&[VERSION, CMD_CONNECT, RSV]).map_err(|_| ())?; + let mut stream = &mut socks5_request[..]; + stream.write_all(&[VERSION, CMD_CONNECT, RSV]).unwrap(); match addr { SocketAddress::TcpIpV4 { addr, port } => { - writer.write_all(&[ATYP_IPV4]).map_err(|_| ())?; - writer.write_all(&addr).map_err(|_| ())?; - writer.write_all(&port.to_be_bytes()).map_err(|_| ())?; + stream.write_all(&[ATYP_IPV4]).unwrap(); + stream.write_all(&addr).unwrap(); + stream.write_all(&port.to_be_bytes()).unwrap(); }, SocketAddress::TcpIpV6 { addr, port } => { - writer.write_all(&[ATYP_IPV6]).map_err(|_| ())?; - writer.write_all(&addr).map_err(|_| ())?; - writer.write_all(&port.to_be_bytes()).map_err(|_| ())?; + stream.write_all(&[ATYP_IPV6]).unwrap(); + stream.write_all(&addr).unwrap(); + stream.write_all(&port.to_be_bytes()).unwrap(); }, ref onion_v3 @ SocketAddress::OnionV3 { port, .. } => { let onion_v3_url = onion_v3.to_string(); let hostname = onion_v3_url.split_once(':').ok_or(())?.0.as_bytes(); - writer.write_all(&[ATYP_DOMAINNAME, hostname.len() as u8]).map_err(|_| ())?; - writer.write_all(hostname).map_err(|_| ())?; - writer.write_all(&port.to_be_bytes()).map_err(|_| ())?; + stream.write_all(&[ATYP_DOMAINNAME, hostname.len() as u8]).unwrap(); + stream.write_all(hostname).unwrap(); + stream.write_all(&port.to_be_bytes()).unwrap(); }, SocketAddress::Hostname { hostname, port } => { - writer.write_all(&[ATYP_DOMAINNAME, hostname.len()]).map_err(|_| ())?; - writer.write_all(hostname.as_bytes()).map_err(|_| ())?; - writer.write_all(&port.to_be_bytes()).map_err(|_| ())?; + stream.write_all(&[ATYP_DOMAINNAME, hostname.len()]).unwrap(); + stream.write_all(hostname.as_bytes()).unwrap(); + stream.write_all(&port.to_be_bytes()).unwrap(); }, SocketAddress::OnionV2 { .. } => return Err(()), }; - let pos = writer.position() as usize; - tokio::io::AsyncWriteExt::write_all(&mut tcp_stream, &socks5_request[..pos]) - .await - .map_err(|_| ())?; - - let mut reply_buffer = [0u8; 4]; - tcp_stream.read_exact(&mut reply_buffer).await.map_err(|_| ())?; - if reply_buffer[..3] != [VERSION, SUCCESS, RSV] { + let bytes_remaining = stream.len(); + tokio::io::AsyncWriteExt::write_all( + &mut tcp_stream, + &socks5_request[..socks5_request.len() - bytes_remaining], + ) + .await + .map_err(|_| ())?; + + let mut socks5_reply_header = [0u8; SOCKS5_REPLY_HEADER_LEN]; + tcp_stream.read_exact(&mut socks5_reply_header).await.map_err(|_| ())?; + if socks5_reply_header[..3] != [VERSION, SUCCESS, RSV] { return Err(()); } - match reply_buffer[3] { + match socks5_reply_header[3] { ATYP_IPV4 => tcp_stream.read_exact(&mut [0u8; IPV4_ADDR_LEN]).await.map_err(|_| ())?, ATYP_DOMAINNAME => { let hostname_len = tcp_stream.read_u8().await.map_err(|_| ())? as usize; From 313c6a5679710587df463da061833d8d12cd28fa Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Mon, 19 Jan 2026 18:06:58 +0000 Subject: [PATCH 4/5] fixup: Encode the password as a hex string RFC 1929 allows arbitrary byte sequences, but not explicitly, so we choose to be conservative. --- lightning-net-tokio/src/lib.rs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/lightning-net-tokio/src/lib.rs b/lightning-net-tokio/src/lib.rs index 069704b52f2..3bbc70b6b4d 100644 --- a/lightning-net-tokio/src/lib.rs +++ b/lightning-net-tokio/src/lib.rs @@ -527,7 +527,9 @@ where // Tor extensions, see https://spec.torproject.org/socks-extensions.html for further details const USERNAME: &[u8] = b"0"; const USERNAME_LEN: usize = USERNAME.len(); - const PASSWORD_LEN: usize = 32; + const PASSWORD_ENTROPY_LEN: usize = 32; + // We encode the password as a hex string on the wire. RFC 1929 allows arbitrary byte sequences but we choose to be conservative. + const PASSWORD_LEN: usize = PASSWORD_ENTROPY_LEN * 2; const USERNAME_PASSWORD_REQUEST_LEN: usize = 1 /* VER */ + 1 /* ULEN */ + USERNAME_LEN + 1 /* PLEN */ + PASSWORD_LEN; @@ -547,13 +549,16 @@ where return Err(()); } - let password: [u8; PASSWORD_LEN] = entropy_source.get_secure_random_bytes(); + let password: [u8; PASSWORD_ENTROPY_LEN] = entropy_source.get_secure_random_bytes(); let mut username_password_request = [0u8; USERNAME_PASSWORD_REQUEST_LEN]; let mut stream = &mut username_password_request[..]; stream.write_all(&[USERNAME_PASSWORD_VERSION, USERNAME_LEN as u8]).unwrap(); stream.write_all(USERNAME).unwrap(); stream.write_all(&[PASSWORD_LEN as u8]).unwrap(); - stream.write_all(&password).unwrap(); + // Encode the password as a hex string even if RFC 1929 allows arbitrary sequences + for byte in password { + write!(stream, "{:02x}", byte).unwrap(); + } debug_assert!(stream.is_empty()); tokio::io::AsyncWriteExt::write_all(&mut tcp_stream, &username_password_request) .await From 460c717dff83153079f15f90a39be4e21b178ec1 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Mon, 19 Jan 2026 18:30:38 +0000 Subject: [PATCH 5/5] fixup: Remind people `tor_connect_outbound` returns a future that yields a future And point them to `connect_outbound` docs. --- lightning-net-tokio/src/lib.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lightning-net-tokio/src/lib.rs b/lightning-net-tokio/src/lib.rs index 3bbc70b6b4d..27d309f2c18 100644 --- a/lightning-net-tokio/src/lib.rs +++ b/lightning-net-tokio/src/lib.rs @@ -477,6 +477,9 @@ where /// Routes [`connect_outbound`] through Tor. Implements stream isolation for each connection /// using a stream isolation parameter sourced from [`EntropySource::get_secure_random_bytes`]. +/// +/// Returns a future (as the fn is async) that yields another future, see [`connect_outbound`] for +/// details on this return value. pub async fn tor_connect_outbound( peer_manager: PM, their_node_id: PublicKey, addr: SocketAddress, tor_proxy_addr: SocketAddr, entropy_source: ES,