---
last_verified: 2026-05-30
verified_version: 0.1.48
owner: backend
freshness_days: 30
---

# Spec — Tor onion-service transport (arti)

> **STATUS (2026-05-31): IMPLEMENTED** in `client/src/tor.rs` behind `--features tor` (`tor-listen` /
> `tor-connect`). Compiles + runs against arti 0.42; reaches the Tor directory system and downloads
> the consensus, but a full onion round-trip needs a network that allows Tor **relay** traffic (the
> dev sandbox filters relay ports). Architecture context: `docs/ADR/004-multi-transport-per-modality.md`.
> The API notes below proved accurate; build gotchas solved: bundled SQLite (`rusqlite/bundled`),
> rustls `ring` provider install, `TorClientConfigBuilder::from_directories`.

This transport slice makes `pvtcoms-client` work **over the open internet, anonymously** via Tor
onion services (no port-forwarding, no IP exposure). The handshake/wire/crypto are done; this is
the transport plumbing. API reverse-engineered from **arti 0.42** source on 2026-05-30 (so the slice
can be implemented without re-discovering it). Held off from landing here only because it needs to be
*iterated and run against the real Tor network*, which the current sandbox can't do (449-crate build,
real-Tor bootstrap, blocking servers killed except in containers).

## Dependencies (demo, behind a `tor` feature)
```toml
[features]
tor = ["dep:arti-client", "dep:tor-hsservice", "dep:tor-cell", "dep:tokio", "dep:futures", "dep:anyhow"]

[dependencies]
# IMPORTANT: default-features = false to avoid native-tls → openssl-sys (system OpenSSL headers).
arti-client = { version = "0.42", optional = true, default-features = false, features = [
    "onion-service-client", "onion-service-service", "tokio", "rustls",
] }
tor-hsservice = { version = "0.42", optional = true }
tor-cell = { version = "0.42", optional = true }
tokio = { version = "1", optional = true, features = ["rt-multi-thread", "macros", "io-util", "net"] }
futures = { version = "0.3", optional = true }
anyhow = { version = "1", optional = true }
```
(The first `cargo build --features tor` failed on `openssl-sys` because arti's **default** features
pull `native-tls`; `default-features = false` + `rustls` fixes it.)

## API (arti 0.42, verified from source)
- **Bootstrap:** `arti_client::TorClient::create_bootstrapped(TorClientConfig::default()).await?`
- **Host an onion service (responder):**
  `let (service, rend_requests) = client.launch_onion_service(cfg)?;`
  returns `(Arc<tor_hsservice::RunningOnionService>, impl Stream<Item = RendRequest>)`.
  - Config: `tor_hsservice::OnionServiceConfig::builder().nickname(nick).build()?`
    (`OnionServiceConfigBuilder` is NOT re-exported — use `OnionServiceConfig::builder()`).
  - Nickname: `tor_hsservice::HsNickname::new("pvtcoms-client".to_string())?`
  - Address: `service.onion_address() -> Option<HsId>` (Display = the `.onion`). ⚠️ Returns `None`
    unless a **keystore** holding the service identity key is configured/writable — the main runtime
    gotcha to solve (configure an arti keystore + state dir in `TorClientConfig`).
  - Accept connections: `tor_hsservice::handle_rend_requests(rend_requests)` → `impl Stream<Item = StreamRequest>`;
    for each, `stream_request.accept(tor_cell::relaycell::msg::Connected::new_empty()).await? -> DataStream`.
    (Confirm the exact `Connected` constructor at build time.)
- **Connect to an onion (initiator):** `client.connect((onion_str, port)).await? -> DataStream`
  (`(&str, u16)` is `IntoTorAddr`; host is `xxxx.onion`).
- **DataStream I/O:** implements `futures::io::AsyncRead`/`AsyncWrite` — use `futures::io::{AsyncReadExt, AsyncWriteExt}`
  (`read_exact`/`write_all`/`flush`). Write **async** frame helpers in the demo (the core `wire::read_frame`/
  `write_frame` are sync `std::io`); reuse the pure `wire::encode_hello/decode_hello/encode_reply/decode_reply`.

## Flow (mirrors the working TCP demo)
- **`tor-listen`**: bootstrap → launch onion service → print `.onion` → `handle_rend_requests` → on first
  stream: `responder_init` → send hello → read reply → `responder_finish` → read+decrypt message.
- **`tor-connect <onion>`**: bootstrap → `client.connect((onion, PORT))` → read hello → `initiator_step`
  → send reply → seal + send message.
- Run each end in its own container (each bootstraps Tor independently); pass the printed `.onion` from
  the listener's logs to the connector. Expect 30–90 s for bootstrap + descriptor publish/fetch.

## Risks to resolve when implementing
1. **Keystore/state config** so `onion_address()` is `Some` (the real blocker).
2. Build weight + `rustls`-only (above).
3. Real-Tor bootstrap reliability in CI/containers (allow long timeouts; it's a network operation).
4. Confirm `Connected::new_empty()` (or the correct constructor) at build time.
