From d5d731e2a7eb66f5ad811edc347a409aac51a358 Mon Sep 17 00:00:00 2001 From: rustaceanrob Date: Sat, 28 Feb 2026 13:01:03 +0000 Subject: [PATCH 1/4] net: Add TorV3 public key to service Test vector taken from Arti ref: https://gitlab.torproject.org/tpo/core/arti/-/blob/main/crates/tor-hscrypto/src/pk.rs?ref_type=heads#L830 --- Cargo.toml | 1 + src/network/socks.rs | 62 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/Cargo.toml b/Cargo.toml index 4a883b87..74de5dc6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ bitcoin = { version = "0.32.7", default-features = false, features = [ bip324 = { version = "0.7.0", default-features = false, features = [ "tokio", ] } +hashes = { package = "bitcoin_hashes", version = "0.20.0" } tokio = { version = "1.19", default-features = false, features = [ "rt-multi-thread", "sync", diff --git a/src/network/socks.rs b/src/network/socks.rs index 439e3fd7..d0a39103 100644 --- a/src/network/socks.rs +++ b/src/network/socks.rs @@ -6,6 +6,7 @@ use std::{ time::Duration, }; +use hashes::sha3_256; use tokio::{ io::{AsyncReadExt, AsyncWriteExt}, net::TcpStream, @@ -22,6 +23,51 @@ const RESPONSE_SUCCESS: u8 = 0; const RSV: u8 = 0; const ADDR_TYPE_IPV4: u8 = 1; const ADDR_TYPE_IPV6: u8 = 4; +// Tor constants +const SALT: &[u8] = b".onion checksum"; +const TOR_VERSION: u8 = 0x03; +const ALPHABET: &[u8; 32] = b"abcdefghijklmnopqrstuvwxyz234567"; + +fn pubkey_to_service(ed25519: [u8; 32]) -> String { + let mut cs_input = Vec::with_capacity(48); + // SHA3(".onion checksum" + public key + version) + cs_input.extend_from_slice(SALT); + cs_input.extend_from_slice(&ed25519); + cs_input.push(TOR_VERSION); + let cs = sha3_256::hash(&cs_input).to_byte_array(); + // Onion address = public key + 2 byte checksum + version + let mut input_buf = [0u8; 35]; + input_buf[..32].copy_from_slice(&ed25519); + input_buf[32] = cs[0]; + input_buf[33] = cs[1]; + input_buf[34] = TOR_VERSION; + let mut encoding = base32_encode(&input_buf); + debug_assert!(encoding.len() == 56); + encoding.push_str(".onion"); + encoding +} + +fn base32_encode(data: &[u8]) -> String { + let mut result = String::with_capacity((data.len() * 8).div_ceil(5)); + let mut buffer: u64 = 0; + let mut bits_left: u32 = 0; + for &byte in data { + buffer = (buffer << 8) | byte as u64; + bits_left += 8; + while bits_left >= 5 { + bits_left -= 5; + let index = ((buffer >> bits_left) & 0x1f) as usize; + result.push(ALPHABET[index] as char); + } + // Keep only the unconsumed bits + buffer &= (1u64 << bits_left) - 1; + } + if bits_left > 0 { + let index = ((buffer << (5 - bits_left)) & 0x1f) as usize; + result.push(ALPHABET[index] as char); + } + result +} pub(crate) async fn create_socks5( proxy: SocketAddr, @@ -87,3 +133,19 @@ pub(crate) async fn create_socks5( // Proxy handshake is complete, the TCP reader/writer can be returned Ok(tcp_stream) } + +#[cfg(test)] +mod tests { + use super::pubkey_to_service; + + #[test] + fn public_key_to_service() { + let hex = "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a"; + let hsid: [u8; 32] = hex::decode(hex).unwrap().try_into().unwrap(); + let service = pubkey_to_service(hsid); + assert_eq!( + "25njqamcweflpvkl73j4szahhihoc4xt3ktcgjnpaingr5yhkenl5sid.onion", + service + ); + } +} From 74c3ae8c61ca3bf5534dc4c3685aa2b7d7f7e74d Mon Sep 17 00:00:00 2001 From: rustaceanrob Date: Sun, 1 Mar 2026 10:14:22 +0000 Subject: [PATCH 2/4] net: Update Socks5 proxy Updates address fields according to the RFC ref: https://datatracker.ietf.org/doc/html/rfc1928 --- src/network/mod.rs | 2 +- src/network/socks.rs | 66 ++++++++++++++++++++++++++++++++++++++------ 2 files changed, 58 insertions(+), 10 deletions(-) diff --git a/src/network/mod.rs b/src/network/mod.rs index 839505d8..032d06b8 100644 --- a/src/network/mod.rs +++ b/src/network/mod.rs @@ -170,7 +170,7 @@ impl ConnectionType { Self::Socks5Proxy(proxy) => { let socks5_timeout = tokio::time::timeout( handshake_timeout, - create_socks5(*proxy, socket_addr, port), + create_socks5(*proxy, socket_addr.into(), port), ) .await .map_err(|_| PeerError::ConnectionFailed)?; diff --git a/src/network/socks.rs b/src/network/socks.rs index d0a39103..81f80dc3 100644 --- a/src/network/socks.rs +++ b/src/network/socks.rs @@ -22,6 +22,7 @@ const CMD_CONNECT: u8 = 1; const RESPONSE_SUCCESS: u8 = 0; const RSV: u8 = 0; const ADDR_TYPE_IPV4: u8 = 1; +const ADDR_TYPE_DOMAIN: u8 = 3; const ADDR_TYPE_IPV6: u8 = 4; // Tor constants const SALT: &[u8] = b".onion checksum"; @@ -69,9 +70,56 @@ fn base32_encode(data: &[u8]) -> String { result } +#[derive(Debug)] +pub(crate) enum SocksConnection { + ClearNet(IpAddr), + OnionService([u8; 32]), +} + +impl SocksConnection { + fn encode(&self) -> Vec { + match self { + Self::ClearNet(net) => match net { + IpAddr::V4(ipv4) => ipv4.octets().to_vec(), + IpAddr::V6(ipv6) => ipv6.octets().to_vec(), + }, + Self::OnionService(onion) => { + let service = pubkey_to_service(*onion); + let enc = service.as_bytes(); + let mut buf = Vec::with_capacity(enc.len() + 1); + buf.push(enc.len() as u8); + buf.extend_from_slice(enc); + buf + } + } + } + + fn type_byte(&self) -> u8 { + match self { + Self::ClearNet(net) => match net { + IpAddr::V4(_) => ADDR_TYPE_IPV4, + IpAddr::V6(_) => ADDR_TYPE_IPV6, + }, + Self::OnionService(_) => ADDR_TYPE_DOMAIN, + } + } +} + +impl From for SocksConnection { + fn from(value: IpAddr) -> Self { + Self::ClearNet(value) + } +} + +impl From<[u8; 32]> for SocksConnection { + fn from(value: [u8; 32]) -> Self { + Self::OnionService(value) + } +} + pub(crate) async fn create_socks5( proxy: SocketAddr, - ip_addr: IpAddr, + addr: SocksConnection, port: u16, ) -> Result { // Connect to the proxy, likely a local Tor daemon. @@ -79,15 +127,9 @@ pub(crate) async fn create_socks5( .await .map_err(|_| Socks5Error::ConnectionTimeout)?; // Format the destination IP address and port according to the Socks5 spec - let dest_ip_bytes = match ip_addr { - IpAddr::V4(ipv4) => ipv4.octets().to_vec(), - IpAddr::V6(ipv6) => ipv6.octets().to_vec(), - }; + let dest_ip_bytes = addr.encode(); let dest_port_bytes = port.to_be_bytes(); - let ip_type_byte = match ip_addr { - IpAddr::V4(_) => ADDR_TYPE_IPV4, - IpAddr::V6(_) => ADDR_TYPE_IPV6, - }; + let ip_type_byte = addr.type_byte(); // Begin the handshake by requesting a connection to the proxy. let mut tcp_stream = timeout.map_err(|_| Socks5Error::ConnectionFailed)?; tcp_stream.write_all(&[VERSION, METHODS, NOAUTH]).await?; @@ -127,6 +169,12 @@ pub(crate) async fn create_socks5( let mut buf = [0_u8; 18]; tcp_stream.read_exact(&mut buf).await?; } + ADDR_TYPE_DOMAIN => { + let mut len = [0_u8; 1]; + tcp_stream.read_exact(&mut len).await?; + let mut buf = vec![0_u8; u8::from_le_bytes(len) as usize]; + tcp_stream.read_exact(&mut buf).await?; + } _ => return Err(Socks5Error::ConnectionFailed), } From fae3dc0a53a0fa499d5fc3304e884bafe728dcc1 Mon Sep 17 00:00:00 2001 From: rustaceanrob Date: Sun, 1 Mar 2026 10:33:58 +0000 Subject: [PATCH 3/4] net: Allow connection to TorV3 addresses with Socks5 --- src/network/mod.rs | 32 +++++++++++++++++++------------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/src/network/mod.rs b/src/network/mod.rs index 032d06b8..18260f71 100644 --- a/src/network/mod.rs +++ b/src/network/mod.rs @@ -23,7 +23,7 @@ use bitcoin::{ }, Block, BlockHash, FeeRate, Wtxid, }; -use socks::create_socks5; +use socks::{create_socks5, SocksConnection}; use tokio::{net::TcpStream, time::Instant}; use error::PeerError; @@ -141,7 +141,9 @@ impl ConnectionType { pub(crate) fn can_connect(&self, addr: &AddrV2) -> bool { match &self { Self::ClearNet => matches!(addr, AddrV2::Ipv4(_) | AddrV2::Ipv6(_)), - Self::Socks5Proxy(_) => matches!(addr, AddrV2::Ipv4(_) | AddrV2::Ipv6(_)), + Self::Socks5Proxy(_) => { + matches!(addr, AddrV2::Ipv4(_) | AddrV2::Ipv6(_) | AddrV2::TorV3(_)) + } } } @@ -151,13 +153,13 @@ impl ConnectionType { port: u16, handshake_timeout: Duration, ) -> Result { - let socket_addr = match addr { - AddrV2::Ipv4(ip) => IpAddr::V4(ip), - AddrV2::Ipv6(ip) => IpAddr::V6(ip), - _ => return Err(PeerError::UnreachableSocketAddr), - }; match &self { Self::ClearNet => { + let socket_addr = match addr { + AddrV2::Ipv4(ip) => IpAddr::V4(ip), + AddrV2::Ipv6(ip) => IpAddr::V6(ip), + _ => return Err(PeerError::UnreachableSocketAddr), + }; let timeout = tokio::time::timeout( handshake_timeout, TcpStream::connect((socket_addr, port)), @@ -168,12 +170,16 @@ impl ConnectionType { Ok(tcp_stream) } Self::Socks5Proxy(proxy) => { - let socks5_timeout = tokio::time::timeout( - handshake_timeout, - create_socks5(*proxy, socket_addr.into(), port), - ) - .await - .map_err(|_| PeerError::ConnectionFailed)?; + let addr = match addr { + AddrV2::Ipv4(ipv4) => SocksConnection::ClearNet(IpAddr::V4(ipv4)), + AddrV2::Ipv6(ipv6) => SocksConnection::ClearNet(IpAddr::V6(ipv6)), + AddrV2::TorV3(onion) => SocksConnection::OnionService(onion), + _ => return Err(PeerError::UnreachableSocketAddr), + }; + let socks5_timeout = + tokio::time::timeout(handshake_timeout, create_socks5(*proxy, addr, port)) + .await + .map_err(|_| PeerError::ConnectionFailed)?; let tcp_stream = socks5_timeout.map_err(PeerError::Socks5)?; Ok(tcp_stream) } From 0d8efcb4a5bf60c55aff0a1b80da7a202ca22b04 Mon Sep 17 00:00:00 2001 From: rustaceanrob Date: Sun, 1 Mar 2026 11:32:25 +0000 Subject: [PATCH 4/4] Add `Socks5Proxy` wrapper type Useful for the `local` constructor which uses the typical proxy hosted by the Tor daemon. --- examples/bitcoin.rs | 2 ++ src/builder.rs | 5 ++--- src/lib.rs | 22 +++++++++++++++++++++- src/network/mod.rs | 10 ++++++---- 4 files changed, 31 insertions(+), 8 deletions(-) diff --git a/examples/bitcoin.rs b/examples/bitcoin.rs index b98439ed..04f0233d 100644 --- a/examples/bitcoin.rs +++ b/examples/bitcoin.rs @@ -32,6 +32,8 @@ async fn main() { )) // Add some initial peers .add_peers(seeds.into_iter().map(From::from)) + // Connections over Tor are supported by Socks5 proxy + // .socks5_proxy(bip157::Socks5Proxy::local()) // Create the node and client .build(); // Run the node on a separate task diff --git a/src/builder.rs b/src/builder.rs index f1fab3f2..3af2073a 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -1,4 +1,3 @@ -use std::net::SocketAddr; use std::{path::PathBuf, time::Duration}; use bitcoin::Network; @@ -6,8 +5,8 @@ use bitcoin::Network; use super::{client::Client, node::Node}; use crate::chain::ChainState; use crate::network::ConnectionType; -use crate::TrustedPeer; use crate::{Config, FilterType}; +use crate::{Socks5Proxy, TrustedPeer}; const MIN_PEERS: u8 = 1; const MAX_PEERS: u8 = 15; @@ -125,7 +124,7 @@ impl Builder { /// Route network traffic through a Tor daemon using a Socks5 proxy. Currently, proxies /// must be reachable by IP address. - pub fn socks5_proxy(mut self, proxy: impl Into) -> Self { + pub fn socks5_proxy(mut self, proxy: impl Into) -> Self { let ip_addr = proxy.into(); let connection = ConnectionType::Socks5Proxy(ip_addr); self.config.connection_type = connection; diff --git a/src/lib.rs b/src/lib.rs index de58f6f1..c4315a81 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -62,7 +62,7 @@ pub mod node; use chain::Filter; -use std::net::{IpAddr, SocketAddr}; +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; use std::path::PathBuf; // Re-exports @@ -294,6 +294,26 @@ impl From for TrustedPeer { } } +/// Route network traffic through a Socks5 proxy, typically used by a Tor daemon. +#[derive(Debug, Clone)] +pub struct Socks5Proxy(SocketAddr); + +impl Socks5Proxy { + /// Connect to the default local Socks5 proxy hosted at `127.0.0.1:9050`. + pub const fn local() -> Self { + Socks5Proxy(SocketAddr::new( + IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), + 9050, + )) + } +} + +impl From for Socks5Proxy { + fn from(value: SocketAddr) -> Self { + Self(value) + } +} + #[derive(Debug, Clone, Copy)] enum NodeState { // We are behind on block headers according to our peers. diff --git a/src/network/mod.rs b/src/network/mod.rs index 18260f71..d8f35e7c 100644 --- a/src/network/mod.rs +++ b/src/network/mod.rs @@ -1,7 +1,7 @@ use std::{ collections::{HashMap, HashSet}, fs::{self, File}, - net::{IpAddr, SocketAddr}, + net::IpAddr, path::PathBuf, time::Duration, }; @@ -28,6 +28,8 @@ use tokio::{net::TcpStream, time::Instant}; use error::PeerError; +use crate::Socks5Proxy; + pub(crate) mod dns; pub(crate) mod error; pub(crate) mod inbound; @@ -130,11 +132,11 @@ impl LastBlockMonitor { } } -#[derive(Debug, Clone, Copy, Default)] +#[derive(Debug, Clone, Default)] pub(crate) enum ConnectionType { #[default] ClearNet, - Socks5Proxy(SocketAddr), + Socks5Proxy(Socks5Proxy), } impl ConnectionType { @@ -177,7 +179,7 @@ impl ConnectionType { _ => return Err(PeerError::UnreachableSocketAddr), }; let socks5_timeout = - tokio::time::timeout(handshake_timeout, create_socks5(*proxy, addr, port)) + tokio::time::timeout(handshake_timeout, create_socks5(proxy.0, addr, port)) .await .map_err(|_| PeerError::ConnectionFailed)?; let tcp_stream = socks5_timeout.map_err(PeerError::Socks5)?;