Lean Tor messenger: Arti in-process, identity=pubkey, onion=locator. No XMPP.
  • Rust 74%
  • Kotlin 24%
  • Shell 1.4%
  • Python 0.6%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
sirius 25e4c67181
Some checks failed
android-ci / android (push) Has been cancelled
ci / test (push) Successful in 6m38s
ci / sdk (push) Successful in 1m18s
ci / fuzz (push) Has been cancelled
Merge pull request 'wallet: land the file-name picker fix and the wallet manager on main (from t_67879b98)' (#153) from onionwire/t_67879b98-wallet-actually-open-the-wallet-file-and into main
Reviewed-on: #153
2026-09-23 02:33:38 +00:00
.forgejo/workflows fix(ci): drop stale Gradle journal locks on the shared /gradle volume 2026-09-18 22:02:22 -04:00
android android: filter the wallet child's last_error where the SDK record becomes the model 2026-09-22 14:11:34 -04:00
crates/onionwire-sdk wallet: actually open the wallet file, and remember its passphrase 2026-09-22 12:52:08 -04:00
docs wallet: actually open the wallet file, and remember its passphrase 2026-09-22 12:52:08 -04:00
fuzz fuzz: cargo-fuzz harness for the ratchet/qr/dispatch/file parsers 2026-09-17 20:52:44 -04:00
references ratchet: freeze the v1 state/frame wire format with guard tests 2026-09-17 20:43:31 -04:00
scripts android-ci: pass --stacktrace to the release gradle build 2026-09-18 19:53:41 -04:00
src tests: take the restart's port from the child that dials it 2026-09-22 15:37:55 -04:00
tests wallet: the file-name picker consults the disk, not only the catalogue 2026-09-22 13:41:35 -04:00
.gitignore feat(android): wallet screen and SDK payment surface 2026-09-13 15:27:57 -04:00
Cargo.lock wallet: land the in-process Cuprate chain source on main 2026-09-18 13:45:59 -04:00
Cargo.toml wallet: land the in-process Cuprate chain source on main 2026-09-18 13:45:59 -04:00
LICENSE Initial Cargo bin scaffold 2026-09-10 01:02:33 -04:00
README.md wallet: actually open the wallet file, and remember its passphrase 2026-09-22 12:52:08 -04:00

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 loc frame.
  • 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. /wipe clears outbox rows with chat history; /wipe-all removes 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, and fil keep 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 rat frame.
  • /wipe clears chat, payments and outbox but keeps ratchet state. /wipe-all removes the data dir.
  • There is no /reset. State drops when the friend's prekey changes (you F3-paste a refreshed invite).
  • An inbound rat frame that does not authenticate is logged as rat 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 — type BACKUP, passphrase twice. Writes owbak1 (Argon2id + ChaCha20-Poly1305). Onion is not in the file.
  • /restore /path — type RESTORE, passphrase. Overwrites self keys. Does not rewrite the roster.

Wipe

  • /wipe — type WIPE. Chat, payments, and outbox gone. Identity and friends stay.
  • /wipe-all — type WIPEALL. 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.

  • SDKonionwire-sdk-<version>.aar. UniFFI Kotlin over the same Rust crate the TUI runs. See crates/onionwire-sdk/README.md.
  • APKonionwire-<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