onionwire/README.md
Sirius DevOps 0ea6fd68c4
Some checks are pending
ci / test (pull_request) Waiting to run
[verified] feat(tui): fail-closed /file transfer (1 MiB)
Typed fil frames chunk a local file to the selected friend over the
existing one-shot pipe. Receive-side 1 MiB cap, basename-only names,
hash check before rename, never overwrite. Chat shows [file] name only.
2026-09-10 23:32:06 -04:00

315 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 (echo off, like other CLI passwords) 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 friends. Mouse-select the `onionwire:v1:…` line to copy. Highlight them in the roster, type in the composer, Enter to send. Fail closed: if their onion is down, send fails — no outbox.
## 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 friends invite |
| `F4` | Rotate **onion** (locator only) |
| `F5` | Selected friends profile (`/who`) |
| Enter | Send chat to the selected friend, or run a `/command` |
| `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 peers onion is down, send fails. v1 has no outbox, no retry queue, no DHT, no name server. There is still no hosted chat server.
## File transfer
`/file /path` sends a local file to the selected friend. Both must be online.
Cap 1 MiB on send and receive. Fail closed: a bad chunk or hash mismatch
deletes the partial (never overwrite). Files land in
`$ONIONWIRE_HOME/inbox/<fingerprint>/`. Chat shows `[file] name (N bytes)`,
never raw frames. No outbox, no resume, no images in the TUI.
## 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 friends 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 ` / `fil ` 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`. Chat and payments history gone (overwrite message bodies, drop `payments`, `VACUUM`, WAL checkpoint). Identity key and friends stay. Not a forensic erase (SSD wear-leveling). `/wipe-all` is the identity burn.
- `/wipe-all` — confirm by typing `WIPEALL`. Deletes the data dir. Next start is a **new person** (new identity key). Same disk caveat. 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`) with AAD bound to `friend_id`, `dir`, and row id, wrapped by a passphrase-derived Argon2id key. A disk grep of `onionwire.db` must not yield the message log. Empty-AAD v0.2 blobs are rewrapped once on unlock.
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` deletes chat and payments history; it is not a forensic erase. Roster and identity stay. `/wipe-all` deletes the data dir (new identity). 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).
## Android (SDK + APK)
Same repository, same protocol, separate product from the Linux TUI. An Android
peer and a Linux peer that exchange invites interoperate — one protocol, not two.
- **SDK** — `onionwire-sdk-<version>.aar`. UniFFI Kotlin bindings over the same
Rust crate the TUI runs on, plus `libonionwire_sdk.so` for `arm64-v8a`.
Another Android app depends on it directly. See
[`crates/onionwire-sdk/README.md`](crates/onionwire-sdk/README.md).
- **APK** — `onionwire-<version>-android-arm64-v8a.apk`. Kotlin + Jetpack
Compose + Material 3 messenger that depends on the SDK and nothing else.
It is not a WebView, and `src/tui.rs` is untouched. Both are built on a host with
the Android SDK/NDK — the Pi runner is aarch64 Linux and cannot produce an APK:
```bash
# one-time: sdkmanager platform 36 / build-tools 36.0.0 / ndk 28.2.13676358,
# rustup target add aarch64-linux-android, cargo install cargo-ndk
scripts/build-android-local.sh # -> dist/, with .sha256 files
PUBLISH_TAG=v0.3.0 scripts/build-android-local.sh # + upload to that release
```
Every asset has a matching `.sha256` so `sha256sum -c` works. The APK is
**debug-signed** and is for sideloading, not for Play. Full build notes, the
`minSdk 26` rationale, the 16 KB page-size check and the signing story are in
[`android/README.md`](android/README.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/build-android-local.sh`](scripts/build-android-local.sh) — Android AAR + APK build, checksums, optional publish (see the Android section above).
- [`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