Building hop6: a peer-to-peer messenger over Tor
June 2, 2026
Series:
- How Tor actually routes your traffic
- Onion services and the rendezvous protocol
- 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:
- drive the local Tor daemon to publish an onion service (inbound),
- dial peers through Tor (outbound),
- run a tiny message protocol over the resulting byte streams,
- and keep a terminal UI responsive while all of that happens over slow circuits.
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:
- Heartbeat. Every connection sends a
Pingevery 15 seconds and expects traffic at least every 45 seconds. Miss that window and the link is declared dead even though the socket never reported an error. - Auto-reconnect with backoff. Outbound peers (whose onion we know) redial after any non-deliberate drop, with exponential backoff from 2s up to 30s, so a conversation heals once the peer or Tor recovers. Inbound peers cannot be redialed (Tor hides the caller), so they simply end and the remote’s reconnect re-establishes them.
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.