Lean Tor messenger: Arti in-process, identity=pubkey, onion=locator. No XMPP.
Find a file
2026-09-10 19:16:58 -04:00
.forgejo/workflows fix(release): refuse to publish 0-byte assets + log on-disk sizes 2026-09-10 14:20:45 -04:00
android feat(android): SDK AAR (UniFFI) + Compose APK, Arti rustls feature split 2026-09-10 18:20:19 -04:00
crates/onionwire-sdk feat(android): SDK AAR (UniFFI) + Compose APK, Arti rustls feature split 2026-09-10 18:20:19 -04:00
docs docs: security audit of 2b42864 (report only) 2026-09-10 18:54:40 -04:00
scripts feat(android): SDK AAR (UniFFI) + Compose APK, Arti rustls feature split 2026-09-10 18:20:19 -04:00
src feat(android): SDK AAR (UniFFI) + Compose APK, Arti rustls feature split 2026-09-10 18:20:19 -04:00
tests feat(store): encrypt chat bodies at rest; Ctrl-Q CLEAR/QUIT 2026-09-10 17:19:50 -04:00
.gitignore feat(android): SDK AAR (UniFFI) + Compose APK, Arti rustls feature split 2026-09-10 18:20:19 -04:00
Cargo.lock feat(android): SDK AAR (UniFFI) + Compose APK, Arti rustls feature split 2026-09-10 18:20:19 -04:00
Cargo.toml feat(android): SDK AAR (UniFFI) + Compose APK, Arti rustls feature split 2026-09-10 18:20:19 -04:00
LICENSE Initial Cargo bin scaffold 2026-09-10 01:02:33 -04:00
README.md feat(android): SDK AAR (UniFFI) + Compose APK, Arti rustls feature split 2026-09-10 18:20:19 -04:00

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. 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):

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):

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):

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 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.

# 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
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:

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):

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 friends. 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 friends invite
F4 Rotate onion (locator only)
F5 Selected friends 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 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.

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:

export ONIONWIRE_WALLET_RPC=http://127.0.0.1:18083

Loopback or .onion only, HTTP, 5s timeout. Unset → chat still works; /pay and /tip say so.

  • /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 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 VACUUMs. 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

# 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.

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.

  • SDKonionwire-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.
  • APKonionwire-<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:

# 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.

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.

# 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.ymlcargo test --locked + cargo clippy --all-targets -- -D warnings on main and PRs (ignored Tor-live tests are not run).
  • .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:

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