Understanding the Bitcoin handshake using Rust

A few days ago, I had a brief technical chat with a friend who finds the world of distributed systems fascinating, regarding the Bitcoin handshake process. Although our conversation initially revolved around the necessity of achieving consensus within systems, we soon focused on the necessary step for one system to start communicating with another, known as the handshake. We only touched on a few aspects, but later, with more time on my hands and out of curiosity, I decided to delve deeper into the Bitcoin network handshake. I realized it would be cool to try to implement it in Rust, more as a theoretical exercise than anything else.

Since I enjoyed this topic, I got to work, and this little article is the result. The first thing to note is that the handshake is a fundamental process in communication between distributed systems, including computer networks and communication protocols like Bitcoin. The handshake has its roots in the early days of computing and telecommunications. In the first computer networks and telecommunications systems, the handshake was a fundamental mechanism for establishing connections between devices. As computer networks evolved, the handshake became a standard component of many communication protocols. For example, the TCP (Transmission Control Protocol), developed in the 1970s, uses a three-way handshake process to establish reliable connections between devices on a network. With the advent of the Internet, the handshake became even more important in security protocols (like SSL/TLS (Secure Sockets Layer / Transport Layer Security).

Here are some key reasons why the handshake is crucial (the following is gathered from ChatGPT, let’s not kid ourselves by saying that everything published is 100% original, although reviewed and corrected, as it sometimes makes significant errors):

  1. Establishing a Secure Connection: The handshake ensures that two systems can establish a secure and reliable connection before exchanging data. In the context of Bitcoin, it is vital to ensure that connecting nodes are using compatible protocol versions and can exchange data securely.
  2. Compatibility and Synchronization: During the handshake, systems exchange information about their software versions and capabilities. This ensures that both systems understand the same protocols and can synchronize correctly. In Bitcoin, this step is essential for blockchain synchronization between nodes.
  3. Authentication: The handshake process can include authentication mechanisms that allow systems to verify each other’s identity. This is crucial to prevent attacks from malicious entities attempting to intercept or manipulate communication.
  4. Parameter Negotiation: The handshake allows systems to negotiate communication parameters, such as data packet size, compression and encryption methods, and other technical details that optimize communication efficiency and security.
  5. Data Integrity: By establishing a reliable and secure connection, the handshake helps ensure that transmitted data is not corrupted or intercepted. In Bitcoin, this ensures that transactions and blocks exchanged between nodes are accurate and verifiable.

Now, the million-dollar question: how many steps does the Bitcoin handshake protocol have? To answer this, we would need to consult the Bitcoin Developer Reference and some of the BIPs dedicated to this topic, including BIP14 (versioning protocol) and BIP37 (bloom filtering for lightweight clients). It’s a lot of work to read them and gather all the documentation, so from my point of view, I’ve summarized all this into a nine-step process, with its implementation in Rust.

You will see that the source code I share on Github has several approaches (5 plus the final one), and the README.md only covers the latest implementation (main.rs). The most important crates we will use are the following: Here’s a summary of each:

  • bitcoin: A library for Bitcoin-related functionalities.
  • hex: A library for encoding and decoding hexadecimal strings.
  • rand: A library for random number generation.
  • trust_dns_resolver: A library for DNS resolution.
  • tokio: An asynchronous runtime for Rust, enabling efficient handling of I/O and other asynchronous operations.

DNS Process to Select a Bitcoin Peer

If you want to perform the handshake, you need to connect with a peer, sometimes if you have a trustful peer, you can use its IP, but is a better approach to use the Bitcoin seed DNS to retrieve this information. The code utilizes the trust-dns-resolver crate to perform DNS resolution. Here’s an explanation of how this process works:

1. Reading the Configuration

The first step is to read the configuration file config.toml, which contains the DNS seeds. These seeds are domain names that resolve to the IP addresses of Bitcoin nodes.

#[derive(Deserialize)]
struct Config {
    nodes: Nodes,
}

#[derive(Deserialize)]
struct Nodes {
    seeds: Vec<String>,
}

fn read_config() -> Config {
    let config_content = fs::read_to_string("config.toml").expect("Failed to read config file");
    toml::from_str(&config_content).expect("Failed to parse config file")
}

In this example, the config.toml file might look like this:

[nodes]
seeds = ["seed.bitcoin.sipa.be", "dnsseed.bluematt.me"]

2. Selecting a Seed

Once the configuration is read, a seed is randomly chosen from the list of seeds provided.

let config = read_config();
let seeds = &config.nodes.seeds;

let seed = seeds.choose(&mut rand::thread_rng()).expect("No seed nodes found");

3. Resolving the Seed to IP Addresses

The selected seed (a DNS name) is then resolved to a list of IP addresses using the trust-dns-resolver crate. This involves querying the DNS server for A (IPv4) or AAAA (IPv6) records associated with the seed domain name.

use trust_dns_resolver::config::*;
use trust_dns_resolver::AsyncResolver;

async fn resolve_bitcoin_peers(seed: &str) -> Vec<SocketAddr> {
    let resolver = AsyncResolver::tokio(ResolverConfig::default(), ResolverOpts::default()).unwrap();
    let response = resolver.lookup_ip(seed).await.unwrap();
    let mut peers = Vec::new();
    for ip in response.iter() {
        let socket_addr = SocketAddr::new(ip, 8333);  // 8333 is the default port for Bitcoin
        peers.push(socket_addr);
    }
    peers
}

Here’s what happens in detail:

  1. Resolver Configuration: An asynchronous DNS resolver is created with default configurations.
  2. DNS Query: The lookup_ip method queries the DNS server for IP addresses associated with the seed.
  3. IP Collection: The IP addresses returned by the DNS server are collected into a vector of SocketAddr structures, with each IP address paired with the default Bitcoin port 8333.

4. Connecting to a Peer

With the list of resolved IP addresses, the next step is to attempt to connect to these peers. This involves trying to establish a TCP connection with each IP address.

use tokio::net::TcpStream;
use tokio::time::timeout;
use std::time::Duration;

async fn connect_to_peer(peer: SocketAddr) -> Result<TcpStream, ()> {
    match timeout(Duration::from_secs(5), TcpStream::connect(peer)).await {
        Ok(Ok(stream)) => {
            println!("Connected to peer: {}", peer);
            Ok(stream)
        }
        _ => {
            println!("Failed to connect to peer: {}", peer);
            Err(())
        }
    }
}

OK. now we can connect with a peer and begin our handshake 🙂

My 9 steps for the handshake

1. Create the version Message

The first step is to create a version message that contains information about the node, such as the protocol version, services supported, timestamp, receiver’s address, sender’s address, nonce, user agent, start height, and relay flag.

fn create_version_message() -> Vec<u8> {
    let mut buf = Vec::new();

    // Version
    WriteBytesExt::write_u32::<LittleEndian>(&mut buf, 70015).unwrap();

    // Services
    WriteBytesExt::write_u64::<LittleEndian>(&mut buf, 0).unwrap();

    // Timestamp
    WriteBytesExt::write_u64::<LittleEndian>(&mut buf, Utc::now().timestamp() as u64).unwrap();

    // Addr_recv (Receiver's address)
    WriteBytesExt::write_u64::<LittleEndian>(&mut buf, 0).unwrap();
    let ip = Ipv4Addr::new(0, 0, 0, 0).octets();
    buf.extend_from_slice(&[0; 10]);
    buf.extend_from_slice(&ip);
    WriteBytesExt::write_u16::<BigEndian>(&mut buf, 8333).unwrap();

    // Addr_from (Sender's address)
    WriteBytesExt::write_u64::<LittleEndian>(&mut buf, 0).unwrap();
    let ip = Ipv4Addr::new(0, 0, 0, 0).octets();
    buf.extend_from_slice(&[0; 10]);
    buf.extend_from_slice(&ip);
    WriteBytesExt::write_u16::<BigEndian>(&mut buf, 8333).unwrap();

    // Nonce
    WriteBytesExt::write_u64::<LittleEndian>(&mut buf, rand::random()).unwrap();

    // User Agent
    let user_agent = "/Satoshi:0.7.2/".as_bytes();
    WriteBytesExt::write_u8(&mut buf, user_agent.len() as u8).unwrap();
    buf.extend_from_slice(user_agent);

    // Start Height
    WriteBytesExt::write_u32::<LittleEndian>(&mut buf, 0).unwrap();

    // Relay
    WriteBytesExt::write_u8(&mut buf, 1).unwrap();

    buf
}

2. Send the version Message

Once the version message is created, it is sent to the peer.

stream.write_all(&version_message).await.unwrap();

3. Receive the version Message from the Peer

The node waits to receive a version message from the peer. A timeout is used to ensure the process does not hang indefinitely.

let mut buf = [0; 1024];
match timeout(Duration::from_secs(10), stream.read(&mut buf)).await {
    Ok(Ok(n)) if n > 0 => {
        let peer_version_message = &buf[..n];
        decode_version_message(peer_version_message);

4. Verify Protocol Version Compatibility

After receiving the version message, the node checks if the peer’s protocol version is compatible.

if !is_version_compatible(peer_version_message) {
    return Err(());
}

5. Verify Supported Services

The node also checks if the services supported by the peer are compatible.

if !are_services_compatible(peer_version_message) {
    return Err(());
}
7. Send the verack Message

If all checks are passed, the node sends a verack message to acknowledge the version message.

6. Check for Loopback Connection

To avoid connecting to itself, the node verifies if the nonce in the version message indicates a loopback connection.

if is_loopback_connection(peer_version_message) {
    return Err(());
}

7. Send the verack Message

If all checks are passed, the node sends a verack message to acknowledge the version message.

let verack_message = create_verack_message();
stream.write_all(&verack_message).await.unwrap();

8. Receive the verack Message from the Peer

The node waits to receive a verack message from the peer, confirming that the peer acknowledges the version message.

match timeout(Duration::from_secs(5), stream.read(&mut buf)).await {
    Ok(Ok(n)) if n > 0 => {
        let peer_verack_message = &buf[..n];
        decode_verack_message(peer_verack_message);
        Ok(())
    }
    _ => {
        Err(())
    }
}

9. Handshake Completed

If the node successfully receives and decodes the verack message, the handshake is considered complete, and the node can now communicate with the peer.

println!("Handshake completed successfully");

Detailed Explanation of Helper Functions

decode_version_message

This function decodes the version message received from the peer, extracting various fields such as version, services, timestamp, addresses, nonce, user agent, start height, and relay flag.

fn decode_version_message(message: &[u8]) {
    let mut rdr = Cursor::new(message);

    let version = ReadBytesExt::read_u32::<LittleEndian>(&mut rdr).unwrap();
    let services = ReadBytesExt::read_u64::<LittleEndian>(&mut rdr).unwrap();
    let timestamp = ReadBytesExt::read_u64::<LittleEndian>(&mut rdr).unwrap();

    // Receiver's address
    let addr_recv_services = ReadBytesExt::read_u64::<LittleEndian>(&mut rdr).unwrap();
    let addr_recv_ip = {
        let mut buf = [0u8; 16];
        Read::read_exact(&mut rdr, &mut buf).unwrap();
        buf
    };
    let addr_recv_port = ReadBytesExt::read_u16::<BigEndian>(&mut rdr).unwrap();

    // Sender's address
    let addr_from_services = ReadBytesExt::read_u64::<LittleEndian>(&mut rdr).unwrap();
    let addr_from_ip = {
        let mut buf = [0u8; 16];
        Read::read_exact(&mut rdr, &mut buf).unwrap();
        buf
    };
    let addr_from_port = ReadBytesExt::read_u16::<BigEndian>(&mut rdr).unwrap();

    let nonce = ReadBytesExt::read_u64::<LittleEndian>(&mut rdr).unwrap();

    // User agent (variable length)
    let user_agent_length = ReadBytesExt::read_u8(&mut rdr).unwrap();
    let mut user_agent = vec![0u8; user_agent_length as usize];
    Read::read_exact(&mut rdr, &mut user_agent).unwrap();

    let start_height = ReadBytesExt::read_u32::<LittleEndian>(&mut rdr).unwrap();
    let relay = ReadBytesExt::read_u8(&mut rdr).unwrap_or(1);

    println!("Decoded version message:");
    println!("Version: {}", version);
    println!("Services: {}", services);
    println!("Timestamp: {}", timestamp);
    println!(
        "Receiver Address: {}:{} with services {}",
        addr_recv_ip.iter().map(|b| b.to_string()).collect::<Vec<_>>().join("."),
        addr_recv_port,
        addr_recv_services
    );
    println!(
        "Sender Address: {}:{} with services {}",
        addr_from_ip.iter().map(|b| b.to_string()).collect::<Vec<_>>().join("."),
        addr_from_port,
        addr_from_services
    );
    println!("Nonce: {}", nonce);
    println!("User Agent: {}", String::from_utf8(user_agent).unwrap_or_default());
    println!("Start Height: {}", start_height);
    println!("Relay: {}", relay);
}

decode_verack_message

The verack message is simple and doesn’t need much decoding. The function just prints out the received message.

fn decode_verack_message(message: &[u8]) {
    println!("Decoded verack message: {:?}", message);
}

GitHub Repository

You can download the code in this link, and you are free to use and modify it 😉 or if you see any errors please let me know to correct it.

Conclusion

These nine steps form the core of the Bitcoin handshake protocol, ensuring that nodes can establish secure and compatible connections. By following this guide, you’ve built a Rust application that effectively handles this complex process, showcasing the power of Rust for network programming and asynchronous I/O.

This concludes the detailed explanation of the handshake steps in the Bitcoin protocol as implemented in the provided Rust code.

Happy coding 😉

Por admin

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *