Prakhar Pandey

backend & protocol engineer


← writes

Building hop6: a peer-to-peer messenger over Tor

June 2, 2026

Series:

  1. How Tor actually routes your traffic
  2. Onion services and the rendezvous protocol
  3. Building hop6 (this post)

With parts 1 and 2 as background, here is how hop6 actually works. It is a serverless, peer-to-peer terminal messenger written in Rust. There is no central server: each instance publishes its own v3 onion service and connects directly to peers’ onion addresses through Tor. Your .onion is your identity.

What Tor gives us, and what is left to build

Part 2 means hop6 gets a lot for free: mutual authentication, location hiding, NAT traversal, and an encrypted transport. So hop6 never implements transport crypto. What it has to do is narrower:

The shape of the program

Two halves run concurrently and share nothing but two channels:

  ┌─────────────┐   NetEvent    ┌──────────────────┐
  │  network.rs │ ────────────▶ │  main UI loop    │ ──draw──▶ ui.rs
  │  (tokio     │               │  (main.rs)       │
  │   tasks)    │ ◀──UiCommand─ │  + app.rs state  │ ◀──keys── crossterm
  └─────────────┘   mpsc        └──────────────────┘
        ▲
        │ ADD_ONION (control port) / SOCKS5
  ┌─────┴───────┐
  │  local Tor  │
  └─────────────┘

The UI thread and the network tasks communicate only through a NetEvent channel (network to UI) and a UiCommand channel (UI to network). Because the two sides touch none of each other’s state, a stalled Tor circuit can never freeze the interface. The render loop just keeps servicing keystrokes while network events trickle in.

The modules:

  main.rs       startup + the tokio::select! render loop
  tor.rs        publish the onion via the control port; keep it alive
  network.rs    connection manager + one task per peer
  wire.rs       the on-the-wire frame format (NDJSON)
  message.rs    NetEvent / UiCommand (the only cross-layer coupling)
  app.rs        pure UI state: keys in, actions out, no I/O
  ui.rs         ratatui rendering
  contacts.rs   name -> onion address book
  fsutil.rs     write secrets as 0600 files in 0700 dirs

Inbound: publishing the onion

hop6 does not implement onion services itself; it asks the local Tor daemon to do it over the control port. On startup it connects to 127.0.0.1:9051, cookie-authenticates, and issues an ADD_ONION for a v3 service that forwards virtual port 80 to a local loopback listener:

  onion:80  ──(Tor)──▶  127.0.0.1:<local_port>   (our TcpListener)

The address is derived from an ed25519 key (part 2). To keep a stable address across restarts, hop6 persists that key and reloads it instead of generating a new one each run:

fn load_or_generate_key(path: &Path) -> Result<(TorSecretKeyV3, bool)> {
    match fs::read(path) {
        Ok(bytes) => Ok((TorSecretKeyV3::from(to_array(bytes)?), false)),
        Err(e) if e.kind() == NotFound => {
            let key = TorSecretKeyV3::generate();
            save_key(path, &key)?; // 0600 file in a 0700 dir
            Ok((key, true))
        }
        Err(e) => Err(e.into()),
    }
}

The key is the entire identity, so it is written with the same care as an SSH private key: owner-only 0600, in a 0700 directory (that is what fsutil.rs enforces).

One sharp edge of the control port: the service is published with detach = false, which means Tor tears it down the instant the control connection closes. So the connection has to be held for the whole program. hop6 parks it in a supervisor task that also probes it every 30 seconds and re-publishes the same onion if the connection died (for example after the laptop slept):

loop {
    check.tick().await;
    if control.noop().await.is_ok() { continue; }      // still alive
    // connection died: reconnect and re-publish the SAME address
    control = connect_and_publish(&key, local_port).await?;
}

Because the address comes from the persisted key, re-publishing restores inbound reachability without changing the address or requiring a restart.

Outbound: dialing a peer

You never resolve a .onion locally; Tor has to do it. hop6 dials through Tor’s SOCKS5 proxy at 127.0.0.1:9050 and hands it the onion address as the target host, so Tor performs the rendezvous from part 2:

let target = format!("{service_id}.onion:80");
let stream = Socks5Stream::connect("127.0.0.1:9050", target).await?;

That is the entire NAT story: no port forwarding, no STUN, no public IP. Both peers are onion services, and Tor splices them at a rendezvous point.

The wire protocol

Once a stream exists (inbound TcpStream or outbound Socks5Stream, the code is generic over both), hop6 speaks newline-delimited JSON. Each line is one frame: a chat message, or a heartbeat. tokio_util::codec::LinesCodec handles framing so there are no hand-rolled length prefixes, and serde handles the rest:

#[derive(Serialize, Deserialize)]
#[serde(tag = "t")]
enum Frame {
    #[serde(rename = "msg")]  Msg(WireMsg),   // { from_onion, body, ts }
    #[serde(rename = "ping")] Ping,
    #[serde(rename = "pong")] Pong,
}

A chat message carries the sender’s own .onion. That matters because on a freshly accepted inbound connection, Tor does not tell you who connected, so the first Msg frame is how the receiver learns and labels the peer.

The connection manager

network.rs runs one manager task plus one task per conversation. The manager owns a map of PeerId -> sender, and routes commands to the right peer task:

  ┌──────────────── manager task ─────────────────────┐
  TcpListener (inbound) ─┤ accept → spawn inbound peer task         │
  UiCommand::Connect ────┤ spawn outbound peer task (dial + retry)  │
  UiCommand::Send ───────┤ build Frame::Msg → route to peer's queue │
  UiCommand::Disconnect ─┤ drop the peer's queue → its task ends    │
  peer task finished ────┤ remove from the roster map               │
  └────────────────────────────────────────────────────┘

Each peer task pumps one connection: it forwards inbound chat frames to the UI, writes outgoing lines, answers pings, and watches a liveness deadline.

Staying alive on a flaky network

Two failure modes get explicit handling, both learned from the laptop-sleep case where a circuit dies silently and you never get an EOF:

  drop detected → wait 2s → redial → fail → wait 4s → redial → ... (cap 30s)
                  reset to 2s on a successful connect

Why the strict decoupling

app.rs is deliberately pure: it performs no I/O. It takes a key event or a NetEvent, mutates in-memory state, and returns an AppAction for main.rs to carry out. The network layer speaks only in NetEvent and UiCommand. That single boundary is what guarantees the UI never blocks on a circuit, and it makes the state logic trivial to reason about and test in isolation.

  network tasks ──NetEvent──▶ mpsc ──▶ App::apply_net_event (pure)
       ▲                                      │
       └──────────── mpsc ◀──UiCommand──── App::on_key (pure)

The user-facing surface is a small set of slash commands (/connect, /add, /contacts, /peers, /disconnect, /help, /quit) plus a saved address book, so you dial a friend by name instead of pasting 56 characters every time.

What hop6 is and is not

It is a small, honest demonstration that you can build a serverless, end-to-end, NAT-traversing messenger with no infrastructure of your own, by standing on Tor’s onion services. The transport security and anonymity are Tor’s; hop6’s job is the plumbing and the protocol.

It is not a hardened secure-messenger product. The onion identity is persistent and therefore linkable across sessions to anyone you have shared it with (that is the point, but it is a tradeoff). There is no offline delivery: both peers have to be online, because there is no server to hold messages. And metadata-resistance is exactly what Tor provides and no more.

If you want to read the code, it is about 2,000 lines of Rust: github.com/prkpndy/hop6.