All checks were successful
ci / test (pull_request) Successful in 3m8s
Refuse unauthenticated ONIONWIRE_WALLET_RPC, speak HTTP Digest like monero-wallet-rpc --rpc-login, cap responses at 1 MiB, and reject .onion RPC URLs instead of half-dialing them over TCP.
279 lines
13 KiB
Markdown
279 lines
13 KiB
Markdown
# OnionWire
|
||
|
||
Lean Tor messenger: one Rust process (ratatui + Noise IK + sqlite + in-process Arti).
|
||
No hosted server. Identity is a public key; the onion is only a locator.
|
||
|
||
**v0.2** — Linux TUI. Two people add each other with an `onionwire:v1:…` invite string (`F2` share / `F3` paste). There is no QR graphic. Chat is Noise IK over your own v3 onion. Optional Monero invoices if you point at a local `monero-wallet-rpc`. Fail closed: peer down → send fails. Arti onion services are still experimental. Chat bodies are encrypted at rest; identity keys in sqlite are still plaintext.
|
||
|
||
You do **not** install a `tor` daemon, `torrc`, Prosody, or XMPP. OnionWire embeds Arti and publishes its own v3 onion.
|
||
|
||
## Install
|
||
|
||
Pick one: a CI-built binary (fastest), or build from source.
|
||
|
||
### Requirements
|
||
|
||
| Need | Notes |
|
||
|---|---|
|
||
| Linux | x86_64 or aarch64. TUI needs a real terminal (not a pipe). |
|
||
| Network | First run bootstraps Tor via Arti. Offline start will fail closed. |
|
||
| Not needed | `tor` package, `torrc`, Prosody, an XMPP account, a DHT. |
|
||
|
||
Arti onion services are still **experimental**. If the hidden service cannot come up, OnionWire exits — it does not fall back to C-tor.
|
||
|
||
### Option A — download a release binary
|
||
|
||
CI (`.forgejo/workflows/release.yml`, Forgejo Actions on the Pi runner) builds the **aarch64** binary on every `v*.*.*` tag and attaches it to the [Forgejo release](https://forgejo.siriusdevops.com/sirius/onionwire/releases). There is no x86_64 runner on this instance, so the **x86_64** asset is built and uploaded by `scripts/build-release-local.sh` on an x86_64 host — both land on the same release page.
|
||
|
||
**x86_64 (most PCs / VMs):**
|
||
|
||
```bash
|
||
curl -fL -O https://forgejo.siriusdevops.com/sirius/onionwire/releases/latest/download/onionwire-x86_64-unknown-linux-gnu
|
||
curl -fL -O https://forgejo.siriusdevops.com/sirius/onionwire/releases/latest/download/onionwire-x86_64-unknown-linux-gnu.sha256
|
||
sha256sum -c onionwire-x86_64-unknown-linux-gnu.sha256
|
||
chmod +x onionwire-x86_64-unknown-linux-gnu
|
||
./onionwire-x86_64-unknown-linux-gnu --version
|
||
```
|
||
|
||
**aarch64 (Raspberry Pi, ARM servers):**
|
||
|
||
```bash
|
||
curl -fL -O https://forgejo.siriusdevops.com/sirius/onionwire/releases/latest/download/onionwire-aarch64-unknown-linux-gnu
|
||
curl -fL -O https://forgejo.siriusdevops.com/sirius/onionwire/releases/latest/download/onionwire-aarch64-unknown-linux-gnu.sha256
|
||
sha256sum -c onionwire-aarch64-unknown-linux-gnu.sha256
|
||
chmod +x onionwire-aarch64-unknown-linux-gnu
|
||
./onionwire-aarch64-unknown-linux-gnu --version
|
||
```
|
||
|
||
Install onto `PATH` if you want `onionwire` as a command (keep the asset filename until after `sha256sum -c`):
|
||
|
||
```bash
|
||
mkdir -p ~/.local/bin
|
||
install -m 755 onionwire-x86_64-unknown-linux-gnu ~/.local/bin/onionwire
|
||
# ensure ~/.local/bin is on PATH, then:
|
||
onionwire
|
||
```
|
||
|
||
The binary is dynamically linked against glibc (Ubuntu 24.04 builders). If `./onionwire` dies with `GLIBC_… not found`, use Option B on that machine.
|
||
|
||
Checksum files are `sha256sum` format (`<hash> onionwire-<target>`). Download both files with `curl -O` so the names match, then `sha256sum -c`.
|
||
|
||
### Option B — build from source
|
||
|
||
Needs [Rust](https://rustup.rs/) **1.91+** (`edition = "2024"`; the pinned Arti 0.46 crates require rustc 1.91). A distro `rustc` is fine if `rustc --version` reports 1.91 or newer. Do not install `tor` for this app.
|
||
|
||
```bash
|
||
# rustup (if you do not already have cargo)
|
||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
||
source "$HOME/.cargo/env"
|
||
rustc --version # must be 1.91 or newer
|
||
```
|
||
|
||
```bash
|
||
git clone https://forgejo.siriusdevops.com/sirius/onionwire.git
|
||
cd onionwire
|
||
cargo test --locked
|
||
cargo clippy --locked -- -D warnings
|
||
cargo build --release --locked
|
||
./target/release/onionwire --version
|
||
./target/release/onionwire
|
||
```
|
||
|
||
Install the binary:
|
||
|
||
```bash
|
||
cargo install --path . --locked --force
|
||
# lands in ~/.cargo/bin/onionwire
|
||
```
|
||
|
||
Build needs `pkg-config` and OpenSSL 3 headers (`libssl-dev` / `openssl-devel`) —
|
||
the released binaries link `libssl.so.3` dynamically.
|
||
|
||
### Two instances on one machine
|
||
|
||
Each process needs its own data dir (identity + onion + sqlite):
|
||
|
||
```bash
|
||
ONIONWIRE_HOME=/tmp/ow-a ./onionwire
|
||
ONIONWIRE_HOME=/tmp/ow-b ./onionwire
|
||
```
|
||
|
||
Then `F2` on A (invite string), `F3`-paste on B (and the other way around). No QR.
|
||
|
||
## First run
|
||
|
||
```
|
||
onionwire
|
||
```
|
||
|
||
or `cargo run --release`.
|
||
|
||
On start you are prompted for a store passphrase (or set `ONIONWIRE_STORE_PASSPHRASE`). Empty passphrase is rejected; a wrong passphrase does not open chat. Then you should see `onionwire: bootstrapping Arti…` on stderr. Directory bootstrap is usually under a minute; the onion is ready once a probe connect works (combined Arti status may still say Bootstrapping). Fail closed at 360s. Data lives in `ONIONWIRE_HOME` if set, otherwise `~/.local/share/onionwire/` (`onionwire.db` + Arti state, mode 0700). First open creates an ed25519 identity key. That key **is** you.
|
||
|
||
Then `F2` to share your invite, `F3` to paste a friend’s. Mouse-select the `onionwire:v1:…` line to copy.
|
||
|
||
## Friends are keys
|
||
|
||
A friend is an ed25519 pubkey (`UNIQUE(pubkey)`). The `.onion` on that row is only where they are reachable right now. Pasting the same `k` updates the locator; it never creates a second person.
|
||
|
||
## Keys (TUI)
|
||
|
||
Focus starts on the composer so typing works immediately. `Tab` cycles panes; `j`/`k` and arrows only move the focused pane (roster or chat). `1`/`2`/`3` switch focus when you are not typing in the composer. `?` opens help from every screen except paste (so a payload can contain `?`).
|
||
|
||
| Key | Action |
|
||
|---|---|
|
||
| `Tab` / `Shift-Tab` | Cycle focus: roster → chat → composer |
|
||
| `1` / `2` / `3` | Focus roster / chat / composer (when not typing) |
|
||
| `j` `k` / `↑` `↓` | Move or scroll the focused pane |
|
||
| `g` / `G` | Jump to top / bottom of the focused pane |
|
||
| `?` | Keybinding help (`Esc` closes) |
|
||
| `F2` | Share invite (`onionwire:v1:…`) |
|
||
| `F3` | Paste a friend’s invite |
|
||
| `F4` | Rotate **onion** (locator only) |
|
||
| `F5` | Selected friend’s profile (`/who`) |
|
||
| Enter | Run `/wipe`, `/wipe-all`, `/profile`, `/who`, `/pay`, `/tip`, `/backup`, `/restore` from the composer |
|
||
| `Esc` | Close overlay / back to Main / clear composer |
|
||
| `Ctrl-Q` | Quit: type `CLEAR`+Enter to wipe history, `QUIT`+Enter to leave it, Esc to stay |
|
||
|
||
## F2 / F3 invite
|
||
|
||
There is no QR. `F2` opens `(o) invite` with truncated fingerprint, current onion, and the full payload (wrapped, mouse-select to copy):
|
||
|
||
`onionwire:v1:k=…:o=…:spk=…:sig=…`
|
||
|
||
Give that string to a friend. They `F3` paste it (`(o) paste invite`). Unknown `k` asks for approval. Same `k` already in the roster updates `onion` only.
|
||
|
||
## F4 rotate onion
|
||
|
||
`F4` is a locator change, not a new identity. Confirm by typing `ROTATE` (Enter alone does nothing).
|
||
|
||
- Your identity fingerprint stays the same.
|
||
- A new onion is published; the old one is hard-cut (no dual-host grace).
|
||
- Online friends get a signed `loc` frame.
|
||
- Offline friends cannot find you until they F3-paste the new invite. There is no directory.
|
||
|
||
## Fail closed
|
||
|
||
If a peer’s onion is down, send fails. v1 has no outbox, no retry queue, no DHT, no name server. There is still no hosted chat server.
|
||
|
||
## Profile
|
||
|
||
`/profile` edits your friend-visible display name, bio, and optional Monero address (64 / 512 byte limits, no images). Enter saves and one-shot sends a signed `prf` frame to the selected friend. `F5` or `/who` shows their last signed profile. There is no directory: unknown pubkeys are ignored.
|
||
|
||
## Monero sidecar
|
||
|
||
OnionWire is not a wallet. Optional JSON-RPC to a user-hosted `monero-wallet-rpc`. The wallet **must** use `--rpc-login`; OnionWire refuses an open RPC (HTTP 200 without a Digest challenge) and refuses URLs with no credentials.
|
||
|
||
```bash
|
||
# monero-wallet-rpc --rpc-bind-ip 127.0.0.1 --rpc-bind-port 18083 --rpc-login onionwire:secret
|
||
export ONIONWIRE_WALLET_RPC=http://onionwire:secret@127.0.0.1:18083
|
||
# or keep the password out of the URL:
|
||
export ONIONWIRE_WALLET_RPC=http://127.0.0.1:18083
|
||
export ONIONWIRE_WALLET_RPC_LOGIN=onionwire:secret
|
||
```
|
||
|
||
Loopback only, HTTP Digest (RFC 2617, matching `--rpc-login`), 5s timeout, 1 MiB response cap. `.onion` RPC URLs are rejected (no Arti dial; do not point this at a remote wallet). Unset → chat still works; `/pay` and `/tip` say so. Do not log the RPC password.
|
||
|
||
- `/pay <xmr> [memo]` — invoice (we want to receive). Uses a wallet subaddress if RPC is up, else the profile `xmr_addr`.
|
||
- `/tip <xmr> [memo]` — pay the selected friend’s profile address, then send a signed `rcp`. Incoming receipts stay unverified until RPC `get_transfers` matches.
|
||
|
||
## Backup / restore
|
||
|
||
Losing `identity_sk` loses every friend relationship.
|
||
|
||
- `/backup /path` — type `BACKUP`, passphrase twice. Writes `owbak1` (Argon2id + ChaCha20-Poly1305). Onion is **not** in the file (locator is disposable; F4 after restore if needed).
|
||
- `/restore /path` — type `RESTORE`, passphrase. Overwrites self keys. **Does not rewrite the roster** — you may become a different person talking to old friends.
|
||
|
||
Treat the backup file like the sqlite db.
|
||
|
||
## Mixed versions
|
||
|
||
0.1.2 peers store unknown plaintext as chat. A 0.2 sender of `prf ` / `inv ` / `rcp ` will leave a garbage line on an un-upgraded peer. Upgrade both sides. The Noise handshake is unchanged.
|
||
|
||
## Wipe
|
||
|
||
Composer (bottom of the roster screen):
|
||
|
||
- `/wipe` — confirm by typing `WIPE`. Overwrites the message log and `VACUUM`s. Identity key and friends stay.
|
||
- `/wipe-all` — confirm by typing `WIPEALL`. Deletes the data dir. Next start is a **new person** (new identity key). Esc cancels. Nothing is wiped without confirm.
|
||
|
||
## Uninstall
|
||
|
||
```bash
|
||
# binary (whichever you used)
|
||
rm -f ~/.local/bin/onionwire ~/.cargo/bin/onionwire
|
||
|
||
# optional: destroy identity, friends, and message log
|
||
# (same as /wipe-all — you become a new person next run)
|
||
rm -rf ~/.local/share/onionwire
|
||
```
|
||
|
||
If you set `ONIONWIRE_HOME`, delete that directory instead.
|
||
|
||
## Seized laptop
|
||
|
||
Chat bodies in sqlite are ChaCha20-Poly1305 (`nonce || ciphertext` in `messages.plaintext`), wrapped by a passphrase-derived Argon2id key. A disk grep of `onionwire.db` must not yield the message log.
|
||
|
||
Still plaintext on disk (unless you add OS/FDE):
|
||
|
||
- your identity secret key (`self.identity_sk`)
|
||
- friend public keys and current locators
|
||
|
||
The message key is **not** wrapped with `identity_sk` (that key is in the same file). sqlcipher is out of v1. `/wipe` overwrites message bodies and vacuums; `/wipe-all` deletes the data dir. Ctrl-Q can clear history on the way out (`CLEAR`) without becoming a new person.
|
||
|
||
Threat model: [`docs/THREAT_MODEL.md`](docs/THREAT_MODEL.md).
|
||
|
||
## Releases (maintainers)
|
||
|
||
Tags trigger Forgejo Actions on the self-hosted Pi runner. The x86_64 asset has
|
||
no runner on this instance, so it is built locally and attached to the same
|
||
release.
|
||
|
||
```bash
|
||
# 1. version in Cargo.toml must match the tag without the leading v
|
||
cargo update --offline -p onionwire # keep Cargo.lock at the new version
|
||
git commit -am "chore: release vX.Y.Z" && git push origin main
|
||
|
||
# 2. tag and push — the release workflow builds + publishes aarch64
|
||
git tag -a vX.Y.Z -m "OnionWire vX.Y.Z"
|
||
git push origin vX.Y.Z
|
||
|
||
# 3. attach x86_64 from an x86_64 host (idempotent — safe to re-run)
|
||
FORGEJO_TOKEN=<pat> scripts/build-release-local.sh vX.Y.Z
|
||
```
|
||
|
||
Workflows (Forgejo Actions, `.forgejo/workflows/` — jobs run on the `docker`
|
||
label of the Pi runner and do their Rust work in a `rust:1.91-bookworm`
|
||
sibling container):
|
||
|
||
- [`.forgejo/workflows/ci.yml`](.forgejo/workflows/ci.yml) — `cargo test --locked` + `cargo clippy --all-targets -- -D warnings` on `main` and PRs (ignored Tor-live tests are not run).
|
||
- [`.forgejo/workflows/release.yml`](.forgejo/workflows/release.yml) — on `v*.*.*` tags: builds `aarch64-unknown-linux-gnu` natively, strips, sha256, publishes to the release. Also runnable via `workflow_dispatch` with a tag input to re-publish.
|
||
|
||
Scripts:
|
||
|
||
- [`scripts/publish-release.sh`](scripts/publish-release.sh) — create-or-update a release and (re)upload assets via the Forgejo API. Needs `FORGEJO_TOKEN` (repo secret in CI, env locally).
|
||
- [`scripts/build-release-local.sh`](scripts/build-release-local.sh) — x86_64 build + strip + checksum + publish.
|
||
- [`scripts/release-body.md`](scripts/release-body.md) — release notes template (`@TAG@` is substituted).
|
||
|
||
Do not run `cargo publish`; `publish = false`. CI needs the repo secret
|
||
`FORGEJO_TOKEN` (an instance user PAT with repo write) to publish releases.
|
||
|
||
## Troubleshooting
|
||
|
||
| Symptom | What to do |
|
||
|---|---|
|
||
| `GLIBC_… not found` | Binary is newer than your libc. Build from source (Option B). |
|
||
| `sha256sum: FAILED` | Re-download both the binary and `.sha256`; run the check in the same directory. |
|
||
| Hang on `bootstrapping Arti…` / `hs status: Bootstrapping` | Outbound network required. Combined Arti status can stay Bootstrapping through a 5 min HsDir upload round; OnionWire probes the onion and proceeds once a connect works. Still fail closed after 360s if neither a probe nor `DegradedReachable`/`Running` — no C-tor fallback. |
|
||
| `onionwire: unknown argument` | No subcommands. Flags are `--version` / `--help` only, then the TUI. |
|
||
| Blank / broken TUI | Run in a real terminal emulator, not `nohup` / systemd without a TTY. |
|
||
| Two chats, same laptop | Separate `ONIONWIRE_HOME` per process. |
|
||
| Friend cannot find you after F4 | Expected if they were offline. They must F3-paste the new invite. Same `k` updates the row. |
|
||
|
||
## Not in v1
|
||
|
||
Prosody, XMPP, s2s, MAM, carbons, outbox, multi-device, DHT / name server, sqlcipher, QR codes.
|
||
|
||
## License
|
||
|
||
MIT
|