- Rust 74%
- Kotlin 24%
- Shell 1.4%
- Python 0.6%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| .forgejo/workflows | ||
| android | ||
| crates/onionwire-sdk | ||
| docs | ||
| fuzz | ||
| references | ||
| scripts | ||
| src | ||
| tests | ||
| .gitignore | ||
| Cargo.lock | ||
| Cargo.toml | ||
| LICENSE | ||
| README.md | ||
OnionWire
Lean Tor messenger: one Rust process (ratatui + Noise IK + sqlite + in-process Arti). No hosted server. Identity is an ed25519 public key; the onion is only a locator.
Current tagged release: v0.14.0. Linux TUI + Android SDK/APK. Two people add
each other with an onionwire:v1:… invite string (F2 share / F3 paste). There
is no invite QR graphic. Chat is Noise IK over your own v3 onion; every chat
message is ratcheted inside that session. Unreachable chat peers go to a local
outbox (device-only retry queue — no relay). File transfer and payment frames
fail closed. Optional Monero: /pay invoices, /tip payments, and in-app wallet
create/import (F6). Arti onion services are still experimental. The store is
SQLCipher; chat bodies add a row-bound AEAD layer.
You do not install a tor daemon, torrc, Prosody, or XMPP. OnionWire embeds
Arti and publishes its own v3 onion. There is no C-tor fallback.
Docs (protocol first, then claims): PROTOCOL.md · threat model · audit notes (in-house, not a third-party audit). Index: docs/README.md. TUI keys: docs/TUI.md. Wallet: docs/WALLET.md. Releases: docs/RELEASING.md.
Install
Pick one: a CI-built binary, 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 fails 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.
Option A — download a release binary
CI does not release on a merge. Releases are manual and batched: a maintainer
dispatches .forgejo/workflows/release.yml at a stopping point (dry_run=true
first), which tags the next v*.*.* version and publishes both Linux binaries to
one Forgejo release.
The Android APK + AAR attach to the same tag by a second dispatch of
android-release with tag=<that tag>. Policy: docs/RELEASING.md.
This Forgejo does not serve /releases/latest/download/… (404); pin the tag.
x86_64:
curl -fL -O https://forgejo.siriusdevops.com/sirius/onionwire/releases/download/v0.14.0/onionwire-x86_64-unknown-linux-gnu
curl -fL -O https://forgejo.siriusdevops.com/sirius/onionwire/releases/download/v0.14.0/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:
curl -fL -O https://forgejo.siriusdevops.com/sirius/onionwire/releases/download/v0.14.0/onionwire-aarch64-unknown-linux-gnu
curl -fL -O https://forgejo.siriusdevops.com/sirius/onionwire/releases/download/v0.14.0/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 (keep the asset filename until after sha256sum -c):
mkdir -p ~/.local/bin
install -m 755 onionwire-x86_64-unknown-linux-gnu ~/.local/bin/onionwire
onionwire
The binary is dynamically linked against glibc (Ubuntu 24.04 builders). If it
dies with GLIBC_… not found, use Option B. Checksum files are sha256sum
format (<hash> onionwire-<target>).
Option B — build from source
Needs Rust 1.91+ (edition = "2024"; Cargo.toml
rust-version = "1.91"; Arti 0.46 requires 1.91). Do not install tor.
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
source "$HOME/.cargo/env"
rustc --version # must be 1.91 or newer
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
cargo install --path . --locked --force
# lands in ~/.cargo/bin/onionwire
Build needs a C toolchain plus perl and make: SQLCipher is compiled from
vendored OpenSSL (rusqlite feature bundled-sqlcipher-vendored-openssl). No
system OpenSSL headers or pkg-config are required — Arti's TLS backend is
rustls.
The crate version in Cargo.toml is not bumped by the release workflow. The
tag (v0.14.0) is the version users download; onionwire --version prints
CARGO_PKG_VERSION from the tree that was built.
Two instances on one machine
Each process needs its own data dir:
ONIONWIRE_HOME=/tmp/ow-a ./onionwire
ONIONWIRE_HOME=/tmp/ow-b ./onionwire
Then F2 on A, F3-paste on B (and the other way). No invite QR.
Run
There are no subcommands. Flags are --version / -V and --help / -h
only (src/main.rs). Anything else exits 2 with onionwire: unknown argument.
onionwire
or cargo run --release.
On start you are prompted for a store passphrase (echo off) or set
ONIONWIRE_STORE_PASSPHRASE. Empty passphrase is rejected; a wrong passphrase
does not open chat. Then 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
(PUBLISH_WAIT in src/hs.rs). 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. Highlight them in the roster, type in the
composer, Enter to send. Chat rows show delivery state from the local outbox
(✓ delivered, ◷ queued (try n/8, retry …), » sending, ✗ failed: …); the
footer shows {n} queued while anything is outstanding. If their onion is down
the message stays on this device and the retry worker re-dials with backoff —
nothing is handed to a third party. File transfer and payments are still one-shot
(both sides online).
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.
Commands and flags
onionwire [--version | -V | --help | -h]
Everything else is the TUI (function keys and /commands in the composer).
Environment
| Variable | Read in | Meaning |
|---|---|---|
ONIONWIRE_HOME |
src/store.rs |
Data dir. Unset → ~/.local/share/onionwire. |
ONIONWIRE_STORE_PASSPHRASE |
src/store.rs |
Non-empty store passphrase; skips the prompt. Empty/missing with no TTY fails closed. |
ONIONWIRE_ASCII |
src/tui.rs |
Present → ASCII chrome (also follows NO_COLOR). |
ONIONWIRE_WALLET_RPC |
src/wallet.rs |
External loopback monero-wallet-rpc URL. Unused by the app-owned wallet child. |
ONIONWIRE_WALLET_RPC_LOGIN |
src/wallet.rs |
user:pass when the URL has no userinfo. |
ONIONWIRE_WALLET_NO_SPEND |
src/wallet.rs |
1 / true / yes / on → refuse /tip locally. |
NO_COLOR (any value) also switches ASCII chrome. Android CI uses
ONIONWIRE_REQUIRE_RELEASE_KEY; the TUI does not read it.
Keys (TUI)
Focus starts on the composer. Tab cycles panes; j/k and arrows only move
the focused pane. 1/2/3 switch focus when you are not typing (on the
wallet screen those digits pick an in-process chain source instead — see
docs/TUI.md). ? 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 (Main, 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) |
F6 |
Wallet (receive QR, create/import, child, chain source) |
F8 |
Rotate prekey (hard cut: every friend re-pastes the invite) |
F9 |
Wallets & settings menu (/settings) |
| 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 |
There is no F7. The not-configured wallet error still says “open F7”; the
live screens are F6 (wallet) and F9 (settings). Full map: docs/TUI.md.
Composer /commands (src/tui.rs parse_cmd): /wipe, /wipe-all, /profile,
/who, /pay <xmr> [memo], /tip <xmr> [memo], /backup /path, /restore /path,
/file /path, /settings, /wallet create, /wallet import.
F2 / F3 invite
There is no invite QR. F2 opens the share screen 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. Unknown k asks for approval.
Same k already in the roster updates onion (and prekey) only.
F4 rotate onion
F4 is a locator change, not a new identity. Confirm by typing ROTATE (Enter
alone does nothing).
- 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
locframe. - Offline friends cannot find you until they F3-paste the new invite. There is no directory.
Local outbox (chat) and fail closed
Chat to an unreachable onion is written to a local outbox row and retried
in-process while the app runs (exponential backoff from 30s, cap 30 min, stop
after 8 attempts or a terminal failure — src/outbox.rs). There is no
server-side queue and no store-and-forward relay — a queued message never
leaves the device, and it dies with the device.
- Device seizure. A pending message sits in the same SQLCipher store as the
rest of the history. Seizure with the passphrase reveals messages that were
never sent; seizure without it reveals nothing in this store.
/wipeclears outbox rows with chat history;/wipe-allremoves them with the data dir. Neither is a forensic erase. - Retry cadence is a local traffic-analysis surface. A queued send re-dials the peer's onion on a backoff schedule while the app runs.
Still fail closed (no queue, no retry):
- file transfer (
/file) and payment frames (inv/rcp/tip) - fingerprint mismatch vs the pinned key
- Arti cannot publish the onion (no C-tor fallback)
- no DHT, no name server, no hosted chat server
Chat ratchet
Chat is ratcheted on top of the Noise session: each chat send is Double-Ratchet
encrypted first, and the resulting rat frame is what the one-shot Noise
session carries. Wire format: docs/PROTOCOL.md.
- Per-friend state lives in the encrypted store (
ratchet_sessions). Nothing about it is transmitted. - Only chat is ratcheted.
loc,prf,inv,rcp,tip,png, andfilkeep the one-shot Noise shape. - Out-of-order delivery works (bounded skipped keys: 2000 per chain jump, 2000
stored). A queued chat message re-sends the same
ratframe. /wipeclears chat, payments and outbox but keeps ratchet state./wipe-allremoves the data dir.- There is no
/reset. State drops when the friend's prekey changes (youF3-paste a refreshed invite). - An inbound
ratframe that does not authenticate is logged asrat dropped (<reason>)on stderr and dropped. There is no in-app desync banner.
File transfer
/file /path sends a local file to the selected friend. Both must be online.
Cap 1 MiB (file::MAX_BYTES). Fail closed: a bad chunk or hash mismatch deletes
the partial. Files land in $ONIONWIRE_HOME/inbox/<fingerprint>/. Chat shows
[file] name (N bytes). Not covered by the chat outbox.
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. Unknown pubkeys are ignored.
Monero (opt-in)
Chat does not need a wallet. Spend is opt-in at wallet create/restore. Optional
JSON-RPC to monero-wallet-rpc on loopback only (you run it, or the app
spawns it). Chain source is your choice: spawned local monerod, in-process
Cuprate (opt-in feature, unvalidated on mainnet), or a daemon address you
type. The wallet must use --rpc-login; OnionWire refuses an open RPC and
refuses URLs with no credentials. Wallet RPC is never on clearnet and never over
.onion. --trusted-daemon is never passed.
The app-owned local daemon RPC port is 18081. 18089 is a daemon's restricted-RPC port and belongs only in examples of a remote address you type.
# externally run wallet RPC (migration path)
export ONIONWIRE_WALLET_RPC=http://onionwire:[email protected]:18083
# or:
export ONIONWIRE_WALLET_RPC=http://127.0.0.1:18083
export ONIONWIRE_WALLET_RPC_LOGIN=onionwire:secret
Prefer ONIONWIRE_WALLET_NO_SPEND=1 until you actually want /tip. Details:
docs/WALLET.md.
| Command | Wire frame | What it does |
|---|---|---|
/pay <xmr> [memo] |
inv |
Invoice to receive. Subaddress if RPC is up, else profile xmr_addr. |
/tip <xmr> [memo] |
rcp + tip |
Chain transfer, then signed receipt and tip. One-shot. Disabled under ONIONWIRE_WALLET_NO_SPEND. |
Peer onion down → /pay and /tip fail. A queued invoice does not exist.
F6 then c / /wallet create generates a spend-capable wallet and shows the
25-word seed once. F6 then i / /wallet import restores from a seed.
Type SPEND then SEEN on create. Each wallet gets its own file name
(onionwire-1, onionwire-2, …); the RPC must be started with --wallet-dir.
monero-wallet-rpc keeps a wallet open only for the life of one process, so the
app opens the configured file itself after the child is up (open_wallet) — and
the passphrase that does it is kept, sealed in the store beside the RPC creds.
That is deliberate and it means the store passphrase is enough to spend; see
docs/THREAT_MODEL.md and
docs/WALLET.md. A wallet the app holds no passphrase for still
starts — the status reports needs passphrase and the app prompts for it once.
Backup / restore
Losing identity_sk loses every friend relationship.
/backup /path— typeBACKUP, passphrase twice. Writesowbak1(Argon2id + ChaCha20-Poly1305). Onion is not in the file./restore /path— typeRESTORE, passphrase. Overwrites self keys. Does not rewrite the roster.
Wipe
/wipe— typeWIPE. Chat, payments, and outbox gone. Identity and friends stay./wipe-all— typeWIPEALL. Deletes the data dir. Next start is a new person.
Not a forensic erase (SSD wear-leveling). Esc cancels.
Uninstall
rm -f ~/.local/bin/onionwire ~/.cargo/bin/onionwire
rm -rf ~/.local/share/onionwire
If you set ONIONWIRE_HOME, delete that directory instead.
Seized laptop
The sqlite file is SQLCipher. A 32-byte key is derived from the store passphrase
with Argon2id over a per-install 16-byte salt in store_salt (mode 0600) and
handed to SQLCipher as a raw key. Chat bodies carry a second, row-bound
ChaCha20-Poly1305 layer. Unlock is fail-closed. At-rest encryption is not a
defense against a live compromise or an attacker who has your passphrase.
Pending outbox rows live in the same encrypted store as delivered chat.
Threat model: docs/THREAT_MODEL.md.
Android (SDK + APK)
Same repository, same protocol. An Android peer and a Linux peer that exchange invites interoperate.
- SDK —
onionwire-sdk-<version>.aar. UniFFI Kotlin over the same Rust crate the TUI runs. See crates/onionwire-sdk/README.md. - APK —
onionwire-<version>-android-arm64-v8a.apk. Kotlin + Jetpack Compose. Build notes: android/README.md.
The APK build is amd64-only. After the Linux release workflow finishes,
dispatch android-release with that tag so the APK/AAR attach to the same
Forgejo release.
scripts/build-android-local.sh # -> dist/, with .sha256
PUBLISH_TAG=v0.14.0 scripts/build-android-local.sh # + upload to that release
Releases (maintainers)
Manual and batched: no merge publishes anything, and neither does a tag push. Full procedure: docs/RELEASING.md.
Actions → release → Run workflow:
| Input | Meaning |
|---|---|
dry_run |
build + checksum both Linux binaries, no tag, no publish. Run this first. |
bump |
patch / minor / major off the newest v*.*.* tag. Default minor. |
version |
explicit X.Y.Z — overrides bump. |
tag |
rebuild / re-publish an existing tag. |
Then dispatch android-release with tag=<that tag> to attach the APK + AAR.
FORGEJO_TOKEN=<pat> scripts/build-release-local.sh v0.14.0
Do not run cargo publish; publish = false.
Troubleshooting
| Symptom | What to do |
|---|---|
GLIBC_… not found |
Build from source (Option B). |
sha256sum: FAILED |
Re-download binary and .sha256; check in the same directory. |
Hang on bootstrapping Arti… |
Outbound network required. Fail closed after 360s. No C-tor fallback. |
onionwire: unknown argument |
No subcommands. --version / --help only, then the TUI. |
| Blank / broken TUI | 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 F3-paste the new invite. |
| Wallet error says “open F7” | There is no F7. Use F6 / F9, or set ONIONWIRE_WALLET_RPC. |
Not in v1
Prosody, XMPP, s2s, MAM, carbons, a server-side outbox / store-and-forward relay, multi-device, DHT / name server, invite QR codes, A/V. The local chat retry queue is in; it never leaves the device. Protocol lock: docs/PROTOCOL.md.
License
MIT