Skip to content

Commit ce78cb6

Browse files
AOrobatorclaude
andcommitted
feat: add Tor SOCKS5 proxy support for outbound .onion connections
Cherry-picks the approach from upstream ldk-node PR lightningdevkit#778 but implements the SOCKS5 protocol directly in connection.rs instead of depending on lightning-net-tokio::tor_connect_outbound (which requires rust-lightning main, not available in v0.2.0). Changes: - Add tor_proxy_address field to Config - Add set_tor_proxy_address() to NodeBuilder and ArcedNodeBuilder - Add SOCKS5 connect function with Tor stream isolation (RFC 1929) - Route OnionV3 peer connections through the SOCKS5 proxy - Add base32 encoder for deriving .onion hostnames from pubkeys - Expose set_tor_proxy_address in UDL bindings Based on: lightningdevkit#778 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 22701b7 commit ce78cb6

File tree

3 files changed

+207
-12
lines changed

3 files changed

+207
-12
lines changed

bindings/ldk_node.udl

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,8 @@ interface Builder {
104104
[Throws=BuildError]
105105
void set_announcement_addresses(sequence<SocketAddress> announcement_addresses);
106106
[Throws=BuildError]
107+
void set_tor_proxy_address(string tor_proxy_address);
108+
[Throws=BuildError]
107109
void set_node_alias(string node_alias);
108110
[Throws=BuildError]
109111
void set_async_payments_role(AsyncPaymentsRole? role);

src/builder.rs

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1316,11 +1316,15 @@ impl ArcedNodeBuilder {
13161316

13171317
/// Set the address which [`Node`] will use as a Tor proxy to connect to peer OnionV3 addresses.
13181318
///
1319-
/// **Note**: If unset, connecting to peer OnionV3 addresses will fail.
1319+
/// The address should be in `host:port` format (e.g., `"127.0.0.1:9050"`).
13201320
///
1321-
/// [`tor_proxy_address`]: Config::tor_proxy_address
1322-
pub fn set_tor_proxy_address(&mut self, tor_proxy_address: core::net::SocketAddr) {
1323-
self.config.tor_proxy_address = Some(tor_proxy_address);
1321+
/// **Note**: If unset, connecting to peer OnionV3 addresses will fail.
1322+
pub fn set_tor_proxy_address(&self, tor_proxy_address: String) -> Result<(), BuildError> {
1323+
let addr: core::net::SocketAddr = tor_proxy_address
1324+
.parse()
1325+
.map_err(|_| BuildError::InvalidListeningAddresses)?;
1326+
self.inner.write().unwrap().set_tor_proxy_address(addr);
1327+
Ok(())
13241328
}
13251329

13261330
/// Sets the node alias that will be used when broadcasting announcements to the gossip

src/connection.rs

Lines changed: 197 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,17 @@ use std::time::Duration;
1313

1414
use bitcoin::secp256k1::PublicKey;
1515
use lightning::ln::msgs::SocketAddress;
16-
use lightning::sign::RandomBytes;
16+
use lightning::sign::{EntropySource, RandomBytes};
17+
18+
use tokio::io::{AsyncReadExt, AsyncWriteExt};
19+
use tokio::net::TcpStream;
1720

1821
use crate::logger::{log_error, log_info, LdkLogger};
1922
use crate::types::PeerManager;
2023
use crate::Error;
2124

25+
const TOR_CONNECT_OUTBOUND_TIMEOUT: u64 = 30;
26+
2227
pub(crate) struct ConnectionManager<L: Deref + Clone + Sync + Send>
2328
where
2429
L::Target: LdkLogger,
@@ -90,13 +95,27 @@ where
9095
self.propagate_result_to_subscribers(&node_id, Err(Error::InvalidSocketAddress));
9196
Error::InvalidSocketAddress
9297
})?;
93-
let connection_future = lightning_net_tokio::tor_connect_outbound(
94-
Arc::clone(&self.peer_manager),
95-
node_id,
96-
addr.clone(),
97-
proxy_addr,
98-
self.tor_proxy_rng.clone(),
99-
);
98+
let rng = self.tor_proxy_rng.clone();
99+
let pm = Arc::clone(&self.peer_manager);
100+
let addr_clone = addr.clone();
101+
let connection_future = async move {
102+
let connect_fut = async {
103+
tor_socks5_connect(addr_clone.clone(), proxy_addr, &*rng)
104+
.await
105+
.map(|s| s.into_std().unwrap())
106+
};
107+
match tokio::time::timeout(
108+
Duration::from_secs(TOR_CONNECT_OUTBOUND_TIMEOUT),
109+
connect_fut,
110+
)
111+
.await
112+
{
113+
Ok(Ok(stream)) => {
114+
Some(lightning_net_tokio::setup_outbound(pm, node_id, stream))
115+
},
116+
_ => None,
117+
}
118+
};
100119
self.await_connection(connection_future, node_id, addr).await
101120
} else {
102121
let socket_addr = addr
@@ -201,3 +220,173 @@ where
201220
}
202221
}
203222
}
223+
224+
/// Connect to a peer's SocketAddress through a Tor SOCKS5 proxy.
225+
/// Uses Tor stream isolation via username/password auth (RFC 1929 + Tor extensions).
226+
async fn tor_socks5_connect<ES: Deref>(
227+
addr: SocketAddress, tor_proxy_addr: core::net::SocketAddr, entropy_source: ES,
228+
) -> Result<TcpStream, ()>
229+
where
230+
ES::Target: EntropySource,
231+
{
232+
use std::io::Write;
233+
234+
// SOCKS5 constants (RFC 1928 / RFC 1929)
235+
const VERSION: u8 = 5;
236+
const NMETHODS: u8 = 1;
237+
const USERNAME_PASSWORD_AUTH: u8 = 2;
238+
const METHOD_SELECT_REPLY_LEN: usize = 2;
239+
const USERNAME_PASSWORD_VERSION: u8 = 1;
240+
const USERNAME_PASSWORD_REPLY_LEN: usize = 2;
241+
const CMD_CONNECT: u8 = 1;
242+
const RSV: u8 = 0;
243+
const ATYP_DOMAINNAME: u8 = 3;
244+
const ATYP_IPV4: u8 = 1;
245+
const ATYP_IPV6: u8 = 4;
246+
const SUCCESS: u8 = 0;
247+
248+
// Tor extensions for stream isolation
249+
const USERNAME: &[u8] = b"<torS0X>0";
250+
const USERNAME_LEN: usize = USERNAME.len();
251+
const PASSWORD_ENTROPY_LEN: usize = 32;
252+
const PASSWORD_LEN: usize = PASSWORD_ENTROPY_LEN * 2; // hex-encoded
253+
254+
const IPV4_ADDR_LEN: usize = 4;
255+
const IPV6_ADDR_LEN: usize = 16;
256+
const HOSTNAME_MAX_LEN: usize = u8::MAX as usize;
257+
258+
const USERNAME_PASSWORD_REQUEST_LEN: usize =
259+
1 + 1 + USERNAME_LEN + 1 + PASSWORD_LEN;
260+
const SOCKS5_REQUEST_MAX_LEN: usize =
261+
1 + 1 + 1 + 1 + 1 + HOSTNAME_MAX_LEN + 2;
262+
const SOCKS5_REPLY_HEADER_LEN: usize = 4; // VER + REP + RSV + ATYP
263+
264+
// Step 1: Connect to the SOCKS5 proxy
265+
let mut tcp_stream = TcpStream::connect(&tor_proxy_addr).await.map_err(|_| ())?;
266+
267+
// Step 2: Method selection — request username/password auth
268+
let method_selection_request = [VERSION, NMETHODS, USERNAME_PASSWORD_AUTH];
269+
tcp_stream.write_all(&method_selection_request).await.map_err(|_| ())?;
270+
271+
let mut method_selection_reply = [0u8; METHOD_SELECT_REPLY_LEN];
272+
tcp_stream.read_exact(&mut method_selection_reply).await.map_err(|_| ())?;
273+
if method_selection_reply != [VERSION, USERNAME_PASSWORD_AUTH] {
274+
return Err(());
275+
}
276+
277+
// Step 3: Authenticate with random password for Tor stream isolation
278+
let password: [u8; PASSWORD_ENTROPY_LEN] = entropy_source.get_secure_random_bytes();
279+
let mut username_password_request = [0u8; USERNAME_PASSWORD_REQUEST_LEN];
280+
{
281+
let mut stream = &mut username_password_request[..];
282+
stream.write_all(&[USERNAME_PASSWORD_VERSION, USERNAME_LEN as u8]).unwrap();
283+
stream.write_all(USERNAME).unwrap();
284+
stream.write_all(&[PASSWORD_LEN as u8]).unwrap();
285+
for byte in password {
286+
write!(stream, "{:02x}", byte).unwrap();
287+
}
288+
}
289+
tcp_stream.write_all(&username_password_request).await.map_err(|_| ())?;
290+
291+
let mut auth_reply = [0u8; USERNAME_PASSWORD_REPLY_LEN];
292+
tcp_stream.read_exact(&mut auth_reply).await.map_err(|_| ())?;
293+
if auth_reply[1] != SUCCESS {
294+
return Err(());
295+
}
296+
297+
// Step 4: Send CONNECT request for the target address
298+
let mut socks5_request = [0u8; SOCKS5_REQUEST_MAX_LEN];
299+
let request_len = {
300+
let mut stream = &mut socks5_request[..];
301+
stream.write_all(&[VERSION, CMD_CONNECT, RSV]).unwrap();
302+
303+
match &addr {
304+
SocketAddress::OnionV3 { ed25519_pubkey, checksum, version, port } => {
305+
// Encode as domain name (base32 .onion hostname)
306+
// OnionV3 address = base32(pubkey[32] || checksum[2] || version[1]) + ".onion"
307+
let mut raw = Vec::with_capacity(35);
308+
raw.extend_from_slice(ed25519_pubkey);
309+
raw.push((checksum >> 8) as u8);
310+
raw.push(*checksum as u8);
311+
raw.push(*version);
312+
let encoded = base32_encode_lowercase(&raw);
313+
let mut onion_host = encoded.into_bytes();
314+
onion_host.extend_from_slice(b".onion");
315+
316+
stream.write_all(&[ATYP_DOMAINNAME, onion_host.len() as u8]).unwrap();
317+
stream.write_all(&onion_host).unwrap();
318+
stream.write_all(&port.to_be_bytes()).unwrap();
319+
},
320+
SocketAddress::TcpIpV4 { addr: ip, port } => {
321+
stream.write_all(&[ATYP_IPV4]).unwrap();
322+
stream.write_all(ip).unwrap();
323+
stream.write_all(&port.to_be_bytes()).unwrap();
324+
},
325+
SocketAddress::TcpIpV6 { addr: ip, port } => {
326+
stream.write_all(&[ATYP_IPV6]).unwrap();
327+
stream.write_all(ip).unwrap();
328+
stream.write_all(&port.to_be_bytes()).unwrap();
329+
},
330+
SocketAddress::Hostname { hostname, port } => {
331+
let host_str = hostname.to_string();
332+
let host_bytes = host_str.as_bytes();
333+
stream.write_all(&[ATYP_DOMAINNAME, host_bytes.len() as u8]).unwrap();
334+
stream.write_all(host_bytes).unwrap();
335+
stream.write_all(&port.to_be_bytes()).unwrap();
336+
},
337+
_ => return Err(()),
338+
}
339+
340+
SOCKS5_REQUEST_MAX_LEN - stream.len()
341+
};
342+
343+
tcp_stream.write_all(&socks5_request[..request_len]).await.map_err(|_| ())?;
344+
345+
// Step 5: Read SOCKS5 reply
346+
let mut reply_header = [0u8; SOCKS5_REPLY_HEADER_LEN];
347+
tcp_stream.read_exact(&mut reply_header).await.map_err(|_| ())?;
348+
349+
if reply_header[1] != SUCCESS {
350+
return Err(());
351+
}
352+
353+
// Consume the bound address from the reply
354+
let addr_len = match reply_header[3] {
355+
ATYP_IPV4 => IPV4_ADDR_LEN + 2,
356+
ATYP_IPV6 => IPV6_ADDR_LEN + 2,
357+
ATYP_DOMAINNAME => {
358+
let mut len_buf = [0u8; 1];
359+
tcp_stream.read_exact(&mut len_buf).await.map_err(|_| ())?;
360+
len_buf[0] as usize + 2
361+
},
362+
_ => return Err(()),
363+
};
364+
let mut addr_buf = vec![0u8; addr_len];
365+
tcp_stream.read_exact(&mut addr_buf).await.map_err(|_| ())?;
366+
367+
Ok(tcp_stream)
368+
}
369+
370+
/// RFC 4648 base32 encoding (lowercase, no padding) for onion v3 address derivation.
371+
fn base32_encode_lowercase(data: &[u8]) -> String {
372+
const ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyz234567";
373+
let mut result = String::with_capacity((data.len() * 8 + 4) / 5);
374+
let mut buffer: u64 = 0;
375+
let mut bits_left = 0;
376+
377+
for &byte in data {
378+
buffer = (buffer << 8) | byte as u64;
379+
bits_left += 8;
380+
while bits_left >= 5 {
381+
bits_left -= 5;
382+
result.push(ALPHABET[((buffer >> bits_left) & 0x1f) as usize] as char);
383+
}
384+
}
385+
386+
if bits_left > 0 {
387+
buffer <<= 5 - bits_left;
388+
result.push(ALPHABET[(buffer & 0x1f) as usize] as char);
389+
}
390+
391+
result
392+
}

0 commit comments

Comments
 (0)