A serverless, peer-to-peer, terminal (TUI) messenger that communicates exclusively over the
Tor network using v3 onion services. There is no central server: each instance publishes
its own .onion address and connects directly to peers' onion addresses through Tor.
- TUI:
ratatui+crossterm - Async runtime:
tokio - Inbound: a v3 onion service published at runtime via Tor's control port (
ADD_ONION) from a persisted key (stable address across restarts), forwarding to a local TCP listener - Outbound: TCP dialed through Tor's SOCKS5 proxy so Tor resolves the
.onion - Wire format: newline-delimited JSON
┌─ status ─ you: <your .onion> · online ─────────────────────┐
├─ peers ───┬─ chat · [2] abc12345… ─────────────────────────┤
│ ▶ *system │ me: hello over tor │
│ [2] ab… │ abc12345…: hi back │
├───────────┴─ input (Enter=send · /help) ───────────────────┤
│ > _ │
└────────────────────────────────────────────────────────────┘
- Rust 1.85+ (edition 2021; stable toolchain).
- A running Tor daemon on the same machine, with the SOCKS port, control port, and cookie authentication enabled.
- macOS (Homebrew):
brew install tor - Debian/Ubuntu:
sudo apt install tor - Arch:
sudo pacman -S tor
A ready-to-use config is included as torrc.sample. It enables exactly what
hop6 needs:
SocksPort 9050
ControlPort 9051
CookieAuthentication 1
Run Tor with it directly (easiest for testing):
tor -f ./torrc.sampleLeave that running in its own terminal.
Or run Tor as a background service using its system config:
- macOS (Homebrew): add the three directives above to
/opt/homebrew/etc/tor/torrc(Apple Silicon; on Intel Macs it's/usr/local/etc/tor/torrc), then restart the service:brew services restart tor
- Linux: merge them into
/etc/tor/torrc, thensudo systemctl restart tor.
Cookie permissions: hop6 reads Tor's control auth cookie. If Tor runs under a different system user than you (common with the packaged service), add
CookieAuthFileGroupReadable 1to the config and make sure your user is in Tor's group — otherwise startup fails withfailed to read Tor auth cookie.
cargo build
cargo run # listens on 127.0.0.1:8080 by defaultOn startup hop6 connects to the control port, publishes its onion service, and shows your
.onion address in the status bar (and in the *system log). Publishing can take a few
seconds. Share that address with whoever you want to chat with (out-of-band — e.g. Signal, in
person; the address is your identity).
Your .onion address is derived from a secret key that hop6 persists across restarts, so
your address stays the same every run.
- On first run hop6 generates the key and saves it to
~/.config/hop6/identity.key(directory0700, file0600— treat it like an SSH private key). Later runs reload it. - The
*systemlog shows whether the identity wascreated neworloaded, and from where. - Override the location with the
HOP6_IDENTITYenvironment variable:HOP6_IDENTITY=./peer-a.key cargo run -- --port 8080 HOP6_IDENTITY=./peer-b.key cargo run -- --port 8081 # distinct identity on the same host - To rotate your identity, delete the key file (a new address is generated next run).
- A persistent onion is a stable, linkable identity — that's the point, but anyone you've shared it with can recognize you across sessions. Delete the key to become unlinkable again.
So you don't paste 56-character onion addresses every time, hop6 keeps a saved name -> onion
address book.
- Save a friend once:
/add alice <their-onion>. Then dial them by name:/connect alice. /connectfalls back to treating its argument as a raw onion if it isn't a known name, so both/connect aliceand/connect <onion>work.- The roster, chat title, and incoming messages show the contact's name instead of a
shortened onion whenever the address matches a saved contact — so even an inbound connection
from a known friend shows up as "alice" rather than
(incoming). /contactslists what you've saved;/remove aliceforgets a contact.- Stored at
~/.config/hop6/contacts.json(override with$HOP6_CONTACTS), written0600in a0700directory. This file is your social graph (who you talk to) — sensitive metadata, so keep it private and don't commit it.
Each instance needs its own local listener port, so give the second one --port:
# terminal A
cargo run -- --port 8080
# terminal B
cargo run -- --port 8081Both publish separate onion services. In terminal B, dial A's address:
/connect <A's-onion-address>
Once connected, both rosters show the peer; type a line and press Enter to chat. Messages route A → Tor → B and back.
Type in the input box at the bottom.
-
Plain text → sent to the currently focused conversation.
-
Slash commands:
command alias effect /connect <name|onion>/cdial a saved contact by name, or a raw .onionaddress (with or without the suffix)/add <name> <onion>save a contact under a memorable name /remove <name>/rmforget a saved contact /contactslist saved contacts in the system log /disconnect/ddrop the focused conversation /peerslist connected peers in the system log /quit/qexit /help/hshow help -
Keys:
key effect Entersend message / run command Tab,↑,↓switch conversation (incl. the *systemlog)PageUp/PageDownscroll the chat history EscorCtrl+Cquit (terminal is restored cleanly)
The *system pane (selected when no peer is focused) shows status messages: your identity,
connection events, dial progress, and errors.
┌─────────────┐ NetEvent ┌──────────────────┐
│ network.rs │ ────────────▶ │ main UI loop │ ──draw──▶ ui.rs
│ (tokio │ │ (main.rs) │
│ tasks) │ ◀──UiCommand─ │ + app.rs state │ ◀──keys── crossterm EventStream
└─────────────┘ mpsc └──────────────────┘
▲
│ ADD_ONION (control) / SOCKS5 (dial)
┌─────┴───────┐
│ local Tor │
└─────────────┘
| file | responsibility |
|---|---|
src/main.rs |
bootstrap, the tokio::select! terminal loop |
src/app.rs |
application state + pure logic (key handling, command parsing); no I/O |
src/ui.rs |
read-only ratatui rendering of the three panes |
src/tor.rs |
control-port connect, cookie auth, ADD_ONION v3 |
src/network.rs |
inbound acceptor, SOCKS5 dialer, per-peer connection tasks |
src/wire.rs |
newline-delimited JSON message type |
src/message.rs |
the NetEvent / UiCommand channel types |
All network I/O lives in spawned tokio tasks that talk to the UI only through mpsc
channels, so the interface never freezes while a slow Tor circuit is being built. The onion
service is published with detach = false, so it's torn down when hop6 exits — but because the
secret key is persisted to disk and reloaded on the next run, your .onion address stays
the same across restarts (see Persistent identity).
- No authentication or encryption beyond Tor itself. Tor onion services already give you
end-to-end encryption and the peer's address authenticates the destination, but hop6 does not
verify that the sender of an inbound message owns the onion it claims in
from_onion. Don't treat identities as cryptographically proven. - No message persistence — history is in-memory and lost on quit.
- No store-and-forward — messages can only be sent while a peer is connected (the input is blocked for offline peers); there's no queued/offline delivery.
- Inbound connections show as
(incoming)until the peer's first message reveals its address. - Brief duplicate entry on reconnect (receiving side). When an outbound peer auto-reconnects after a drop, the inbound side sees a brand-new connection (Tor hides the caller), so it momentarily appears as a second entry. As soon as the reconnecting peer's first message reveals its onion, that entry is merged back into the original conversation (history preserved) and the stale one is dropped — so the duplicate only lasts until the first post-reconnect message.
hop6 tries to survive a laptop sleeping or the network changing under it:
- Heartbeat. Each connection exchanges ping/pong every ~15s; if nothing is heard for ~45s the link is declared dead (otherwise a silently-dropped Tor circuit would look "connected" forever). The peer then shows offline.
- Auto-reconnect (outbound). Peers you dialed reconnect automatically with exponential
backoff (2s → 30s) until they come back or you
/disconnect— so a conversation heals itself once Tor recovers. Inbound peers can't be redialed by us; the remote's auto-reconnect re-establishes them. - Onion re-publish. A background supervisor probes the Tor control connection every ~30s; if it died (sleep / Tor restart) it reconnects and re-publishes the same onion address, restoring inbound reachability without a manual restart.
Note: right after waking, give Tor a short while to rebuild circuits — reconnects land
automatically once it's healthy again; watch the *system pane for status.
| symptom | fix |
|---|---|
could not reach Tor control port |
Tor isn't running, or ControlPort 9051 isn't set. Start tor -f ./torrc.sample. |
Tor offered no usable auth method |
add CookieAuthentication 1 to your torrc. |
failed to read Tor auth cookie |
Tor runs as another user; add CookieAuthFileGroupReadable 1 and join Tor's group. |
could not connect to <onion> |
peer offline, wrong address, or its onion not yet published — wait and retry. |
Licensed under either of
- MIT license (LICENSE-MIT)
- Apache License, Version 2.0 (LICENSE-APACHE)
at your option.