hypertor

Rust · Python

Tor, without the daemon.

Make HTTP requests over the Tor network and host onion services, from Rust or Python. hypertor is a thin layer over arti, the Tor Project's own Rust implementation of Tor, and hyper, the HTTP stack reqwest is built on.

No tor daemon, no Tor Browser, no torsocks — hypertor speaks the Tor protocol itself. It implements no Tor protocol and no HTTP protocol of its own: cryptography and protocol handling belong in the projects that specialise in them.

use hypertor::TorClient;

#[tokio::main]
async fn main() -> hypertor::Result<()> {
    let client = TorClient::new().await?;

    let body = client
        .get("https://check.torproject.org/api/ip")?
        .send()
        .await?
        .error_for_status()?
        .text()?;

    println!("{body}");
    Ok(())
}
import hypertor

URL = "https://check.torproject.org/api/ip"

with hypertor.Client() as client:
    response = client.get(URL)
    response.raise_for_status()
    print(response.json())

What it adds over wiring arti and hyper together yourself

Pooling that actually pools

The Tor connector plugs into hyper’s pooling client, so a warm circuit is reused across requests. Building a circuit costs seconds; a request on an established one costs a few hundred milliseconds.

Circuit isolation, all the way down

Decide explicitly which activities may be linked. Each isolation group gets its own connection pool and its own TLS session store, so a resumed session cannot hand a server the link the circuits were keeping apart.

Redirects that don’t betray you

Credentials are stripped whenever a redirect crosses an origin, dot segments are resolved before the request is sent, and a redirect from a .onion out to clearnet is refused unless you ask for it.

No local DNS, ever

Hostnames are resolved by the exit relay. The SOCKS proxy refuses IP literals by default and speaks Tor’s RESOLVE extension, so a misconfigured client fails loudly instead of leaking a plaintext query from your real address.

Post-quantum key exchange

Clearnet TLS leads with the X25519MLKEM768 hybrid, so a session key is safe unless both halves break. “Harvest now, decrypt later” describes an adversary with exactly the patience to care about Tor traffic.

Services that don’t stand out

A hosted onion service answers only BEGIN streams on the ports it publishes, and emits no Date, Server or reproducible ETag — the headers used to match a hidden service to the host behind it.

Streams, not buffers

Upload from a file and download to one without holding either in memory. Decompression is incremental, so the size limit bounds a decompression bomb instead of discovering one.

Errors that don’t leak

Hostnames are scrubbed from error messages, because error messages end up in logs, bug reports and issue trackers.

Host an onion service

A small routed HTTP framework on top of a real onion service. The HTTP is hyper’s, so parsing, chunked bodies and keep-alive behave exactly as they do anywhere else — and both HTTP/1.1 and HTTP/2 are served on the same virtual port.

Hosting guide →

use hypertor::{OnionApp, ServeResponse};

let app = OnionApp::new()
    .get("/", |_req| async {
        ServeResponse::html("<h1>hello</h1>")
    })
    .get("/users/{id}", |req| async move {
        let id = req.param("id").unwrap_or_default();
        ServeResponse::json(&serde_json::json!({ "id": id }))
    });

let service = app.serve("my-service").await?;
println!("live at {}", service.onion_address());
service.wait().await

Install

cargo add hypertor
cargo add tokio --features full
pip install hypertor

Rust 1.91 or later; Python 3.10 or later. Wheels ship for Linux and macOS on x86-64 and arm64, and for Windows on x86-64, so the Python package needs no Rust toolchain.