Lean Tor messenger: Arti in-process, identity=pubkey, onion=locator. No XMPP.
Find a file
Sirius DevOps d2d3b0c827
All checks were successful
ci / test (pull_request) Successful in 3m30s
fix(sdk): enable rustls' ring provider and install it in the init path
GREEN. rustls 0.23 selects its CryptoProvider from its own 'ring' /
'aws-lc-rs' crate features. Arti reaches rustls through tor-rtcompat with
default-features = false and nothing in this workspace turned a provider
feature on, so rustls was compiled with zero providers and the first TLS
config built from the process default panicked:

    Could not automatically determine the process-level CryptoProvider from
    Rustls crate features. ...

That is the 'Failed to start' screen on the unlock path. The 'ring' crate
being present in the graph (via snow, for Noise) never had anything to do
with it.

'ring', not 'aws-lc-rs': aws-lc-rs wants CMake and a C toolchain for the
NDK and fights the cross-compile.

Belt and braces: open_wire() now calls install_crypto_provider() before
Node::start_with_passphrase, a OnceLock-guarded
ring::default_provider().install_default() with the already-installed Err
ignored. That keeps the cdylib correct regardless of how a consumer's
feature graph resolves rustls.

aws-lc-rs is absent from the lock, so there is no two-provider conflict to
report.

Evidence:
  cargo test --release --test provider_resolution
    before: panicked at rustls-0.23.44/src/crypto/mod.rs:249 (device message)
    after:  1 passed
  cargo tree -f '{p} [{f}]' -p rustls
    rustls v0.23.44 [log,logging,ring,std,tls12]
2026-09-10 22:03:40 -04:00
.forgejo/workflows fix(ci): serialize Pi jobs; valid XMR fixture after F4 2026-09-10 20:12:41 -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 fix(sdk): enable rustls' ring provider and install it in the init path 2026-09-10 22:03:40 -04:00
docs Merge F5 wallet digest RPC onto audit bundle 2026-09-10 20:36:10 -04:00
scripts feat(android): SDK AAR (UniFFI) + Compose APK, Arti rustls feature split 2026-09-10 18:20:19 -04:00
src fix(cli): hide store passphrase like a password prompt 2026-09-10 21:10:50 -04:00
tests fix(cli): hide store passphrase like a password prompt 2026-09-10 21:10: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 Merge branch 'main' into feat/hidden-store-passphrase 2026-09-10 21:18:32 -04:00
Cargo.toml Merge branch 'main' into feat/hidden-store-passphrase 2026-09-10 21:18:32 -04:00
LICENSE Initial Cargo bin scaffold 2026-09-10 01:02:33 -04:00
README.md fix(cli): hide store passphrase like a password prompt 2026-09-10 21:10:50 -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 (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.

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. The wallet must use --rpc-login; OnionWire refuses an open RPC (HTTP 200 without a Digest challenge) and refuses URLs with no credentials.

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

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

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