Compare commits

..

53 commits

Author SHA1 Message Date
af7d69ace8 Merge pull request 'feat(tui): fail-closed /file transfer (1 MiB)' (#16) from feat/tui-file-transfer into main
Some checks are pending
ci / test (push) Waiting to run
Reviewed-on: #16
2026-09-11 04:52:55 +00:00
Sirius DevOps
0ea6fd68c4
[verified] feat(tui): fail-closed /file transfer (1 MiB)
Some checks are pending
ci / test (pull_request) Waiting to run
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
Sirius DevOps
c4ccca7e8c
feat(tui): Enter sends chat to the selected friend
Composer Enter only ran slash commands, so typed messages were dropped.
Wire Node::send, keep /commands, reject unknown /foo as not-chat.
2026-09-10 23:07:22 -04:00
c141eb45b8 Merge pull request 'fix(sdk): enable rustls' ring provider — APK died at 'Failed to start / Could not determine the process-level CryptoProvider'' (#15) from wt/t_dcb8565a into main
All checks were successful
ci / test (push) Successful in 4m5s
Reviewed-on: #15
2026-09-10 22:35:31 -04:00
Sirius DevOps
d2d3b0c827
fix(sdk): enable rustls' ring provider and install it in the init path
All checks were successful
ci / test (pull_request) Successful in 3m30s
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
Sirius DevOps
411194a8b2
test(sdk): reproduce rustls CryptoProvider panic from the device
RED. The APK builds, installs and opens, but the first rustls config built
from the process default panics one call deep inside Arti's rustls backend:

    Could not automatically determine the process-level CryptoProvider from
    Rustls crate features.
    Call CryptoProvider::install_default() before this point ...

rustls 0.23 picks its provider from its own 'ring' / 'aws-lc-rs' crate
features, not from which crypto crates happen to be linked. Arti reaches
rustls via tor-rtcompat with default-features = false, so neither provider
feature is on and rustls compiles with zero providers. 'ring' does appear in
cargo tree, pulled in by snow for Noise -- unrelated and irrelevant.

This commit adds rustls as an explicit dependency with no provider feature
(no behaviour change: it is already in the graph that way) so the test can
name the type, plus the regression test. On the host it reproduces the
device's panic verbatim.

The SDK lockfile is resynced with the root Cargo.toml on main (onionwire
0.2.1, rpassword, md-5, rtoolbox) in the same commit so the test build is
against a consistent lock.
2026-09-10 22:02:02 -04:00
389d8f16c4 Merge pull request 'fix(cli): hide store passphrase like a password prompt' (#14) from feat/hidden-store-passphrase into main
All checks were successful
ci / test (push) Successful in 3m32s
Reviewed-on: #14
2026-09-10 21:18:46 -04:00
b354632552 Merge branch 'main' into feat/hidden-store-passphrase
All checks were successful
ci / test (pull_request) Successful in 3m44s
2026-09-10 21:18:32 -04:00
Sirius DevOps
c5019f1737
fix(cli): hide store passphrase like a password prompt
All checks were successful
ci / test (pull_request) Successful in 4m54s
stdin read_line echoed the passphrase. rpassword disables tty echo.
ONIONWIRE_STORE_PASSPHRASE still skips the prompt.
2026-09-10 21:10:50 -04:00
2abb9af4ad Merge pull request 'chore: bump crate version to 0.2.1' (#13) from feat/v0.2.1 into main
All checks were successful
ci / test (push) Successful in 5m10s
Reviewed-on: #13
2026-09-10 21:08:20 -04:00
Sirius DevOps
51e47ff90c
chore: bump crate version to 0.2.1
All checks were successful
release / aarch64 (push) Successful in 5m19s
ci / test (pull_request) Successful in 5m34s
Tag and --version have to move together. Ships the post-v0.2.0 main
line: security audit F1–F7, Pi CI serialize, Android SDK already on main.
2026-09-10 20:52:24 -04:00
f109223678 Merge pull request 'fix: security audit F5–F7 + Pi CI races (bundle)' (#12) from feat/audit-fixes-bundle into main
All checks were successful
ci / test (push) Successful in 2m45s
Reviewed-on: #12
2026-09-10 20:41:40 -04:00
Sirius DevOps
08ced86a34
Merge F5 wallet digest RPC onto audit bundle
All checks were successful
ci / test (pull_request) Successful in 3m33s
Keep sha3 (F4 checksums) and md-5 (HTTP Digest). Threat model: digest login
plus conjunctive verified receipts. Wallet tests: F1 matcher fixtures plus
F5 digest/cap coverage.
2026-09-10 20:36:10 -04:00
Sirius DevOps
39e7b3bf16
Merge remote-tracking branch 'origin/wt/t_792593f3' into feat/audit-fixes-bundle 2026-09-10 20:33:50 -04:00
Sirius DevOps
424002c0f5
[verified] fix(hs): redact onion in publish logs; cache 0700
All checks were successful
ci / test (pull_request) Successful in 3m0s
wait_until_published now logs HsId via safelog, not the locator.
Store and client_config mkdir Arti cache/state 0700. Arti
dangerously_trust_everyone stays on storage only.
2026-09-10 20:30:18 -04:00
Sirius DevOps
45c105de8a
fix(store): drop payments on /wipe; document non-forensic
All checks were successful
ci / test (pull_request) Successful in 3m1s
/wipe now deletes payments as well as chat, checkpoints WAL, and
enables sqlite secure_delete (flash still not crypto-shred).
2026-09-10 20:22:31 -04:00
Sirius DevOps
125aafb816
[verified] feat(wallet): require RPC digest login and cap reads
All checks were successful
ci / test (pull_request) Successful in 3m8s
Refuse unauthenticated ONIONWIRE_WALLET_RPC, speak HTTP Digest like
monero-wallet-rpc --rpc-login, cap responses at 1 MiB, and reject
.onion RPC URLs instead of half-dialing them over TCP.
2026-09-10 20:17:25 -04:00
Sirius DevOps
bfd28fce6b
fix(ci): serialize Pi jobs; valid XMR fixture after F4
All checks were successful
ci / test (pull_request) Successful in 2m49s
Overlapping Actions used a fixed docker name onionwire-ci (Conflict)
and OOM-killed with 137. Unique run_id names + one-at-a-time
concurrency. F1 ingest test still used 8+B*94 which F4 checksums reject.
2026-09-10 20:12:41 -04:00
b3c5b78f25 Merge pull request 'fix(pay): checksum Monero addresses (base58 + keccak)' (#7) from wt/t_45d30b55 into main
Some checks failed
ci / test (push) Failing after 3m13s
Reviewed-on: #7
2026-09-10 20:03:10 -04:00
2041302da7 Merge branch 'main' into wt/t_45d30b55
Some checks failed
ci / test (pull_request) Failing after 12s
2026-09-10 20:02:59 -04:00
Sirius DevOps
666b9d2e86
fix(pay): checksum Monero addresses (base58 + keccak)
All checks were successful
ci / test (pull_request) Successful in 3m16s
Reject prefix+length junk so /pay, /tip, and profile save cannot
sign or store garbage xmr_addr. Empty profile address stays allowed.
2026-09-10 20:01:45 -04:00
a56029b750 Merge pull request 'fix(qr): bind invite sig fields; cap decode' (#6) from wt/t_8b24e067 into main
All checks were successful
ci / test (push) Successful in 3m15s
Reviewed-on: #6
2026-09-10 19:52:12 -04:00
deee357772 Merge branch 'main' into wt/t_8b24e067
Some checks failed
ci / test (pull_request) Failing after 24s
2026-09-10 19:51:58 -04:00
Sirius DevOps
68da7fec3b
[verified] fix(qr): bind invite sig fields; cap decode
Some checks failed
ci / test (pull_request) Failing after 54s
Reject non-32-byte spk, non-v3 onion, and invites over 4KiB before
hex alloc. Sign onionwire-invite-v1 with 0x00 separators; verify that
first. Well-formed v1 concat still verifies after field checks.
2026-09-10 19:52:44 -04:00
b1427041c2 Merge pull request 'fix(store): bind chat AEAD to row AAD' (#5) from wt/t_69a6b3f9 into main
All checks were successful
ci / test (push) Successful in 3m4s
Reviewed-on: #5
2026-09-10 19:41:57 -04:00
Sirius DevOps
f75c434db7
[verified] fix(store): bind chat AEAD to row AAD
Some checks failed
ci / test (pull_request) Failing after 2m59s
ChaCha20-Poly1305 at rest now MACs owmsg1||friend_id_le64||dir||0x00||row_id_le64.
Swapped ciphertext fails closed. Empty-AAD v0.2 blobs rewrap once on unlock;
list_messages never falls back to empty AAD.
2026-09-10 19:41:00 -04:00
bdea9578e4 Merge pull request 'fix(wallet): conjunctive receipt verify' (#4) from wt/t_70fc1f81 into main
All checks were successful
ci / test (push) Successful in 2m56s
Reviewed-on: #4
2026-09-10 19:28:14 -04:00
Sirius DevOps
1a465581c2
[verified] fix(wallet): conjunctive receipt verify
Some checks failed
ci / test (pull_request) Failing after 35s
Require non-empty txid AND amount AND address against one
get_transfers row. Signed rcp alone never sets verified=1.
2026-09-10 19:29:39 -04:00
0dfd807b10 Merge pull request 'docs: security audit of 2b42864 (report only)' (#3) from wt/t_d85060fb into main
All checks were successful
ci / test (push) Successful in 3m8s
Reviewed-on: #3
2026-09-10 19:16:58 -04:00
95b0fa4dee Merge pull request 'feat(android): SDK AAR (UniFFI) + Compose APK, Arti rustls feature split' (#2) from wt/t_b7176a1b into main
All checks were successful
ci / test (push) Successful in 3m3s
Reviewed-on: #2
2026-09-10 19:16:44 -04:00
Sirius DevOps
dfa4ea8f39
docs: security audit of 2b42864 (report only)
All checks were successful
ci / test (pull_request) Successful in 2m57s
Evidence-backed audit at docs/SECURITY_AUDIT.md. No src/ changes.
2026-09-10 18:54:40 -04:00
apk-dev
672f2c9990
feat(android): SDK AAR (UniFFI) + Compose APK, Arti rustls feature split
All checks were successful
ci / test (pull_request) Successful in 3m22s
Adds the Android product alongside the existing Linux TUI, in one repo.

SDK — crates/onionwire-sdk is a UniFFI facade over the very same `onionwire`
crate the TUI runs on. Identity, invite, friend upsert, send/receive, rotate
and wipe all delegate; no protocol is reimplemented, so an Android peer and a
Linux peer interoperate. It is its own Cargo workspace because Arti's TLS
backends are non-additive: the TUI keeps native-tls (OpenSSL), Android needs
rustls + static-sqlite (no OpenSSL, no system libsqlite3 in the NDK).

android/ — Gradle project. :sdk produces the AAR (Kotlin bindings generated at
build time + libonionwire_sdk.so via cargo-ndk), :app is a Kotlin/Compose/M3
messenger depending on :sdk only. minSdk 26, targetSdk/compileSdk 36,
INTERNET-only, data in filesDir, backups excluded.

Root Cargo.toml grows `native-tls` (default) and `rustls` features so exactly
one Arti TLS backend is selected per build graph. The default build is
unchanged: same backend, ratatui still a normal dependency, src/tui.rs
untouched.

Also: Store::self_fingerprint/set_petname + Node wrappers (additive only),
scripts/build-android-local.sh, README sections, .gitignore for local SDK paths.
2026-09-10 18:20:19 -04:00
2b42864eba Merge pull request 'feat(store): encrypt chat bodies at rest; Ctrl-Q CLEAR/QUIT' (#1) from wt/t_b0456c4b into main
All checks were successful
ci / test (push) Successful in 2m44s
release / aarch64 (push) Successful in 5m33s
Reviewed-on: #1
2026-09-10 17:29:45 -04:00
Sirius DevOps
6323619d80
feat(store): encrypt chat bodies at rest; Ctrl-Q CLEAR/QUIT
All checks were successful
ci / test (pull_request) Successful in 3m13s
Wrap a random 32-byte message key with Argon2id+ChaCha20-Poly1305.
sqlite messages.plaintext is nonce||ciphertext; identity keys stay
plaintext. Empty/wrong passphrase fails closed. Ctrl-Q types CLEAR or
QUIT then Enter (Esc cancels).
2026-09-10 17:19:50 -04:00
Sirius DevOps
1d7f8e1f35
docs: v0.2 status — invite string, no QR graphic
All checks were successful
ci / test (push) Successful in 2m43s
README lede and F2/F3 now match the TUI: share/paste onionwire:v1
invite, no terminal QR.
2026-09-10 17:02:34 -04:00
Sirius DevOps
4fb47444d5
[verified] feat(tui): drop unicode QR, share onionwire:v1 invite
Some checks failed
ci / test (push) Has been cancelled
F2 shows the invite string with fp/onion hints instead of a terminal QR.
Keep encode/decode and F3 paste; drop the qrcode crate.
2026-09-10 16:53:03 -04:00
Sirius DevOps
e902a9041d
fix(hs): probe onion reachability instead of waiting on Bootstrapping
All checks were successful
ci / test (push) Successful in 2m59s
Arti combined status stays Bootstrapping through a 5 min HsDir upload
round. Treat a successful connect as published, floor cbtinitialtimeout
with cbtmintimeout at 20s, and skip preemptive 80/443 circuits so IPT
and HsDir builds are not starved. PUBLISH_WAIT stays 360s fail-closed.
2026-09-10 16:14:10 -04:00
Sirius DevOps
e63203ff56
feat: incoming rate limit, identity backup, v0.2.0
All checks were successful
ci / test (push) Successful in 4m11s
App-level token bucket on rend accepts (30/60s, burst 10), panic-safe TUI restore, sqlite WAL + integrity_check fail-closed. Encrypted owbak1 identity backup/restore. README and threat model cover profile, XMR sidecar, backup, mixed-version frames.
2026-09-10 14:50:01 -04:00
Sirius DevOps
1a9b54d138
feat: Monero invoice/receipt frames, wallet RPC, /pay /tip
All checks were successful
ci / test (push) Successful in 2m50s
Signed inv/rcp codecs keyed by identity, payments table with unverified
incoming receipts, optional JSON-RPC sidecar (loopback/.onion HTTP only),
and TUI /pay /tip. OnionWire still holds no spend keys.
2026-09-10 14:37:43 -04:00
Sirius DevOps
d304cc4c3b
feat: signed friend profile frames and TUI /profile /who
Some checks failed
ci / test (push) Waiting to run
release / aarch64 (push) Failing after 14m14s
prf frames are ed25519-signed, friend-visible only (unknown pubkey is ignored).
/profile edits self; F5 and /who show the selected friend's last profile.
2026-09-10 14:25:31 -04:00
Sirius DevOps
ef78be4035
fix(release): refuse to publish 0-byte assets + log on-disk sizes
Some checks failed
ci / test (push) Has been cancelled
Run 309 reported success but uploaded 0 bytes for both aarch64 assets
(a 0.85s 'upload' of an 18MB file). The Pack step had just listed
18258736 bytes and sha256sum -c'd OK, so the failure is in the handoff,
not the build.

- publish-release.sh now fails loudly on a 0-byte asset instead of
  publishing a release that silently breaks every download
- the workflow logs pwd, GITHUB_WORKSPACE, ls -l dist/ and the byte count
  of each dist file immediately before publishing

Ruled out: the api_curl retry wrapper. Reproduced from inside a
node:20-bullseye container (curl 7.74, same as CI) — wrapper and plain
curl both uploaded 994 bytes correctly through Cloudflare.
2026-09-10 14:20:45 -04:00
Sirius DevOps
c9d85d9ae5
feat: drop unknown typed frames instead of storing as chat
Some checks failed
ci / test (push) Has been cancelled
release / aarch64 (push) Failing after 2m8s
Classify decrypted plaintext before append_message so loc/prf/inv/rcp/png
and unknown xxx[space] prefixes never land in the message log.
2026-09-10 14:18:06 -04:00
Sirius DevOps
cbbf3f6baa
fix(release): harden publish curl against Cloudflare HTTP/2 stream errors
All checks were successful
ci / test (push) Successful in 6m1s
The dispatched v0.1.1 build succeeded a third time (2m06s, valid ARM ELF)
and then died at the first API call inside the publish step with curl exit
92 (HTTP/2 stream error) — job containers reach Forgejo through Cloudflare,
where that is intermittent.

- all API calls go through api_curl: --http1.1 --retry 5
  --retry-all-errors --retry-delay 3 --connect-timeout 20 --max-time 300
- HTTP/1.1 is the documented workaround for this instance's HTTP/2 resets

Job containers cannot reach http://forgejo:3000 (isolated GITEA-ACTIONS
network, name unresolvable, container IP unroutable — measured), so the
public hostname plus retries is the available fix.

Verified locally: create + idempotent re-run against a throwaway tag, both
clean; test release and tag deleted.
2026-09-10 14:08:39 -04:00
Sirius DevOps
9441bccf58
fix(release): tooling checkout so old tags can publish; never move a tag
Some checks failed
ci / test (push) Successful in 5m46s
release / aarch64 (push) Failing after 5m46s
The dispatched build of v0.1.1 kept failing at publish with
'scripts/publish-release.sh: No such file or directory' — the workflow is
read from the dispatch ref (main) but the workspace was the v0.1.1 tag,
whose tree predates scripts/ and .forgejo/.

- second checkout of the workflow's own ref into .ci-tools; publish runs
  .ci-tools/scripts/publish-release.sh (and .ci-tools/scripts/release-body.md)
- exclude ./.ci-tools from the build context tar
- target_commitish is now opt-in in publish-release.sh and only set for a
  push event, so a re-publish can never move an existing tag

Verified locally end-to-end against a throwaway tag: create, idempotent
re-run (stale assets deleted + replaced), public download, sha256sum -c,
--version. Test release and tag deleted afterwards.
2026-09-10 13:58:46 -04:00
Sirius DevOps
154bed7823
fix(ci): clippy clean on tests/hs.rs + pin step cwd to GITHUB_WORKSPACE
Some checks failed
ci / test (push) Successful in 3m47s
release / aarch64 (push) Failing after 5m25s
Two separate breakages from the first green-ish CI run:

1. tests/hs.rs asserted on a constant, which clippy rejects under
   -D warnings (clippy::assertions_on_constants) because the compiler
   folds the assert away. Compare through a runtime binding instead.

2. The release workflow built the aarch64 binary fine (13m02s, verified
   ARM ELF) but the publish step died with:
       /var/run/act/workflow/3: line 6: scripts/publish-release.sh: No such file or directory
   run: steps execute with act's cwd, not the repo root, so relative
   paths miss. Pin every run step with cd "${GITHUB_WORKSPACE}" (the
   pattern the osint-dashboard workflow already relies on).

Verified locally before pushing: cargo test 41 passed, cargo clippy
--all-targets -- -D warnings clean.
2026-09-10 13:50:57 -04:00
Sirius DevOps
7701f61c71
hs: wait 360s for HsDir publish; floor cbtmintimeout at 10s
Some checks failed
ci / test (push) Failing after 6m21s
180s fail-closed cut a working descriptor upload while status was still
Bootstrapping. Learned CBT can also drop to ~1s and kill HsDir circuits.
2026-09-10 13:43:23 -04:00
Sirius DevOps
d4fb93c27e
git: ignore /.worktrees/ (kanban worktrees live there)
All checks were successful
ci / test (push) Successful in 3m41s
2026-09-10 13:30:11 -04:00
Sirius DevOps
a278e71627
ci: install the clippy component in the rust image
Some checks failed
ci / test (push) Has been cancelled
rust:1.91-bookworm ships without clippy, so the clippy step died with
'cargo-clippy is not installed for the toolchain' after all 39 tests
passed. Also pushes the watcher fix (1=success 2=failure in Forgejo job
statuses, not action_run's numbering).
2026-09-10 13:24:59 -04:00
Sirius DevOps
e36070cb72
scripts: watcher with correct Forgejo job status codes
1=success 2=failure 3=cancelled 4=skipped 5=waiting 6=running 7=blocked,
terminal is 1..4. The first cut treated 2 as success and reported a failed
job as green.
2026-09-10 13:15:12 -04:00
Sirius DevOps
19af4cc768
ci: build on rust 1.91 — the locked Arti 0.46 crates require rustc >= 1.91
Some checks failed
ci / test (push) Failing after 9m2s
The rust:1.87-bookworm job image failed every builder step with
'tor-*@0.46.0 requires rustc 1.91 ... either upgrade rustc or select
compatible dependency versions'. Cargo.toml declared rust-version 1.87,
which was simply wrong: cargo 1.87 could not even resolve the lockfile.

- rust-version 1.87 -> 1.91 (README requirement text too)
- RUST_IMAGE rust:1.87-bookworm -> rust:1.91-bookworm in ci and release
2026-09-10 13:14:55 -04:00
Sirius DevOps
e7c0b98a2b
ci(release): build the dispatched tag, not the dispatch ref
Some checks failed
ci / test (push) Failing after 21s
Without this a workflow_dispatch re-publish would have built main and
attached a binary whose --version did not match the release tag.
2026-09-10 13:12:39 -04:00
Sirius DevOps
91fd9601b8
ci: Forgejo-native ci + release workflows
Some checks failed
ci / test (push) Failing after 1m6s
The GitHub workflows could never run on this instance: jobs asked for
ubuntu-24.04 runners (the Pi runner only registers the 'docker' label, so
every job sat in waiting), and release.yml published to github.com, which
is not a remote of this repo.

- .forgejo/workflows/ci.yml  — cargo test + clippy on the 'docker' label,
  Rust work in a rust:1.87-bookworm sibling container (aarch64 native)
- .forgejo/workflows/release.yml — on v*.*.* tags, build aarch64, strip,
  sha256, publish to the Forgejo release; workflow_dispatch takes a tag
  to re-publish
- scripts/publish-release.sh — idempotent create-or-update release +
  asset upload via the Forgejo API (replaces same-named assets)
- scripts/build-release-local.sh — x86_64 path (no x86_64 runner exists)
- scripts/release-body.md — release notes template (@TAG@ substituted)
- README: install URLs -> Forgejo, accurate maintainer release procedure
- remove .github/workflows/*
2026-09-10 13:10:56 -04:00
Sirius DevOps
b9f2dc3806
chore: release v0.1.1
Some checks are pending
ci / test (push) Waiting to run
release / x86_64-unknown-linux-gnu (push) Waiting to run
release / aarch64-unknown-linux-gnu (push) Waiting to run
release / publish (push) Blocked by required conditions
Binary --version reports CARGO_PKG_VERSION, so the tag and the crate
version have to move together.
2026-09-10 13:05:02 -04:00
82 changed files with 15599 additions and 389 deletions

56
.forgejo/workflows/ci.yml Normal file
View file

@ -0,0 +1,56 @@
name: ci
# Tests and clippy run on the Pi runner (aarch64) inside a rust container, so
# this CI proves the aarch64 build too. Jobs use the runner's `docker` label.
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
# One cargo job on the Pi at a time. Overlapping PR+main runs shared the
# container name `onionwire-ci` (docker Conflict) and OOM-killed with 137.
concurrency:
group: onionwire-ci-pi
cancel-in-progress: false
env:
CARGO_TERM_COLOR: never
RUST_IMAGE: rust:1.91-bookworm
CARGO_REGISTRY_VOLUME: onionwire-cargo-registry
CARGO_TARGET_VOLUME: onionwire-target-ci
BUILD_CONTAINER: onionwire-ci-${{ github.run_id }}
jobs:
test:
runs-on: docker
timeout-minutes: 120
steps:
- name: Checkout
uses: https://code.forgejo.org/actions/checkout@v4
- name: cargo test + clippy on aarch64
run: |
set -euo pipefail
cd "${GITHUB_WORKSPACE}"
echo "workspace: $GITHUB_WORKSPACE"
docker volume create "$CARGO_REGISTRY_VOLUME" > /dev/null
docker volume create "$CARGO_TARGET_VOLUME" > /dev/null
docker rm -f "$BUILD_CONTAINER" > /dev/null 2>&1 || true
docker create --name "$BUILD_CONTAINER" -i \
-e CARGO_TARGET_DIR=/target \
-e CARGO_BUILD_JOBS=2 \
-e CARGO_TERM_COLOR=never \
-v "$CARGO_REGISTRY_VOLUME":/usr/local/cargo/registry \
-v "$CARGO_TARGET_VOLUME":/target \
-w /src \
"$RUST_IMAGE" \
sh -euxc 'mkdir -p /src && tar xzf - -C /src && cd /src \
&& apt-get update \
&& apt-get install -y --no-install-recommends pkg-config libssl-dev \
&& rustup component add clippy \
&& cargo test --locked \
&& cargo clippy --locked --all-targets -- -D warnings'
tar czf - --exclude=./target --exclude=./.git --exclude=./.worktrees . \
| docker start -a -i "$BUILD_CONTAINER"
docker rm -f "$BUILD_CONTAINER" > /dev/null

View file

@ -0,0 +1,111 @@
name: release
# Native aarch64 build on the Pi runner, published to the Forgejo release.
# x86_64 is NOT built here: there is no x86_64 runner on this instance — build
# it on an x86_64 host with scripts/build-release-local.sh (same release, same
# asset naming), or the release will carry aarch64 only.
on:
push:
tags:
- "v*.*.*"
workflow_dispatch:
inputs:
tag:
description: Existing tag to (re)build and publish
required: true
env:
CARGO_TERM_COLOR: never
TARGET: aarch64-unknown-linux-gnu
# Job steps run inside node:20-bullseye (the runner's docker label image) with
# the host docker socket mounted, so builds happen in a sibling rust container.
RUST_IMAGE: rust:1.91-bookworm
CARGO_REGISTRY_VOLUME: onionwire-cargo-registry
CARGO_TARGET_VOLUME: onionwire-target-aarch64
BUILD_CONTAINER: onionwire-release-build-${{ github.run_id }}
jobs:
aarch64:
runs-on: docker
timeout-minutes: 120
steps:
- name: Checkout the source tag
uses: https://code.forgejo.org/actions/checkout@v4
with:
# A tag push builds that tag; a manual dispatch builds the tag the
# caller named (otherwise the binary version would not match the
# release it is attached to).
ref: ${{ github.event.inputs.tag || github.ref_name }}
- name: Checkout the CI tooling
uses: https://code.forgejo.org/actions/checkout@v4
with:
# scripts/ and .forgejo/ only exist on the branch (older tags predate
# them), and the workflow itself is read from the dispatched ref — so
# fetch the same ref into .ci-tools and run the scripts from there.
ref: ${{ github.ref_name }}
path: .ci-tools
- name: Build ${{ env.TARGET }} in a rust container
run: |
set -euo pipefail
cd "${GITHUB_WORKSPACE}"
echo "workspace: $GITHUB_WORKSPACE"
docker volume create "$CARGO_REGISTRY_VOLUME" > /dev/null
docker volume create "$CARGO_TARGET_VOLUME" > /dev/null
docker rm -f "$BUILD_CONTAINER" > /dev/null 2>&1 || true
# The workspace lives in a per-task volume the host daemon cannot
# resolve, so pipe the source tree in over stdin (tar) and pull the
# binary back out with docker cp.
docker create --name "$BUILD_CONTAINER" -i \
-e CARGO_TARGET_DIR=/target \
-e CARGO_BUILD_JOBS=2 \
-e CARGO_TERM_COLOR=never \
-v "$CARGO_REGISTRY_VOLUME":/usr/local/cargo/registry \
-v "$CARGO_TARGET_VOLUME":/target \
-w /src \
"$RUST_IMAGE" \
sh -euxc 'mkdir -p /src && tar xzf - -C /src && cd /src \
&& apt-get update \
&& apt-get install -y --no-install-recommends pkg-config libssl-dev \
&& cargo build --release --locked \
&& strip /target/release/onionwire \
&& ls -l /target/release/onionwire'
tar czf - --exclude=./target --exclude=./.git --exclude=./.worktrees \
--exclude=./.ci-tools . \
| docker start -a -i "$BUILD_CONTAINER"
mkdir -p dist
docker cp "$BUILD_CONTAINER:/target/release/onionwire" "dist/onionwire-$TARGET"
docker rm -f "$BUILD_CONTAINER" > /dev/null
- name: Pack and checksum
run: |
set -euo pipefail
cd "${GITHUB_WORKSPACE}/dist"
file "onionwire-$TARGET"
sha256sum "onionwire-$TARGET" > "onionwire-$TARGET.sha256"
sha256sum -c "onionwire-$TARGET.sha256"
ls -l
- name: Publish to the Forgejo release
env:
FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }}
REPO_API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
EVENT_NAME: ${{ github.event_name }}
EVENT_SHA: ${{ github.sha }}
REF: ${{ github.ref_name }}
INPUT_TAG: ${{ github.event.inputs.tag }}
run: |
set -euo pipefail
cd "${GITHUB_WORKSPACE}"
echo "pwd=$PWD workspace=$GITHUB_WORKSPACE"
ls -l dist/ 2>&1 || true
for f in dist/*; do printf 'on disk: %s %s bytes\n' "$f" "$(wc -c < "$f")"; done
tag="${INPUT_TAG:-$REF}"
# Only a tag push may create the tag; a re-publish must not move it.
if [ "$EVENT_NAME" = "push" ]; then export TARGET_COMMITISH="$EVENT_SHA"; fi
echo "publishing $tag from $REPO_API (event=$EVENT_NAME)"
.ci-tools/scripts/publish-release.sh \
"$tag" "OnionWire $tag" \
.ci-tools/scripts/release-body.md \
"dist/onionwire-$TARGET" "dist/onionwire-$TARGET.sha256"

View file

@ -1,29 +0,0 @@
name: ci
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: 1
jobs:
test:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy
- uses: Swatinem/rust-cache@v2
- name: Test
run: cargo test --locked
- name: Clippy
run: cargo clippy --locked -- -D warnings

View file

@ -1,93 +0,0 @@
name: release
on:
push:
tags:
- "v*.*.*"
workflow_dispatch:
permissions:
contents: write
env:
CARGO_TERM_COLOR: always
jobs:
build:
name: ${{ matrix.target }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-24.04
target: x86_64-unknown-linux-gnu
- os: ubuntu-24.04-arm
target: aarch64-unknown-linux-gnu
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@v2
with:
key: release-${{ matrix.target }}
- name: Build
run: cargo build --release --locked --target ${{ matrix.target }}
- name: Pack
run: |
set -euo pipefail
bin="target/${{ matrix.target }}/release/onionwire"
strip "$bin"
asset="onionwire-${{ matrix.target }}"
mkdir -p dist
cp "$bin" "dist/${asset}"
(cd dist && sha256sum "${asset}" > "${asset}.sha256")
ls -l dist
- uses: actions/upload-artifact@v4
with:
name: onionwire-${{ matrix.target }}
path: dist/*
if-no-files-found: error
publish:
needs: build
runs-on: ubuntu-24.04
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
steps:
- uses: actions/download-artifact@v4
with:
path: dist
merge-multiple: true
- name: Checksums
run: |
set -euo pipefail
cd dist
ls -l
cat *.sha256
- uses: softprops/action-gh-release@v2
with:
files: dist/*
generate_release_notes: true
fail_on_unmatched_files: true
body: |
Prebuilt Linux binaries. No `tor` package required.
```bash
# x86_64 — keep the asset filename so sha256sum -c matches
curl -fL -O https://github.com/sirius0xdev/onionwire/releases/download/${{ github.ref_name }}/onionwire-x86_64-unknown-linux-gnu
curl -fL -O https://github.com/sirius0xdev/onionwire/releases/download/${{ github.ref_name }}/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
./onionwire-x86_64-unknown-linux-gnu
```
See the README install guide for aarch64, checksums, and building from source.

16
.gitignore vendored
View file

@ -1 +1,17 @@
/target
/dist
/.worktrees/
# Machine-local Android build config. sdk.dir/ndk.dir are absolute paths to
# whatever the developer has installed — never commit them.
/.android-env.sh
android/local.properties
# Gradle / Android build output
android/.gradle/
android/build/
android/app/build/
android/sdk/build/
# Rust: the SDK is its own Cargo workspace
crates/onionwire-sdk/target/

120
Cargo.lock generated
View file

@ -134,6 +134,18 @@ dependencies = [
"num-traits",
]
[[package]]
name = "argon2"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
dependencies = [
"base64ct",
"blake2",
"cpufeatures 0.2.17",
"password-hash",
]
[[package]]
name = "arrayvec"
version = "0.7.8"
@ -762,6 +774,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"rand_core 0.6.4",
"typenum",
]
@ -1544,6 +1557,17 @@ dependencies = [
"syn 3.0.5",
]
[[package]]
name = "futures-rustls"
version = "0.26.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8f2f12607f92c69b12ed746fabf9ca4f5c482cba46679c1a75b874ed7c26adb"
dependencies = [
"futures-io",
"rustls",
"rustls-pki-types",
]
[[package]]
name = "futures-sink"
version = "0.3.34"
@ -2335,6 +2359,16 @@ dependencies = [
"regex-automata",
]
[[package]]
name = "md-5"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
dependencies = [
"cfg-if",
"digest 0.10.7",
]
[[package]]
name = "memchr"
version = "2.8.3"
@ -2595,16 +2629,22 @@ dependencies = [
[[package]]
name = "onionwire"
version = "0.1.0"
version = "0.2.1"
dependencies = [
"argon2",
"arti-client",
"chacha20poly1305",
"ed25519-dalek",
"futures",
"qrcode",
"md-5",
"rand 0.8.8",
"ratatui",
"rpassword",
"rusqlite",
"safelog",
"serde_json",
"sha2",
"sha3 0.10.9",
"snow",
"tempfile",
"tokio",
@ -2788,6 +2828,17 @@ dependencies = [
"windows-link",
]
[[package]]
name = "password-hash"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
dependencies = [
"base64ct",
"rand_core 0.6.4",
"subtle",
]
[[package]]
name = "paste"
version = "1.0.15"
@ -3043,12 +3094,6 @@ dependencies = [
"thiserror 2.0.20",
]
[[package]]
name = "qrcode"
version = "0.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec"
[[package]]
name = "quote"
version = "1.0.47"
@ -3338,6 +3383,17 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "rpassword"
version = "7.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2da316a15f47e3d053de9cb2c439650bd8fa4aaeb9365f2e5f27f492ff73c196"
dependencies = [
"libc",
"rtoolbox",
"windows-sys 0.61.2",
]
[[package]]
name = "rsa"
version = "0.9.10"
@ -3359,6 +3415,16 @@ dependencies = [
"zeroize",
]
[[package]]
name = "rtoolbox"
version = "0.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a1efe12a1469752d0e6ff5ebec0b6ef4924cc5c4c71046b0ec730040535819d"
dependencies = [
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "rusqlite"
version = "0.36.0"
@ -3405,6 +3471,40 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "rustls"
version = "0.23.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6725596c3f2c3a0aef021139e145d4eafe314a6623e4680ca83852b2c67ab2ba"
dependencies = [
"log",
"once_cell",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-pki-types"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
dependencies = [
"zeroize",
]
[[package]]
name = "rustls-webpki"
version = "0.103.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
dependencies = [
"ring",
"rustls-pki-types",
"untrusted",
]
[[package]]
name = "rustversion"
version = "1.0.23"
@ -5318,11 +5418,15 @@ dependencies = [
"dyn-clone",
"educe",
"futures",
"futures-rustls",
"hex",
"libc",
"native-tls",
"paste",
"pin-project",
"rustls",
"rustls-pki-types",
"rustls-webpki",
"socket2",
"thiserror 2.0.20",
"tokio",

View file

@ -1,28 +1,52 @@
[package]
name = "onionwire"
version = "0.1.0"
version = "0.2.1"
edition = "2024"
rust-version = "1.87"
rust-version = "1.91"
description = "Lean Tor messenger: Arti in-process, identity=pubkey, onion=locator. No XMPP."
license = "MIT"
publish = false
repository = "https://github.com/sirius0xdev/onionwire"
# The Linux TUI build is unchanged: `default` keeps Arti's native-tls
# (OpenSSL) backend exactly as before, and ratatui remains a normal
# dependency so `src/tui.rs` is untouched.
#
# Android has no OpenSSL in the NDK, so the SDK crate depends on this crate
# with `default-features = false, features = ["rustls"]`. Arti marks these two
# backends non-additive, so exactly one may be selected per build graph — hence
# exposing them as crate features.
[features]
default = ["native-tls"]
native-tls = ["arti-client/native-tls"]
rustls = ["arti-client/rustls"]
[dependencies]
arti-client = { version = "0.46", features = ["tokio", "onion-service-client", "onion-service-service"] }
arti-client = { version = "0.46", default-features = false, features = [
"tokio",
"onion-service-client",
"onion-service-service",
"compression",
] }
futures = "0.3"
safelog = "0.9"
ed25519-dalek = { version = "2", features = ["rand_core"] }
rand = "0.8"
rusqlite = { version = "0.36", features = ["bundled"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-util", "time"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-util", "time", "net"] }
tor-cell = "0.46"
tor-hsservice = "0.46"
tor-rtcompat = { version = "0.46", features = ["tokio"] }
qrcode = { version = "0.14.1", default-features = false }
ratatui = { version = "0.30.2", default-features = false, features = ["crossterm"] }
x25519-dalek = { version = "2", features = ["static_secrets"] }
snow = "0.10"
serde_json = "1"
argon2 = "0.5"
chacha20poly1305 = "0.10"
sha3 = "0.10"
sha2 = "0.10"
md-5 = "0.10"
rpassword = "7"
[dev-dependencies]
tempfile = "3"

173
README.md
View file

@ -1,7 +1,9 @@
# OnionWire
Lean Tor messenger: one Rust process (ratatui + Noise IK + sqlite + in-process Arti).
Two people scan a QR, then chat. No hosted server. Identity is a public key; the onion is only a locator.
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.
@ -21,13 +23,13 @@ Arti onion services are still **experimental**. If the hidden service cannot com
### Option A — download a release binary
CI builds stripped Linux binaries on every `v*.*.*` tag and attaches them to [GitHub Releases](https://github.com/sirius0xdev/onionwire/releases).
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://github.com/sirius0xdev/onionwire/releases/latest/download/onionwire-x86_64-unknown-linux-gnu
curl -fL -O https://github.com/sirius0xdev/onionwire/releases/latest/download/onionwire-x86_64-unknown-linux-gnu.sha256
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
@ -36,8 +38,8 @@ chmod +x onionwire-x86_64-unknown-linux-gnu
**aarch64 (Raspberry Pi, ARM servers):**
```bash
curl -fL -O https://github.com/sirius0xdev/onionwire/releases/latest/download/onionwire-aarch64-unknown-linux-gnu
curl -fL -O https://github.com/sirius0xdev/onionwire/releases/latest/download/onionwire-aarch64-unknown-linux-gnu.sha256
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
@ -58,17 +60,17 @@ Checksum files are `sha256sum` format (`<hash> onionwire-<target>`). Download b
### Option B — build from source
Needs [Rust](https://rustup.rs/) **1.87+** (`edition = "2024"`). A distro `rustc` is fine if `rustc --version` reports 1.87 or newer. Do not install `tor` for this app.
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.87 or newer
rustc --version # must be 1.91 or newer
```
```bash
git clone https://github.com/sirius0xdev/onionwire.git
git clone https://forgejo.siriusdevops.com/sirius/onionwire.git
cd onionwire
cargo test --locked
cargo clippy --locked -- -D warnings
@ -84,7 +86,8 @@ cargo install --path . --locked --force
# lands in ~/.cargo/bin/onionwire
```
Source of record also lives at `https://forgejo.siriusdevops.com/sirius/onionwire` (same tree). GitHub is where CI publishes downloadable artifacts.
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
@ -95,7 +98,7 @@ ONIONWIRE_HOME=/tmp/ow-a ./onionwire
ONIONWIRE_HOME=/tmp/ow-b ./onionwire
```
Then F2 on A, F3-paste on B (and the other way around).
Then `F2` on A (invite string), `F3`-paste on B (and the other way around). No QR.
## First run
@ -105,11 +108,13 @@ onionwire
or `cargo run --release`.
On start you should see `onionwire: bootstrapping Arti…` on stderr. First bootstrap can take a minute. 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.
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. Re-scanning the same `k` updates the locator; it never creates a second person.
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)
@ -122,20 +127,21 @@ Focus starts on the composer so typing works immediately. `Tab` cycles panes; `j
| `j` `k` / `↑` `↓` | Move or scroll the focused pane |
| `g` / `G` | Jump to top / bottom of the focused pane |
| `?` | Keybinding help (`Esc` closes) |
| `F2` | Share: terminal QR + payload |
| `F3` | Paste a friends payload |
| `F2` | Share invite (`onionwire:v1:…`) |
| `F3` | Paste a friends invite |
| `F4` | Rotate **onion** (locator only) |
| Enter | Run `/wipe` or `/wipe-all` from the composer |
| `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 |
| `Ctrl-Q` | Quit: type `CLEAR`+Enter to wipe history, `QUIT`+Enter to leave it, Esc to stay |
## F2 QR
## F2 / F3 invite
`F2` shows a terminal QR and the payload:
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=…`
`F3` pastes a payload. Unknown `k` asks for approval. Same `k` already in the roster updates `onion` only.
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
@ -144,18 +150,60 @@ Focus starts on the composer so typing works immediately. `Tab` cycles panes; `j
- 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 rescan the new QR. There is no directory.
- 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.
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`. Overwrites the message log and `VACUUM`s. 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.
- `/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
@ -172,32 +220,79 @@ If you set `ONIONWIRE_HOME`, delete that directory instead.
## Seized laptop
v1 stores **plaintext** on disk:
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):
- message log (sqlite `messages.plaintext`)
- your identity secret key (`self.identity_sk`)
- friend public keys and current locators
Full-disk encryption plus `/wipe` / `/wipe-all` is the mitigation. There is no sqlcipher in v1.
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).
## Releases (maintainers)
## Android (SDK + APK)
Push a version tag. GitHub Actions builds Linux binaries and publishes a GitHub Release.
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
# version in Cargo.toml must match the tag without the leading v
git tag v0.1.0
git push github v0.1.0 # GitHub remote — this is what triggers CI
# 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
```
Workflows:
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).
- [`.github/workflows/ci.yml`](.github/workflows/ci.yml) — `cargo test --locked` and clippy on `main` / PRs (ignored Tor-live tests are not run).
- [`.github/workflows/release.yml`](.github/workflows/release.yml) — on `v*.*.*` tags, `cargo build --release` for `x86_64-unknown-linux-gnu` and `aarch64-unknown-linux-gnu`, strip, sha256, attach to the release.
## Releases (maintainers)
Do not run `cargo publish`; `publish = false`.
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
@ -205,15 +300,15 @@ Do not run `cargo publish`; `publish = false`.
|---|---|
| `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…` | Need outbound network. First consensus fetch is slow. Wait a couple of minutes; if it never publishes, it fails closed — no C-tor fallback. |
| 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 QR. Same `k` updates the row. |
| 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.
Prosody, XMPP, s2s, MAM, carbons, outbox, multi-device, DHT / name server, sqlcipher, QR codes.
## License

138
android/README.md Normal file
View file

@ -0,0 +1,138 @@
# OnionWire for Android
Two artifacts out of one Gradle build:
| Module | Output | What it is |
|---|---|---|
| `:sdk` | `sdk-release.aar` | The reusable library. UniFFI Kotlin bindings + `libonionwire_sdk.so`. Any Android app can depend on this. |
| `:app` | `app-release.apk` / `app-debug.apk` | A Jetpack Compose + Material 3 messenger that depends on `:sdk` and nothing else. |
The Linux TUI in the repository root is a **separate product** with a separate
Cargo workspace. Nothing here turns it into a WebView, and nothing here imports
its TUI code.
## Requirements
* JDK 17+ (built and tested on JDK 21)
* Android SDK with **platform 36** and **build-tools 36.0.0**
* NDK **28.2.13676358** (NDK r28c) — also what Rust's Android link step uses
* Rust with the `aarch64-linux-android` target and `cargo-ndk`
* `ANDROID_HOME` set, or `sdk.dir` in `android/local.properties` (gitignored)
One-time setup:
```bash
sdkmanager --install "platform-tools" "platforms;android-36" \
"build-tools;36.0.0" "ndk;28.2.13676358"
rustup target add aarch64-linux-android x86_64-linux-android
cargo install cargo-ndk
```
## Build
```bash
cd android
./gradlew :sdk:assembleRelease :app:assembleDebug # or :app:assembleRelease
```
The Rust cross-compile and the UniFFI Kotlin bindings are wired into the Gradle
tasks — there is no manual codegen step. `:sdk:preBuild` depends on them.
Emulator / x86_64 slice:
```bash
./gradlew :app:assembleDebug -Ponionwire.abis=arm64-v8a,x86_64
```
Or from the repo root, with checksums and an optional publish step:
```bash
scripts/build-android-local.sh # -> dist/onionwire-<v>-android-arm64-v8a.apk
PUBLISH_TAG=v0.3.0 scripts/build-android-local.sh
```
## Decisions you should know about
### `minSdk = 26` (Android 8.0)
The floor is not NDK-imposed — Rust's `aarch64-linux-android` std and NDK 28
both happily target API 21. We take 26 deliberately:
* it is the first API level where adaptive icons and notification channels are
unconditional, so there is one icon path instead of two;
* it keeps a single `arm64-v8a` APK inside Play's currently-supported range
without carrying legacy ART/JIT workarounds for pre-O devices;
* the crypto stack (`ring`, `rustls`, `argon2`, `chacha20poly1305`) is
exercised on 4.4+ kernels here, which is what we actually test.
**Devices cut:** Android 7.1 and older. If you need those, the change is
`-Ponionwire.api=24` **and** a real device test — do not bump it silently.
### 16 KB page size
The NDK r28 link step emits `max-page-size=16384` for 64-bit Android, which is
what Play requires for new apps. Verify with:
```bash
readelf -l android/sdk/build/rustJniLibs/arm64-v8a/libonionwire_sdk.so | grep LOAD
```
### Permissions
`INTERNET` only, declared at first launch. No camera (invites are pasted, not
scanned), no contacts, no storage permission — the data directory is
`context.filesDir`, which is already app-private. There is **no**
`FOREGROUND_SERVICE` and no keep-alive notification in v1: the node lives only
while the process does, and the UI says so. Adding one is a product decision
(battery, Play's FGS type declarations), not a silent fix.
### Data at rest
`context.filesDir/onionwire/` (mode 0700), holding `onionwire.db` and the Arti
state dir. `android:allowBackup="false"` plus explicit `data_extraction_rules`
exclusions, because an auto-backup of `onionwire.db` would hand the wrapped
message key *and* the plaintext identity keys to Google Drive. Chat bodies are
encrypted at rest; identity keys are not — that is the locked v1 posture.
### Signing
Both `debug` and `release` are signed with the **standard debug keystore**
(`~/.android/debug.keystore`, auto-created by AGP). This is what makes the
release APK installable for sideloading. It is **not** a Play upload key;
nothing here should be uploaded to Play. When a real upload key exists, put it
in `~/.gradle/gradle.properties` or CI secrets and reference it from
`signingConfigs` — never in this repository.
### Release builds are not minified
`isMinifyEnabled = false` for release. Keeps for the UniFFI/JNI surface already
exist in `sdk/consumer-rules.pro`, but enabling R8 without a mapping-file upload
path and an on-device test of the JNI surface is how you ship an
`UnsatisfiedLinkError` nobody can read. Turn it on together with crash
deobfuscation, not before.
## CI
`.forgejo/workflows/release.yml` runs on an aarch64 Linux container on the Pi.
It cannot build an APK. Android artifacts are built on a host with the SDK/NDK
via `scripts/build-android-local.sh`, exactly like the x86_64 Linux binary is
built via `scripts/build-release-local.sh`.
## Layout
```
android/
settings.gradle.kts
build.gradle.kts # plugin aliases only
gradle/libs.versions.toml # version catalog
sdk/ # -> AAR
build.gradle.kts # cargo-ndk + uniffi-bindgen wiring
consumer-rules.pro
src/main/AndroidManifest.xml
app/ # -> APK
build.gradle.kts
src/main/java/com/siriusdevops/onionwire/
MainActivity.kt # NavHost
WireViewModel.kt # owns the single Wire node
ui/ # Compose screens (M3)
```

View file

@ -0,0 +1,90 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
}
android {
namespace = "com.siriusdevops.onionwire"
compileSdk = 36
// Same ABI selection as :sdk. JNA ships libjnidispatch.so for several ABIs;
// without this filter the APK carries native code for ABIs we never built
// our Rust lib for.
val onionwireAbis: List<String> = (findProperty("onionwire.abis") as String?)
?.split(',')?.map(String::trim)?.filter(String::isNotEmpty)
?: listOf("arm64-v8a")
defaultConfig {
applicationId = "com.siriusdevops.onionwire"
minSdk = 26
targetSdk = 36
versionCode = 1
versionName = "0.1.0"
ndk {
abiFilters += onionwireAbis
}
}
buildTypes {
debug {
// Sideload / device-test builds. Signed with the standard debug key.
applicationIdSuffix = ".debug"
versionNameSuffix = "-debug"
}
release {
// Deliberately NOT minified in this card: there is no on-device
// crash-deobfuscation story yet, and shipping R8 without testing
// the JNI/uniffi surface on a real device is how you get a
// mysterious UnsatisfiedLinkError in the field.
// Enable together with mapping-file upload; keeps already exist in
// :sdk/consumer-rules.pro.
isMinifyEnabled = false
isShrinkResources = false
// Signed with the debug keystore so the release APK installs.
// This is NOT a Play upload key — see android/README.md.
signingConfig = signingConfigs.getByName("debug")
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
buildFeatures {
compose = true
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
}
kotlin {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_17)
}
}
dependencies {
implementation(project(":sdk"))
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.navigation.compose)
implementation(platform(libs.compose.bom))
implementation(libs.compose.ui)
implementation(libs.compose.ui.graphics)
implementation(libs.compose.ui.tooling.preview)
implementation(libs.compose.material3)
debugImplementation(libs.compose.ui.tooling)
}

View file

@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!--
INTERNET is the only permission requested at first launch.
No camera (invites are typed/pasted, not scanned), no contacts, no
storage. Tor runs in-process; there is no foreground service and no
keep-alive notification in v1, so the node only lives while the app does.
-->
<uses-permission android:name="android.permission.INTERNET" />
<application
android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="false"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher"
android:supportsRtl="true"
android:theme="@style/Theme.OnionWire">
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View file

@ -0,0 +1,120 @@
package com.siriusdevops.onionwire
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Surface
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.siriusdevops.onionwire.ui.ChatScreen
import com.siriusdevops.onionwire.ui.PasteInviteScreen
import com.siriusdevops.onionwire.ui.RosterScreen
import com.siriusdevops.onionwire.ui.RotateScreen
import com.siriusdevops.onionwire.ui.ShareInviteScreen
import com.siriusdevops.onionwire.ui.UnlockScreen
import com.siriusdevops.onionwire.ui.OnionWireTheme
import kotlinx.coroutines.launch
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
OnionWireTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background,
) {
AppRoot()
}
}
}
}
}
@Composable
private fun AppRoot(vm: WireViewModel = viewModel()) {
val phase by vm.phase.collectAsStateWithLifecycle()
when (val p = phase) {
is Phase.Ready -> ReadyGraph(vm, p)
else -> UnlockScreen(phase = p, onUnlock = vm::unlock)
}
}
@Composable
private fun ReadyGraph(vm: WireViewModel, ready: Phase.Ready) {
val nav = rememberNavController()
val friends by vm.friends.collectAsStateWithLifecycle()
val scope = rememberCoroutineScope()
NavHost(navController = nav, startDestination = "roster") {
composable("roster") {
RosterScreen(
fingerprint = ready.fingerprint,
onion = ready.onion,
sdkVersion = ready.sdkVersion,
friends = friends,
onOpenChat = { nav.navigate("chat/${it.pubkeyHex}") },
onShare = { nav.navigate("share") },
onPaste = { nav.navigate("paste") },
onRotate = { nav.navigate("rotate") },
onWipe = { scope.launch { vm.wipeMessages() } },
onLock = vm::lock,
)
}
composable("share") {
ShareInviteScreen(ownerInvite = vm::invite, onBack = { nav.popBackStack() })
}
composable("paste") {
PasteInviteScreen(
onDecode = vm::decode,
onAdd = vm::addFriend,
onBack = { nav.popBackStack() },
)
}
composable("rotate") {
RotateScreen(
currentOnion = ready.onion,
onRotate = {
val out = vm.rotateOnion()
out.notified.toInt() to out.friends.toInt()
},
onBack = { nav.popBackStack() },
)
}
composable(
route = "chat/{pk}",
arguments = listOf(navArgument("pk") { type = NavType.StringType }),
) { entry ->
val pk = entry.arguments?.getString("pk").orEmpty()
val friend = friends.firstOrNull { it.pubkeyHex == pk }
ChatScreen(
title = friend?.petname?.takeIf { it.isNotBlank() }
?: friend?.fingerprint
?: pk.take(16),
fingerprint = friend?.fingerprint ?: pk.take(16),
onion = friend?.onion.orEmpty(),
loadMessages = { vm.messages(pk) },
onSend = { body -> vm.send(pk, body) },
onBack = { nav.popBackStack() },
)
}
}
}

View file

@ -0,0 +1,124 @@
package com.siriusdevops.onionwire
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import uniffi.onionwire_sdk.ChatMessage
import uniffi.onionwire_sdk.FriendInfo
import uniffi.onionwire_sdk.InviteInfo
import uniffi.onionwire_sdk.RotateOutcome
import uniffi.onionwire_sdk.Wire
import uniffi.onionwire_sdk.decodeInvite
import uniffi.onionwire_sdk.openWire
import java.io.File
sealed interface Phase {
/** No identity unlocked. Nothing Tor-related is running. */
data object Locked : Phase
/** Bootstrap + onion publish. Cold start is minutes, not seconds. */
data object Opening : Phase
data class Ready(
val fingerprint: String,
val onion: String,
val sdkVersion: String,
) : Phase
data class Failed(val message: String) : Phase
}
/**
* Owns the single Wire node for the process.
*
* The node lives in the ViewModel because a configuration change must not
* drop a published onion service and re-bootstrap Tor.
*/
class WireViewModel(app: Application) : AndroidViewModel(app) {
/** `context.filesDir` — app-private, never external storage. */
private val home: File = File(app.filesDir, "onionwire")
private val _phase = MutableStateFlow<Phase>(Phase.Locked)
val phase: StateFlow<Phase> = _phase.asStateFlow()
private val _friends = MutableStateFlow<List<FriendInfo>>(emptyList())
val friends: StateFlow<List<FriendInfo>> = _friends.asStateFlow()
private var wire: Wire? = null
/**
* Opens (or on first run, creates) the identity and starts hosting.
*
* An empty passphrase fails closed the SDK maps it straight to an error,
* it is not a "skip" path.
*/
fun unlock(passphrase: String) {
if (_phase.value is Phase.Opening) return
_phase.value = Phase.Opening
viewModelScope.launch {
try {
val w = openWire(home.absolutePath, passphrase)
wire = w
_phase.value = Phase.Ready(
fingerprint = w.fingerprint(),
onion = w.onion(),
sdkVersion = w.version(),
)
refreshFriends()
} catch (t: Throwable) {
_phase.value = Phase.Failed(t.message ?: t.toString())
}
}
}
/** Drops our handle. The onion service stops; nothing is sent after this. */
fun lock() {
wire = null
_friends.value = emptyList()
_phase.value = Phase.Locked
}
private fun requireWire(): Wire = wire ?: error("locked")
suspend fun refreshFriends() {
_friends.value = requireWire().friends()
}
suspend fun invite(): String = requireWire().invite()
suspend fun addFriend(invite: String): FriendInfo {
val f = requireWire().addFriend(invite)
refreshFriends()
return f
}
suspend fun setPetname(pubkeyHex: String, petname: String?) {
requireWire().setPetname(pubkeyHex, petname?.takeIf { it.isNotBlank() })
refreshFriends()
}
suspend fun messages(pubkeyHex: String): List<ChatMessage> =
requireWire().messages(pubkeyHex)
suspend fun send(pubkeyHex: String, body: String) =
requireWire().send(pubkeyHex, body)
suspend fun wipeMessages() {
requireWire().wipeMessages()
}
suspend fun rotateOnion(): RotateOutcome {
val out = requireWire().rotateOnion()
_phase.value = (_phase.value as? Phase.Ready)?.copy(onion = requireWire().onion())
?: _phase.value
refreshFriends()
return out
}
suspend fun decode(invite: String): InviteInfo = decodeInvite(invite)
}

View file

@ -0,0 +1,206 @@
package com.siriusdevops.onionwire.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
import uniffi.onionwire_sdk.ChatMessage
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ChatScreen(
title: String,
fingerprint: String,
onion: String,
loadMessages: suspend () -> List<ChatMessage>,
onSend: suspend (String) -> Unit,
onBack: () -> Unit,
) {
var messages by remember { mutableStateOf<List<ChatMessage>?>(null) }
var draft by remember { mutableStateOf("") }
var pending by remember { mutableStateOf<String?>(null) }
var error by remember { mutableStateOf<String?>(null) }
val listState = rememberLazyListState()
// Poll: the node appends inbound messages in the background; there is no
// push channel into the UI in v1.
LaunchedEffect(Unit) {
while (true) {
runCatching { loadMessages() }
.onSuccess { messages = it }
.onFailure { error = it.message ?: it.toString() }
delay(2_000)
}
}
// Fail-closed send. `send` retries the peer's onion for up to 3 minutes and
// then errors — nothing is spooled, so a failure is a real failure.
LaunchedEffect(pending) {
val body = pending ?: return@LaunchedEffect
error = null
runCatching { onSend(body) }
.onFailure { error = it.message ?: it.toString() }
pending = null
}
LaunchedEffect(messages?.size) {
val n = messages?.size ?: 0
if (n > 0) listState.animateScrollToItem(n - 1)
}
Scaffold(
topBar = {
TopAppBar(
title = {
Column {
Text(title, style = MaterialTheme.typography.titleSmall)
Text(
fingerprint,
style = MaterialTheme.typography.labelSmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.primary,
)
}
},
navigationIcon = {
IconButton(onClick = onBack) { Text("<") }
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background,
),
)
},
) { pad ->
Column(Modifier.padding(pad).fillMaxSize()) {
Text(
"onion: $onion",
style = MaterialTheme.typography.labelSmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 12.dp),
)
val list = messages
when {
list == null -> {
Spacer(Modifier.height(16.dp))
CircularProgressIndicator(Modifier.padding(16.dp))
}
list.isEmpty() -> {
Column(
Modifier.fillMaxWidth().padding(24.dp),
verticalArrangement = Arrangement.Center,
) {
Text("No messages", style = MaterialTheme.typography.titleSmall)
Spacer(Modifier.height(6.dp))
Text(
"Sends fail closed: if their onion is down the message is " +
"not spooled anywhere. There is no outbox in v1.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
else -> {
LazyColumn(
state = listState,
modifier = Modifier.weight(1f).fillMaxWidth(),
contentPadding = PaddingValues(12.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
items(list) { m ->
val mine = m.direction == "out"
Row(
Modifier.fillMaxWidth(),
horizontalArrangement =
if (mine) Arrangement.End else Arrangement.Start,
) {
Text(
m.body,
style = MaterialTheme.typography.bodyMedium,
color = if (mine) MaterialTheme.colorScheme.onPrimary
else MaterialTheme.colorScheme.onSurface,
modifier = Modifier
.background(
if (mine) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.surfaceVariant,
RoundedCornerShape(10.dp),
)
.padding(horizontal = 10.dp, vertical = 8.dp),
)
}
}
}
}
}
error?.let {
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
)
}
Row(
Modifier.fillMaxWidth().padding(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedTextField(
value = draft,
onValueChange = { draft = it },
modifier = Modifier.weight(1f),
maxLines = 4,
label = { Text("message") },
)
Spacer(Modifier.width(8.dp))
if (pending != null) {
CircularProgressIndicator()
} else {
Button(
enabled = draft.isNotBlank(),
onClick = {
pending = draft
draft = ""
},
) { Text("Send") }
}
}
}
}
}

View file

@ -0,0 +1,259 @@
package com.siriusdevops.onionwire.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.dp
import uniffi.onionwire_sdk.FriendInfo
import uniffi.onionwire_sdk.InviteInfo
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ShareInviteScreen(ownerInvite: suspend () -> String, onBack: () -> Unit) {
var invite by remember { mutableStateOf<String?>(null) }
var error by remember { mutableStateOf<String?>(null) }
var copied by remember { mutableStateOf(false) }
val clip = LocalClipboardManager.current
LaunchedEffect(Unit) {
runCatching { ownerInvite() }
.onSuccess { invite = it }
.onFailure { error = it.message ?: it.toString() }
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Your invite") },
navigationIcon = { IconButton(onClick = onBack) { Text("<") } },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background,
),
)
},
) { pad ->
Column(
Modifier.padding(pad).fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp),
) {
Text(
"Hand this to one person, over a channel you already trust. " +
"It is not published anywhere and cannot be looked up.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(12.dp))
when {
error != null -> Text(
error!!,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
invite == null -> CircularProgressIndicator()
else -> {
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
) {
Text(
invite!!,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(12.dp),
)
}
Spacer(Modifier.height(12.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = {
clip.setText(AnnotatedString(invite!!))
copied = true
}) { Text(if (copied) "Copied" else "Copy") }
}
Spacer(Modifier.height(20.dp))
Text("What is in it", style = MaterialTheme.typography.titleSmall)
Spacer(Modifier.height(6.dp))
Mono("k ed25519 identity pubkey — who you are")
Mono("o current onion locator — where you are now")
Mono("spk x25519 signed prekey — session start material")
Mono("sig signature over k‖o‖spk — reject if it fails")
Spacer(Modifier.height(12.dp))
Text(
"The onion in here goes stale when you rotate. The key does not. " +
"If you re-share later, the same k updates their entry — it never " +
"creates a second friend.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PasteInviteScreen(
onDecode: suspend (String) -> InviteInfo,
onAdd: suspend (String) -> FriendInfo,
onBack: () -> Unit,
) {
var raw by remember { mutableStateOf("") }
var preview by remember { mutableStateOf<InviteInfo?>(null) }
var error by remember { mutableStateOf<String?>(null) }
var added by remember { mutableStateOf<FriendInfo?>(null) }
var busy by remember { mutableStateOf(false) }
Scaffold(
topBar = {
TopAppBar(
title = { Text("Add a friend") },
navigationIcon = { IconButton(onClick = onBack) { Text("<") } },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background,
),
)
},
) { pad ->
Column(
Modifier.padding(pad).fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp),
) {
OutlinedTextField(
value = raw,
onValueChange = {
raw = it
preview = null
error = null
added = null
},
label = { Text("onionwire:v1:…") },
minLines = 4,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(12.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(
enabled = raw.isNotBlank() && !busy,
onClick = {
busy = true
error = null
},
) { Text("Verify") }
if (added != null) {
TextButton(onClick = onBack) { Text("Done") }
}
}
LaunchedEffect(busy) {
if (!busy) return@LaunchedEffect
runCatching { onDecode(raw.trim()) }
.onSuccess { preview = it }
.onFailure { error = it.message ?: it.toString() }
busy = false
}
Spacer(Modifier.height(16.dp))
error?.let {
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
Spacer(Modifier.height(8.dp))
Text(
"Unsigned or tampered invites are rejected here, before anything is stored.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
preview?.let { p ->
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
modifier = Modifier.fillMaxWidth(),
) {
Column(Modifier.padding(12.dp)) {
Label("THEY ARE")
Mono(p.pubkeyHex)
Spacer(Modifier.height(8.dp))
Text(
"fingerprint ${p.pubkeyHex.take(16)}… — compare this with them " +
"over a second channel before you trust it.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(8.dp))
Label("REACHABLE AT")
Mono(p.onion)
}
}
Spacer(Modifier.height(12.dp))
Button(
enabled = !busy && added == null,
onClick = {
busy = true
error = null
},
) { Text("Approve & add") }
LaunchedEffect(busy, preview) {
if (!busy || preview == null || added != null) return@LaunchedEffect
runCatching { onAdd(raw.trim()) }
.onSuccess { added = it }
.onFailure { error = it.message ?: it.toString() }
busy = false
}
}
added?.let { f ->
Spacer(Modifier.height(16.dp))
Text(
"Added ${f.petname ?: f.fingerprint}",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
)
Spacer(Modifier.height(4.dp))
Text(
"If that key was already in your roster, only the locator was updated. " +
"One key is always one friend.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}

View file

@ -0,0 +1,229 @@
package com.siriusdevops.onionwire.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import uniffi.onionwire_sdk.FriendInfo
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RosterScreen(
fingerprint: String,
onion: String,
sdkVersion: String,
friends: List<FriendInfo>,
onOpenChat: (FriendInfo) -> Unit,
onShare: () -> Unit,
onPaste: () -> Unit,
onRotate: () -> Unit,
onWipe: () -> Unit,
onLock: () -> Unit,
) {
var confirmWipe by remember { mutableStateOf(false) }
Scaffold(
topBar = {
TopAppBar(
title = { Text("OnionWire") },
actions = {
TextButton(onClick = onLock) { Text("Lock") }
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background,
),
)
},
) { pad ->
Column(Modifier.padding(pad).fillMaxSize()) {
Card(
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
) {
Column(Modifier.padding(12.dp)) {
Label("YOU (stable forever)")
Mono(fingerprint)
Spacer(Modifier.height(10.dp))
Label("ONION (locator — rotates)")
Mono(onion)
Spacer(Modifier.height(10.dp))
Text(
"sdk $sdkVersion · identity = ed25519 pubkey",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Spacer(Modifier.height(8.dp))
Row(
Modifier.fillMaxWidth().padding(horizontal = 8.dp),
horizontalArrangement = Arrangement.SpaceEvenly,
) {
TextButton(onClick = onShare) { Text("Invite") }
TextButton(onClick = onPaste) { Text("Add") }
TextButton(onClick = onRotate) { Text("Rotate") }
TextButton(onClick = { confirmWipe = true }) { Text("Wipe chat") }
}
if (confirmWipe) {
ConfirmStrip(
text = "Delete every message body? Identity and friends stay.",
confirm = "WIPE",
onConfirm = { confirmWipe = false; onWipe() },
onCancel = { confirmWipe = false },
)
}
HorizontalDivider(Modifier.padding(vertical = 8.dp))
if (friends.isEmpty()) {
Column(
Modifier.fillMaxSize().padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text("No friends yet", style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(8.dp))
Text(
"Share your invite over a channel you already trust, or paste " +
"someone else's. Invites are not discoverable — nothing is published.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} else {
LazyColumn(Modifier.fillMaxSize()) {
items(friends, key = { it.pubkeyHex }) { f ->
FriendRow(f) { onOpenChat(f) }
HorizontalDivider()
}
}
}
}
}
}
@Composable
private fun FriendRow(f: FriendInfo, onClick: () -> Unit) {
Column(
Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.background(MaterialTheme.colorScheme.background)
.padding(horizontal = 16.dp, vertical = 12.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
f.petname?.takeIf { it.isNotBlank() } ?: f.fingerprint,
style = MaterialTheme.typography.titleSmall,
)
if (!f.lastConnectOk) {
Spacer(Modifier.height(0.dp))
Text(
" locator stale",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.error,
)
}
}
Spacer(Modifier.height(2.dp))
Text(
f.fingerprint,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.primary,
)
Text(
f.onion,
style = MaterialTheme.typography.labelSmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@Composable
fun Label(text: String) {
Text(
text,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
@Composable
fun Mono(text: String) {
Text(
text,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurface,
)
}
@Composable
fun ConfirmStrip(
text: String,
confirm: String,
onConfirm: () -> Unit,
onCancel: () -> Unit,
) {
var typed by remember { mutableStateOf("") }
Card(
modifier = Modifier.fillMaxWidth().padding(12.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
) {
Column(Modifier.padding(12.dp)) {
Text(text, style = MaterialTheme.typography.bodySmall)
Spacer(Modifier.height(8.dp))
androidx.compose.material3.OutlinedTextField(
value = typed,
onValueChange = { typed = it },
singleLine = true,
label = { Text("Type $confirm") },
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.End, modifier = Modifier.fillMaxWidth()) {
TextButton(onClick = onCancel) { Text("Cancel") }
TextButton(
onClick = onConfirm,
enabled = typed.trim().equals(confirm, ignoreCase = true),
) { Text("Confirm") }
}
}
}
}

View file

@ -0,0 +1,139 @@
package com.siriusdevops.onionwire.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
/**
* Rotating publishes a brand new onion service and hard-cuts the old one.
*
* It is a typed confirmation on purpose: it is not a single tap, and it is not
* hidden. Friends who are online get a signed `loc` update; friends who are
* offline cannot find you again until they re-scan your invite. Your identity
* key and therefore your fingerprint does not change.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RotateScreen(
currentOnion: String,
onRotate: suspend () -> Pair<Int, Int>, // notified to friends
onBack: () -> Unit,
) {
var typed by remember { mutableStateOf("") }
var busy by remember { mutableStateOf(false) }
var result by remember { mutableStateOf<Pair<Int, Int>?>(null) }
var error by remember { mutableStateOf<String?>(null) }
Scaffold(
topBar = {
TopAppBar(
title = { Text("Rotate onion") },
navigationIcon = { IconButton(onClick = onBack) { Text("<") } },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background,
),
)
},
) { pad ->
Column(
Modifier.padding(pad).fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp),
) {
Text("Rotate onion address?", style = MaterialTheme.typography.titleLarge)
Spacer(Modifier.height(12.dp))
Text("Your identity key stays the same.", style = MaterialTheme.typography.bodyMedium)
Text(
"Online friends get a signed location update.",
style = MaterialTheme.typography.bodyMedium,
)
Text(
"Offline friends CANNOT find you until they re-scan your invite.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
)
Spacer(Modifier.height(16.dp))
Label("CURRENT")
Mono(currentOnion)
Spacer(Modifier.height(20.dp))
if (result == null) {
OutlinedTextField(
value = typed,
onValueChange = { typed = it },
singleLine = true,
label = { Text("Type ROTATE to confirm") },
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(12.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(
enabled = typed.trim().equals("ROTATE", ignoreCase = true) &&
!busy,
onClick = { busy = true; error = null },
) { Text("Rotate") }
if (busy) CircularProgressIndicator()
}
}
LaunchedEffect(busy) {
if (!busy) return@LaunchedEffect
runCatching { onRotate() }
.onSuccess { result = it }
.onFailure { error = it.message ?: it.toString() }
busy = false
}
error?.let {
Spacer(Modifier.height(16.dp))
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
result?.let { (notified, friends) ->
Spacer(Modifier.height(20.dp))
Text(
"Rotated · notified $notified/$friends friends",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
)
Spacer(Modifier.height(8.dp))
Text(
"The old onion service is already gone. Anyone still holding the old " +
"invite will fail to connect — that is the documented v1 behaviour, " +
"not a bug.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(16.dp))
Button(onClick = onBack) { Text("Back — share the new invite") }
}
}
}
}

View file

@ -0,0 +1,34 @@
package com.siriusdevops.onionwire.ui
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
private val OnionViolet = Color(0xFF7C4DFF)
private val WireCyan = Color(0xFF38F2C7)
private val DeepVoid = Color(0xFF0B0B10)
private val PanelVoid = Color(0xFF15151C)
private val OnionWireColors = darkColorScheme(
primary = WireCyan,
onPrimary = DeepVoid,
primaryContainer = OnionViolet,
onPrimaryContainer = Color.White,
secondary = OnionViolet,
onSecondary = Color.White,
background = DeepVoid,
onBackground = Color(0xFFE6E6F0),
surface = DeepVoid,
onSurface = Color(0xFFE6E6F0),
surfaceVariant = PanelVoid,
onSurfaceVariant = Color(0xFFB9B9CC),
outline = Color(0xFF3A3A4A),
error = Color(0xFFFF6B6B),
onError = DeepVoid,
)
@Composable
fun OnionWireTheme(content: @Composable () -> Unit) {
MaterialTheme(colorScheme = OnionWireColors, content = content)
}

View file

@ -0,0 +1,130 @@
package com.siriusdevops.onionwire.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import com.siriusdevops.onionwire.Phase
@Composable
fun UnlockScreen(phase: Phase, onUnlock: (String) -> Unit) {
var pass by remember { mutableStateOf("") }
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(24.dp),
verticalArrangement = Arrangement.Center,
) {
Text("ONIONWIRE", style = MaterialTheme.typography.headlineMedium)
Spacer(Modifier.height(4.dp))
Text(
"Tor messenger. No server, no account. Your identity is a key.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(32.dp))
when (phase) {
is Phase.Opening -> {
Text("Publishing onion service…", style = MaterialTheme.typography.titleSmall)
Spacer(Modifier.height(8.dp))
Text(
"First launch bootstraps Arti and uploads a v3 onion descriptor. " +
"On a cold network this takes minutes, not seconds. Keep the screen on.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(16.dp))
CircularProgressIndicator()
}
else -> {
Text(
"Passphrase",
style = MaterialTheme.typography.titleSmall,
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = pass,
onValueChange = { pass = it },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done,
),
label = { Text("wraps the local message key") },
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(12.dp))
Text(
"Empty is not a bypass — it fails closed. There is no recovery: " +
"lose this and the stored chat bodies stay unreadable.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(20.dp))
Button(
onClick = { onUnlock(pass) },
enabled = pass.isNotEmpty(),
modifier = Modifier.fillMaxWidth(),
) { Text("Open") }
if (phase is Phase.Failed) {
Spacer(Modifier.height(20.dp))
Text(
"Failed to start",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.error,
)
Spacer(Modifier.height(4.dp))
Text(
(phase as Phase.Failed).message,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.error,
)
Spacer(Modifier.height(8.dp))
Text(
"OnionWire fails closed: if the onion service cannot publish, " +
"it does not fall back to a clearnet or C-tor path.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
Spacer(Modifier.height(24.dp))
Text(
"INTERNET is the only permission this app requests.",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#0B0B10"
android:pathData="M0,0h108v108h-108z" />
</vector>

View file

@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Onion bulb. Adaptive-icon foreground: keep art inside the 66dp safe zone. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- bulb -->
<path
android:fillColor="#7C4DFF"
android:pathData="M54,28 C72,28 82,46 82,62 C82,78 69,88 54,88 C39,88 26,78 26,62 C26,46 36,28 54,28 Z" />
<!-- sprout -->
<path
android:strokeColor="#38F2C7"
android:strokeWidth="3"
android:strokeLineCap="round"
android:pathData="M54,28 L54,18" />
<!-- layers -->
<path
android:strokeColor="#38F2C7"
android:strokeWidth="2.5"
android:strokeLineCap="round"
android:fillColor="#00000000"
android:pathData="M38,40 C33,54 35,74 48,84" />
<path
android:strokeColor="#38F2C7"
android:strokeWidth="2.5"
android:strokeLineCap="round"
android:fillColor="#00000000"
android:pathData="M70,40 C75,54 73,74 60,84" />
<path
android:strokeColor="#0B0B10"
android:strokeWidth="2.5"
android:strokeLineCap="round"
android:fillColor="#00000000"
android:pathData="M54,32 L54,86" />
</vector>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">OnionWire</string>
</resources>

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Compose paints the real UI; this only sets the window before the
first frame so there is no white flash on a dark app. -->
<style name="Theme.OnionWire" parent="android:Theme.Material.NoActionBar">
<item name="android:windowBackground">#FF0B0B10</item>
<item name="android:statusBarColor">#FF0B0B10</item>
<item name="android:navigationBarColor">#FF0B0B10</item>
</style>
</resources>

View file

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Chat bodies are encrypted at rest, but nothing is cloud-synced or
device-transferred: an Android auto-backup of onionwire.db would hand the
wrapped message key and the plaintext identity keys to Google Drive.
Identity is local by design — back it up with the SDK's owbak1 export instead.
-->
<data-extraction-rules>
<cloud-backup>
<exclude domain="file" />
<exclude domain="database" />
<exclude domain="sharedpref" />
</cloud-backup>
<device-transfer>
<exclude domain="file" />
<exclude domain="database" />
<exclude domain="sharedpref" />
</device-transfer>
</data-extraction-rules>

6
android/build.gradle.kts Normal file
View file

@ -0,0 +1,6 @@
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.android.library) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) apply false
}

12
android/gradle.properties Normal file
View file

@ -0,0 +1,12 @@
# Gradle
org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g -Dfile.encoding=UTF-8
org.gradle.parallel=true
org.gradle.caching=true
org.gradle.configuration-cache=false
# Android
android.useAndroidX=true
android.nonTransitiveRClass=true
# Kotlin
kotlin.code.style=official

View file

@ -0,0 +1,31 @@
[versions]
agp = "8.13.2"
kotlin = "2.2.21"
coreKtx = "1.17.0"
activityCompose = "1.12.4"
lifecycle = "2.9.4"
navigationCompose = "2.9.8"
composeBom = "2025.12.01"
jna = "5.19.1"
coroutines = "1.11.0"
[libraries]
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "coreKtx" }
jna = { module = "net.java.dev.jna:jna", version.ref = "jna" }
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" }
androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" }
androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" }
androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" }
compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBom" }
compose-ui = { module = "androidx.compose.ui:ui" }
compose-ui-graphics = { module = "androidx.compose.ui:ui-graphics" }
compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
compose-material3 = { module = "androidx.compose.material3:material3" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
android-library = { id = "com.android.library", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }

Binary file not shown.

View file

@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.5-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

251
android/gradlew vendored Executable file
View file

@ -0,0 +1,251 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

94
android/gradlew.bat vendored Normal file
View file

@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

View file

@ -0,0 +1,163 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
}
// ---------------------------------------------------------------------------
// Rust: build crates/onionwire-sdk for the requested Android ABIs and generate
// the UniFFI Kotlin bindings from the host cdylib.
//
// Nothing here touches the Linux TUI crate's feature graph: the SDK crate is
// its own Cargo workspace that selects Arti's rustls backend.
// ---------------------------------------------------------------------------
val repoRoot: File = rootProject.projectDir.parentFile
val sdkCrateDir: File = repoRoot.resolve("crates/onionwire-sdk")
/** NDK version used for the Rust link step. Must match an installed NDK. */
val onionwireNdkVersion: String = (findProperty("onionwire.ndk") as String?)
?: "28.2.13676358"
/** Compile against this API level. Keep in sync with `minSdk`. */
val onionwireApiLevel: Int = ((findProperty("onionwire.api") as String?) ?: "26").toInt()
val androidSdkDir: File = sequenceOf(
System.getenv("ANDROID_HOME"),
System.getenv("ANDROID_SDK_ROOT"),
).filterNotNull().map(::File).firstOrNull()
?: rootProject.file("local.properties")
.takeIf { it.exists() }
?.readLines()
?.firstOrNull { it.startsWith("sdk.dir=") }
?.substringAfter('=')
?.trim()
?.let(::File)
?: error("ANDROID_HOME / ANDROID_SDK_ROOT unset and no sdk.dir in android/local.properties")
val ndkDir: File = androidSdkDir.resolve("ndk/$onionwireNdkVersion")
require(ndkDir.isDirectory) {
"NDK $onionwireNdkVersion not found at $ndkDir — run: sdkmanager --install \"ndk;$onionwireNdkVersion\""
}
val cargoExe: String = (findProperty("onionwire.cargo") as String?)
?: System.getenv("CARGO")
?: File(System.getProperty("user.home"), ".cargo/bin/cargo").takeIf { it.canExecute() }?.absolutePath
?: "cargo"
/** `-Ponionwire.abis=arm64-v8a,x86_64` to build more than one ABI. */
val onionwireAbis: List<String> = (findProperty("onionwire.abis") as String?)
?.split(',')?.map(String::trim)?.filter(String::isNotEmpty)
?: listOf("arm64-v8a")
val jniLibsDir = layout.buildDirectory.dir("rustJniLibs")
val uniffiKotlinDir = layout.buildDirectory.dir("generated/uniffi")
/** Host cdylib. UniFFI's `--library` mode dlopens it, so it must be host-arch. */
val cargoHostLib by tasks.registering(Exec::class) {
group = "onionwire"
description = "Builds libonionwire_sdk.so for the host (used only to emit bindings)."
workingDir = sdkCrateDir
environment("ANDROID_HOME", androidSdkDir.absolutePath)
commandLine(cargoExe, "build", "--release", "--lib")
}
val uniffiBindgen by tasks.registering(Exec::class) {
group = "onionwire"
description = "Generates the Kotlin UniFFI bindings into build/generated/uniffi."
dependsOn(cargoHostLib)
workingDir = sdkCrateDir
inputs.file(sdkCrateDir.resolve("src/lib.rs"))
outputs.dir(uniffiKotlinDir)
environment("ANDROID_HOME", androidSdkDir.absolutePath)
doFirst { uniffiKotlinDir.get().asFile.mkdirs() }
commandLine(
cargoExe, "run", "--release", "--bin", "uniffi-bindgen", "--",
"generate",
"--library", "target/release/libonionwire_sdk.so",
"--language", "kotlin",
"--no-format",
"--config", "uniffi.toml",
"--out-dir", uniffiKotlinDir.get().asFile.absolutePath,
)
}
val cargoBuildAndroid by tasks.registering(Exec::class) {
group = "onionwire"
description = "Cross-compiles crates/onionwire-sdk for ${onionwireAbis.joinToString()}."
workingDir = sdkCrateDir
inputs.dir(sdkCrateDir.resolve("src"))
inputs.file(sdkCrateDir.resolve("Cargo.toml"))
inputs.file(sdkCrateDir.resolve("Cargo.lock"))
inputs.dir(repoRoot.resolve("src"))
inputs.file(repoRoot.resolve("Cargo.toml"))
outputs.dir(jniLibsDir)
environment("ANDROID_HOME", androidSdkDir.absolutePath)
environment("ANDROID_NDK_HOME", ndkDir.absolutePath)
environment("NDK_HOME", ndkDir.absolutePath)
commandLine(
buildList {
add(cargoExe); add("ndk")
onionwireAbis.forEach { add("-t"); add(it) }
add("--platform"); add(onionwireApiLevel.toString())
add("-o"); add(jniLibsDir.get().asFile.absolutePath)
add("build"); add("--release"); add("--lib")
}
)
}
// ---------------------------------------------------------------------------
android {
namespace = "com.siriusdevops.onionwire.sdk"
compileSdk = 36
ndkVersion = onionwireNdkVersion
defaultConfig {
minSdk = onionwireApiLevel
consumerProguardFiles("consumer-rules.pro")
ndk {
abiFilters += onionwireAbis
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
sourceSets["main"].jniLibs.srcDir(jniLibsDir)
sourceSets["main"].kotlin.srcDir(uniffiKotlinDir)
buildTypes {
release {
isMinifyEnabled = false
}
}
}
kotlin {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_17)
}
}
dependencies {
// UniFFI's Kotlin backend talks to the Rust cdylib through JNA direct
// mapping, and the async bindings need coroutines. Both are part of the
// public API surface, so they are `api` — a consumer of the AAR needs them
// on its own compile classpath. JNA's `aar` variant is what carries
// libjnidispatch.so for each ABI.
api(variantOf(libs.jna) { artifactType("aar") })
api(libs.kotlinx.coroutines.core)
}
tasks.withType<KotlinCompile>().configureEach {
dependsOn(uniffiBindgen)
}
tasks.matching { it.name == "preBuild" }.configureEach {
dependsOn(cargoBuildAndroid, uniffiBindgen)
}

View file

@ -0,0 +1,9 @@
# Applied to any app that depends on :sdk (AAR consumer rules).
#
# UniFFI calls back into Kotlin from the Rust side over JNI, so the generated
# classes are reachable only via reflection/native lookup. Without these keeps
# an R8-enabled consumer app strips them and fails at load time.
-keep class uniffi.onionwire_sdk.** { *; }
-keepclasseswithmembernames class uniffi.onionwire_sdk.** {
native <methods>;
}

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The only permission the SDK itself needs: Tor runs over INTERNET. -->
<uses-permission android:name="android.permission.INTERNET" />
</manifest>

View file

@ -0,0 +1,28 @@
pluginManagement {
repositories {
google {
content {
includeGroupByRegex("com\\.android.*")
includeGroupByRegex("com\\.google.*")
includeGroupByRegex("androidx.*")
}
}
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "onionwire-android"
// The Android library that ships as the AAR (Kotlin bindings + native .so).
include(":sdk")
// The installable Compose messenger. Depends on :sdk only — never on the TUI.
include(":app")

6603
crates/onionwire-sdk/Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,51 @@
[package]
name = "onionwire-sdk"
version = "0.1.0"
edition = "2024"
rust-version = "1.91"
description = "OnionWire Android/Kotlin SDK: in-process Arti onion transport, Noise IK, identity = ed25519 pubkey."
license = "MIT"
publish = false
# Standalone workspace on purpose.
#
# Arti's TLS backends (`native-tls` vs `rustls`) are non-additive: exactly one
# may be enabled per feature resolution. The Linux TUI crate keeps native-tls,
# Android has no OpenSSL in the NDK and needs rustls. Two separate workspaces
# keep the two resolutions from colliding — this crate path-depends on the
# root package without joining its feature graph.
[workspace]
[lib]
name = "onionwire_sdk"
crate-type = ["cdylib", "lib"]
[dependencies]
onionwire = { path = "../..", default-features = false, features = ["rustls"] }
# `static-sqlite`: Android has no system libsqlite3 to link against.
arti-client = { version = "0.46", default-features = false, features = [
"tokio",
"onion-service-client",
"onion-service-service",
"compression",
"rustls",
"static-sqlite",
] }
tokio = { version = "1", features = ["rt-multi-thread", "time"] }
# rustls 0.23 resolves its provider from its OWN `ring` / `aws-lc-rs` features,
# never from whichever crypto crates happen to be linked in the graph. Arti
# pulls rustls in through `tor-rtcompat` with `default-features = false`, so
# without this line rustls compiles with zero providers and the first TLS config
# built from the process default fails at runtime — the "Failed to start /
# Could not automatically determine the process-level CryptoProvider" screen.
#
# `ring`, not `aws-lc-rs`: aws-lc-rs needs CMake and a C toolchain for the NDK
# and fights the Android cross-compile. The `ring` crate was already in the
# graph (via `snow`, for Noise) but that is a different thing entirely.
rustls = { version = "0.23", default-features = false, features = ["ring"] }
uniffi = { version = "0.32", features = ["cli", "tokio"] }
thiserror = "2"
[[bin]]
name = "uniffi-bindgen"
path = "src/bin/uniffi-bindgen.rs"

View file

@ -0,0 +1,122 @@
# onionwire-sdk (Android)
Kotlin-facing UniFFI bindings over the same Rust crate the OnionWire Linux TUI
runs on. An Android peer and a Linux peer that exchange invites interoperate —
there is one protocol, not two.
This is **not** a WebView wrapper and **not** a client for a hosted service.
Each install runs Arti in-process (`arti-client` 0.46, `onion-service-client` +
`onion-service-service`), hosts its own v3 onion service, and dials friends'
onions directly. There is no `tor` binary, no torrc, no Orbot as the pipe.
## Locked protocol facts
| Piece | Rule |
|---|---|
| Identity | ed25519 keypair. The pubkey **is** who you are. |
| Roster key | `friends.pubkey`, `UNIQUE`. Petnames are local only. |
| Onion | A **locator**, not an identity. It rotates. |
| Invite | `onionwire:v1:k=…:o=…:spk=…:sig=…` |
| Crypto | Noise IK (`Noise_IK_25519_ChaChaPoly_BLAKE2s`). `spk` is x25519. |
| Fail closed | Peer onion down → the send fails. No outbox, no spool in v1. |
| At rest | Chat bodies encrypted (Argon2id wrap + ChaCha20-Poly1305). Identity keys in sqlite are plaintext — that is honest, not an oversight. |
### Invite string
```
onionwire:v1:k=<ed25519 pubkey hex64>:o=<v3 onion address>:spk=<x25519 prekey hex64>:sig=<ed25519 sig hex128>
```
* `sig` is a signature over the concatenation `k ‖ o ‖ spk` as raw ASCII bytes.
* A malformed, unsigned, or tampered invite is rejected before anything is stored.
* **Same `k` never creates a second friend.** It updates the stored locator on
the existing row. That is the whole point of keying the roster on the pubkey.
* The invite carries no display name and no Monero address — those arrive later
over the wire as signed `prf` frames, not through the invite.
## Adding the AAR to another Android app
The SDK ships as `onionwire-sdk-<version>.aar` on the Forgejo release, with a
matching `.sha256`.
```kotlin
// settings.gradle.kts
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
flatDir { dirs("libs") } // or however you vendor the AAR
}
}
// app/build.gradle.kts
dependencies {
implementation(files("libs/onionwire-sdk-0.1.0.aar"))
}
```
Requirements:
* `minSdk >= 26`. The AAR is built with the NDK against `android-26`.
* `abiFilters` must include an ABI the AAR ships. The release AAR includes
`arm64-v8a`. Add `x86_64` at build time with `-Ponionwire.abis=arm64-v8a,x86_64`
if you need an emulator.
* Keep `uniffi.onionwire_sdk.**` (see the AAR's `consumer-rules.pro`, applied
automatically by AGP) if you enable R8.
## Kotlin surface
Everything is a `suspend fun` — Arti bootstrap and onion publish take minutes,
and a send retries the peer for up to 3 minutes before failing.
```kotlin
import uniffi.onionwire_sdk.*
// 1. Open (or create) the identity inside app-private storage.
// `filesDir`, never external storage. Empty passphrase fails closed.
val wire: Wire = openWire(File(context.filesDir, "onionwire").absolutePath, passphrase)
// 2. Who you are, and where you are right now.
val fingerprint: String = wire.fingerprint() // stable forever
val onion: String = wire.onion() // changes on rotate
// 3. Invite out / invite in.
val mine: String = wire.invite()
val preview: InviteInfo = decodeInvite(pasted) // verifies sig, stores nothing
val friend: FriendInfo = wire.addFriend(pasted)
// 4. Roster and chat.
val roster: List<FriendInfo> = wire.friends()
wire.setPetname(friend.pubkeyHex, "ada")
wire.send(friend.pubkeyHex, "hello wire")
val msgs: List<ChatMessage> = wire.messages(friend.pubkeyHex)
// 5. Locator rotation. Do NOT wire this to a single tap — the app that embeds
// the SDK owns the typed confirmation.
val out: RotateOutcome = wire.rotateOnion() // out.notified / out.friends
```
All peer references are lowercase hex strings so nothing Rust-shaped leaks into
the AAR.
## Building the bindings yourself
The Gradle task `:sdk:uniffiBindgen` runs, inside `crates/onionwire-sdk`:
```
cargo build --release --lib # host cdylib
cargo run --release --bin uniffi-bindgen -- generate \
--library target/release/libonionwire_sdk.so \
--language kotlin --out-dir <generated dir>
```
Do not hand-edit anything under `android/sdk/build/generated/`; it is a build
output.
## Why this crate has its own Cargo workspace
Arti's TLS backends are **non-additive**: exactly one of `native-tls` and
`rustls` may be enabled in a given feature resolution. The Linux TUI keeps
`native-tls` (OpenSSL, as it always has). Android has no OpenSSL in the NDK, so
this crate selects `rustls` plus `static-sqlite` (no system `libsqlite3` on
Android). Two separate workspaces keep the two resolutions from colliding.

View file

@ -0,0 +1,8 @@
//! UniFFI binding generator entry point.
//!
//! `cargo run -p onionwire-sdk --bin uniffi-bindgen -- generate --library \
//! target/release/libonionwire_sdk.so --language kotlin --out-dir <dir>`
fn main() {
uniffi::uniffi_bindgen_main()
}

View file

@ -0,0 +1,258 @@
//! OnionWire Android/Kotlin SDK.
//!
//! UniFFI façade over the existing `onionwire` library — the same crate the
//! Linux TUI runs on. Nothing about the wire protocol is reimplemented here:
//! an Android peer and a Linux peer that exchange invites interoperate.
//!
//! Locked facts this surface assumes:
//! * Identity is an ed25519 pubkey. Fingerprint is a display of that key.
//! * `onionwire:v1:k=…:o=…:spk=…:sig=…` — the onion is a *locator* and can
//! change; re-scanning the same `k` updates the locator, never duplicates
//! the friend.
//! * Transport is in-process Arti. There is no `tor` binary, no torrc, no
//! Orbot pipe. If the onion service cannot publish, this fails closed.
//! * Chat bodies are encrypted at rest. Identity keys in sqlite are not.
//!
//! The façade deliberately exposes only plain data (records + hex strings) so
//! no TUI/ratatui type leaks into the AAR.
use std::path::PathBuf;
use std::sync::{Arc, OnceLock};
use onionwire::node::Node;
use onionwire::qr;
uniffi::setup_scaffolding!();
/// Install rustls's process-level default `CryptoProvider`, exactly once.
///
/// Belt and braces. `Cargo.toml` pins `rustls` with the `ring` feature, which
/// is what actually fixes the missing-provider failure: with it, rustls
/// resolves a provider from crate features on its own. This function exists
/// because the cdylib is loaded into a process we do not own — an explicitly
/// installed provider makes the SDK independent of how the surrounding app's
/// feature graph happens to resolve rustls.
///
/// Idempotent and cheap after the first call (`OnceLock` short-circuits it).
/// An `Err` means some provider is already installed process-wide, which is the
/// outcome we want, so it is deliberately ignored — including the case of a
/// consumer that installed `aws-lc-rs` itself. A genuine *conflict* — both
/// provider features compiled in — is a build-graph bug, not something to
/// paper over here; `Cargo.toml` enables `ring` only.
///
/// Not exported over UniFFI: it is Rust-side plumbing, not part of the Kotlin
/// surface.
pub fn install_crypto_provider() {
static INSTALLED: OnceLock<()> = OnceLock::new();
INSTALLED.get_or_init(|| {
let _ = rustls::crypto::ring::default_provider().install_default();
});
}
#[derive(Debug, thiserror::Error, uniffi::Error)]
#[uniffi(flat_error)]
pub enum WireError {
#[error("{message}")]
Failed { message: String },
}
impl WireError {
fn new(e: impl std::fmt::Display) -> Self {
Self::Failed {
message: e.to_string(),
}
}
}
type WResult<T> = std::result::Result<T, WireError>;
#[derive(Debug, Clone, uniffi::Record)]
pub struct FriendInfo {
/// ed25519 identity pubkey, lowercase hex. This is the roster key.
pub pubkey_hex: String,
/// Display form of the same key.
pub fingerprint: String,
/// Local label. Never sent on the wire.
pub petname: Option<String>,
/// Current locator. May be stale if the peer rotated while you were down.
pub onion: String,
pub last_connect_ok: bool,
}
#[derive(Debug, Clone, uniffi::Record)]
pub struct ChatMessage {
/// "in" or "out".
pub direction: String,
pub body: String,
}
#[derive(Debug, Clone, uniffi::Record)]
pub struct RotateOutcome {
pub notified: u32,
pub friends: u32,
}
#[derive(Debug, Clone, uniffi::Record)]
pub struct InviteInfo {
pub pubkey_hex: String,
pub onion: String,
}
/// A running OnionWire node: identity + Arti client + hosted v3 onion service.
#[derive(uniffi::Object)]
pub struct Wire {
inner: Arc<Node>,
}
/// Open (or create) the identity in `home` and start hosting an onion service.
///
/// `home` must be app-private storage (`context.filesDir`), not external.
/// `passphrase` wraps the local message key; an empty passphrase fails closed.
///
/// This resolves only once the onion service is published — on a cold network
/// that is minutes, not seconds. Call it off the main thread.
#[uniffi::export(async_runtime = "tokio")]
pub async fn open_wire(home: String, passphrase: String) -> WResult<Arc<Wire>> {
// Before anything that can build a TLS config: Arti's rustls backend dies
// with "Could not automatically determine the process-level CryptoProvider"
// if no provider is resolvable. See `install_crypto_provider`.
install_crypto_provider();
let node = Node::start_with_passphrase(PathBuf::from(home), &passphrase)
.await
.map_err(WireError::new)?;
Ok(Arc::new(Wire { inner: node }))
}
/// Decode an invite without adding the friend. Verifies the signature.
#[uniffi::export]
pub fn decode_invite(invite: String) -> WResult<InviteInfo> {
let p = qr::decode(&invite).map_err(WireError::new)?;
Ok(InviteInfo {
pubkey_hex: hex(&p.pubkey),
onion: p.onion,
})
}
#[uniffi::export(async_runtime = "tokio")]
impl Wire {
/// Fingerprint of our own identity key. Stable forever for this install.
pub async fn fingerprint(&self) -> WResult<String> {
self.inner.fingerprint().map_err(WireError::new)
}
/// Current onion locator. Changes on rotate; identity does not.
pub async fn onion(&self) -> String {
self.inner.onion()
}
/// `onionwire:v1:…` invite string to hand to the other person out of band.
pub async fn invite(&self) -> WResult<String> {
self.inner.qr_payload().map_err(WireError::new)
}
/// Roster, sorted by petname then fingerprint.
pub async fn friends(&self) -> WResult<Vec<FriendInfo>> {
let list = self.inner.list_friends().map_err(WireError::new)?;
Ok(list
.into_iter()
.map(|f| FriendInfo {
pubkey_hex: hex(&f.pubkey),
fingerprint: f.fingerprint,
petname: f.petname,
onion: f.onion,
last_connect_ok: f.last_connect_ok,
})
.collect())
}
/// Add a friend from an invite, or update the locator if the key is known.
/// Same `k` never creates a second row.
pub async fn add_friend(&self, invite: String) -> WResult<FriendInfo> {
self.inner.add_friend_from_qr(&invite).map_err(WireError::new)?;
let p = qr::decode(&invite).map_err(WireError::new)?;
let f = self.inner.friend(&p.pubkey).map_err(WireError::new)?;
Ok(FriendInfo {
pubkey_hex: hex(&f.pubkey),
fingerprint: f.fingerprint,
petname: f.petname,
onion: f.onion,
last_connect_ok: f.last_connect_ok,
})
}
/// Local label. Pass `None` to clear. Never goes on the wire.
pub async fn set_petname(&self, pubkey_hex: String, petname: Option<String>) -> WResult<()> {
let pk = unhex(&pubkey_hex)?;
self.inner
.set_petname(&pk, petname.as_deref())
.map_err(WireError::new)
}
/// Send one message. Fail closed: if the peer's onion is down, this errors
/// after retrying. There is no outbox in v1 — no silent spooling.
pub async fn send(&self, pubkey_hex: String, body: String) -> WResult<()> {
let pk = unhex(&pubkey_hex)?;
self.inner
.send(&pk, body.as_bytes())
.await
.map_err(WireError::new)
}
/// Chat history for a friend, oldest first. Bodies are decrypted here;
/// they are stored encrypted at rest.
pub async fn messages(&self, pubkey_hex: String) -> WResult<Vec<ChatMessage>> {
let pk = unhex(&pubkey_hex)?;
let rows = self.inner.list_messages(&pk).map_err(WireError::new)?;
Ok(rows
.into_iter()
.map(|m| ChatMessage {
direction: m.dir,
body: String::from_utf8_lossy(&m.plaintext).into_owned(),
})
.collect())
}
/// Delete every message body. Identity and friends survive.
pub async fn wipe_messages(&self) -> WResult<()> {
self.inner.wipe_messages().map_err(WireError::new)
}
/// Publish a NEW onion service and hard-cut the old one, then push a signed
/// location update to every reachable friend.
///
/// The identity key does not change. Friends who are offline cannot find
/// you until they rescan your invite — that is the documented v1 behaviour,
/// not a bug. Confirm with a typed phrase in the UI; never one tap.
pub async fn rotate_onion(&self) -> WResult<RotateOutcome> {
let r = self.inner.rotate().await.map_err(WireError::new)?;
Ok(RotateOutcome {
notified: r.notified as u32,
friends: r.friends as u32,
})
}
/// SDK version, for diagnostics.
pub async fn version(&self) -> String {
env!("CARGO_PKG_VERSION").to_string()
}
}
fn hex(bytes: &[u8]) -> String {
let mut s = String::with_capacity(bytes.len() * 2);
for b in bytes {
s.push_str(&format!("{b:02x}"));
}
s
}
fn unhex(s: &str) -> WResult<Vec<u8>> {
if s.is_empty() || !s.len().is_multiple_of(2) || !s.bytes().all(|c| c.is_ascii_hexdigit()) {
return Err(WireError::new("pubkey_hex must be even-length hex"));
}
(0..s.len())
.step_by(2)
.map(|i| {
u8::from_str_radix(&s[i..i + 2], 16).map_err(|e| WireError::new(format!("hex: {e}")))
})
.collect()
}

View file

@ -0,0 +1,30 @@
//! `open_wire` must not depend on how the consumer's feature graph resolves a
//! rustls provider — it installs the process default itself, first.
//!
//! This is the belt-and-braces half of the fix for the on-device "Failed to
//! start / Could not automatically determine the process-level CryptoProvider"
//! screen; the primary fix is the `rustls` `ring` feature in `Cargo.toml`,
//! covered by `tests/provider_resolution.rs`. Kept in its own test binary on
//! purpose: installing a provider here would mask that test if they shared a
//! process.
#[test]
fn sdk_installs_the_process_default_provider() {
assert!(
rustls::crypto::CryptoProvider::get_default().is_none(),
"this test must start with no provider installed"
);
onionwire_sdk::install_crypto_provider();
assert!(
rustls::crypto::CryptoProvider::get_default().is_some(),
"install_crypto_provider() left the process without a default provider"
);
// Second call: idempotent, not a panic and not an error.
onionwire_sdk::install_crypto_provider();
// The call Arti makes that blew up on device.
let _ = rustls::ClientConfig::builder();
}

View file

@ -0,0 +1,45 @@
//! Regression test for the on-device failure: the APK built, installed and
//! opened, but pressing **Open** on the unlock screen died one call deep inside
//! Arti's rustls backend with
//!
//! ```text
//! Could not automatically determine the process-level CryptoProvider from
//! Rustls crate features.
//! Call CryptoProvider::install_default() before this point to select a
//! provider manually, or make sure exactly one of the 'aws-lc-rs' and 'ring'
//! features is enabled.
//! ```
//!
//! rustls 0.23 chooses its provider from its own `ring` / `aws-lc-rs` **crate
//! features**, not from which crypto crates happen to be linked. Arti reaches
//! rustls through `tor-rtcompat` with `default-features = false`, so neither
//! provider feature is on and rustls is compiled with *no* provider at all:
//! `ring` showing up in `cargo tree` (pulled in by `snow`, for Noise) proves
//! nothing. Everything compiles, the APK ships, and the process-default lookup
//! fails at runtime.
//!
//! This test is the invariant Arti relies on: the process default must be
//! resolvable **without** anyone calling `install_default()` first.
//!
//! Deliberately the only test in this file — a second test that installs a
//! provider would race with it inside the same test binary and could mask the
//! regression.
/// Building a `rustls` config the way Arti does, straight from the process
/// default, must not panic.
#[test]
fn rustls_default_provider_resolves_without_manual_install() {
assert!(
rustls::crypto::CryptoProvider::get_default().is_none(),
"this test must start with no provider installed"
);
// Pre-fix this panics with the device's exact message.
let _ = rustls::ClientConfig::builder();
assert!(
rustls::crypto::CryptoProvider::get_default().is_some(),
"rustls resolved no CryptoProvider — the crate feature that selects a \
provider is not enabled in this workspace"
);
}

View file

@ -0,0 +1,9 @@
# UniFFI Kotlin bindings configuration.
#
# `disable_java_cleaner`: the JVM cleaner path pulls in androidx.annotation and
# branches on API 34. JNA's own Cleaner works on every API level we support, so
# we take the single code path and drop the extra dependency.
[bindings.kotlin]
package_name = "uniffi.onionwire_sdk"
disable_java_cleaner = true
kotlin_target_version = "2.2.21"

194
docs/SECURITY_AUDIT.md Normal file
View file

@ -0,0 +1,194 @@
# OnionWire Security Audit
Commit: 2b42864ebaaef2aab64e66055ee482c525180f63 (`2b42864`)
Baseline: `cargo test --locked` **pass** (100 passed, 3 ignored), `cargo clippy --locked --all-targets -- -D warnings` **pass**
Auditor: rust-dev (no access to running hidden services / live Monero wallet)
Tree: worktree `wt/t_d85060fb` at `/home/lancelot/Projects/onionwire/.worktrees/t_d85060fb`
Remote: `origin/main` = same SHA (`https://forgejo.siriusdevops.com/sirius/onionwire.git`)
Ignored tests (`needs live Tor network`): `rotate_hs`, `tor_hs`, `two_node`. Not re-run.
## Severity key
Critical = remote key compromise or plaintext disclosure
High = local key/plaintext disclosure, authn bypass, or payment forgery
Medium = DoS, nonce/IV weakness, metadata leak
Low = hygiene, error-path leakage, docs mismatch
Info = observation
## Findings
### F1 — Incoming receipt `verified=1` on unrelated wallet history [High]
Location: `src/wallet.rs:142-148`, used at `src/node.rs:667-683`
Evidence: confirmation is not “this txid paid this amount to this address”.
```142:148:src/wallet.rs
pub fn transfers_match(rows: &[TransferRow], txid: &str, amount: &str, address: &str) -> bool {
rows.iter().any(|r| {
r.txid == txid
|| (!address.is_empty()
&& r.address == address
&& (amount.is_empty() || r.amount == amount))
})
}
```
`ingest_receipt` verifies the **ed25519** on the `rcp` frame (so the *friend* signed it), inserts `verified: false`, then flips `verified` if `transfers_match` is true (`src/node.rs:671-682`). Replica of that predicate against two wallet rows `{txid:aaa111, amount:1000, addr:4AAA…}` and `{txid:bbb222, amount:5, addr:8BBB…}`:
```
txid-only match (wrong amount+addr): True
addr+amount match (wrong txid): True
honest miss: False
```
Impact: a friend who completed Noise IK can send a signed receipt for an arbitrary amount/address and get the TUI line `[receipt] N XMR` (verified) if *either* (a) `txid` appears anywhere in `get_transfers` `in`/`pending`, or (b) some inbound row already has that address and amount. That is payment forgery against the local “verified” bit. It is not a third-party wire injection: the frame still has to decrypt under the pinned session. `docs/THREAT_MODEL.md:33` says “Never trust a `rcp` frame without RPC confirmation (`verified` stays 0)” — the code *does* promote `verified`, and the RPC check does not bind amount+address+txid together. Tests encode the store default (`tests/pay.rs` `incoming_receipt_is_not_verified`) but never exercise `transfers_match` against mismatched amount.
Fix: require `txid == row.txid && amount == row.amount && address == row.address` (and reject empty fields). Do not OR. Keep `verified=0` if RPC is down.
### F2 — Chat AEAD has empty AAD; ciphertext rows are interchangeable [Medium]
Location: `src/backup.rs:98-108` (`aead_encrypt`), `src/store.rs:649` / `670`
Evidence: bodies are `nonce || ChaCha20-Poly1305(key, nonce, pt)` with no associated data. The same 32-byte `msg_key` wraps every row. A DB writer who cannot open the passphrase can still swap `messages.plaintext` blobs. Throwaway against this tree (`/tmp/ow-audit-repro`, `CARGO_TARGET_DIR` = this worktree `target`):
```
SWAP: alice sees "secret-for-bob"
SWAP: bob sees "secret-for-alice"
```
Both `list_messages` calls returned `Ok`; Poly1305 verified. `dir` / `friend_id` / `id` / `created_at` are plaintext columns and are not in the MAC.
Impact: anyone with write access to `onionwire.db` (same uid, stolen unlocked file, or a bug that writes sqlite) can reattribute ciphertext across friends and in/out without the passphrase. This is *not* remote plaintext disclosure. Identity secret keys are already plaintext in `self` (threat model says so); this is extra: the body encryption does not bind a row to its owner. Nonces are 96-bit random per `generate_nonce` — reuse across restarts/`wipe-all` (new key) / first-unlock rewrap is not the failure mode here.
Fix: encrypt as `Aead::encrypt` with AAD = `friend_id || dir || row_id` (or a committed header), or include those fields in the plaintext that is MACed. Reject decrypt if AAD does not match the row.
### F3 — Invite `sig` is over concatenated strings, not length-prefixed fields [Medium]
Location: `src/qr.rs:67-72` (`sign_msg`), `src/qr.rs:36-64` (`decode`)
Evidence: `sig` covers `k.as_bytes() || onion.as_bytes() || spk.as_bytes()` with no delimiters or lengths. `k` is 64 hex chars after the 32-byte check, so it cannot shift. `o` and `spk` can. Encode a real v3 onion + 32-byte `spk`, then move the first 8 hex chars of `spk` onto `o`, keep `k` and `sig`. `qr::decode` **accepts** the mutant:
```
CONCAT: mutated invite accepted
CONCAT: onion_changed=true
CONCAT: onion=abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopqrstuvwxyz234567.onionabababab
CONCAT: spk_len=28 (want 32)
CONCAT: pubkey_unchanged=true
```
`decode` does not require `signed_prekey.len()==32` or a v3 onion. `Node::add_friend_payload` then `upsert_friend` (same `k` **replaces** locator) and `set_friend_prekey`. Duplicate/unknown fields are rejected (`src/qr.rs:85-106`); all four fields are required. Empty `sig` fails `from_hex`. This is not a classic steal-the-identity concat: `k` stays the signer.
Impact: a mutated invite still verifies under the real identity key. F3-paste (or a same-`k` rescan) can poison `friends.onion` / `prekey` for that pubkey. The shifted onion is not an arbitrary attacker HS (you can only append a hex prefix of the original `spk`), so this is roster integrity / availability, not a silent MITM. Handshake then fails (`prekey` length ≠ 32 at `node.rs:388`). No panic on this path. `from_hex` has **no size cap**: a 4,000,000-char hex string decoded to 2,000,000 bytes in 0.34s in CPython; `decode` allocates that before `pubkey.len()!=32` rejects.
Fix: sign a domain-separated encoding (`k` || `0x00` || `o` || `0x00` || `spk`, or length prefixes). Reject `spk` ≠ 32 bytes and onion ≠ v3. Cap invite length before `from_hex` (a few KiB).
### F4 — Monero address check is prefix+length, not checksum [Medium]
Location: `src/pay.rs:38-48`; tests *require* the junk form to pass (`tests/pay.rs:15-50`)
Evidence:
```38:48:src/pay.rs
pub fn check_address(addr: &str) -> Result<()> {
let ok = match addr.as_bytes().first() {
Some(b'4') if addr.len() == 95 || addr.len() == 106 => true,
Some(b'8') if addr.len() == 95 => true,
_ => false,
};
if ok && !addr.contains('\n') {
Ok(())
} else {
Err(Error("invalid Monero address".into()))
}
}
```
`4` + `'A' * 94` is accepted (len 95). No network byte, no Keccak checksum, no alphabet check. `profile::check_fields` (`src/profile.rs:118-128`) does not call `check_address` at all — `xmr_addr` may be `"4abc"` (`tests/profile.rs`). `/tip` does call `check_address` on the stored profile address (`src/node.rs:331`). `/pay` invoices are signed over that address (`src/node.rs:290`).
Impact: OnionWire will persist and sign invoices/receipts for strings no Monero wallet should pay. A live `monero-wallet-rpc` will usually reject checksum failures on `transfer`, so this is not by itself silent theft. It *is* a local fail-open: garbage becomes a signed `inv`/`rcp` payload and a payments row. Combined with F1, a verified receipt can cite such an address.
Fix: decode base58, check network prefix + checksum. Empty profile `xmr_addr` stays allowed; non-empty must pass the same check.
### F5 — Wallet RPC is unauthenticated HTTP and can mark receipts from its full transfer list [Medium]
Location: `src/wallet.rs:151-171`, `223-237`, `117-138`; README documents `ONIONWIRE_WALLET_RPC=http://127.0.0.1:18083`
Evidence: URL parser requires `http://` (no TLS), host must be loopback or `.onion` (`allowed_host`, tested in `tests/wallet.rs` `refuse_non_loopback_non_onion_host`). There is no `user:pass` / Digest / header. `http_post` is raw `TcpStream` + `read_to_end` with a 5s timeout, no body size cap. `.onion` hosts are allowed but `TcpStream::connect((onion, port))` does **not** go through Arti — so an onion RPC URL fail-closes at connect (not examined live). `eprintln!("ONIONWIRE_WALLET_RPC: {e}")` prints the parse error, not the URL, on bad env.
Impact: the documented operator setup is “HTTP to loopback, no login”. Any local process that can reach that port can `transfer` (spend) and `get_transfers` (the same list F1 trusts). OnionWire never holds spend keys (threat model — true); it also never authenticates to the process that does. This is local, not remote, if the operator actually bound loopback. Code cannot express `--rpc-login`.
Fix: require digest (or a unix socket). Refuse URLs without credentials. Cap RPC read size. If onion RPC is a goal, dial it through the Arti client, not `TcpStream`.
### F6 — `/wipe` does not touch payments; sqlite is not `secure_delete` [Low]
Location: `src/store.rs:677-685`, `122-125`
Evidence: `wipe_messages` overwrites `messages.plaintext` with `zeroblob(length)`, `DELETE FROM messages`, `VACUUM`. No `PRAGMA secure_delete`. No `DELETE FROM payments`. WAL is required (`journal_mode = WAL` fail-closed). Threat model (`docs/THREAT_MODEL.md:21`) says “`/wipe` overwrites message bodies and vacuums” — that part matches. It does not say payments go away; they do not. Identity / friend pubkeys / onions stay plaintext (disclosed, not a finding). `onionwire.db` itself is never `chmod 0600`; the home and `arti/` dirs are `0700` after `create_dir_all` (`src/store.rs:753-758`, `tests/store.rs:61-62`). Backup files *are* `0o600` at create (`src/node.rs:145-150`).
Impact: `/wipe` is not a forensic erase (SSD wear-leveling, WAL snapshots, payments table, roster). A seized disk after `/wipe` still has who you pay and who you talk to. `/wipe-all` is `remove_dir_all` — same disk caveat.
Fix: if `/wipe` should mean “chat history gone”, also drop `payments` (and checkpoint WAL). Document that `/wipe` is not crypto-shred. Optional `secure_delete` is still not a guarantee on flash.
### F7 — HS publish logs the onion; `dangerously_trust_everyone` is Arti-only [Low]
Location: `src/hs.rs:31-36`, `src/hs.rs:95`, `src/node.rs:95`
Evidence: `wait_until_published(..., &onion, &onion)` uses the unredacted onion as `label`. `eprintln!("{label} hs status: {state:?}")` and probe lines go to stderr. `onion_string` uses `display_unredacted` (`src/hs.rs:70`) — required to persist the locator; the leak is the log. `client_config` calls `builder.storage().permissions().dangerously_trust_everyone()` after `create_dir_all` on the Arti state/cache paths. That API is fs-mistrust for **Artis** directories, not sqlite. `Store::open_at_with_passphrase` `mkdir_700`s `home` and `home/arti` first; `home/cache` is created by Artis `create_dir_all` without `0700`. `cbtmintimeout` / `cbtinitialtimeout` = 20s is a circuit-build floor (perf / publish reliability), not an auth bypass.
Impact: journald/script logs contain the current v3 locator. Arti state/cache may be created `0755` until something else tightens them; sqlite lives under the `0700` home. A world-readable Arti cache is descriptor/consensus metadata, not chat bodies.
Fix: log a redacted onion (safelog). `mkdir_700` the cache dir before `client_config`. Keep `dangerously_trust_everyone` scoped to Arti storage; do not reuse it for `onionwire.db`.
### F8 — Threat model overstates receipt verification and omits F1F3 [Low]
Location: `docs/THREAT_MODEL.md:17-21`, `:33`, `:39-41`
Evidence: TM correctly describes live-only send, loc rules, identity-vs-locator, passphrase-wrapped message key, plaintext identity/roster, experimental Arti, global 30/60s burst-10 token bucket, backup = identity. It claims RPC confirmation keeps `verified` at 0 unless the chain view agrees — F1 shows the matcher is not that. It does not mention empty AEAD AAD, invite concat, shape-only XMR addresses, or env passphrase (`ONIONWIRE_STORE_PASSPHRASE` in `src/store.rs:745-750`, visible in `/proc/<pid>/environ`).
Impact: an operator who treats TM as the capability list will believe “verified receipt ⇒ wallet saw that payment”.
Fix: either implement F1s conjunctive match or change the sentence to “incoming `rcp` is displayed; `verified` is best-effort and must not be trusted in v0.2”.
## Verified correct
- Noise pattern is actually `Noise_IK_25519_ChaChaPoly_BLAKE2s` with prologue `onionwire-v1` (`src/session.rs:8-9, 111-122`). Initiator sets `remote_public_key` to the QR/roster x25519 prekey (`src/session.rs:189`). Responder takes remote static from snow (`get_remote_static`) and looks up the friend (`src/session.rs:255-260`, `src/node.rs:550-555`).
- Mutual identity proofs are `ed25519_sign(handshake_hash)` with the 32-byte pubkey prefix; `verify_proof` calls `VerifyingKey::verify` (`src/session.rs:289-310`). Initiator compares proof prefix to the **pinned** identity (`src/session.rs:219-222`); responder to the roster id (`src/session.rs:273-276`). Mismatch is `Error::mismatch` → send hard-fails (`src/node.rs:490`).
- Transport nonces are snow `TransportState` counters (Noise spec: increment, reject reuse). Application code does not set ChaCha nonces on the wire. Session keys include ephemeral DH → compromise of long-term static does not decrypt **past** transport; it does allow impersonation **forward**. That is IK, not a bug.
- Loc frames: `apply_loc` verifies ed25519 against the **session** `peer_identity`, requires the pubkey already in `friends`, and requires `ts > onion_updated_at` (`src/store.rs:452-480`, `src/loc.rs:38-55`, `src/node.rs:561-568`). A peer cannot silently move you to an onion they control unless they hold that identity key (Noise proof + loc sig). An old loc with smaller `ts` cannot rewind after a later rotate. (F3 invite paste can still overwrite locator; that is out-of-band.)
- Unknown typed prefixes (`xyz `) are `Kind::Drop`, not fatal (`src/dispatch.rs:30-38`). Unparseable loc/inv/rcp are ignored (`src/node.rs:562`, `610-611`, `643-644`). Handshake/frame errors print `incoming: {e}` and the rend task ends; the accept loop continues (`src/node.rs:723-729`).
- Frames are length-prefixed, `MAX_FRAME = 65535`, checked **before** allocating the body (`src/frame.rs:5, 63-67`).
- Token bucket is **one global** `TokenBucket::default()` = 30 tokens / 60s, burst 10 (`src/ratelimit.rs:41-44`, `src/node.rs:86, 712-720`). Matches TM; one flood can starve every friend (availability, not auth).
- Backup: Argon2id v0x13, `m=19456` KiB, `t=2`, `p=1` (`src/backup.rs:52-53`) = OWASP 2023 minimum. Per-export 16-byte salt + 12-byte random nonce in the file. `open` AEAD-fails with a single error before `replace_identity_keys` (`src/backup.rs:91-95`, `src/node.rs:155-168`). Onion is not in the blob (`tests/backup.rs`). Wrong passphrase does not write keys.
- First-run message key: 32 random bytes, wrapped with the same KDF/AEAD, stored in `store_meta` (`src/store.rs:281-293`). Empty passphrase refused. Integrity check fail-closed (`src/store.rs:191-194`).
- Amounts on the pay path are decimal **integer piconero** (`src/pay.rs:51-59`); `xmr_to_atomic` pads a ≤12-digit fraction without float (`src/pay.rs:62-85`). `/tip` then `parse`s to `u64` for RPC (`src/node.rs:332-334`).
- Incoming `inv`/`rcp` are verified against **session peer identity**, not a field inside the frame (`src/node.rs:613`, `646`).
- Locked product decisions (no server/XMPP/MAM/DHT, live-only send, identity=pubkey, onion=locator, no dual-host grace) match the code. Not findings.
## Not examined / out of scope
- Live hidden-service reachability, IPT/HsDir, and two-node Tor tests (ignored; no HS from this auditor).
- Live `monero-wallet-rpc` (auth defaults, `get_transfers` JSON shape vs `json_amount`, unlock/spend confirm).
- snow 0.10 internals beyond the `Builder`/`TransportState` API used here (constant-time, rekey at 2^64).
- Arti keystore encryption at rest, fs-mistrust semantics of `dangerously_trust_everyone` beyond “it is called on Arti storage”.
- Timing of Argon2 / ed25519 verify (failed backup passphrase is one error string; KDF still runs).
- TUI rendering of hostile chat (ratatui text; no HTML).
- Traffic analysis / HS existence (TM already declines that).
- `cargo audit`: **not installed** (`which cargo-audit` empty). Lockfile inspected by hand; no RustSec lookup was executed against this `Cargo.lock`.
## Dependencies (Cargo.lock)
| Crate | Lock version | Cargo.toml |
|---|---|---|
| snow | 0.10.0 | `0.10` |
| chacha20poly1305 | 0.10.1 | `0.10` |
| argon2 | 0.5.3 | `0.5` |
| ed25519-dalek | 2.2.0 | `2` |
| x25519-dalek | 2.0.1 | `2` |
| rusqlite | 0.36.0 | `0.36` (bundled) |
| arti-client | 0.46.0 | `0.46` + `onion-service-client` + `onion-service-service` |
| tor-hsservice | 0.46.0 | `0.46` |
Caret reqs are not `=`; `cargo update` can move 0.10.x / 0.46.x without a Cargo.toml edit. `--locked` CI is the real pin. Arti onion services are still experimental upstream (TM + onionwire skill); this tree fail-closes, no C-tor fallback (`src/hs.rs:55`).
## Open questions for Lance
- Is a “verified” receipt allowed to mean anything in v0.2, or should the UI only ever show “unverified” until F1 is conjunctive and covered by a test?
- Invite encoding: length-prefix / `0x00` separators now, or wait for `onionwire:v2` (v1 strings stay in the wild)?
- Store passphrase: keep `ONIONWIRE_STORE_PASSPHRASE` (proc-visible) or prompt / kernel keyring?
- Wallet RPC: document “loopback + `--rpc-login` you type into a wrapper”, or teach OnionWire digest?
## Stop / go (auditor, not a ship decision)
Go for **friends-only chat** under the written seizure model (identity keys plaintext; bodies encrypted; Tor relays are not a server). **Do not** treat `verified` receipts as money moved. **Do not** treat F3-paste of a string you did not copy yourself as an integrity-checked locator. No Critical remote key/plaintext bug found in this revision.

View file

@ -8,20 +8,38 @@ Each install hosts its own v3 onion and dials friends onions through in-proce
## Location updates are not a missing-person finder
A signed `loc` frame (`onion`, `ts`, `sig`) rewrites a friends locator **only if** that friend is already in the roster, the signature verifies against the pinned pubkey, and `ts` is newer. There is no directory, DHT, or introduction server. If you rotated while they were offline, they cannot find you until they rescan your QR. OnionWire will not hunt for a missing person.
A signed `loc` frame (`onion`, `ts`, `sig`) rewrites a friends locator **only if** that friend is already in the roster, the signature verifies against the pinned pubkey, and `ts` is newer. There is no directory, DHT, or introduction server. If you rotated while they were offline, they cannot find you until they F3-paste your new invite. OnionWire will not hunt for a missing person.
## Rotating the onion does not revoke a friend
`F4` changes the locator only. The identity key is unchanged. Anyone who already has your pubkey can still prove they are talking to you, and a later QR/rescan with the same `k` updates their row. To become a new person, `/wipe-all` (new identity key). There is no in-band unfriend/revoke in v1.
`F4` changes the locator only. The identity key is unchanged. Anyone who already has your pubkey can still prove they are talking to you, and a later invite paste with the same `k` updates their row. To become a new person, `/wipe-all` (new identity key). There is no in-band unfriend/revoke in v1.
## v1 stores plaintext locally
## Message bodies are encrypted at rest; keys are not
The message log, identity secret key, and friend public keys sit on disk unencrypted (aside from whatever the OS/FDE provides). A seized laptop yields the chat history and who you talk to. `/wipe` overwrites message bodies and vacuums; `/wipe-all` deletes the data dir. sqlcipher is out of v1.
Chat bodies in sqlite are ChaCha20-Poly1305 (`nonce || ciphertext` in the `messages.plaintext` column) with AAD `owmsg1 || friend_id_le64 || dir || 0x00 || row_id_le64`. Swapping ciphertext between rows fails closed. A random 32-byte data key is wrapped with Argon2id (same params as identity backup) from a non-empty passphrase. Salt + wrapped key live in `store_meta`. Unlock is fail-closed: wrong or empty passphrase does not open chat. Empty-AAD v0.2 blobs are rewrapped once on unlock; `list_messages` never falls back to empty AAD.
Identity secret key, friend public keys, and locators remain plaintext in the same db. The message key is not wrapped with `identity_sk` (that key is already on disk). A seized laptop still yields who you talk to and your identity unless you add OS/FDE. sqlcipher is out of v1. `/wipe` deletes chat and payments history (overwrite message bodies, `VACUUM`, WAL checkpoint); roster and identity stay. It is not a forensic erase — SSD wear-leveling can keep copies. `/wipe-all` deletes the data dir (new identity); same disk caveat.
## Fail closed
Peer onion down → send fails. Fingerprint mismatch vs the pinned key → hard fail, no send. Arti HS experimental: if it cannot publish, OnionWire stops; it does not fall back to C-tor.
## Profile is friend-visible, not a directory
A signed `prf` frame is shown to people who already have a session with you. Apply only for existing friends. There is still no name lookup, DHT, or public profile server. Anyone who already has a Noise session can see the profile you send them; that is not confidentiality against that friend.
## Monero sidecar is not a wallet
OnionWire never holds spend keys. Optional `ONIONWIRE_WALLET_RPC` talks HTTP Digest to a user-hosted `monero-wallet-rpc` on loopback (`--rpc-login` required; open RPC is refused). A Noise friend can sign any `rcp`; the signature proves who sent the claim, not that a payment happened. `verified=1` only after a conjunctive RPC match: one `get_transfers` row with the same non-empty `txid`, `amount`, and `address`. Incoming `rcp` stays `verified=0` if RPC is down, errors, or no exact row. Subaddress reuse is the users wallet policy.
## Backup file is the identity
`/backup` writes the identity and Noise static secrets. Treat the file like `onionwire.db`. Restore overwrites self keys and does **not** rewrite the friends table.
## Rate limit is availability, not anonymity
Incoming rendezvous accepts are token-bucket limited (30/60s, burst 10). Excess is dropped without handshake. That is a DoS/availability control. It does not hide that you run an onion, and it is not a traffic-analysis defense.
## Out of v1
Prosody, XMPP, s2s, MAM, carbons, outbox, multi-device, DHT / name server, sqlcipher.

66
scripts/build-android-local.sh Executable file
View file

@ -0,0 +1,66 @@
#!/usr/bin/env bash
# Build the Android artifacts on this host and publish them to a Forgejo
# release. There is no Android toolchain in CI: .forgejo/workflows/release.yml
# runs on an aarch64 Linux container and cannot produce an APK. This is the
# Android path, exactly like scripts/build-release-local.sh is the x86_64 path
# for the Linux binary.
#
# Usage:
# scripts/build-android-local.sh [version] # build + checksums only
# PUBLISH_TAG=v0.3.0 scripts/build-android-local.sh # also upload
#
# Requires: ANDROID_HOME (or android/local.properties), an installed NDK, the
# Rust android targets, cargo-ndk, JDK 17+, and a working `cargo` on PATH.
# Does not create or push tags: creating the tag is a human decision.
set -euo pipefail
root="$(cd "$(dirname "$0")/.." && pwd)"
cd "$root"
version="${1:-$(sed -n 's/.*versionName = "\(.*\)".*/\1/p' android/app/build.gradle.kts | head -1)}"
[ -n "$version" ] || { echo "could not determine version" >&2; exit 1; }
abi="${ONIONWIRE_ANDROID_ABI:-arm64-v8a}"
gradlew="$root/android/gradlew"
echo "==> android build: version $version, abi $abi"
( cd android && "./gradlew" --no-daemon \
"-Ponionwire.abis=$abi" \
:sdk:assembleRelease :app:assembleRelease )
mkdir -p dist
apk_src="android/app/build/outputs/apk/release/app-release.apk"
aar_src="android/sdk/build/outputs/aar/sdk-release.aar"
apk="dist/onionwire-$version-android-$abi.apk"
aar="dist/onionwire-sdk-$version.aar"
for f in "$apk_src" "$aar_src"; do
[ -s "$f" ] || { echo "missing or empty build output: $f" >&2; exit 1; }
done
cp "$apk_src" "$apk"
cp "$aar_src" "$aar"
# Refuse 0-byte release assets before they ever reach a release.
for f in "$apk" "$aar"; do
bytes=$(wc -c < "$f")
[ "$bytes" -gt 0 ] || { echo "refusing to ship empty $f" >&2; exit 1; }
( cd dist && sha256sum "$(basename "$f")" > "$(basename "$f").sha256" )
done
echo "==> verify"
( cd dist && sha256sum -c ./*.sha256 )
file "$apk" "$aar"
ls -l "$apk" "$aar" "$apk.sha256" "$aar.sha256"
if [ -n "${PUBLISH_TAG:-}" ]; then
: "${FORGEJO_TOKEN:?FORGEJO_TOKEN required to publish}"
echo "==> publishing to release $PUBLISH_TAG"
scripts/publish-release.sh "$PUBLISH_TAG" "OnionWire $PUBLISH_TAG" \
scripts/release-body.md \
"$apk" "$apk.sha256" "$aar" "$aar.sha256"
else
echo "==> not publishing (set PUBLISH_TAG=<tag> and FORGEJO_TOKEN to upload)"
fi

35
scripts/build-release-local.sh Executable file
View file

@ -0,0 +1,35 @@
#!/usr/bin/env bash
# Build the x86_64 release asset on this host and publish it to the Forgejo
# release for <tag>. CI (.forgejo/workflows/release.yml) only covers aarch64 —
# there is no x86_64 runner on the instance, so this is the x86_64 path.
#
# Usage: FORGEJO_TOKEN=... scripts/build-release-local.sh v0.1.2
#
# Does not create or push the tag: tag and push first, then run this so the
# release body/asset set matches a real tag. Idempotent (assets are replaced).
set -euo pipefail
tag="${1:?usage: build-release-local.sh <tag>}"
target=x86_64-unknown-linux-gnu
root="$(cd "$(dirname "$0")/.." && pwd)"
cd "$root"
expected="$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1)"
if [ "$tag" != "v$expected" ]; then
echo "warn: tag $tag does not match Cargo.toml version $expected (--version will report $expected)"
fi
echo "==> cargo build --release --locked"
cargo build --release --locked
mkdir -p dist
asset="onionwire-$target"
cp "target/release/onionwire" "dist/$asset"
strip "dist/$asset"
(cd dist && sha256sum "$asset" > "$asset.sha256" && sha256sum -c "$asset.sha256")
file "dist/$asset"
"dist/$asset" --version
echo "==> publishing to the release for $tag"
scripts/publish-release.sh "$tag" "OnionWire $tag" scripts/release-body.md \
"dist/$asset" "dist/$asset.sha256"

111
scripts/publish-release.sh Executable file
View file

@ -0,0 +1,111 @@
#!/usr/bin/env bash
# Create-or-update a Forgejo release and (re)upload its assets.
#
# Usage: publish-release.sh <tag> <release-name> <body-file> <asset> [<asset>...]
# Env: FORGEJO_TOKEN user PAT with repo write (required)
# REPO_API default https://forgejo.siriusdevops.com/api/v1/repos/sirius/onionwire
# TARGET_COMMITISH optional; set it only when the release may have to
# create the tag (e.g. a push event's commit sha).
# Leave empty to never move an existing tag.
#
# Idempotent: re-running for the same tag reuses the release and replaces
# same-named assets instead of failing with 409.
set -euo pipefail
# Every API call goes through here. Publishing runs from CI containers that
# reach Forgejo through Cloudflare, where a bare HTTP/2 request intermittently
# dies with curl exit 92 (stream error) — retries plus forcing HTTP/1.1 make
# that a non-event.
api_curl() {
curl -sS --http1.1 --retry 5 --retry-all-errors --retry-delay 3 \
--connect-timeout 20 --max-time 300 "$@"
}
tag="${1:?usage: publish-release.sh <tag> <name> <body-file> <asset>...}"
name="${2:?missing release name}"
body_file="${3:?missing body file}"
shift 3
: "${FORGEJO_TOKEN:?FORGEJO_TOKEN is not set}"
api="${REPO_API:-https://forgejo.siriusdevops.com/api/v1/repos/sirius/onionwire}"
target="${TARGET_COMMITISH:-}"
jqp() { python3 -c "import json,sys; d=json.load(sys.stdin); print($1)"; }
# Body with @TAG@ substituted, JSON-encoded by python (handles newlines/quotes).
python3 - "$body_file" "$tag" "$target" "$name" > /tmp/release-body.json <<'PY'
import json, sys
body = open(sys.argv[1]).read().replace("@TAG@", sys.argv[2])
payload = {
"tag_name": sys.argv[2],
"name": sys.argv[4],
"body": body,
"draft": False,
"prerelease": False,
}
# Only send target_commitish when asked: on an existing tag it is a request to
# move the tag, which is never what a re-publish wants.
if sys.argv[3]:
payload["target_commitish"] = sys.argv[3]
print(json.dumps(payload))
PY
code=$(api_curl -o /tmp/release-rel.json -w '%{http_code}' \
-H "Authorization: Bearer $FORGEJO_TOKEN" "$api/releases/tags/$tag")
if [ "$code" = "404" ]; then
echo "publish: creating release $tag"
api_curl -sf -X POST "$api/releases" \
-H "Authorization: Bearer $FORGEJO_TOKEN" \
-H 'Content-Type: application/json' \
--data @/tmp/release-body.json -o /tmp/release-rel.json
elif [ "$code" = "200" ]; then
echo "publish: release $tag exists, updating"
rid=$(jqp "d['id']" < /tmp/release-rel.json)
api_curl -sf -X PATCH "$api/releases/$rid" \
-H "Authorization: Bearer $FORGEJO_TOKEN" \
-H 'Content-Type: application/json' \
--data @/tmp/release-body.json -o /tmp/release-rel.json
else
echo "publish: unexpected status $code from GET releases/tags/$tag" >&2
cat /tmp/release-rel.json >&2; exit 1
fi
rid=$(jqp "d['id']" < /tmp/release-rel.json)
echo "publish: release id $rid ($tag)"
# Replace same-named assets so re-runs are clean.
for f in "$@"; do
[ -f "$f" ] || { echo "publish: missing asset $f" >&2; exit 1; }
# Never publish an empty asset: a 0-byte binary/checksum looks like a
# successful release and silently breaks everyone who downloads it.
bytes=$(wc -c < "$f")
if [ "$bytes" -eq 0 ]; then
echo "publish: refusing to upload empty asset $f (0 bytes, cwd=$PWD)" >&2
exit 1
fi
asset=$(basename "$f")
echo "publish: asset $asset is $bytes bytes on disk"
api_curl -s -H "Authorization: Bearer $FORGEJO_TOKEN" "$api/releases/$rid/assets" \
| python3 -c "
import json,sys
for a in json.load(sys.stdin):
print(a['id'], a.get('name',''))
" | while read -r aid aname; do
if [ "$aname" = "$asset" ]; then
echo "publish: deleting stale asset $aname ($aid)"
api_curl -sf -X DELETE -H "Authorization: Bearer $FORGEJO_TOKEN" \
"$api/releases/$rid/assets/$aid" > /dev/null
fi
done
api_curl -sf -X POST "$api/releases/$rid/assets?name=$asset" \
-H "Authorization: Bearer $FORGEJO_TOKEN" \
-F "attachment=@$f" -o /tmp/release-asset.json
python3 -c "
import json;d=json.load(open('/tmp/release-asset.json'))
print('publish: uploaded', d['name'], d['size'], 'bytes')"
done
api_curl -s -H "Authorization: Bearer $FORGEJO_TOKEN" "$api/releases/$rid/assets" \
| python3 -c "
import json,sys
for a in json.load(sys.stdin):
print('publish: asset ', a['name'], a['size'])"

24
scripts/release-body.md Normal file
View file

@ -0,0 +1,24 @@
# OnionWire @TAG@
Prebuilt Linux binaries. No `tor` package required — Arti runs in-process.
```bash
# aarch64 (Raspberry Pi, ARM servers) — CI builds this one on the Pi runner
curl -fL -O https://forgejo.siriusdevops.com/sirius/onionwire/releases/download/@TAG@/onionwire-aarch64-unknown-linux-gnu
curl -fL -O https://forgejo.siriusdevops.com/sirius/onionwire/releases/download/@TAG@/onionwire-aarch64-unknown-linux-gnu.sha256
sha256sum -c onionwire-aarch64-unknown-linux-gnu.sha256
chmod +x onionwire-aarch64-unknown-linux-gnu
# x86_64 (most PCs / VMs) — built locally with scripts/build-release-local.sh
curl -fL -O https://forgejo.siriusdevops.com/sirius/onionwire/releases/download/@TAG@/onionwire-x86_64-unknown-linux-gnu
curl -fL -O https://forgejo.siriusdevops.com/sirius/onionwire/releases/download/@TAG@/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-<target> --version
```
Keep the asset filenames so `sha256sum -c` matches. Both binaries link OpenSSL 3
dynamically (`libssl.so.3`), which is standard on Debian 12+/Ubuntu 24.04+.
Building from source is covered in the README install guide.

47
scripts/watch-release-run.sh Executable file
View file

@ -0,0 +1,47 @@
#!/usr/bin/env bash
# Wait for a Forgejo Actions run to reach a terminal state, then report.
#
# Forgejo job status codes (action_run_job.status):
# 0 unknown 1 success 2 failure 3 cancelled 4 skipped
# 5 waiting 6 running 7 blocked (needs a parent job)
# Terminal = 1..4. Usage: watch-release-run.sh [run_id] [timeout_minutes]
set -uo pipefail
run_id="${1:-}"
timeout_min="${2:-60}"
TOK=$(grep '^FORGEJO_TOKEN=' ~/.hermes/.env | cut -d= -f2)
q() {
ssh -o ConnectTimeout=10 sirius@rpi \
"docker exec forgejo-db psql -U forgejo -d forgejo -t -A -F'|' -c \"$1\"" 2>/dev/null
}
if [ -z "$run_id" ]; then
run_id=$(q "select max(id) from action_run;")
fi
meta=$(q "select r.id, r.status, r.event, r.title from action_run r where r.id = $run_id;")
echo "watching run: $meta"
deadline=$(( $(date +%s) + timeout_min * 60 ))
while :; do
jobs=$(q "select j.status, j.name from action_run_job j where j.run_id = $run_id order by j.id;")
echo "[$(date +%H:%M:%S)] $(echo "$jobs" | tr '\n' ' ')"
all_jobs=$(q "select count(*) from action_run_job j where j.run_id = $run_id;")
done_jobs=$(q "select count(*) from action_run_job j where j.run_id = $run_id and j.status in (1,2,3,4);")
if [ "$done_jobs" = "$all_jobs" ] && [ "$all_jobs" != "0" ]; then
break
fi
if [ "$(date +%s)" -ge "$deadline" ]; then
echo "watch: timed out after ${timeout_min}m (run $run_id still not terminal)"
exit 1
fi
sleep 45
done
echo "=== final ==="
echo "$jobs"
if echo "$jobs" | grep -qE '^2\|'; then
echo "run $run_id: FAILED (job status 2) — read the log on the Pi:"
echo " sudo find /opt/siriusdevops/forgejo/data/gitea/data/actions_log/sirius/<repo> -newermt '-20 min' -name '*.zst'"
fi
echo "run_id=$run_id"

135
src/backup.rs Normal file
View file

@ -0,0 +1,135 @@
//! Encrypted identity backup. Onion (locator) is not included.
use argon2::{Algorithm, Argon2, Params, Version};
use chacha20poly1305::aead::{Aead, AeadCore, KeyInit, OsRng, Payload};
use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce};
use rand::RngCore;
pub const MAGIC: &[u8] = b"owbak1";
const SALT_LEN: usize = 16;
const NONCE_LEN: usize = 12;
const KEY_LEN: usize = 32;
const PLAIN_LEN: usize = KEY_LEN * 4;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BackupKeys {
pub identity_sk: [u8; 32],
pub identity_pk: [u8; 32],
pub prekey_sk: [u8; 32],
pub prekey_pk: [u8; 32],
}
impl BackupKeys {
pub fn to_bytes(&self) -> [u8; PLAIN_LEN] {
let mut out = [0u8; PLAIN_LEN];
out[0..32].copy_from_slice(&self.identity_sk);
out[32..64].copy_from_slice(&self.identity_pk);
out[64..96].copy_from_slice(&self.prekey_sk);
out[96..128].copy_from_slice(&self.prekey_pk);
out
}
pub fn from_bytes(b: &[u8]) -> Result<Self, String> {
if b.len() != PLAIN_LEN {
return Err("backup plaintext length".into());
}
Ok(Self {
identity_sk: b[0..32].try_into().unwrap(),
identity_pk: b[32..64].try_into().unwrap(),
prekey_sk: b[64..96].try_into().unwrap(),
prekey_pk: b[96..128].try_into().unwrap(),
})
}
}
pub(crate) fn kdf(passphrase: &str, salt: &[u8]) -> Result<[u8; 32], String> {
if passphrase.is_empty() {
return Err("empty passphrase".into());
}
if salt.len() != SALT_LEN {
return Err("salt length".into());
}
let params = Params::new(19_456, 2, 1, Some(32)).map_err(|e| e.to_string())?;
let argon = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
let mut key = [0u8; 32];
argon
.hash_password_into(passphrase.as_bytes(), salt, &mut key)
.map_err(|e| e.to_string())?;
Ok(key)
}
pub fn seal(passphrase: &str, keys: &BackupKeys) -> Result<Vec<u8>, String> {
let mut salt = [0u8; SALT_LEN];
rand::rngs::OsRng.fill_bytes(&mut salt);
let key = kdf(passphrase, &salt)?;
let cipher = ChaCha20Poly1305::new(Key::from_slice(&key));
let nonce = ChaCha20Poly1305::generate_nonce(&mut OsRng);
if nonce.len() != NONCE_LEN {
return Err("nonce length".into());
}
let ct = cipher
.encrypt(&nonce, keys.to_bytes().as_ref())
.map_err(|_| "encrypt failed".to_string())?;
let mut out = Vec::with_capacity(MAGIC.len() + SALT_LEN + NONCE_LEN + ct.len());
out.extend_from_slice(MAGIC);
out.extend_from_slice(&salt);
out.extend_from_slice(&nonce);
out.extend_from_slice(&ct);
Ok(out)
}
pub fn open(passphrase: &str, blob: &[u8]) -> Result<BackupKeys, String> {
let min = MAGIC.len() + SALT_LEN + NONCE_LEN + 16;
if blob.len() < min || !blob.starts_with(MAGIC) {
return Err("not an onionwire backup".into());
}
let salt = &blob[MAGIC.len()..MAGIC.len() + SALT_LEN];
let nonce_off = MAGIC.len() + SALT_LEN;
let nonce = Nonce::from_slice(&blob[nonce_off..nonce_off + NONCE_LEN]);
let ct = &blob[nonce_off + NONCE_LEN..];
let key = kdf(passphrase, salt)?;
let cipher = ChaCha20Poly1305::new(Key::from_slice(&key));
let pt = cipher
.decrypt(nonce, ct)
.map_err(|_| "wrong passphrase or corrupt backup".to_string())?;
BackupKeys::from_bytes(&pt)
}
pub(crate) fn aead_encrypt(
key: &[u8; 32],
plaintext: &[u8],
aad: &[u8],
) -> Result<Vec<u8>, String> {
let cipher = ChaCha20Poly1305::new(Key::from_slice(key));
let nonce = ChaCha20Poly1305::generate_nonce(&mut OsRng);
let ct = cipher
.encrypt(
&nonce,
Payload {
msg: plaintext,
aad,
},
)
.map_err(|_| "encrypt failed".to_string())?;
let mut out = Vec::with_capacity(NONCE_LEN + ct.len());
out.extend_from_slice(&nonce);
out.extend_from_slice(&ct);
Ok(out)
}
pub(crate) fn aead_decrypt(key: &[u8; 32], blob: &[u8], aad: &[u8]) -> Result<Vec<u8>, String> {
if blob.len() < NONCE_LEN + 16 {
return Err("ciphertext length".into());
}
let nonce = Nonce::from_slice(&blob[..NONCE_LEN]);
let cipher = ChaCha20Poly1305::new(Key::from_slice(key));
cipher
.decrypt(
nonce,
Payload {
msg: &blob[NONCE_LEN..],
aad,
},
)
.map_err(|_| "wrong passphrase or corrupt".to_string())
}

43
src/dispatch.rs Normal file
View file

@ -0,0 +1,43 @@
//! Classify decrypted application plaintext before it hits the message log.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Kind {
Chat,
Loc,
Profile,
Invoice,
Receipt,
Ping,
File,
Drop,
}
pub fn classify(pt: &[u8]) -> Kind {
if pt.starts_with(b"loc ") {
return Kind::Loc;
}
if pt.starts_with(b"prf ") {
return Kind::Profile;
}
if pt.starts_with(b"inv ") {
return Kind::Invoice;
}
if pt.starts_with(b"rcp ") {
return Kind::Receipt;
}
if pt.starts_with(b"png ") {
return Kind::Ping;
}
if pt.starts_with(b"fil ") {
return Kind::File;
}
if pt.len() >= 4
&& pt[0].is_ascii_lowercase()
&& pt[1].is_ascii_lowercase()
&& pt[2].is_ascii_lowercase()
&& pt[3] == b' '
{
return Kind::Drop;
}
Kind::Chat
}

387
src/file.rs Normal file
View file

@ -0,0 +1,387 @@
//! Fail-closed file frames. One file = N one-shot `fil ` payloads.
use std::collections::HashMap;
use std::fs::{self, OpenOptions};
use std::io::{Read, Write};
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
use std::path::{Path, PathBuf};
use rand::RngCore;
use sha2::{Digest, Sha256};
use crate::frame;
pub type Result<T> = std::result::Result<T, Error>;
#[derive(Debug)]
pub struct Error(String);
impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.0.fmt(f)
}
}
impl std::error::Error for Error {}
impl From<std::io::Error> for Error {
fn from(e: std::io::Error) -> Self {
Self(e.to_string())
}
}
pub const MAX_BYTES: usize = 1024 * 1024;
const PREFIX: &[u8] = b"fil ";
const NOISE_TAG: usize = 16;
const NAME_MAX: usize = 128;
const XFER_LEN: usize = 16;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Chunk {
pub xfer_id: [u8; XFER_LEN],
pub filename: String,
pub sha256: [u8; 32],
pub idx: u32,
pub total: u32,
pub data: Vec<u8>,
}
struct Inflight {
filename: String,
sha256: [u8; 32],
total: u32,
next: u32,
written: usize,
}
pub struct Inbox {
root: PathBuf,
// ponytail: no timeout janitor. A vanished peer leaves .partial-* until
// a later bad chunk for that xfer_id or process exit. Size is still capped.
inflight: HashMap<[u8; XFER_LEN], Inflight>,
}
pub fn safe_name(name: &str) -> Result<&str> {
if name.is_empty() || name.len() > NAME_MAX {
return Err(Error("bad file name".into()));
}
if name.contains('\0')
|| name.contains('/')
|| name.contains('\n')
|| name == ".."
|| name == "."
{
return Err(Error("bad file name".into()));
}
if Path::new(name).file_name().and_then(|s| s.to_str()) != Some(name) {
return Err(Error("bad file name".into()));
}
Ok(name)
}
fn safe_fp(fp: &str) -> bool {
!fp.is_empty() && fp.len() <= 64 && fp.bytes().all(|b| b.is_ascii_hexdigit())
}
pub fn read_limited(path: &Path) -> Result<(String, Vec<u8>)> {
let meta = fs::metadata(path)?;
if meta.len() > MAX_BYTES as u64 {
return Err(Error("file larger than 1 MiB".into()));
}
let name = path
.file_name()
.and_then(|s| s.to_str())
.ok_or_else(|| Error("bad file name".into()))?
.to_string();
safe_name(&name)?;
let bytes = fs::read(path)?;
if bytes.len() > MAX_BYTES {
return Err(Error("file larger than 1 MiB".into()));
}
Ok((name, bytes))
}
pub fn chunks(filename: &str, bytes: &[u8]) -> Result<Vec<Chunk>> {
safe_name(filename)?;
if bytes.len() > MAX_BYTES {
return Err(Error("file larger than 1 MiB".into()));
}
let mut hasher = Sha256::new();
hasher.update(bytes);
let sha256: [u8; 32] = hasher.finalize().into();
let mut xfer_id = [0u8; XFER_LEN];
rand::rngs::OsRng.fill_bytes(&mut xfer_id);
let total = total_chunks(filename, bytes.len())?;
let cap = data_cap(filename, total);
let mut out = Vec::with_capacity(total as usize);
for idx in 0..total {
let start = (idx as usize).saturating_mul(cap);
let end = (start + cap).min(bytes.len());
out.push(Chunk {
xfer_id,
filename: filename.to_string(),
sha256,
idx,
total,
data: bytes[start..end].to_vec(),
});
}
Ok(out)
}
pub fn encode(chunk: &Chunk) -> Vec<u8> {
let mut out = Vec::from(PREFIX);
out.extend_from_slice(to_hex(&chunk.xfer_id).as_bytes());
out.push(b'\n');
out.extend_from_slice(chunk.filename.as_bytes());
out.push(b'\n');
out.extend_from_slice(to_hex(&chunk.sha256).as_bytes());
out.push(b'\n');
out.extend_from_slice(chunk.idx.to_string().as_bytes());
out.push(b'/');
out.extend_from_slice(chunk.total.to_string().as_bytes());
out.push(b'\n');
out.extend_from_slice(&chunk.data);
out
}
pub fn decode(pt: &[u8]) -> Option<Chunk> {
let rest = pt.strip_prefix(PREFIX)?;
let mut parts = rest.splitn(5, |&b| b == b'\n');
let xfer_hex = std::str::from_utf8(parts.next()?).ok()?;
let filename = std::str::from_utf8(parts.next()?).ok()?;
let sha_hex = std::str::from_utf8(parts.next()?).ok()?;
let idx_total = std::str::from_utf8(parts.next()?).ok()?;
let data = parts.next()?.to_vec();
safe_name(filename).ok()?;
let xfer_id: [u8; XFER_LEN] = from_hex(xfer_hex)?.try_into().ok()?;
let sha256: [u8; 32] = from_hex(sha_hex)?.try_into().ok()?;
let (idx_s, total_s) = idx_total.split_once('/')?;
let idx: u32 = idx_s.parse().ok()?;
let total: u32 = total_s.parse().ok()?;
if total == 0 || idx >= total {
return None;
}
Some(Chunk {
xfer_id,
filename: filename.to_string(),
sha256,
idx,
total,
data,
})
}
pub fn chat_line(name: &str, nbytes: usize) -> String {
format!("[file] {name} ({nbytes} bytes)")
}
impl Inbox {
pub fn new(root: impl Into<PathBuf>) -> Self {
Self {
root: root.into(),
inflight: HashMap::new(),
}
}
pub fn ingest(&mut self, peer_fp: &str, chunk: &Chunk) -> Result<Option<PathBuf>> {
if safe_name(&chunk.filename).is_err() {
return Err(Error("bad file name".into()));
}
if chunk.total == 0 || chunk.idx >= chunk.total {
return Err(Error("bad chunk index".into()));
}
if !safe_fp(peer_fp) {
return Err(Error("bad fingerprint".into()));
}
if chunk.data.len() > MAX_BYTES {
return Err(Error("file larger than 1 MiB".into()));
}
let partial = self.partial_path(&chunk.xfer_id);
if chunk.idx == 0 {
self.drop_partial(&chunk.xfer_id);
if let Err(e) = (|| {
ensure_dir(&self.root)?;
write_partial(&partial, &chunk.data, false)
})() {
self.drop_partial(&chunk.xfer_id);
return Err(e);
}
self.inflight.insert(
chunk.xfer_id,
Inflight {
filename: chunk.filename.clone(),
sha256: chunk.sha256,
total: chunk.total,
next: 1,
written: chunk.data.len(),
},
);
} else {
let ok = self.inflight.get(&chunk.xfer_id).is_some_and(|st| {
st.filename == chunk.filename
&& st.sha256 == chunk.sha256
&& st.total == chunk.total
&& st.next == chunk.idx
});
if !ok {
self.drop_partial(&chunk.xfer_id);
return Err(Error("chunk mismatch".into()));
}
let next_len = self
.inflight
.get(&chunk.xfer_id)
.map(|st| st.written.saturating_add(chunk.data.len()))
.unwrap_or(usize::MAX);
if next_len > MAX_BYTES {
self.drop_partial(&chunk.xfer_id);
return Err(Error("file larger than 1 MiB".into()));
}
if let Err(e) = write_partial(&partial, &chunk.data, true) {
self.drop_partial(&chunk.xfer_id);
return Err(e);
}
if let Some(st) = self.inflight.get_mut(&chunk.xfer_id) {
st.next = chunk.idx + 1;
st.written = next_len;
}
}
if chunk.idx + 1 != chunk.total {
return Ok(None);
}
let finish = (|| {
let hashed = hash_file(&partial)?;
if hashed != chunk.sha256 {
return Err(Error("hash mismatch".into()));
}
let dest_dir = self.root.join(peer_fp);
ensure_dir(&dest_dir)?;
let dest = unique_path(&dest_dir, &chunk.filename)?;
fs::rename(&partial, &dest)?;
chmod(&dest, 0o600);
Ok(dest)
})();
match finish {
Ok(dest) => {
self.inflight.remove(&chunk.xfer_id);
Ok(Some(dest))
}
Err(e) => {
self.drop_partial(&chunk.xfer_id);
Err(e)
}
}
}
fn partial_path(&self, xfer_id: &[u8; XFER_LEN]) -> PathBuf {
self.root.join(format!(".partial-{}", to_hex(xfer_id)))
}
fn drop_partial(&mut self, xfer_id: &[u8; XFER_LEN]) {
self.inflight.remove(xfer_id);
let _ = fs::remove_file(self.partial_path(xfer_id));
}
}
fn total_chunks(filename: &str, len: usize) -> Result<u32> {
if len == 0 {
return Ok(1);
}
let mut total = 1u32;
loop {
let cap = data_cap(filename, total);
if cap == 0 {
return Err(Error("file name too long for a frame".into()));
}
let need = u32::try_from(len.div_ceil(cap)).map_err(|_| Error("too many chunks".into()))?;
if need <= total {
return Ok(need.max(1));
}
total = need;
}
}
fn data_cap(filename: &str, total: u32) -> usize {
let digits = total.to_string().len().max(1);
let header =
PREFIX.len() + XFER_LEN * 2 + 1 + filename.len() + 1 + 64 + 1 + digits + 1 + digits + 1;
frame::MAX_FRAME
.saturating_sub(NOISE_TAG)
.saturating_sub(header)
}
fn write_partial(path: &Path, data: &[u8], append: bool) -> Result<()> {
let mut opts = OpenOptions::new();
opts.write(true).mode(0o600);
if append {
opts.append(true);
} else {
opts.create(true).truncate(true);
}
let mut f = opts.open(path)?;
f.write_all(data)?;
Ok(())
}
fn hash_file(path: &Path) -> Result<[u8; 32]> {
let mut f = fs::File::open(path)?;
let mut hasher = Sha256::new();
let mut buf = [0u8; 8192];
loop {
let n = f.read(&mut buf)?;
if n == 0 {
break;
}
hasher.update(&buf[..n]);
}
Ok(hasher.finalize().into())
}
fn unique_path(dir: &Path, name: &str) -> Result<PathBuf> {
let first = dir.join(name);
if !first.exists() {
return Ok(first);
}
for n in 2..1000 {
let p = dir.join(format!("{name}-{n}"));
if !p.exists() {
return Ok(p);
}
}
Err(Error("name collision".into()))
}
fn ensure_dir(path: &Path) -> Result<()> {
fs::create_dir_all(path)?;
chmod(path, 0o700);
Ok(())
}
fn chmod(path: &Path, mode: u32) {
if let Ok(meta) = fs::metadata(path) {
let mut p = meta.permissions();
p.set_mode(mode);
let _ = fs::set_permissions(path, p);
}
}
fn to_hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
fn from_hex(s: &str) -> Option<Vec<u8>> {
if s.is_empty() || !s.len().is_multiple_of(2) {
return None;
}
if !s.bytes().all(|c| c.is_ascii_hexdigit()) {
return None;
}
(0..s.len())
.step_by(2)
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok())
.collect()
}

View file

@ -1,5 +1,6 @@
//! In-process Arti onion-service helpers (no C-tor).
use std::os::unix::fs::PermissionsExt;
use std::sync::Arc;
use std::time::{Duration, Instant};
@ -8,18 +9,55 @@ use arti_client::{TorClient, TorClientConfig};
use futures::StreamExt;
use safelog::DisplayRedacted;
use tor_hsservice::status::State;
use tor_hsservice::{HsNickname, OnionServiceConfig, RunningOnionService};
use tor_hsservice::{HsId, HsNickname, OnionServiceConfig, RunningOnionService};
use tor_rtcompat::PreferredRuntime;
pub const HS_PORT: u16 = 80;
/// HsDir descriptor upload often stays Bootstrapping past 3 minutes.
pub const PUBLISH_WAIT: Duration = Duration::from_secs(360);
/// Consensus default `cbtmintimeout` is 10ms; learned CBT can drop to ~1s.
/// 4-hop vanguard HS circuits need more than 10s on a slow net.
pub const CBT_MIN_TIMEOUT_MS: i32 = 20_000;
/// Consensus `cbtinitialtimeout` can be ~2s; match the min floor.
pub const CBT_INITIAL_TIMEOUT_MS: i32 = CBT_MIN_TIMEOUT_MS;
/// OnionWire never uses exit ports. Default 80/443 preemptive circuits
/// compete with IPT + HsDir builds during publish.
pub const PREEMPTIVE_PREDICTED_PORTS: &[u16] = &[];
const PROBE_EVERY: Duration = Duration::from_secs(15);
const PROBE_TIMEOUT: Duration = Duration::from_secs(12);
pub type Client = Arc<TorClient<PreferredRuntime>>;
fn mkdir_700(path: &std::path::Path) {
std::fs::create_dir_all(path).expect("mkdir");
let mut perms = std::fs::metadata(path).expect("metadata").permissions();
perms.set_mode(0o700);
std::fs::set_permissions(path, perms).expect("chmod 0700");
}
/// Status/probe log label: safelog-redacted v3 onion, never the locator.
pub fn log_label(onion: &str) -> String {
match onion.parse::<HsId>() {
Ok(id) => id.display_redacted().to_string(),
Err(_) => safelog::sensitive(onion).to_string(),
}
}
pub fn client_config(state_dir: &std::path::Path, cache_dir: &std::path::Path) -> TorClientConfig {
std::fs::create_dir_all(state_dir).expect("state dir");
std::fs::create_dir_all(cache_dir).expect("cache dir");
mkdir_700(state_dir);
mkdir_700(cache_dir);
let mut builder = TorClientConfigBuilder::from_directories(state_dir, cache_dir);
builder.storage().permissions().dangerously_trust_everyone();
builder
.override_net_params()
.insert("cbtmintimeout".to_string(), CBT_MIN_TIMEOUT_MS);
builder
.override_net_params()
.insert("cbtinitialtimeout".to_string(), CBT_INITIAL_TIMEOUT_MS);
builder
.preemptive_circuits()
.set_initial_predicted_ports(PREEMPTIVE_PREDICTED_PORTS.to_vec());
builder.build().expect("TorClientConfig")
}
@ -48,28 +86,61 @@ pub fn onion_string(svc: &RunningOnionService) -> Result<String, String> {
Ok(id.display_unredacted().to_string())
}
pub async fn wait_until_published(svc: &RunningOnionService, label: &str) -> Result<(), String> {
let deadline = Instant::now() + Duration::from_secs(180);
/// Combined Arti status stays Bootstrapping through a 5 min HsDir upload
/// round. A successful connect means some HsDir already has the descriptor.
pub fn hs_is_ready(state: State, probe_ok: bool) -> bool {
match state {
State::Broken | State::Shutdown => false,
State::Running | State::DegradedReachable => true,
_ => probe_ok,
}
}
pub async fn wait_until_published(
client: &Client,
svc: &RunningOnionService,
onion: &str,
) -> Result<(), String> {
let label = log_label(onion);
let deadline = Instant::now() + PUBLISH_WAIT;
let mut events = svc.status_events();
let mut next_probe = Instant::now() + PROBE_EVERY;
loop {
let st = svc.status();
eprintln!("{label} hs status: {:?}", st.state());
match st.state() {
State::Running | State::DegradedReachable => return Ok(()),
State::Broken => {
let state = st.state();
eprintln!("{label} hs status: {state:?}");
if hs_is_ready(state, false) {
return Ok(());
}
if matches!(state, State::Broken) {
return Err(format!(
"{label}: onion service broken: {:?}",
st.current_problem()
));
}
_ => {}
}
if Instant::now() >= deadline {
return Err(format!(
"{label}: onion service did not publish within 180s: {:?}",
st.state()
"{label}: onion service did not publish within {}s: {state:?}",
PUBLISH_WAIT.as_secs(),
));
}
let now = Instant::now();
if now >= next_probe {
eprintln!("{label} probing reachability");
let probe_ok = tokio::time::timeout(PROBE_TIMEOUT, client.connect((onion, HS_PORT)))
.await
.ok()
.and_then(Result::ok)
.is_some();
if probe_ok {
eprintln!("{label} hs reachable while status {state:?}");
}
if hs_is_ready(state, probe_ok) {
return Ok(());
}
next_probe = Instant::now() + PROBE_EVERY;
continue;
}
tokio::select! {
_ = events.next() => {}
_ = tokio::time::sleep(Duration::from_secs(2)) => {}

View file

@ -1,10 +1,20 @@
pub mod backup;
pub mod dispatch;
pub mod file;
pub mod frame;
pub mod hs;
pub mod loc;
pub mod node;
pub mod pay;
pub mod profile;
pub mod qr;
pub mod ratelimit;
pub mod session;
mod store;
pub mod tui;
pub mod wallet;
pub use store::{Friend, Message, SelfIdentity, Store};
pub use store::{
Friend, FriendProfile, Message, Payment, PaymentWrite, SelfIdentity, Store,
resolve_store_passphrase,
};

View file

@ -34,8 +34,10 @@ async fn main() {
async fn boot() -> Result<(), String> {
let home = onionwire::Store::home_dir().map_err(|e| e.to_string())?;
let pass = onionwire::resolve_store_passphrase(|p| rpassword::prompt_password(p))
.map_err(|e| e.to_string())?;
eprintln!("onionwire: bootstrapping Arti…");
let node = onionwire::node::Node::start(home.clone()).await?;
let node = onionwire::node::Node::start_with_passphrase(home.clone(), &pass).await?;
let handle = tokio::runtime::Handle::current();
let exit = tokio::task::spawn_blocking(move || onionwire::tui::run(node, handle))
.await

View file

@ -9,12 +9,18 @@ use futures::io::{AsyncRead, AsyncWrite};
use tor_cell::relaycell::msg::Connected;
use tor_hsservice::{RunningOnionService, handle_rend_requests};
use crate::dispatch::{self, Kind};
use crate::file;
use crate::frame;
use crate::hs::{self, Client, HS_PORT};
use crate::loc;
use crate::pay;
use crate::profile;
use crate::qr;
use crate::ratelimit::TokenBucket;
use crate::session::{self, Keys};
use crate::store::{Friend, Message, Store};
use crate::store::{Friend, FriendProfile, Message, PaymentWrite, Store};
use crate::wallet::{self, Wallet};
pub struct RotateResult {
pub notified: usize,
@ -38,12 +44,27 @@ pub struct Node {
client: Client,
hs: Mutex<Option<HsHandle>>,
onion: Mutex<String>,
keys: Keys,
keys: Mutex<Keys>,
wallet: Wallet,
incoming_limit: Mutex<TokenBucket>,
inbox: Mutex<file::Inbox>,
}
impl Node {
pub async fn start(home: PathBuf) -> Result<Arc<Self>, String> {
let store = Store::open_at(&home).map_err(|e| e.to_string())?;
let pass = std::env::var("ONIONWIRE_STORE_PASSPHRASE")
.map_err(|_| "ONIONWIRE_STORE_PASSPHRASE required".to_string())?;
if pass.is_empty() {
return Err("empty passphrase".into());
}
Self::start_with_passphrase(home, &pass).await
}
pub async fn start_with_passphrase(
home: PathBuf,
passphrase: &str,
) -> Result<Arc<Self>, String> {
let store = Store::open_at_with_passphrase(&home, passphrase).map_err(|e| e.to_string())?;
let me = store.self_identity().map_err(|e| e.to_string())?;
let keys = Keys::from_self(&me).map_err(|e| e.to_string())?;
let nickname = store.hs_nickname().map_err(|e| e.to_string())?;
@ -56,18 +77,31 @@ impl Node {
.ok_or_else(|| "onion service disabled in config — fail closed".to_string())?;
let (svc, rend) = launched;
let onion = hs::onion_string(&svc)?;
hs::wait_until_published(&svc, &onion).await?;
store.set_onion(&onion).map_err(|e| e.to_string())?;
let inbox = file::Inbox::new(home.join("inbox"));
let node = Arc::new(Self {
home,
store: Mutex::new(store),
client,
hs: Mutex::new(None),
onion: Mutex::new(onion),
keys,
onion: Mutex::new(onion.clone()),
keys: Mutex::new(keys),
wallet: Wallet::from_env(),
incoming_limit: Mutex::new(TokenBucket::default()),
inbox: Mutex::new(inbox),
});
// Accept rens before waiting so a reachability probe can succeed
// while combined status is still Bootstrapping.
let rend = spawn_rend(Arc::clone(&node), rend);
*node.hs.lock().map_err(|e| e.to_string())? = Some(HsHandle { _svc: svc, rend });
*node.hs.lock().map_err(|e| e.to_string())? = Some(HsHandle {
_svc: Arc::clone(&svc),
rend,
});
hs::wait_until_published(&node.client, &svc, &onion).await?;
node.store
.lock()
.map_err(|e| e.to_string())?
.set_onion(&onion)
.map_err(|e| e.to_string())?;
Ok(node)
}
@ -75,8 +109,15 @@ impl Node {
self.onion.lock().map(|g| g.clone()).unwrap_or_default()
}
fn keys(&self) -> Result<Keys, String> {
self.keys
.lock()
.map(|g| g.clone())
.map_err(|e| e.to_string())
}
pub fn identity_pk(&self) -> [u8; 32] {
self.keys.identity_pk
self.keys().map(|k| k.identity_pk).unwrap_or([0; 32])
}
pub fn arti_dir(&self) -> PathBuf {
@ -88,8 +129,48 @@ impl Node {
}
pub fn qr_payload(&self) -> Result<String, String> {
qr::encode(&self.keys.identity_sk, &self.onion(), &self.keys.prekey_pk)
.map_err(|e| e.to_string())
let k = self.keys()?;
qr::encode(&k.identity_sk, &self.onion(), &k.prekey_pk).map_err(|e| e.to_string())
}
pub fn write_backup(&self, path: &str, passphrase: &str) -> Result<(), String> {
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
let k = self.keys()?;
let blob = crate::backup::seal(
passphrase,
&crate::backup::BackupKeys {
identity_sk: k.identity_sk,
identity_pk: k.identity_pk,
prekey_sk: k.prekey_sk,
prekey_pk: k.prekey_pk,
},
)?;
let mut f = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(path)
.map_err(|e| e.to_string())?;
f.write_all(&blob).map_err(|e| e.to_string())
}
pub fn restore_backup(&self, path: &str, passphrase: &str) -> Result<(), String> {
let blob = std::fs::read(path).map_err(|e| e.to_string())?;
let k = crate::backup::open(passphrase, &blob)?;
self.store
.lock()
.map_err(|e| e.to_string())?
.replace_identity_keys(&k.identity_sk, &k.identity_pk, &k.prekey_sk, &k.prekey_pk)
.map_err(|e| e.to_string())?;
*self.keys.lock().map_err(|e| e.to_string())? = Keys {
identity_sk: k.identity_sk,
identity_pk: k.identity_pk,
prekey_sk: k.prekey_sk,
prekey_pk: k.prekey_pk,
};
Ok(())
}
pub fn add_friend_from_qr(&self, raw: &str) -> Result<(), String> {
@ -124,6 +205,24 @@ impl Node {
.map_err(|e| e.to_string())
}
/// Fingerprint of our own identity key (identity = pubkey).
pub fn fingerprint(&self) -> Result<String, String> {
self.store
.lock()
.map_err(|e| e.to_string())?
.self_fingerprint()
.map_err(|e| e.to_string())
}
/// Local-only label. Does not go on the wire.
pub fn set_petname(&self, pubkey: &[u8], petname: Option<&str>) -> Result<(), String> {
self.store
.lock()
.map_err(|e| e.to_string())?
.set_petname(pubkey, petname)
.map_err(|e| e.to_string())
}
pub fn friend(&self, pubkey: &[u8]) -> Result<Friend, String> {
self.store
.lock()
@ -149,6 +248,173 @@ impl Node {
.map_err(|e| e.to_string())
}
pub fn self_profile(&self) -> Result<FriendProfile, String> {
self.store
.lock()
.map_err(|e| e.to_string())?
.self_profile()
.map_err(|e| e.to_string())
}
pub fn friend_profile(&self, pubkey: &[u8]) -> Result<Option<FriendProfile>, String> {
self.store
.lock()
.map_err(|e| e.to_string())?
.friend_profile(pubkey)
.map_err(|e| e.to_string())
}
pub fn set_self_profile(
&self,
display_name: &str,
bio: &str,
xmr_addr: &str,
) -> Result<(), String> {
self.store
.lock()
.map_err(|e| e.to_string())?
.set_self_profile(display_name, bio, xmr_addr)
.map_err(|e| e.to_string())
}
/// One-shot signed `prf` to a friend. Fail closed; no retry, no outbox.
pub async fn push_self_profile(&self, friend_pk: &[u8]) -> Result<(), String> {
let me = self.self_profile()?;
let k = self.keys()?;
let prf = profile::sign(
&k.identity_sk,
&me.display_name,
&me.bio,
&me.xmr_addr,
me.updated_at,
)
.map_err(|e| e.to_string())?;
let pt = profile::encode(&prf);
let (onion, prekey) = {
let f = self.friend(friend_pk)?;
if f.prekey.len() != 32 {
return Err("friend missing prekey".into());
}
(f.onion, f.prekey)
};
self.try_send(&onion, friend_pk, &prekey, &pt).await
}
/// Invoice to receive. Wallet subaddress if up, else self_profile.xmr_addr.
pub async fn pay_invoice(
&self,
friend_pk: &[u8],
atomic: &str,
memo: &str,
) -> Result<(), String> {
let address = self.invoice_address().await?;
let k = self.keys()?;
let inv = pay::sign_invoice(&k.identity_sk, atomic, &address, memo, unix_now())
.map_err(|e| e.to_string())?;
let pt = pay::encode_invoice(&inv);
self.send_once(friend_pk, &pt).await?;
let store = self.store.lock().map_err(|e| e.to_string())?;
store
.insert_payment(
friend_pk,
PaymentWrite {
dir: "out",
kind: "invoice",
amount_atomic: atomic,
address: &address,
memo,
txid: None,
verified: false,
},
)
.map_err(|e| e.to_string())?;
store
.append_message(
friend_pk,
"out",
pay::invoice_chat_line(atomic, memo).as_bytes(),
)
.map_err(|e| e.to_string())?;
Ok(())
}
/// Pay selected friend via wallet RPC, then signed receipt.
pub async fn tip(&self, friend_pk: &[u8], atomic: &str, _memo: &str) -> Result<(), String> {
if !self.wallet.configured() {
return Err("wallet not connected — set ONIONWIRE_WALLET_RPC".into());
}
let address = self
.friend_profile(friend_pk)?
.map(|p| p.xmr_addr)
.unwrap_or_default();
if address.is_empty() {
return Err("friend has no Monero address — they must /profile".into());
}
pay::check_address(&address).map_err(|e| e.to_string())?;
let amount: u64 = atomic
.parse()
.map_err(|_| "amount too large for wallet RPC".to_string())?;
let txid = self
.wallet
.transfer(&address, amount)
.await
.map_err(|e| e.to_string())?;
let k = self.keys()?;
let rcp = pay::sign_receipt(&k.identity_sk, &txid, atomic, &address, unix_now())
.map_err(|e| e.to_string())?;
let pt = pay::encode_receipt(&rcp);
self.send_once(friend_pk, &pt).await?;
let store = self.store.lock().map_err(|e| e.to_string())?;
store
.insert_payment(
friend_pk,
PaymentWrite {
dir: "out",
kind: "receipt",
amount_atomic: atomic,
address: &address,
memo: "",
txid: Some(&txid),
verified: true,
},
)
.map_err(|e| e.to_string())?;
store
.append_message(
friend_pk,
"out",
pay::receipt_chat_line(atomic, true).as_bytes(),
)
.map_err(|e| e.to_string())?;
Ok(())
}
async fn invoice_address(&self) -> Result<String, String> {
if self.wallet.configured()
&& let Ok(addr) = self.wallet.create_address().await
&& pay::check_address(&addr).is_ok()
{
return Ok(addr);
}
let profile_addr = self.self_profile()?.xmr_addr;
if profile_addr.is_empty() {
return Err("no receive address — set profile xmr or ONIONWIRE_WALLET_RPC".into());
}
pay::check_address(&profile_addr).map_err(|e| e.to_string())?;
Ok(profile_addr)
}
async fn send_once(&self, friend_pk: &[u8], plaintext: &[u8]) -> Result<(), String> {
let (onion, prekey) = {
let f = self.friend(friend_pk)?;
if f.prekey.len() != 32 {
return Err("friend missing prekey".into());
}
(f.onion, f.prekey)
};
self.try_send(&onion, friend_pk, &prekey, plaintext).await
}
pub async fn connect_onion(&self, onion: &str) -> Result<(), String> {
tokio::time::timeout(
Duration::from_secs(20),
@ -177,9 +443,12 @@ impl Node {
let onion = hs::onion_string(&svc)?;
// ponytail: hard-cut old HS before waiting; keeping both stalled ow1 at Bootstrapping.
*self.hs.lock().map_err(|e| e.to_string())? = None;
hs::wait_until_published(&svc, &onion).await?;
let rend = spawn_rend(Arc::clone(self), rend);
*self.hs.lock().map_err(|e| e.to_string())? = Some(HsHandle { _svc: svc, rend });
*self.hs.lock().map_err(|e| e.to_string())? = Some(HsHandle {
_svc: Arc::clone(&svc),
rend,
});
hs::wait_until_published(&self.client, &svc, &onion).await?;
{
let store = self.store.lock().map_err(|e| e.to_string())?;
store.set_onion(&onion).map_err(|e| e.to_string())?;
@ -190,7 +459,8 @@ impl Node {
*self.onion.lock().map_err(|e| e.to_string())? = onion.clone();
let ts = unix_now();
let loc = loc::sign(&self.keys.identity_sk, &onion, ts).map_err(|e| e.to_string())?;
let k = self.keys()?;
let loc = loc::sign(&k.identity_sk, &onion, ts).map_err(|e| e.to_string())?;
let loc_pt = loc::encode(&loc);
let friends = self
.store
@ -215,6 +485,25 @@ impl Node {
})
}
/// One-shot file to a friend. Fail closed; no outbox, no resume.
pub async fn send_file(&self, friend_pk: &[u8], path: &Path) -> Result<String, String> {
let (name, bytes) = file::read_limited(path).map_err(|e| e.to_string())?;
let chunks = file::chunks(&name, &bytes).map_err(|e| e.to_string())?;
for chunk in &chunks {
self.send_once(friend_pk, &file::encode(chunk)).await?;
}
self.store
.lock()
.map_err(|e| e.to_string())?
.append_message(
friend_pk,
"out",
file::chat_line(&name, bytes.len()).as_bytes(),
)
.map_err(|e| e.to_string())?;
Ok(name)
}
pub async fn send(&self, friend_pk: &[u8], plaintext: &[u8]) -> Result<(), String> {
let (onion, prekey) = {
let store = self.store.lock().map_err(|e| e.to_string())?;
@ -282,8 +571,8 @@ impl Node {
.connect((onion, HS_PORT))
.await
.map_err(|e| format!("connect {onion}:{HS_PORT}: {e}"))?;
let mut sess =
session::handshake_initiator(&mut stream, &self.keys, pinned_id, remote_prekey)
let keys = self.keys()?;
let mut sess = session::handshake_initiator(&mut stream, &keys, pinned_id, remote_prekey)
.await
.map_err(session_err)?;
let ct = sess.encrypt(plaintext).map_err(session_err)?;
@ -298,7 +587,8 @@ impl Node {
S: AsyncRead + AsyncWrite + Unpin,
{
let store = &self.store;
let mut sess = session::handshake_responder(stream, &self.keys, |spk| {
let keys = self.keys()?;
let mut sess = session::handshake_responder(stream, &keys, |spk| {
let Ok(g) = store.lock() else {
return None;
};
@ -308,6 +598,8 @@ impl Node {
.map_err(session_err)?;
let ct = frame::read_frame(stream).await.map_err(|e| e.to_string())?;
let pt = sess.decrypt(&ct).map_err(session_err)?;
match dispatch::classify(&pt) {
Kind::Loc => {
if let Some(loc) = loc::decode(&pt) {
let applied = store.lock().map_err(|e| e.to_string())?.apply_loc(
&sess.peer_identity,
@ -317,11 +609,31 @@ impl Node {
);
match applied {
Ok(true) => {}
Ok(false) => eprintln!("loc dropped (bad sig, stale ts, or unknown friend)"),
Ok(false) => {
eprintln!("loc dropped (bad sig, stale ts, or unknown friend)")
}
Err(e) => return Err(e.to_string()),
}
return Ok(());
}
Ok(())
}
Kind::Profile => {
if let Some(prf) = profile::decode(&pt) {
let applied = store
.lock()
.map_err(|e| e.to_string())?
.apply_profile(&sess.peer_identity, &prf);
match applied {
Ok(true) => {}
Ok(false) => {
eprintln!("prf dropped (bad sig, stale ts, or unknown friend)")
}
Err(e) => return Err(e.to_string()),
}
}
Ok(())
}
Kind::Chat => {
store
.lock()
.map_err(|e| e.to_string())?
@ -329,6 +641,137 @@ impl Node {
.map_err(|e| e.to_string())?;
Ok(())
}
Kind::Invoice => self.ingest_invoice(&sess.peer_identity, &pt).await,
Kind::Receipt => self.ingest_receipt(&sess.peer_identity, &pt).await,
Kind::File => self.ingest_file(&sess.peer_identity, &pt),
Kind::Ping | Kind::Drop => Ok(()),
}
}
fn ingest_file(&self, peer: &[u8], pt: &[u8]) -> Result<(), String> {
let Some(chunk) = file::decode(pt) else {
return Ok(());
};
let fp: String = peer.iter().map(|b| format!("{b:02x}")).collect();
let done = {
let mut inbox = self.inbox.lock().map_err(|e| e.to_string())?;
match inbox.ingest(&fp, &chunk) {
Ok(p) => p,
Err(e) => {
eprintln!("fil dropped ({e})");
return Ok(());
}
}
};
if let Some(path) = done {
let nbytes = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0) as usize;
let name = path
.file_name()
.and_then(|s| s.to_str())
.unwrap_or(&chunk.filename);
self.store
.lock()
.map_err(|e| e.to_string())?
.append_message(peer, "in", file::chat_line(name, nbytes).as_bytes())
.map_err(|e| e.to_string())?;
}
Ok(())
}
async fn ingest_invoice(&self, peer: &[u8], pt: &[u8]) -> Result<(), String> {
let Some(inv) = pay::decode_invoice(pt) else {
return Ok(());
};
if !pay::verify_invoice(peer, &inv) {
eprintln!("inv dropped (bad sig)");
return Ok(());
}
let store = self.store.lock().map_err(|e| e.to_string())?;
store
.insert_payment(
peer,
PaymentWrite {
dir: "in",
kind: "invoice",
amount_atomic: &inv.amount_atomic,
address: &inv.address,
memo: &inv.memo,
txid: None,
verified: false,
},
)
.map_err(|e| e.to_string())?;
store
.append_message(
peer,
"in",
pay::invoice_chat_line(&inv.amount_atomic, &inv.memo).as_bytes(),
)
.map_err(|e| e.to_string())?;
Ok(())
}
async fn ingest_receipt(&self, peer: &[u8], pt: &[u8]) -> Result<(), String> {
let Some(rcp) = pay::decode_receipt(pt) else {
return Ok(());
};
if !pay::verify_receipt(peer, &rcp) {
eprintln!("rcp dropped (bad sig)");
return Ok(());
}
let id = {
let store = self.store.lock().map_err(|e| e.to_string())?;
store
.insert_payment(
peer,
PaymentWrite {
dir: "in",
kind: "receipt",
amount_atomic: &rcp.amount_atomic,
address: &rcp.address,
memo: "",
txid: Some(&rcp.txid),
verified: false,
},
)
.map_err(|e| e.to_string())?
};
let mut verified = false;
if self.wallet.configured() {
match self.wallet.get_transfers().await {
Ok(rows)
if wallet::transfers_match(
&rows,
&rcp.txid,
&rcp.amount_atomic,
&rcp.address,
) =>
{
self.store
.lock()
.map_err(|e| e.to_string())?
.mark_verified(id)
.map_err(|e| e.to_string())?;
verified = true;
}
Ok(_) => {}
Err(e) => eprintln!("receipt unverified: {e}"),
}
}
if !verified {
eprintln!("receipt unverified");
}
self.store
.lock()
.map_err(|e| e.to_string())?
.append_message(
peer,
"in",
pay::receipt_chat_line(&rcp.amount_atomic, verified).as_bytes(),
)
.map_err(|e| e.to_string())?;
Ok(())
}
}
fn spawn_rend(
@ -338,6 +781,16 @@ fn spawn_rend(
tokio::spawn(async move {
let mut requests = std::pin::pin!(handle_rend_requests(rend));
while let Some(req) = requests.next().await {
let allow = node
.incoming_limit
.lock()
.map(|mut b| b.try_acquire())
.unwrap_or(false);
if !allow {
eprintln!("rate-limit drop");
drop(req);
continue;
}
let serve = Arc::clone(&node);
tokio::spawn(async move {
let Ok(mut stream) = req.accept(Connected::new_empty()).await else {

382
src/pay.rs Normal file
View file

@ -0,0 +1,382 @@
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
use sha3::{Digest, Keccak256};
pub type Result<T> = std::result::Result<T, Error>;
#[derive(Debug)]
pub struct Error(String);
impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.0.fmt(f)
}
}
impl std::error::Error for Error {}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Invoice {
pub amount_atomic: String,
pub address: String,
pub memo: String,
pub ts: i64,
pub sig: Vec<u8>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Receipt {
pub txid: String,
pub amount_atomic: String,
pub address: String,
pub ts: i64,
pub sig: Vec<u8>,
}
const INV_PREFIX: &[u8] = b"inv ";
const RCP_PREFIX: &[u8] = b"rcp ";
const PICONERO: u128 = 1_000_000_000_000;
/// Bitcoin-style alphabet; Monero encodes 8-byte blocks (11 chars), not raw base58.
const B58: &[u8] = b"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
const B58_ENC_LEN: [usize; 9] = [0, 2, 3, 5, 6, 7, 9, 10, 11];
pub fn check_address(addr: &str) -> Result<()> {
let raw = decode_monero_b58(addr).ok_or_else(|| Error("invalid Monero address".into()))?;
if raw.len() != 69 && raw.len() != 77 {
return Err(Error("invalid Monero address".into()));
}
let (payload, ck) = raw.split_at(raw.len() - 4);
let hash = Keccak256::digest(payload);
if hash.as_slice().get(..4) != Some(ck) {
return Err(Error("invalid Monero address".into()));
}
let ok = match (payload[0], raw.len()) {
(18 | 24 | 42 | 36, 69) => true, // mainnet/stagenet standard + subaddress
(19 | 25, 77) => true, // mainnet/stagenet integrated
_ => false,
};
if ok {
Ok(())
} else {
Err(Error("invalid Monero address".into()))
}
}
fn decode_monero_b58(addr: &str) -> Option<Vec<u8>> {
let bytes = addr.as_bytes();
if bytes.is_empty() || !bytes.iter().all(|b| B58.contains(b)) {
return None;
}
let mut out = Vec::new();
let mut i = 0;
while i < bytes.len() {
let rest = bytes.len() - i;
let (enc_len, dec_len) = if rest >= 11 {
(11, 8)
} else {
let dec_len = B58_ENC_LEN.iter().position(|&n| n == rest)?;
(rest, dec_len)
};
out.extend_from_slice(&decode_b58_block(&bytes[i..i + enc_len], dec_len)?);
i += enc_len;
}
Some(out)
}
fn decode_b58_block(enc: &[u8], out_len: usize) -> Option<Vec<u8>> {
let mut acc: u128 = 0;
for &c in enc {
let d = B58.iter().position(|&a| a == c)? as u128;
acc = acc.checked_mul(58)?.checked_add(d)?;
}
let max = if out_len >= 16 {
return None;
} else if out_len == 0 {
0
} else {
(1u128 << (8 * out_len)) - 1
};
if acc > max {
return None;
}
Some(acc.to_be_bytes()[16 - out_len..].to_vec())
}
pub fn parse_atomic(s: &str) -> Result<u128> {
if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) {
return Err(Error("amount must be decimal piconero".into()));
}
let n: u128 = s.parse().map_err(|_| Error("amount out of range".into()))?;
if n == 0 {
return Err(Error("amount must be > 0".into()));
}
Ok(n)
}
pub fn xmr_to_atomic(s: &str) -> Result<String> {
let s = s.trim();
let (whole, frac) = match s.split_once('.') {
Some((w, f)) => (w, f),
None => (s, ""),
};
if whole.is_empty() || !whole.bytes().all(|b| b.is_ascii_digit()) {
return Err(Error("invalid XMR amount".into()));
}
if frac.len() > 12 || !frac.bytes().all(|b| b.is_ascii_digit()) {
return Err(Error("invalid XMR amount".into()));
}
let mut frac_pad = frac.to_string();
while frac_pad.len() < 12 {
frac_pad.push('0');
}
let combined = format!("{whole}{frac_pad}");
let n: u128 = combined
.parse()
.map_err(|_| Error("amount out of range".into()))?;
if n == 0 {
return Err(Error("amount must be > 0".into()));
}
Ok(n.to_string())
}
pub fn atomic_to_xmr_str(s: &str) -> String {
let n: u128 = s.parse().unwrap_or(0);
let whole = n / PICONERO;
let frac = n % PICONERO;
if frac == 0 {
return whole.to_string();
}
let mut f = format!("{frac:012}");
while f.ends_with('0') {
f.pop();
}
format!("{whole}.{f}")
}
pub fn invoice_chat_line(amount_atomic: &str, memo: &str) -> String {
let xmr = atomic_to_xmr_str(amount_atomic);
if memo.is_empty() {
format!("[invoice] {xmr} XMR")
} else {
format!("[invoice] {xmr} XMR — {memo}")
}
}
pub fn receipt_chat_line(amount_atomic: &str, verified: bool) -> String {
let xmr = atomic_to_xmr_str(amount_atomic);
if verified {
format!("[receipt] {xmr} XMR")
} else {
format!("[receipt] {xmr} XMR — unverified")
}
}
pub fn sign_invoice(
identity_sk: &[u8],
amount_atomic: &str,
address: &str,
memo: &str,
ts: i64,
) -> Result<Invoice> {
check_invoice_fields(amount_atomic, address, memo)?;
let sig = sign_bytes(identity_sk, &inv_sign_msg(amount_atomic, address, memo, ts))?;
Ok(Invoice {
amount_atomic: amount_atomic.to_string(),
address: address.to_string(),
memo: memo.to_string(),
ts,
sig,
})
}
pub fn verify_invoice(identity_pk: &[u8], inv: &Invoice) -> bool {
if check_invoice_fields(&inv.amount_atomic, &inv.address, &inv.memo).is_err() {
return false;
}
verify_bytes(
identity_pk,
&inv_sign_msg(&inv.amount_atomic, &inv.address, &inv.memo, inv.ts),
&inv.sig,
)
}
pub fn encode_invoice(inv: &Invoice) -> Vec<u8> {
let mut out = Vec::from(INV_PREFIX);
out.extend_from_slice(inv.amount_atomic.as_bytes());
out.push(b'\n');
out.extend_from_slice(inv.address.as_bytes());
out.push(b'\n');
out.extend_from_slice(inv.memo.as_bytes());
out.push(b'\n');
out.extend_from_slice(inv.ts.to_string().as_bytes());
out.push(b'\n');
out.extend_from_slice(to_hex(&inv.sig).as_bytes());
out
}
pub fn decode_invoice(pt: &[u8]) -> Option<Invoice> {
let rest = pt.strip_prefix(INV_PREFIX)?;
let text = std::str::from_utf8(rest).ok()?;
let mut parts = text.splitn(5, '\n');
let amount_atomic = parts.next()?.to_string();
let address = parts.next()?.to_string();
let memo = parts.next()?.to_string();
let ts = parts.next()?.parse::<i64>().ok()?;
let sig = from_hex(parts.next()?)?;
if check_invoice_fields(&amount_atomic, &address, &memo).is_err() {
return None;
}
Some(Invoice {
amount_atomic,
address,
memo,
ts,
sig,
})
}
pub fn sign_receipt(
identity_sk: &[u8],
txid: &str,
amount_atomic: &str,
address: &str,
ts: i64,
) -> Result<Receipt> {
check_receipt_fields(txid, amount_atomic, address)?;
let sig = sign_bytes(identity_sk, &rcp_sign_msg(txid, amount_atomic, address, ts))?;
Ok(Receipt {
txid: txid.to_string(),
amount_atomic: amount_atomic.to_string(),
address: address.to_string(),
ts,
sig,
})
}
pub fn verify_receipt(identity_pk: &[u8], rcp: &Receipt) -> bool {
if check_receipt_fields(&rcp.txid, &rcp.amount_atomic, &rcp.address).is_err() {
return false;
}
verify_bytes(
identity_pk,
&rcp_sign_msg(&rcp.txid, &rcp.amount_atomic, &rcp.address, rcp.ts),
&rcp.sig,
)
}
pub fn encode_receipt(rcp: &Receipt) -> Vec<u8> {
let mut out = Vec::from(RCP_PREFIX);
out.extend_from_slice(rcp.txid.as_bytes());
out.push(b'\n');
out.extend_from_slice(rcp.amount_atomic.as_bytes());
out.push(b'\n');
out.extend_from_slice(rcp.address.as_bytes());
out.push(b'\n');
out.extend_from_slice(rcp.ts.to_string().as_bytes());
out.push(b'\n');
out.extend_from_slice(to_hex(&rcp.sig).as_bytes());
out
}
pub fn decode_receipt(pt: &[u8]) -> Option<Receipt> {
let rest = pt.strip_prefix(RCP_PREFIX)?;
let text = std::str::from_utf8(rest).ok()?;
let mut parts = text.splitn(5, '\n');
let txid = parts.next()?.to_string();
let amount_atomic = parts.next()?.to_string();
let address = parts.next()?.to_string();
let ts = parts.next()?.parse::<i64>().ok()?;
let sig = from_hex(parts.next()?)?;
if check_receipt_fields(&txid, &amount_atomic, &address).is_err() {
return None;
}
Some(Receipt {
txid,
amount_atomic,
address,
ts,
sig,
})
}
fn check_invoice_fields(amount_atomic: &str, address: &str, memo: &str) -> Result<()> {
parse_atomic(amount_atomic)?;
check_address(address)?;
if memo.contains('\n') {
return Err(Error("memo must not contain newlines".into()));
}
Ok(())
}
fn check_receipt_fields(txid: &str, amount_atomic: &str, address: &str) -> Result<()> {
if txid.is_empty() || txid.contains('\n') {
return Err(Error("invalid txid".into()));
}
parse_atomic(amount_atomic)?;
check_address(address)
}
fn inv_sign_msg(amount_atomic: &str, address: &str, memo: &str, ts: i64) -> Vec<u8> {
let mut msg = Vec::from(amount_atomic.as_bytes());
msg.push(b'\n');
msg.extend_from_slice(address.as_bytes());
msg.push(b'\n');
msg.extend_from_slice(memo.as_bytes());
msg.push(b'\n');
msg.extend_from_slice(ts.to_string().as_bytes());
msg
}
fn rcp_sign_msg(txid: &str, amount_atomic: &str, address: &str, ts: i64) -> Vec<u8> {
let mut msg = Vec::from(txid.as_bytes());
msg.push(b'\n');
msg.extend_from_slice(amount_atomic.as_bytes());
msg.push(b'\n');
msg.extend_from_slice(address.as_bytes());
msg.push(b'\n');
msg.extend_from_slice(ts.to_string().as_bytes());
msg
}
fn sign_bytes(identity_sk: &[u8], msg: &[u8]) -> Result<Vec<u8>> {
let sk_bytes: [u8; 32] = identity_sk
.try_into()
.map_err(|_| Error("identity secret key must be 32 bytes".into()))?;
let sk = SigningKey::from_bytes(&sk_bytes);
Ok(sk.sign(msg).to_bytes().to_vec())
}
fn verify_bytes(identity_pk: &[u8], msg: &[u8], sig: &[u8]) -> bool {
if identity_pk.len() != 32 || sig.len() != 64 {
return false;
}
let pk: [u8; 32] = match identity_pk.try_into() {
Ok(p) => p,
Err(_) => return false,
};
let sig: [u8; 64] = match sig.try_into() {
Ok(s) => s,
Err(_) => return false,
};
let Ok(vk) = VerifyingKey::from_bytes(&pk) else {
return false;
};
vk.verify(msg, &Signature::from_bytes(&sig)).is_ok()
}
fn to_hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
fn from_hex(s: &str) -> Option<Vec<u8>> {
if s.is_empty() || !s.len().is_multiple_of(2) {
return None;
}
if !s.bytes().all(|c| c.is_ascii_hexdigit()) {
return None;
}
(0..s.len())
.step_by(2)
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok())
.collect()
}

160
src/profile.rs Normal file
View file

@ -0,0 +1,160 @@
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
pub type Result<T> = std::result::Result<T, Error>;
#[derive(Debug)]
pub struct Error(String);
impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.0.fmt(f)
}
}
impl std::error::Error for Error {}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Profile {
pub display_name: String,
pub bio: String,
pub xmr_addr: String,
pub ts: i64,
pub sig: Vec<u8>,
}
const PREFIX: &[u8] = b"prf ";
const NAME_MAX: usize = 64;
const BIO_MAX: usize = 512;
pub fn validate(display_name: &str, bio: &str, xmr_addr: &str) -> Result<()> {
check_fields(display_name, bio, xmr_addr)
}
pub fn sign(
identity_sk: &[u8],
display_name: &str,
bio: &str,
xmr_addr: &str,
ts: i64,
) -> Result<Profile> {
check_fields(display_name, bio, xmr_addr)?;
let sk_bytes: [u8; 32] = identity_sk
.try_into()
.map_err(|_| Error("identity secret key must be 32 bytes".into()))?;
let sk = SigningKey::from_bytes(&sk_bytes);
let sig = sk
.sign(&sign_msg(display_name, bio, xmr_addr, ts))
.to_bytes()
.to_vec();
Ok(Profile {
display_name: display_name.to_string(),
bio: bio.to_string(),
xmr_addr: xmr_addr.to_string(),
ts,
sig,
})
}
pub fn verify(identity_pk: &[u8], prf: &Profile) -> bool {
if check_fields(&prf.display_name, &prf.bio, &prf.xmr_addr).is_err() {
return false;
}
if identity_pk.len() != 32 || prf.sig.len() != 64 {
return false;
}
let pk: [u8; 32] = match identity_pk.try_into() {
Ok(p) => p,
Err(_) => return false,
};
let sig: [u8; 64] = match prf.sig.as_slice().try_into() {
Ok(s) => s,
Err(_) => return false,
};
let Ok(vk) = VerifyingKey::from_bytes(&pk) else {
return false;
};
vk.verify(
&sign_msg(&prf.display_name, &prf.bio, &prf.xmr_addr, prf.ts),
&Signature::from_bytes(&sig),
)
.is_ok()
}
pub fn encode(prf: &Profile) -> Vec<u8> {
let mut out = Vec::from(PREFIX);
out.extend_from_slice(prf.display_name.as_bytes());
out.push(b'\n');
out.extend_from_slice(prf.bio.as_bytes());
out.push(b'\n');
out.extend_from_slice(prf.xmr_addr.as_bytes());
out.push(b'\n');
out.extend_from_slice(prf.ts.to_string().as_bytes());
out.push(b'\n');
out.extend_from_slice(to_hex(&prf.sig).as_bytes());
out
}
pub fn decode(pt: &[u8]) -> Option<Profile> {
let rest = pt.strip_prefix(PREFIX)?;
let text = std::str::from_utf8(rest).ok()?;
let mut parts = text.splitn(5, '\n');
let display_name = parts.next()?.to_string();
let bio = parts.next()?.to_string();
let xmr_addr = parts.next()?.to_string();
let ts = parts.next()?.parse::<i64>().ok()?;
let sig = from_hex(parts.next()?)?;
if check_fields(&display_name, &bio, &xmr_addr).is_err() {
return None;
}
Some(Profile {
display_name,
bio,
xmr_addr,
ts,
sig,
})
}
fn check_fields(display_name: &str, bio: &str, xmr_addr: &str) -> Result<()> {
if display_name.len() > NAME_MAX {
return Err(Error("display_name longer than 64 bytes".into()));
}
if bio.len() > BIO_MAX {
return Err(Error("bio longer than 512 bytes".into()));
}
if display_name.contains('\n') || bio.contains('\n') || xmr_addr.contains('\n') {
return Err(Error("profile fields must not contain newlines".into()));
}
if !xmr_addr.is_empty() {
crate::pay::check_address(xmr_addr).map_err(|e| Error(e.to_string()))?;
}
Ok(())
}
fn sign_msg(display_name: &str, bio: &str, xmr_addr: &str, ts: i64) -> Vec<u8> {
let mut msg = Vec::from(display_name.as_bytes());
msg.push(b'\n');
msg.extend_from_slice(bio.as_bytes());
msg.push(b'\n');
msg.extend_from_slice(xmr_addr.as_bytes());
msg.push(b'\n');
msg.extend_from_slice(ts.to_string().as_bytes());
msg
}
fn to_hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
fn from_hex(s: &str) -> Option<Vec<u8>> {
if s.is_empty() || !s.len().is_multiple_of(2) {
return None;
}
if !s.bytes().all(|c| c.is_ascii_hexdigit()) {
return None;
}
(0..s.len())
.step_by(2)
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok())
.collect()
}

101
src/qr.rs
View file

@ -1,6 +1,4 @@
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
use qrcode::render::unicode::Dense1x2;
use qrcode::QrCode;
pub type Result<T> = std::result::Result<T, Error>;
@ -22,30 +20,51 @@ pub struct QrPayload {
pub signed_prekey: Vec<u8>,
}
/// `onionwire:v1:k={pubkey_hex}:o={v3onion}:spk={signed_prekey}:sig={sign(k||o||spk)}`
/// Cap invite text before hex decode. Honest v1 is a few hundred bytes.
const MAX_INVITE: usize = 4096;
const HEX_K: usize = 64;
const HEX_SPK: usize = 64;
const HEX_SIG: usize = 128;
/// `onionwire:v1:k={pubkey_hex}:o={v3onion}:spk={signed_prekey}:sig={sign(v2)}`
pub fn encode(identity_sk: &[u8], onion: &str, signed_prekey: &[u8]) -> Result<String> {
if signed_prekey.len() != 32 {
return Err(Error("spk must be 32 bytes".into()));
}
if !is_v3_onion(onion) {
return Err(Error("o must be a v3 onion".into()));
}
let sk_bytes: [u8; 32] = identity_sk
.try_into()
.map_err(|_| Error("identity secret key must be 32 bytes".into()))?;
let sk = SigningKey::from_bytes(&sk_bytes);
let k = to_hex(&sk.verifying_key().to_bytes());
let spk = to_hex(signed_prekey);
let msg = sign_msg(&k, onion, &spk);
let msg = sign_msg_v2(&k, onion, &spk);
let sig = to_hex(&sk.sign(&msg).to_bytes());
Ok(format!("onionwire:v1:k={k}:o={onion}:spk={spk}:sig={sig}"))
}
pub fn decode(raw: &str) -> Result<QrPayload> {
if raw.len() > MAX_INVITE {
return Err(Error("invite too long".into()));
}
let (k, onion, spk, sig) = parse_fields(raw)?;
if !is_hex_len(&k, HEX_K) {
return Err(Error("k must be 32 bytes".into()));
}
if !is_hex_len(&spk, HEX_SPK) {
return Err(Error("spk must be 32 bytes".into()));
}
if !is_hex_len(&sig, HEX_SIG) {
return Err(Error("sig must be 64 bytes".into()));
}
if !is_v3_onion(&onion) {
return Err(Error("o must be a v3 onion".into()));
}
let pubkey = from_hex(&k)?;
let signed_prekey = from_hex(&spk)?;
let sig_bytes = from_hex(&sig)?;
if pubkey.len() != 32 {
return Err(Error("k must be 32 bytes".into()));
}
if sig_bytes.len() != 64 {
return Err(Error("sig must be 64 bytes".into()));
}
let pk_arr: [u8; 32] = pubkey
.as_slice()
.try_into()
@ -56,9 +75,15 @@ pub fn decode(raw: &str) -> Result<QrPayload> {
.map_err(|_| Error("sig must be 64 bytes".into()))?;
let vk = VerifyingKey::from_bytes(&pk_arr).map_err(|e| Error(e.to_string()))?;
let signature = Signature::from_bytes(&sig_arr);
let msg = sign_msg(&k, &onion, &spk);
vk.verify(&msg, &signature)
.map_err(|_| Error("bad signature".into()))?;
let ok_v2 = vk
.verify(&sign_msg_v2(&k, &onion, &spk), &signature)
.is_ok();
let ok_v1 = vk
.verify(&sign_msg_v1(&k, &onion, &spk), &signature)
.is_ok();
if !ok_v2 && !ok_v1 {
return Err(Error("bad signature".into()));
}
Ok(QrPayload {
pubkey,
onion,
@ -66,12 +91,30 @@ pub fn decode(raw: &str) -> Result<QrPayload> {
})
}
pub fn render_unicode(payload: &str) -> Result<String> {
let code = QrCode::new(payload.as_bytes()).map_err(|e| Error(e.to_string()))?;
Ok(code.render::<Dense1x2>().build())
fn is_v3_onion(s: &str) -> bool {
let Some(addr) = s.strip_suffix(".onion") else {
return false;
};
addr.len() == 56 && addr.bytes().all(|b| matches!(b, b'a'..=b'z' | b'2'..=b'7'))
}
fn sign_msg(k: &str, onion: &str, spk: &str) -> Vec<u8> {
fn is_hex_len(s: &str, n: usize) -> bool {
s.len() == n && s.bytes().all(|c| c.is_ascii_hexdigit())
}
fn sign_msg_v2(k: &str, onion: &str, spk: &str) -> Vec<u8> {
let mut msg = Vec::with_capacity(19 + 3 + k.len() + onion.len() + spk.len());
msg.extend_from_slice(b"onionwire-invite-v1");
msg.push(0);
msg.extend_from_slice(k.as_bytes());
msg.push(0);
msg.extend_from_slice(onion.as_bytes());
msg.push(0);
msg.extend_from_slice(spk.as_bytes());
msg
}
fn sign_msg_v1(k: &str, onion: &str, spk: &str) -> Vec<u8> {
let mut msg = Vec::with_capacity(k.len() + onion.len() + spk.len());
msg.extend_from_slice(k.as_bytes());
msg.extend_from_slice(onion.as_bytes());
@ -125,7 +168,7 @@ fn to_hex(bytes: &[u8]) -> String {
}
fn from_hex(s: &str) -> Result<Vec<u8>> {
if s.is_empty() || !s.len().is_multiple_of(2) {
if s.len() > HEX_SIG || s.is_empty() || !s.len().is_multiple_of(2) {
return Err(Error("invalid hex".into()));
}
if !s.bytes().all(|c| c.is_ascii_hexdigit()) {
@ -136,3 +179,25 @@ fn from_hex(s: &str) -> Result<Vec<u8>> {
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).map_err(|_| Error("invalid hex".into())))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn v3() -> String {
format!("{}.onion", "a".repeat(56))
}
#[test]
fn well_formed_v1_concat_still_decodes() {
let sk = SigningKey::from_bytes(&[7u8; 32]);
let k = to_hex(&sk.verifying_key().to_bytes());
let onion = v3();
let spk = to_hex(&[9u8; 32]);
let sig = to_hex(&sk.sign(&sign_msg_v1(&k, &onion, &spk)).to_bytes());
let raw = format!("onionwire:v1:k={k}:o={onion}:spk={spk}:sig={sig}");
let p = decode(&raw).expect("legacy concat");
assert_eq!(p.onion, onion);
assert_eq!(p.signed_prekey, vec![9u8; 32]);
}
}

45
src/ratelimit.rs Normal file
View file

@ -0,0 +1,45 @@
//! App-level token bucket for incoming rendezvous accepts.
use std::time::{Duration, Instant};
pub struct TokenBucket {
rate_per_sec: f64,
burst: f64,
tokens: f64,
last: Instant,
}
impl TokenBucket {
/// `count` tokens replenished over `window`, starting full up to `burst`.
pub fn new(count: u32, window: Duration, burst: u32) -> Self {
let secs = window.as_secs_f64().max(f64::EPSILON);
Self {
rate_per_sec: f64::from(count) / secs,
burst: f64::from(burst),
tokens: f64::from(burst),
last: Instant::now(),
}
}
pub fn try_acquire(&mut self) -> bool {
self.try_acquire_at(Instant::now())
}
pub fn try_acquire_at(&mut self, now: Instant) -> bool {
let elapsed = now.saturating_duration_since(self.last).as_secs_f64();
self.last = now;
self.tokens = (self.tokens + elapsed * self.rate_per_sec).min(self.burst);
if self.tokens >= 1.0 {
self.tokens -= 1.0;
true
} else {
false
}
}
}
impl Default for TokenBucket {
fn default() -> Self {
Self::new(30, Duration::from_secs(60), 10)
}
}

View file

@ -4,6 +4,7 @@ use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use ed25519_dalek::SigningKey;
use rand::RngCore;
use rand::rngs::OsRng;
use rusqlite::{Connection, OptionalExtension, params};
use x25519_dalek::{PublicKey as X25519Public, StaticSecret};
@ -33,8 +34,15 @@ impl From<std::io::Error> for Error {
}
}
impl From<String> for Error {
fn from(e: String) -> Self {
Self(e)
}
}
pub struct Store {
conn: Connection,
msg_key: [u8; 32],
}
pub struct SelfIdentity {
@ -54,12 +62,42 @@ pub struct Friend {
pub prekey: Vec<u8>,
}
pub struct FriendProfile {
pub display_name: String,
pub bio: String,
pub xmr_addr: String,
pub updated_at: i64,
}
#[derive(Debug)]
pub struct Message {
pub dir: String,
pub plaintext: Vec<u8>,
}
#[derive(Debug)]
pub struct Payment {
pub id: i64,
pub dir: String,
pub kind: String,
pub amount_atomic: String,
pub address: String,
pub memo: String,
pub txid: Option<String>,
pub verified: bool,
pub created_at: i64,
}
pub struct PaymentWrite<'a> {
pub dir: &'a str,
pub kind: &'a str,
pub amount_atomic: &'a str,
pub address: &'a str,
pub memo: &'a str,
pub txid: Option<&'a str>,
pub verified: bool,
}
impl Store {
pub fn open() -> Result<Self> {
Self::open_at(&home_dir()?)
@ -70,10 +108,25 @@ impl Store {
}
pub fn open_at(home: &Path) -> Result<Self> {
Self::open_at_with_passphrase(home, &passphrase_from_env()?)
}
pub fn open_at_with_passphrase(home: &Path, passphrase: &str) -> Result<Self> {
if passphrase.is_empty() {
return Err(Error("empty passphrase".into()));
}
mkdir_700(home)?;
mkdir_700(&home.join("arti"))?;
mkdir_700(&home.join("cache"))?;
let db_path = home.join("onionwire.db");
let conn = Connection::open(&db_path)?;
let journal: String = conn.query_row("PRAGMA journal_mode = WAL", [], |row| row.get(0))?;
if !journal.eq_ignore_ascii_case("wal") {
return Err(Error(format!("journal_mode WAL failed: {journal}")));
}
// Overwrite freed pages on DELETE. Flash wear-leveling can still keep copies;
// this is not a forensic / SSD crypto-shred.
conn.pragma_update(None, "secure_delete", "ON")?;
conn.execute_batch(
"
PRAGMA foreign_keys = ON;
@ -105,14 +158,81 @@ impl Store {
prekey_pk BLOB NOT NULL,
hs_nickname TEXT NOT NULL DEFAULT 'ow0'
);
CREATE TABLE IF NOT EXISTS self_profile (
id INTEGER PRIMARY KEY CHECK (id = 1),
display_name TEXT NOT NULL DEFAULT '',
bio TEXT NOT NULL DEFAULT '',
xmr_addr TEXT NOT NULL DEFAULT '',
updated_at INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS friend_profiles (
pubkey BLOB PRIMARY KEY,
display_name TEXT NOT NULL,
bio TEXT NOT NULL,
xmr_addr TEXT NOT NULL DEFAULT '',
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS store_meta (
id INTEGER PRIMARY KEY CHECK (id = 1),
kdf_salt BLOB NOT NULL,
wrapped_key BLOB NOT NULL
);
CREATE TABLE IF NOT EXISTS payments (
id INTEGER PRIMARY KEY,
friend_id INTEGER NOT NULL REFERENCES friends(id),
dir TEXT NOT NULL CHECK(dir IN ('in','out')),
kind TEXT NOT NULL CHECK(kind IN ('invoice','receipt')),
amount_atomic TEXT NOT NULL,
address TEXT NOT NULL,
memo TEXT NOT NULL DEFAULT '',
txid TEXT,
verified INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL
);
INSERT OR IGNORE INTO self_profile (id) VALUES (1);
",
)?;
let store = Self { conn };
let check: String = conn.query_row("PRAGMA integrity_check", [], |row| row.get(0))?;
if check != "ok" {
return Err(Error(format!("integrity_check: {check}")));
}
let mut store = Self {
conn,
msg_key: [0u8; 32],
};
store.migrate()?;
store.ensure_self()?;
store.unlock_messages(passphrase)?;
Ok(store)
}
pub fn journal_mode(&self) -> Result<String> {
self.conn
.query_row("PRAGMA journal_mode", [], |row| row.get(0))
.map_err(Into::into)
}
pub fn replace_identity_keys(
&self,
identity_sk: &[u8],
identity_pk: &[u8],
prekey_sk: &[u8],
prekey_pk: &[u8],
) -> Result<()> {
if identity_sk.len() != 32
|| identity_pk.len() != 32
|| prekey_sk.len() != 32
|| prekey_pk.len() != 32
{
return Err(Error("identity key length".into()));
}
self.conn.execute(
"UPDATE self SET identity_sk = ?1, identity_pk = ?2, prekey_sk = ?3, prekey_pk = ?4 WHERE id = 1",
params![identity_sk, identity_pk, prekey_sk, prekey_pk],
)?;
Ok(())
}
fn migrate(&self) -> Result<()> {
self.add_column_if_missing(
"friends",
@ -142,6 +262,109 @@ impl Store {
Ok(())
}
fn unlock_messages(&mut self, passphrase: &str) -> Result<()> {
let row: Option<(Vec<u8>, Vec<u8>)> = self
.conn
.query_row(
"SELECT kdf_salt, wrapped_key FROM store_meta WHERE id = 1",
[],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.optional()?;
match row {
Some((salt, wrapped)) => {
let wrap_key = crate::backup::kdf(passphrase, &salt)?;
let raw = crate::backup::aead_decrypt(&wrap_key, &wrapped, b"")
.map_err(|_| Error("wrong passphrase".into()))?;
if raw.len() != 32 {
return Err(Error("wrapped message key length".into()));
}
self.msg_key.copy_from_slice(&raw);
self.rewrap_empty_aad_messages()?;
Ok(())
}
None => {
let mut data_key = [0u8; 32];
OsRng.fill_bytes(&mut data_key);
let mut salt = [0u8; 16];
OsRng.fill_bytes(&mut salt);
let wrap_key = crate::backup::kdf(passphrase, &salt)?;
let wrapped = crate::backup::aead_encrypt(&wrap_key, &data_key, b"")?;
self.conn.execute(
"INSERT INTO store_meta (id, kdf_salt, wrapped_key) VALUES (1, ?1, ?2)",
params![salt.as_slice(), wrapped],
)?;
self.msg_key = data_key;
self.reencrypt_legacy_messages()?;
Ok(())
}
}
}
fn reencrypt_legacy_messages(&self) -> Result<()> {
let mut stmt = self
.conn
.prepare("SELECT id, friend_id, dir, plaintext FROM messages")?;
let rows: Vec<(i64, i64, String, Vec<u8>)> = stmt
.query_map([], |row| {
Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
})?
.collect::<std::result::Result<_, _>>()?;
drop(stmt);
if rows.is_empty() {
return Ok(());
}
let tx = self.conn.unchecked_transaction()?;
for (id, friend_id, dir, plain) in rows {
let aad = message_aad(friend_id, &dir, id);
let blob = crate::backup::aead_encrypt(&self.msg_key, &plain, &aad)?;
tx.execute(
"UPDATE messages SET plaintext = ?1 WHERE id = ?2",
params![blob, id],
)?;
}
tx.commit()?;
let _ = self.conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE)");
Ok(())
}
/// One-shot: empty-AAD v0.2 blobs → row-bound AAD. list_messages never falls back.
fn rewrap_empty_aad_messages(&self) -> Result<()> {
let mut stmt = self
.conn
.prepare("SELECT id, friend_id, dir, plaintext FROM messages")?;
let rows: Vec<(i64, i64, String, Vec<u8>)> = stmt
.query_map([], |row| {
Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
})?
.collect::<std::result::Result<_, _>>()?;
drop(stmt);
let mut updates = Vec::new();
for (id, friend_id, dir, blob) in rows {
let aad = message_aad(friend_id, &dir, id);
if crate::backup::aead_decrypt(&self.msg_key, &blob, &aad).is_ok() {
continue;
}
if let Ok(pt) = crate::backup::aead_decrypt(&self.msg_key, &blob, b"") {
let new_blob = crate::backup::aead_encrypt(&self.msg_key, &pt, &aad)?;
updates.push((id, new_blob));
}
}
if updates.is_empty() {
return Ok(());
}
let tx = self.conn.unchecked_transaction()?;
for (id, blob) in updates {
tx.execute(
"UPDATE messages SET plaintext = ?1 WHERE id = ?2",
params![blob, id],
)?;
}
tx.commit()?;
let _ = self.conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE)");
Ok(())
}
fn add_column_if_missing(&self, table: &str, column: &str, ddl: &str) -> Result<()> {
let sql =
format!("SELECT COUNT(*) FROM pragma_table_info('{table}') WHERE name = '{column}'");
@ -209,6 +432,23 @@ impl Store {
.map_err(Into::into)
}
/// Display fingerprint of our own identity key (identity = pubkey).
pub fn self_fingerprint(&self) -> Result<String> {
Ok(fingerprint(&self.self_identity()?.identity_pk))
}
/// Local petname only. Never part of the wire protocol.
pub fn set_petname(&self, pubkey: &[u8], petname: Option<&str>) -> Result<()> {
let n = self.conn.execute(
"UPDATE friends SET petname = ?1 WHERE pubkey = ?2",
params![petname, pubkey],
)?;
if n == 0 {
return Err(Error("friend not found".into()));
}
Ok(())
}
pub fn upsert_friend(&self, pubkey: &[u8], onion: &str, petname: Option<&str>) -> Result<()> {
let now = unix_now();
let fp = fingerprint(pubkey);
@ -304,6 +544,79 @@ impl Store {
Ok(n > 0)
}
pub fn set_self_profile(&self, display_name: &str, bio: &str, xmr_addr: &str) -> Result<()> {
crate::profile::validate(display_name, bio, xmr_addr).map_err(|e| Error(e.to_string()))?;
let prev: i64 = self.conn.query_row(
"SELECT updated_at FROM self_profile WHERE id = 1",
[],
|row| row.get(0),
)?;
let now = unix_now().max(prev + 1);
self.conn.execute(
"UPDATE self_profile SET display_name = ?1, bio = ?2, xmr_addr = ?3, updated_at = ?4 WHERE id = 1",
params![display_name, bio, xmr_addr, now],
)?;
Ok(())
}
pub fn self_profile(&self) -> Result<FriendProfile> {
self.conn
.query_row(
"SELECT display_name, bio, xmr_addr, updated_at FROM self_profile WHERE id = 1",
[],
profile_from_row,
)
.map_err(Into::into)
}
/// Apply a signed profile. Unknown friend / bad sig / stale ts → no change, no insert.
pub fn apply_profile(&self, pubkey: &[u8], prf: &crate::profile::Profile) -> Result<bool> {
if !crate::profile::verify(pubkey, prf) {
return Ok(false);
}
let exists: i64 = self.conn.query_row(
"SELECT COUNT(*) FROM friends WHERE pubkey = ?1",
params![pubkey],
|row| row.get(0),
)?;
if exists == 0 {
return Ok(false);
}
let prev: Option<i64> = self
.conn
.query_row(
"SELECT updated_at FROM friend_profiles WHERE pubkey = ?1",
params![pubkey],
|row| row.get(0),
)
.optional()?;
if prev.is_some_and(|t| prf.ts <= t) {
return Ok(false);
}
self.conn.execute(
"INSERT INTO friend_profiles (pubkey, display_name, bio, xmr_addr, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5)
ON CONFLICT(pubkey) DO UPDATE SET
display_name = excluded.display_name,
bio = excluded.bio,
xmr_addr = excluded.xmr_addr,
updated_at = excluded.updated_at",
params![pubkey, prf.display_name, prf.bio, prf.xmr_addr, prf.ts],
)?;
Ok(true)
}
pub fn friend_profile(&self, pubkey: &[u8]) -> Result<Option<FriendProfile>> {
self.conn
.query_row(
"SELECT display_name, bio, xmr_addr, updated_at FROM friend_profiles WHERE pubkey = ?1",
params![pubkey],
profile_from_row,
)
.optional()
.map_err(Into::into)
}
pub fn set_friend_prekey(&self, pubkey: &[u8], prekey: &[u8]) -> Result<()> {
let n = self.conn.execute(
"UPDATE friends SET prekey = ?1 WHERE pubkey = ?2",
@ -326,6 +639,65 @@ impl Store {
.map_err(Into::into)
}
pub fn insert_payment(&self, friend_pk: &[u8], p: PaymentWrite<'_>) -> Result<i64> {
if p.dir != "in" && p.dir != "out" {
return Err(Error("dir must be in or out".into()));
}
if p.kind != "invoice" && p.kind != "receipt" {
return Err(Error("kind must be invoice or receipt".into()));
}
crate::pay::parse_atomic(p.amount_atomic).map_err(|e| Error(e.to_string()))?;
crate::pay::check_address(p.address).map_err(|e| Error(e.to_string()))?;
let friend_id: i64 = self
.conn
.query_row(
"SELECT id FROM friends WHERE pubkey = ?1",
params![friend_pk],
|row| row.get(0),
)
.map_err(|_| Error("friend not found".into()))?;
self.conn.execute(
"INSERT INTO payments (friend_id, dir, kind, amount_atomic, address, memo, txid, verified, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
params![
friend_id,
p.dir,
p.kind,
p.amount_atomic,
p.address,
p.memo,
p.txid,
i64::from(p.verified),
unix_now()
],
)?;
Ok(self.conn.last_insert_rowid())
}
pub fn list_payments(&self, friend_pk: &[u8]) -> Result<Vec<Payment>> {
let mut stmt = self.conn.prepare(
"SELECT p.id, p.dir, p.kind, p.amount_atomic, p.address, p.memo, p.txid, p.verified, p.created_at
FROM payments p
JOIN friends f ON f.id = p.friend_id
WHERE f.pubkey = ?1
ORDER BY p.id",
)?;
let rows = stmt.query_map(params![friend_pk], payment_from_row)?;
let mut out = Vec::new();
for row in rows {
out.push(row?);
}
Ok(out)
}
pub fn mark_verified(&self, id: i64) -> Result<bool> {
let n = self.conn.execute(
"UPDATE payments SET verified = 1 WHERE id = ?1",
params![id],
)?;
Ok(n > 0)
}
pub fn append_message(&self, friend_pk: &[u8], dir: &str, plaintext: &[u8]) -> Result<()> {
if dir != "in" && dir != "out" {
return Err(Error("dir must be in or out".into()));
@ -338,41 +710,59 @@ impl Store {
|row| row.get(0),
)
.map_err(|_| Error("friend not found".into()))?;
self.conn.execute(
"INSERT INTO messages (friend_id, dir, plaintext, created_at) VALUES (?1, ?2, ?3, ?4)",
params![friend_id, dir, plaintext, unix_now()],
let tx = self.conn.unchecked_transaction()?;
tx.execute(
"INSERT INTO messages (friend_id, dir, plaintext, created_at) VALUES (?1, ?2, x'', ?3)",
params![friend_id, dir, unix_now()],
)?;
let row_id = tx.last_insert_rowid();
let aad = message_aad(friend_id, dir, row_id);
let blob = crate::backup::aead_encrypt(&self.msg_key, plaintext, &aad)?;
tx.execute(
"UPDATE messages SET plaintext = ?1 WHERE id = ?2",
params![blob, row_id],
)?;
tx.commit()?;
Ok(())
}
pub fn list_messages(&self, friend_pk: &[u8]) -> Result<Vec<Message>> {
let mut stmt = self.conn.prepare(
"SELECT m.dir, m.plaintext FROM messages m
"SELECT m.id, m.friend_id, m.dir, m.plaintext FROM messages m
JOIN friends f ON f.id = m.friend_id
WHERE f.pubkey = ?1
ORDER BY m.id",
)?;
let rows = stmt.query_map(params![friend_pk], |row| {
Ok(Message {
dir: row.get(0)?,
plaintext: row.get(1)?,
})
Ok((
row.get::<_, i64>(0)?,
row.get::<_, i64>(1)?,
row.get::<_, String>(2)?,
row.get::<_, Vec<u8>>(3)?,
))
})?;
let mut out = Vec::new();
for row in rows {
out.push(row?);
let (id, friend_id, dir, blob) = row?;
let aad = message_aad(friend_id, &dir, id);
let plaintext = crate::backup::aead_decrypt(&self.msg_key, &blob, &aad)
.map_err(|_| Error("message decrypt failed".into()))?;
out.push(Message { dir, plaintext });
}
Ok(out)
}
/// Overwrite message bodies, delete rows, VACUUM. Identity + friends stay.
/// Drop chat + payments history. Identity + friends stay.
/// Not a forensic erase: SSD wear-leveling can keep copies.
pub fn wipe_messages(&self) -> Result<()> {
self.conn.execute(
"UPDATE messages SET plaintext = zeroblob(length(plaintext))",
[],
)?;
self.conn.execute("DELETE FROM messages", [])?;
self.conn.execute("DELETE FROM payments", [])?;
self.conn.execute_batch("VACUUM")?;
self.conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE)")?;
Ok(())
}
@ -385,6 +775,29 @@ impl Store {
}
}
fn payment_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Payment> {
Ok(Payment {
id: row.get(0)?,
dir: row.get(1)?,
kind: row.get(2)?,
amount_atomic: row.get(3)?,
address: row.get(4)?,
memo: row.get(5)?,
txid: row.get(6)?,
verified: row.get::<_, i64>(7)? != 0,
created_at: row.get(8)?,
})
}
fn profile_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<FriendProfile> {
Ok(FriendProfile {
display_name: row.get(0)?,
bio: row.get(1)?,
xmr_addr: row.get(2)?,
updated_at: row.get(3)?,
})
}
fn friend_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Friend> {
Ok(Friend {
pubkey: row.get(0)?,
@ -410,6 +823,34 @@ fn home_dir() -> Result<PathBuf> {
Ok(PathBuf::from(home).join(".local/share/onionwire"))
}
fn passphrase_from_env() -> Result<String> {
match std::env::var("ONIONWIRE_STORE_PASSPHRASE") {
Ok(p) if p.is_empty() => Err(Error("empty passphrase".into())),
Ok(p) => Ok(p),
Err(_) => Err(Error("ONIONWIRE_STORE_PASSPHRASE required".into())),
}
}
/// Env `ONIONWIRE_STORE_PASSPHRASE` if set (non-empty). Otherwise `read_secret`
/// (TTY, no echo). Empty values fail closed.
pub fn resolve_store_passphrase(
read_secret: impl FnOnce(&str) -> std::io::Result<String>,
) -> Result<String> {
match std::env::var("ONIONWIRE_STORE_PASSPHRASE") {
Ok(p) if p.is_empty() => Err(Error("empty passphrase".into())),
Ok(p) => Ok(p),
Err(_) => {
let s = read_secret("onionwire: store passphrase: ")?;
let s = s.trim_end_matches(['\n', '\r']).to_string();
if s.is_empty() {
Err(Error("empty passphrase".into()))
} else {
Ok(s)
}
}
}
}
fn mkdir_700(path: &Path) -> Result<()> {
fs::create_dir_all(path)?;
let mut perms = fs::metadata(path)?.permissions();
@ -418,6 +859,17 @@ fn mkdir_700(path: &Path) -> Result<()> {
Ok(())
}
/// AAD: `owmsg1` || friend_id_le64 || dir || 0x00 || row_id_le64
fn message_aad(friend_id: i64, dir: &str, row_id: i64) -> Vec<u8> {
let mut aad = Vec::with_capacity(6 + 8 + dir.len() + 1 + 8);
aad.extend_from_slice(b"owmsg1");
aad.extend_from_slice(&friend_id.to_le_bytes());
aad.extend_from_slice(dir.as_bytes());
aad.push(0);
aad.extend_from_slice(&row_id.to_le_bytes());
aad
}
fn unix_now() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
@ -428,3 +880,187 @@ fn unix_now() -> i64 {
fn fingerprint(pubkey: &[u8]) -> String {
pubkey.iter().map(|b| format!("{b:02x}")).collect()
}
#[cfg(test)]
mod at_rest {
use super::*;
#[test]
fn swapped_ciphertext_does_not_show_other_friends_body() {
let dir = tempfile::tempdir().unwrap();
let store = Store::open_at_with_passphrase(dir.path(), "aad-pass").unwrap();
let alice = [1u8; 32];
let bob = [2u8; 32];
store.upsert_friend(&alice, "a.onion", None).unwrap();
store.upsert_friend(&bob, "b.onion", None).unwrap();
store
.append_message(&alice, "out", b"secret-for-alice")
.unwrap();
store
.append_message(&bob, "out", b"secret-for-bob")
.unwrap();
let blobs: Vec<(i64, Vec<u8>)> = {
let mut stmt = store
.conn
.prepare("SELECT id, plaintext FROM messages ORDER BY id")
.unwrap();
stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))
.unwrap()
.collect::<rusqlite::Result<Vec<_>>>()
.unwrap()
};
assert_eq!(blobs.len(), 2);
store
.conn
.execute(
"UPDATE messages SET plaintext = ?1 WHERE id = ?2",
params![&blobs[1].1, blobs[0].0],
)
.unwrap();
store
.conn
.execute(
"UPDATE messages SET plaintext = ?1 WHERE id = ?2",
params![&blobs[0].1, blobs[1].0],
)
.unwrap();
if let Ok(msgs) = store.list_messages(&alice) {
assert!(
msgs.iter()
.all(|m| m.plaintext.as_slice() != b"secret-for-bob"),
"alice saw bob's body after ciphertext swap"
);
}
if let Ok(msgs) = store.list_messages(&bob) {
assert!(
msgs.iter()
.all(|m| m.plaintext.as_slice() != b"secret-for-alice"),
"bob saw alice's body after ciphertext swap"
);
}
}
#[test]
fn empty_aad_rows_rewrap_on_unlock_then_swap_fails() {
let dir = tempfile::tempdir().unwrap();
let store = Store::open_at_with_passphrase(dir.path(), "rewrap-pass").unwrap();
let alice = [1u8; 32];
let bob = [2u8; 32];
store.upsert_friend(&alice, "a.onion", None).unwrap();
store.upsert_friend(&bob, "b.onion", None).unwrap();
store
.append_message(&alice, "out", b"secret-for-alice")
.unwrap();
store
.append_message(&bob, "out", b"secret-for-bob")
.unwrap();
let ids: Vec<i64> = {
let mut stmt = store
.conn
.prepare("SELECT id FROM messages ORDER BY id")
.unwrap();
stmt.query_map([], |r| r.get(0))
.unwrap()
.collect::<rusqlite::Result<Vec<_>>>()
.unwrap()
};
let a_blob = crate::backup::aead_encrypt(&store.msg_key, b"secret-for-alice", b"").unwrap();
let b_blob = crate::backup::aead_encrypt(&store.msg_key, b"secret-for-bob", b"").unwrap();
store
.conn
.execute(
"UPDATE messages SET plaintext = ?1 WHERE id = ?2",
params![a_blob, ids[0]],
)
.unwrap();
store
.conn
.execute(
"UPDATE messages SET plaintext = ?1 WHERE id = ?2",
params![b_blob, ids[1]],
)
.unwrap();
drop(store);
let store = Store::open_at_with_passphrase(dir.path(), "rewrap-pass").unwrap();
assert_eq!(
store.list_messages(&alice).unwrap()[0].plaintext,
b"secret-for-alice"
);
assert_eq!(
store.list_messages(&bob).unwrap()[0].plaintext,
b"secret-for-bob"
);
let blobs: Vec<(i64, Vec<u8>)> = {
let mut stmt = store
.conn
.prepare("SELECT id, plaintext FROM messages ORDER BY id")
.unwrap();
stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))
.unwrap()
.collect::<rusqlite::Result<Vec<_>>>()
.unwrap()
};
store
.conn
.execute(
"UPDATE messages SET plaintext = ?1 WHERE id = ?2",
params![&blobs[1].1, blobs[0].0],
)
.unwrap();
store
.conn
.execute(
"UPDATE messages SET plaintext = ?1 WHERE id = ?2",
params![&blobs[0].1, blobs[1].0],
)
.unwrap();
if let Ok(msgs) = store.list_messages(&alice) {
assert!(
msgs.iter()
.all(|m| m.plaintext.as_slice() != b"secret-for-bob"),
"alice saw bob's body after post-rewrap swap"
);
}
}
#[test]
fn first_unlock_reencrypts_legacy_plaintext_rows() {
let dir = tempfile::tempdir().unwrap();
let store = Store::open_at_with_passphrase(dir.path(), "migrate-pass").unwrap();
let pk = [1u8; 32];
store.upsert_friend(&pk, "a.onion", None).unwrap();
store
.append_message(&pk, "out", b"legacy-plain-xyz")
.unwrap();
store.conn.execute("DELETE FROM store_meta", []).unwrap();
store
.conn
.execute(
"UPDATE messages SET plaintext = ?1",
params![b"legacy-plain-xyz".as_slice()],
)
.unwrap();
let _ = store.conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE)");
drop(store);
let store = Store::open_at_with_passphrase(dir.path(), "migrate-pass").unwrap();
let msgs = store.list_messages(&pk).unwrap();
assert_eq!(msgs[0].plaintext, b"legacy-plain-xyz");
drop(store);
let needle = b"legacy-plain-xyz";
for name in ["onionwire.db", "onionwire.db-wal", "onionwire.db-shm"] {
let path = dir.path().join(name);
let Ok(bytes) = std::fs::read(&path) else {
continue;
};
assert!(
!bytes.windows(needle.len()).any(|w| w == needle),
"{name} still contains legacy plaintext"
);
}
}
}

File diff suppressed because it is too large Load diff

476
src/wallet.rs Normal file
View file

@ -0,0 +1,476 @@
use std::time::Duration;
use rand::RngCore;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
pub type Result<T> = std::result::Result<T, Error>;
#[derive(Debug)]
pub struct Error(String);
impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.0.fmt(f)
}
}
impl std::error::Error for Error {}
#[derive(Clone)]
struct Endpoint {
host: String,
port: u16,
path: String,
user: String,
pass: String,
}
impl std::fmt::Debug for Endpoint {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Endpoint")
.field("host", &self.host)
.field("port", &self.port)
.field("path", &self.path)
.field("user", &self.user)
.field("pass", &"<redacted>")
.finish()
}
}
#[derive(Debug, Clone)]
pub struct Wallet {
endpoint: Option<Endpoint>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TransferRow {
pub txid: String,
pub amount: String,
pub address: String,
}
const RPC_TIMEOUT: Duration = Duration::from_secs(5);
impl Wallet {
pub fn disabled() -> Self {
Self { endpoint: None }
}
pub fn from_env() -> Self {
match std::env::var("ONIONWIRE_WALLET_RPC") {
Ok(s) if !s.trim().is_empty() => {
let login = std::env::var("ONIONWIRE_WALLET_RPC_LOGIN")
.ok()
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty());
match parse_http_url(s.trim(), login.as_deref()) {
Ok(endpoint) => Self {
endpoint: Some(endpoint),
},
Err(e) => {
eprintln!("ONIONWIRE_WALLET_RPC: {e}");
Self::disabled()
}
}
}
_ => Self::disabled(),
}
}
pub fn from_url(url: &str) -> Result<Self> {
Ok(Self {
endpoint: Some(parse_http_url(url, None)?),
})
}
pub fn configured(&self) -> bool {
self.endpoint.is_some()
}
pub async fn get_address(&self) -> Result<String> {
let v = self
.rpc("get_address", serde_json::json!({"account_index": 0}))
.await?;
json_str(&v, "address")
}
pub async fn create_address(&self) -> Result<String> {
let v = self
.rpc("create_address", serde_json::json!({"account_index": 0}))
.await?;
json_str(&v, "address")
}
pub async fn transfer(&self, address: &str, amount: u64) -> Result<String> {
let v = self
.rpc(
"transfer",
serde_json::json!({
"destinations": [{"amount": amount, "address": address}],
"account_index": 0
}),
)
.await?;
json_str(&v, "tx_hash")
}
pub async fn get_transfers(&self) -> Result<Vec<TransferRow>> {
let v = self
.rpc(
"get_transfers",
serde_json::json!({"in": true, "pending": true}),
)
.await?;
let mut out = Vec::new();
for key in ["in", "pending"] {
if let Some(arr) = v.get(key).and_then(|x| x.as_array()) {
for item in arr {
out.push(TransferRow {
txid: json_str(item, "txid").unwrap_or_default(),
amount: json_amount(item),
address: json_str(item, "address").unwrap_or_default(),
});
}
}
}
Ok(out)
}
async fn rpc(&self, method: &str, params: serde_json::Value) -> Result<serde_json::Value> {
let ep = self
.endpoint
.as_ref()
.ok_or_else(|| Error("not configured".into()))?;
let body = serde_json::json!({
"jsonrpc": "2.0",
"id": "0",
"method": method,
"params": params,
})
.to_string();
let raw = post_timeout(ep, &build_req(ep, &body, None)).await?;
let status = http_status(&raw).unwrap_or(0);
if status == 200 {
return Err(Error(
"wallet RPC requires digest auth (open RPC refused)".into(),
));
}
if status != 401 {
return parse_json_rpc(&raw);
}
let challenge = www_authenticate(&raw).ok_or_else(|| {
Error("wallet RPC digest required (--rpc-login / HTTP Digest)".into())
})?;
if !challenge.trim().to_ascii_lowercase().starts_with("digest") {
return Err(Error(
"wallet RPC digest required (--rpc-login / HTTP Digest)".into(),
));
}
let auth = digest_authorization(ep, &challenge)?;
let raw = post_timeout(ep, &build_req(ep, &body, Some(&auth))).await?;
parse_json_rpc(&raw)
}
}
pub fn transfers_match(rows: &[TransferRow], txid: &str, amount: &str, address: &str) -> bool {
if txid.is_empty() || amount.is_empty() || address.is_empty() {
return false;
}
rows.iter()
.any(|r| r.txid == txid && r.amount == amount && r.address == address)
}
fn credentials_required() -> Error {
Error("wallet RPC requires credentials (user:pass in URL or ONIONWIRE_WALLET_RPC_LOGIN)".into())
}
fn parse_login(login: &str) -> Result<(String, String)> {
let (user, pass) = login.split_once(':').ok_or_else(credentials_required)?;
if user.is_empty() || pass.is_empty() {
return Err(credentials_required());
}
Ok((user.to_string(), pass.to_string()))
}
fn parse_http_url(url: &str, extra_login: Option<&str>) -> Result<Endpoint> {
let rest = url
.strip_prefix("http://")
.ok_or_else(|| Error("wallet RPC must be http:// (no TLS)".into()))?;
if rest.contains("://") {
return Err(Error("wallet RPC must be http:// (no TLS)".into()));
}
let (userinfo, rest) = match rest.rsplit_once('@') {
Some((ui, hostpart)) => (Some(ui), hostpart),
None => (None, rest),
};
let (user, pass) = match userinfo {
Some(ui) => parse_login(ui)?,
None => match extra_login {
Some(login) => parse_login(login)?,
None => return Err(credentials_required()),
},
};
let (hostport, path) = match rest.split_once('/') {
Some((hp, p)) => (hp, format!("/{p}")),
None => (rest, "/json_rpc".into()),
};
let path = if path == "/" {
"/json_rpc".into()
} else {
path
};
let (host, port) = parse_hostport(hostport)?;
if host.trim().to_ascii_lowercase().ends_with(".onion") {
return Err(Error(
"wallet RPC over .onion is not supported (loopback only; no Arti dial)".into(),
));
}
if !allowed_host(&host) {
return Err(Error("wallet RPC host must be loopback".into()));
}
Ok(Endpoint {
host,
port,
path,
user,
pass,
})
}
fn parse_hostport(hostport: &str) -> Result<(String, u16)> {
if let Some(rest) = hostport.strip_prefix('[') {
let (host, rest) = rest
.split_once(']')
.ok_or_else(|| Error("invalid IPv6 host".into()))?;
let port = match rest.strip_prefix(':') {
Some(p) if !p.is_empty() => parse_port(p)?,
_ => 18083,
};
if host.is_empty() {
return Err(Error("empty host".into()));
}
return Ok((host.to_string(), port));
}
match hostport.rsplit_once(':') {
Some((h, p)) if !h.is_empty() && !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit()) => {
Ok((h.to_string(), parse_port(p)?))
}
_ if !hostport.is_empty() => Ok((hostport.to_string(), 18083)),
_ => Err(Error("empty host".into())),
}
}
fn parse_port(p: &str) -> Result<u16> {
p.parse()
.map_err(|_| Error("invalid wallet RPC port".into()))
}
fn allowed_host(host: &str) -> bool {
let h = host.trim();
if h.eq_ignore_ascii_case("localhost") {
return true;
}
h.parse::<std::net::IpAddr>()
.map(|ip| ip.is_loopback())
.unwrap_or(false)
}
fn host_header(host: &str, port: u16) -> String {
if host.contains(':') {
format!("[{host}]:{port}")
} else {
format!("{host}:{port}")
}
}
const MAX_RPC_BYTES: usize = 1024 * 1024;
async fn post_timeout(ep: &Endpoint, req: &str) -> Result<Vec<u8>> {
tokio::time::timeout(RPC_TIMEOUT, http_post(ep, req.as_bytes()))
.await
.map_err(|_| Error("wallet RPC timed out".into()))?
}
fn build_req(ep: &Endpoint, body: &str, authorization: Option<&str>) -> String {
let host_hdr = host_header(&ep.host, ep.port);
let mut req = format!(
"POST {} HTTP/1.1\r\nHost: {host_hdr}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n",
ep.path,
body.len()
);
if let Some(auth) = authorization {
req.push_str("Authorization: ");
req.push_str(auth);
req.push_str("\r\n");
}
req.push_str("\r\n");
req.push_str(body);
req
}
async fn http_post(ep: &Endpoint, req: &[u8]) -> Result<Vec<u8>> {
let mut stream = TcpStream::connect((ep.host.as_str(), ep.port))
.await
.map_err(|e| Error(format!("wallet connect: {e}")))?;
stream
.write_all(req)
.await
.map_err(|e| Error(format!("wallet write: {e}")))?;
let mut buf = Vec::new();
let mut tmp = [0u8; 8192];
loop {
let n = stream
.read(&mut tmp)
.await
.map_err(|e| Error(format!("wallet read: {e}")))?;
if n == 0 {
break;
}
if buf.len().saturating_add(n) > MAX_RPC_BYTES {
return Err(Error("wallet RPC response too large".into()));
}
buf.extend_from_slice(&tmp[..n]);
}
Ok(buf)
}
fn http_status(raw: &[u8]) -> Option<u16> {
let text = std::str::from_utf8(raw).ok()?;
let line = text.lines().next()?;
let mut parts = line.split_whitespace();
let _http = parts.next()?;
parts.next()?.parse().ok()
}
fn www_authenticate(raw: &[u8]) -> Option<String> {
let text = std::str::from_utf8(raw).ok()?;
let (head, _) = text
.split_once("\r\n\r\n")
.or_else(|| text.split_once("\n\n"))?;
for line in head.lines().skip(1) {
let (k, v) = match line.split_once(':') {
Some(kv) => kv,
None => continue,
};
if k.eq_ignore_ascii_case("www-authenticate") {
return Some(v.trim().to_string());
}
}
None
}
fn digest_param(challenge: &str, key: &str) -> Option<String> {
let t = challenge.trim();
let rest = if t.len() >= 6 && t[..6].eq_ignore_ascii_case("digest") {
t[6..].trim()
} else {
return None;
};
for part in rest.split(',') {
let part = part.trim();
let (k, v) = match part.split_once('=') {
Some(kv) => kv,
None => continue,
};
if k.eq_ignore_ascii_case(key) {
return Some(v.trim().trim_matches('"').to_string());
}
}
None
}
fn md5_hex(s: &str) -> String {
use md5::{Digest, Md5};
hex_lower(&Md5::digest(s.as_bytes()))
}
fn hex_lower(bytes: &[u8]) -> String {
const H: &[u8; 16] = b"0123456789abcdef";
let mut out = String::with_capacity(bytes.len() * 2);
for &b in bytes {
out.push(H[(b >> 4) as usize] as char);
out.push(H[(b & 0xf) as usize] as char);
}
out
}
fn digest_authorization(ep: &Endpoint, challenge: &str) -> Result<String> {
if let Some(alg) = digest_param(challenge, "algorithm")
&& !alg.eq_ignore_ascii_case("MD5")
{
return Err(Error("wallet RPC digest algorithm not MD5".into()));
}
let realm = digest_param(challenge, "realm").unwrap_or_default();
let nonce = digest_param(challenge, "nonce")
.ok_or_else(|| Error("wallet RPC digest required (--rpc-login / HTTP Digest)".into()))?;
let qop = digest_param(challenge, "qop");
let ha1 = md5_hex(&format!("{}:{realm}:{}", ep.user, ep.pass));
let ha2 = md5_hex(&format!("POST:{}", ep.path));
let (qop_part, resp) = if qop
.as_deref()
.is_some_and(|q| q.split(',').any(|x| x.trim() == "auth"))
{
let mut cnonce_bytes = [0u8; 8];
rand::thread_rng().fill_bytes(&mut cnonce_bytes);
let cnonce = hex_lower(&cnonce_bytes);
let nc = "00000001";
let response = md5_hex(&format!("{ha1}:{nonce}:{nc}:{cnonce}:auth:{ha2}"));
(
format!(", qop=auth, nc={nc}, cnonce=\"{cnonce}\""),
response,
)
} else {
(String::new(), md5_hex(&format!("{ha1}:{nonce}:{ha2}")))
};
Ok(format!(
"Digest username=\"{}\", realm=\"{realm}\", nonce=\"{nonce}\", uri=\"{}\", algorithm=MD5, response=\"{resp}\"{qop_part}",
ep.user, ep.path
))
}
fn parse_json_rpc(raw: &[u8]) -> Result<serde_json::Value> {
let text = std::str::from_utf8(raw).map_err(|_| Error("wallet RPC not UTF-8".into()))?;
let (head, body) = text
.split_once("\r\n\r\n")
.or_else(|| text.split_once("\n\n"))
.ok_or_else(|| Error("wallet RPC truncated".into()))?;
let status = head.lines().next().unwrap_or("");
if status.contains(" 3") {
return Err(Error("wallet RPC redirect refused".into()));
}
if !status.contains(" 200 ") && !status.ends_with(" 200") {
return Err(Error(format!("wallet RPC HTTP {status}")));
}
let json: serde_json::Value =
serde_json::from_str(body.trim()).map_err(|e| Error(format!("wallet RPC json: {e}")))?;
if let Some(err) = json.get("error") {
let msg = err
.get("message")
.and_then(|m| m.as_str())
.unwrap_or("rpc error");
return Err(Error(msg.into()));
}
json.get("result")
.cloned()
.ok_or_else(|| Error("wallet RPC missing result".into()))
}
fn json_str(v: &serde_json::Value, key: &str) -> Result<String> {
v.get(key)
.and_then(|x| x.as_str())
.map(str::to_string)
.ok_or_else(|| Error(format!("wallet RPC missing {key}")))
}
fn json_amount(v: &serde_json::Value) -> String {
match v.get("amount") {
Some(serde_json::Value::Number(n)) => n
.as_u64()
.map(|x| x.to_string())
.or_else(|| n.as_i64().map(|x| x.to_string()))
.unwrap_or_default(),
Some(serde_json::Value::String(s)) => s.clone(),
_ => String::new(),
}
}

135
tests/backup.rs Normal file
View file

@ -0,0 +1,135 @@
//! Encrypted identity backup: owbak1 || salt[16] || nonce[12] || ciphertext.
use onionwire::Store;
use onionwire::backup::{self, BackupKeys};
use onionwire::tui::{
BackupDecision, BackupPrompt, SlashCmd, backup_screen_text, parse_cmd, restore_screen_text,
};
fn keys(tag: u8) -> BackupKeys {
BackupKeys {
identity_sk: [tag; 32],
identity_pk: [tag.wrapping_add(1); 32],
prekey_sk: [tag.wrapping_add(2); 32],
prekey_pk: [tag.wrapping_add(3); 32],
}
}
#[test]
fn roundtrip_keys() {
let k = keys(7);
let blob = backup::seal("correct horse", &k).expect("seal");
assert!(blob.starts_with(b"owbak1"), "magic");
assert_eq!(&blob[0..6], b"owbak1");
let opened = backup::open("correct horse", &blob).expect("open");
assert_eq!(opened.identity_sk, k.identity_sk);
assert_eq!(opened.identity_pk, k.identity_pk);
assert_eq!(opened.prekey_sk, k.prekey_sk);
assert_eq!(opened.prekey_pk, k.prekey_pk);
}
#[test]
fn onion_is_not_in_backup() {
let k = keys(3);
let blob = backup::seal("pw", &k).unwrap();
let onion = b"abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopq.onion";
assert!(
!blob.windows(onion.len()).any(|w| w == onion),
"locator must not be in the backup file"
);
}
#[test]
fn wrong_passphrase_fails() {
let blob = backup::seal("right", &keys(1)).unwrap();
assert!(backup::open("wrong", &blob).is_err());
}
#[test]
fn garbage_file_fails() {
assert!(backup::open("pw", b"nope").is_err());
assert!(backup::open("pw", b"owbak1").is_err());
let mut blob = backup::seal("pw", &keys(2)).unwrap();
blob.push(0xff);
assert!(backup::open("pw", &blob).is_err());
}
#[test]
fn restore_overwrites_self_keys_friends_stay() {
let dir = tempfile::tempdir().expect("tempdir");
let store = Store::open_at_with_passphrase(dir.path(), "onionwire-test").expect("open");
store
.upsert_friend(&[9u8; 32], "alice.onion", Some("alice"))
.unwrap();
let old = store.self_identity().unwrap();
let incoming = keys(42);
store
.replace_identity_keys(
&incoming.identity_sk,
&incoming.identity_pk,
&incoming.prekey_sk,
&incoming.prekey_pk,
)
.unwrap();
let me = store.self_identity().unwrap();
assert_eq!(me.identity_sk, incoming.identity_sk);
assert_eq!(me.identity_pk, incoming.identity_pk);
assert_eq!(me.prekey_sk, incoming.prekey_sk);
assert_eq!(me.prekey_pk, incoming.prekey_pk);
assert_eq!(me.onion, old.onion, "onion is locator, not restored");
assert_eq!(store.friend_count().unwrap(), 1);
let f = store.get_friend(&[9u8; 32]).unwrap().unwrap();
assert_eq!(f.petname.as_deref(), Some("alice"));
}
#[test]
fn slash_backup_restore() {
assert_eq!(
parse_cmd("/backup /tmp/id.owbak"),
Some(SlashCmd::Backup {
path: "/tmp/id.owbak".into()
})
);
assert_eq!(
parse_cmd(" /restore /tmp/id.owbak "),
Some(SlashCmd::Restore {
path: "/tmp/id.owbak".into()
})
);
assert_eq!(parse_cmd("/backup"), None);
assert_eq!(parse_cmd("/restore"), None);
}
#[test]
fn backup_requires_typing_not_enter() {
let mut p = BackupPrompt::backup();
assert_eq!(p.on_esc(), BackupDecision::Cancel);
assert_eq!(p.on_char('\n'), BackupDecision::Pending);
for c in "BACKU".chars() {
assert_eq!(p.on_char(c), BackupDecision::Pending);
}
assert_eq!(p.on_char('P'), BackupDecision::Confirm);
}
#[test]
fn restore_requires_typing_restore() {
let mut p = BackupPrompt::restore();
assert_eq!(p.on_char('\n'), BackupDecision::Pending);
for c in "RESTOR".chars() {
assert_eq!(p.on_char(c), BackupDecision::Pending);
}
assert_eq!(p.on_char('E'), BackupDecision::Confirm);
}
#[test]
fn restore_screen_warns_roster_stays() {
let t = restore_screen_text();
assert!(t.contains("does not rewrite the roster"), "{t}");
assert!(t.contains("Type RESTORE to confirm"), "{t}");
let b = backup_screen_text();
assert!(
t.contains("different person") || b.contains("identity"),
"{t}\n{b}"
);
assert!(b.contains("Type BACKUP to confirm"), "{b}");
}

39
tests/dispatch.rs Normal file
View file

@ -0,0 +1,39 @@
//! M6: typed-frame dispatcher — classify before append_message.
use onionwire::dispatch::{classify, Kind};
#[test]
fn chat_is_chat() {
assert_eq!(classify(b"hello wire"), Kind::Chat);
}
#[test]
fn loc_still_loc() {
assert_eq!(classify(b"loc abc.onion\n1\n00"), Kind::Loc);
}
#[test]
fn unknown_typed_prefix_is_drop() {
assert_eq!(classify(b"zzz not a real type"), Kind::Drop);
}
#[test]
fn short_or_binary_without_prefix_is_chat() {
assert_eq!(classify(b"hi"), Kind::Chat);
assert_eq!(classify(&[0xff, 0xfe]), Kind::Chat);
}
#[test]
fn known_prefixes_are_typed() {
assert_eq!(classify(b"prf name"), Kind::Profile);
assert_eq!(classify(b"inv 1"), Kind::Invoice);
assert_eq!(classify(b"rcp tx"), Kind::Receipt);
assert_eq!(classify(b"png 1"), Kind::Ping);
}
#[test]
fn uppercase_or_digit_prefix_is_chat() {
assert_eq!(classify(b"ZZZ not typed"), Kind::Chat);
assert_eq!(classify(b"ab1 leftover"), Kind::Chat);
assert_eq!(classify(b"abcd"), Kind::Chat);
}

133
tests/file.rs Normal file
View file

@ -0,0 +1,133 @@
//! M10: fail-closed file transfer — frames, names, assemble, slash parse.
use onionwire::dispatch::{Kind, classify};
use onionwire::file::{self, MAX_BYTES};
use onionwire::tui::{SlashCmd, parse_cmd};
#[test]
fn encode_decode_roundtrip_one_chunk() {
let body = b"hello file";
let chunks = file::chunks("note.txt", body).unwrap();
assert_eq!(chunks.len(), 1);
let encoded = file::encode(&chunks[0]);
let decoded = file::decode(&encoded).expect("decode");
assert_eq!(decoded.filename, "note.txt");
assert_eq!(decoded.idx, 0);
assert_eq!(decoded.total, 1);
assert_eq!(decoded.data, body);
assert_eq!(decoded.sha256, chunks[0].sha256);
assert_eq!(decoded.xfer_id, chunks[0].xfer_id);
}
#[test]
fn chat_is_not_file() {
assert_eq!(file::decode(b"hello wire"), None);
}
#[test]
fn name_with_dotdot_or_slash_rejected() {
assert!(file::chunks("../secret", b"x").is_err());
assert!(file::chunks("a/b", b"x").is_err());
assert!(file::safe_name("..").is_err());
assert!(file::safe_name("foo/bar").is_err());
assert!(file::safe_name("a\0b").is_err());
assert!(file::safe_name("ok.txt").is_ok());
}
#[test]
fn oversize_rejected_before_send() {
let too_big = vec![0u8; MAX_BYTES + 1];
assert!(file::chunks("big.bin", &too_big).is_err());
assert!(file::chunks("ok.bin", &vec![0u8; MAX_BYTES]).is_ok());
}
#[test]
fn assemble_two_chunks_writes_file_and_matches_hash() {
let dir = tempfile::tempdir().unwrap();
let body = vec![7u8; 80_000];
let chunks = file::chunks("pic.bin", &body).unwrap();
assert!(chunks.len() >= 2, "expected split, got {}", chunks.len());
let mut inbox = file::Inbox::new(dir.path());
let fp = "aabbccddeeff";
let mut done = None;
for c in &chunks {
done = inbox.ingest(fp, c).unwrap();
}
let path = done.expect("assembled path");
assert_eq!(std::fs::read(&path).unwrap(), body);
assert!(path.ends_with("pic.bin"));
assert!(path.to_string_lossy().contains(fp));
}
#[test]
fn bad_hash_leaves_no_inbox_file() {
let dir = tempfile::tempdir().unwrap();
let chunks = file::chunks("evil.bin", b"payload").unwrap();
let mut bad = chunks[0].clone();
bad.sha256 = [0u8; 32];
let mut inbox = file::Inbox::new(dir.path());
let fp = "deadbeef";
assert!(inbox.ingest(fp, &bad).is_err());
let dest = dir.path().join(fp).join("evil.bin");
assert!(!dest.exists(), "hash mismatch must not write inbox file");
let partials: Vec<_> = std::fs::read_dir(dir.path())
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| e.file_name().to_string_lossy().starts_with(".partial-"))
.collect();
assert!(
partials.is_empty(),
"partial must be deleted on hash mismatch"
);
}
#[test]
fn ingest_rejects_oversize_and_deletes_partial() {
let dir = tempfile::tempdir().unwrap();
let mut inbox = file::Inbox::new(dir.path());
let mut chunk = file::chunks("fat.bin", b"x").unwrap().remove(0);
chunk.total = 2;
chunk.idx = 0;
chunk.data = vec![1u8; MAX_BYTES + 1];
assert!(inbox.ingest("aa", &chunk).is_err());
assert!(!dir.path().join("aa").join("fat.bin").exists());
let leftover: Vec<_> = std::fs::read_dir(dir.path())
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| e.file_name().to_string_lossy().starts_with(".partial-"))
.collect();
assert!(leftover.is_empty());
}
#[test]
fn ingest_rejects_cumulative_oversize() {
let dir = tempfile::tempdir().unwrap();
let mut inbox = file::Inbox::new(dir.path());
let mut a = file::chunks("fat.bin", b"x").unwrap().remove(0);
a.total = 2;
a.idx = 0;
a.data = vec![1u8; MAX_BYTES - 10];
assert_eq!(inbox.ingest("aa", &a).unwrap(), None);
let mut b = a.clone();
b.idx = 1;
b.data = vec![1u8; 11];
assert!(inbox.ingest("aa", &b).is_err());
assert!(!dir.path().join("aa").join("fat.bin").exists());
}
#[test]
fn slash_file_parses_path_and_empty_is_none() {
assert_eq!(
parse_cmd("/file /tmp/a"),
Some(SlashCmd::File {
path: "/tmp/a".into()
})
);
assert_eq!(parse_cmd("/file"), None);
}
#[test]
fn dispatch_fil_is_file_not_chat() {
assert_eq!(classify(b"fil abc"), Kind::File);
assert_eq!(classify(b"hello wire"), Kind::Chat);
}

97
tests/hs.rs Normal file
View file

@ -0,0 +1,97 @@
//! HS publish wait and CBT floor — 180s fail-closed cuts a working HsDir upload.
use std::os::unix::fs::PermissionsExt;
use std::time::Duration;
use onionwire::hs;
use tor_hsservice::status::State;
#[test]
fn publish_wait_covers_hsdir_retries() {
assert!(
hs::PUBLISH_WAIT >= Duration::from_secs(360),
"180s cuts a working HsDir publish while status is still Bootstrapping"
);
}
#[test]
fn cbt_min_timeout_floor_is_at_least_10s() {
// Drive the comparison through a runtime value: asserting on the constant
// directly is folded away by the compiler and clippy rejects it under
// -D warnings (clippy::assertions_on_constants).
let floor_ms = hs::CBT_MIN_TIMEOUT_MS;
assert!(floor_ms >= 10_000, "learned CBT ~1s kills HsDir circuits");
}
#[test]
fn cbt_initial_timeout_matches_the_floor() {
// Consensus cbtinitialtimeout can be ~2s; 4-hop vanguard HS circuits
// need the same floor as cbtmintimeout before the estimator has samples.
let initial = hs::CBT_INITIAL_TIMEOUT_MS;
let floor = hs::CBT_MIN_TIMEOUT_MS;
assert!(initial >= floor, "initial CBT below min floor");
assert!(initial >= 10_000, "initial CBT too low for HsDir circuits");
}
#[test]
fn no_preemptive_exit_ports() {
// Default predicted 80/443 circuits compete with IPT + HsDir builds.
// OnionWire never exits; keep the predicted list empty.
assert!(
hs::PREEMPTIVE_PREDICTED_PORTS.is_empty(),
"preemptive exit circuits starve HS publish"
);
}
#[test]
fn arti_status_running_is_ready_without_probe() {
assert!(hs::hs_is_ready(State::Running, false));
assert!(hs::hs_is_ready(State::DegradedReachable, false));
}
#[test]
fn bootstrapping_is_not_ready_until_a_probe_connects() {
// Combined status stays Bootstrapping through Arti's 5 min HsDir upload
// round even after some descriptors are already fetchable.
assert!(!hs::hs_is_ready(State::Bootstrapping, false));
assert!(hs::hs_is_ready(State::Bootstrapping, true));
}
#[test]
fn degraded_unreachable_is_ready_only_if_probe_connects() {
assert!(!hs::hs_is_ready(State::DegradedUnreachable, false));
assert!(hs::hs_is_ready(State::DegradedUnreachable, true));
}
#[test]
fn broken_or_shutdown_never_ready() {
assert!(!hs::hs_is_ready(State::Broken, true));
assert!(!hs::hs_is_ready(State::Shutdown, true));
}
#[test]
fn hs_log_label_is_not_the_full_v3_onion() {
// Public v3 address; checksum is valid so HsId::from_str works.
let onion = "facebookwkhpilnemxj7asaniu7vnjjbiltxjqhye3mhbshg7kx5tfyd.onion";
let label = hs::log_label(onion);
assert_ne!(label, onion, "status/probe logs must not use the locator");
assert!(
!label.contains("facebookwkhpilnemxj7asaniu7vnjjbiltxjqhye3mhbshg7kx5tfyd"),
"log label leaked the onion body: {label}"
);
assert!(
label.contains('…') || label.contains("[scrubbed]"),
"expected safelog redaction, got {label}"
);
}
#[test]
fn client_config_mkdirs_state_and_cache_0700() {
let root = tempfile::tempdir().expect("tempdir");
let state = root.path().join("arti");
let cache = root.path().join("cache");
let _cfg = hs::client_config(&state, &cache);
let mode = |p: &std::path::Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777;
assert_eq!(mode(&state), 0o700);
assert_eq!(mode(&cache), 0o700);
}

209
tests/pay.rs Normal file
View file

@ -0,0 +1,209 @@
//! M8: signed Monero invoice/receipt frames + store + slash parse.
use ed25519_dalek::SigningKey;
use onionwire::pay::{self};
use onionwire::tui::{SlashCmd, WipeKind, parse_cmd, parse_slash};
use onionwire::{PaymentWrite, Store};
use rand::rngs::OsRng;
fn store() -> (tempfile::TempDir, Store) {
let dir = tempfile::tempdir().expect("tempdir");
let store = Store::open_at_with_passphrase(dir.path(), "onionwire-test").expect("open");
(dir, store)
}
// Official mainnet standard from Monero docs (not live RPC).
const MAINNET_STD: &str = "4AdUndXHHZ6cfufTMvppY6JwXNouMBzSkbLYfpAV5Usx3skxNgYeYTRj5UzqtReoS44qo9mtmXCqY45DJ852K5Jv2684Rge";
// Same documented spend/view keys, mainnet subaddress (0x2A) and integrated (0x13 + 8 zero pid).
const MAINNET_SUB: &str = "8BTd81B7syWcfufTMvppY6JwXNouMBzSkbLYfpAV5Usx3skxNgYeYTRj5UzqtReoS44qo9mtmXCqY45DJ852K5Jv25pnJx6";
const MAINNET_INT: &str = "4LL9oSLmtpccfufTMvppY6JwXNouMBzSkbLYfpAV5Usx3skxNgYeYTRj5UzqtReoS44qo9mtmXCqY45DJ852K5Jv2WK48GNSUQf17NLRTG";
// Same keys, stagenet standard (0x18).
const STAGENET_STD: &str = "5AqWsUSEwACcfufTMvppY6JwXNouMBzSkbLYfpAV5Usx3skxNgYeYTRj5UzqtReoS44qo9mtmXCqY45DJ852K5Jv23X7tqA";
fn addr_std() -> String {
MAINNET_STD.to_string()
}
fn addr_sub() -> String {
MAINNET_SUB.to_string()
}
fn payw<'a>(
dir: &'a str,
kind: &'a str,
amount: &'a str,
address: &'a str,
memo: &'a str,
txid: Option<&'a str>,
verified: bool,
) -> PaymentWrite<'a> {
PaymentWrite {
dir,
kind,
amount_atomic: amount,
address,
memo,
txid,
verified,
}
}
#[test]
fn valid_and_invalid_xmr_addresses() {
assert!(pay::check_address(MAINNET_STD).is_ok());
assert!(pay::check_address(MAINNET_SUB).is_ok());
assert!(pay::check_address(MAINNET_INT).is_ok());
assert!(pay::check_address(STAGENET_STD).is_ok());
// prefix+length junk that the old checker accepted
assert!(pay::check_address(&format!("4{}", "A".repeat(94))).is_err());
assert!(pay::check_address(&format!("8{}", "B".repeat(94))).is_err());
assert!(pay::check_address(&format!("4{}", "C".repeat(105))).is_err());
assert!(pay::check_address(&format!("4{}", "A".repeat(93))).is_err());
assert!(pay::check_address(&format!("8{}", "B".repeat(95))).is_err());
assert!(pay::check_address(&format!("{MAINNET_STD}\n")).is_err());
let mut bad_ck = MAINNET_STD.to_string();
bad_ck.replace_range(94..95, "f");
assert!(pay::check_address(&bad_ck).is_err());
assert!(pay::check_address("").is_err());
assert!(pay::check_address("not-an-address").is_err());
}
#[test]
fn atomic_display_trims_trailing_zeros() {
assert_eq!(pay::atomic_to_xmr_str("1000000000000"), "1");
assert_eq!(pay::atomic_to_xmr_str("120000000000"), "0.12");
assert_eq!(pay::atomic_to_xmr_str("1"), "0.000000000001");
}
#[test]
fn invoice_sign_verify_and_wrong_key() {
let sk = SigningKey::generate(&mut OsRng);
let pk = sk.verifying_key().to_bytes();
let addr = addr_std();
let inv = pay::sign_invoice(&sk.to_bytes(), "120000000000", &addr, "coffee", 42).unwrap();
assert!(pay::verify_invoice(&pk, &inv));
let other = SigningKey::generate(&mut OsRng);
assert!(!pay::verify_invoice(
&other.verifying_key().to_bytes(),
&inv
));
let bytes = pay::encode_invoice(&inv);
let parsed = pay::decode_invoice(&bytes).expect("invoice frame");
assert_eq!(parsed.amount_atomic, "120000000000");
assert_eq!(parsed.address, addr);
assert_eq!(parsed.memo, "coffee");
assert_eq!(parsed.ts, 42);
assert_eq!(parsed.sig, inv.sig);
}
#[test]
fn receipt_sign_verify_roundtrip() {
let sk = SigningKey::generate(&mut OsRng);
let pk = sk.verifying_key().to_bytes();
let addr = addr_sub();
let rcp = pay::sign_receipt(&sk.to_bytes(), "deadbeef", "1", &addr, 99).unwrap();
assert!(pay::verify_receipt(&pk, &rcp));
let bytes = pay::encode_receipt(&rcp);
let parsed = pay::decode_receipt(&bytes).expect("receipt frame");
assert_eq!(parsed.txid, "deadbeef");
assert_eq!(parsed.amount_atomic, "1");
assert_eq!(parsed.address, addr);
assert_eq!(parsed.ts, 99);
}
#[test]
fn chat_is_not_invoice_or_receipt() {
assert!(pay::decode_invoice(b"hello wire").is_none());
assert!(pay::decode_receipt(b"hello wire").is_none());
}
#[test]
fn zero_amount_rejected() {
let sk = [7u8; 32];
let addr = addr_std();
assert!(pay::sign_invoice(&sk, "0", &addr, "", 1).is_err());
assert!(pay::sign_receipt(&sk, "tx", "0", &addr, 1).is_err());
}
#[test]
fn store_insert_invoice_for_friend_unknown_fails() {
let (_dir, store) = store();
let sk = SigningKey::generate(&mut OsRng);
let pk = sk.verifying_key().to_bytes();
let addr = addr_std();
assert!(
store
.insert_payment(&pk, payw("out", "invoice", "1", &addr, "m", None, false))
.is_err()
);
store.upsert_friend(&pk, "a.onion", None).unwrap();
let id = store
.insert_payment(&pk, payw("out", "invoice", "1", &addr, "m", None, false))
.unwrap();
assert!(id > 0);
let rows = store.list_payments(&pk).unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].kind, "invoice");
assert_eq!(rows[0].dir, "out");
assert_eq!(rows[0].amount_atomic, "1");
assert!(!rows[0].verified);
}
#[test]
fn incoming_receipt_is_not_verified() {
let (_dir, store) = store();
let pk = [9u8; 32];
store.upsert_friend(&pk, "b.onion", None).unwrap();
let addr = addr_sub();
let id = store
.insert_payment(
&pk,
payw("in", "receipt", "5", &addr, "", Some("txid1"), false),
)
.unwrap();
let rows = store.list_payments(&pk).unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].kind, "receipt");
assert_eq!(rows[0].txid.as_deref(), Some("txid1"));
assert!(!rows[0].verified);
assert!(store.mark_verified(id).unwrap());
let rows = store.list_payments(&pk).unwrap();
assert!(rows[0].verified);
}
#[test]
fn invoice_chat_line_never_raw_prefix() {
let line = pay::invoice_chat_line("120000000000", "coffee");
assert_eq!(line, "[invoice] 0.12 XMR — coffee");
assert!(!line.contains("inv "));
let bare = pay::invoice_chat_line("120000000000", "");
assert_eq!(bare, "[invoice] 0.12 XMR");
let r = pay::receipt_chat_line("120000000000", false);
assert_eq!(r, "[receipt] 0.12 XMR — unverified");
let v = pay::receipt_chat_line("120000000000", true);
assert_eq!(v, "[receipt] 0.12 XMR");
assert!(!v.contains("rcp "));
}
#[test]
fn slash_pay_tip_and_wipe_still_parse() {
assert_eq!(
parse_cmd("/pay 0.12 coffee please"),
Some(SlashCmd::Pay {
atomic: "120000000000".into(),
memo: "coffee please".into(),
})
);
assert_eq!(
parse_cmd(" /tip 1 "),
Some(SlashCmd::Tip {
atomic: "1000000000000".into(),
memo: String::new(),
})
);
assert_eq!(parse_cmd("/pay"), None);
assert_eq!(parse_cmd("/pay 0"), None);
assert_eq!(parse_cmd("/wipe"), Some(SlashCmd::Wipe(WipeKind::Messages)));
assert_eq!(parse_slash("/wipe-all"), Some(WipeKind::All));
assert_eq!(parse_slash("/pay 0.12"), None);
}

165
tests/profile.rs Normal file
View file

@ -0,0 +1,165 @@
//! M7: signed friend-visible profile frames + slash parse.
use ed25519_dalek::SigningKey;
use onionwire::Store;
use onionwire::profile::{self};
use onionwire::tui::{SlashCmd, WipeKind, parse_cmd, parse_slash};
use rand::rngs::OsRng;
fn store() -> (tempfile::TempDir, Store) {
let dir = tempfile::tempdir().expect("tempdir");
let store = Store::open_at_with_passphrase(dir.path(), "onionwire-test").expect("open");
(dir, store)
}
#[test]
fn signed_profile_upserts_friend_row_keeps_fingerprint() {
let (_dir, store) = store();
let sk = SigningKey::generate(&mut OsRng);
let pk = sk.verifying_key().to_bytes();
store
.upsert_friend(&pk, "alice.onion", Some("alice"))
.unwrap();
let before = store.get_friend(&pk).unwrap().unwrap();
let prf = profile::sign(&sk.to_bytes(), "Ali", "hi", "", 2_000_000_000).expect("sign");
assert!(
store.apply_profile(&pk, &prf).unwrap(),
"newer signed profile must apply"
);
let after = store.get_friend(&pk).unwrap().unwrap();
assert_eq!(after.fingerprint, before.fingerprint);
assert_eq!(after.petname.as_deref(), Some("alice"));
assert_eq!(after.onion, "alice.onion");
assert_eq!(store.friend_count().unwrap(), 1);
let got = store.friend_profile(&pk).unwrap().expect("row");
assert_eq!(got.display_name, "Ali");
assert_eq!(got.bio, "hi");
assert_eq!(got.xmr_addr, "");
assert_eq!(got.updated_at, 2_000_000_000);
}
#[test]
fn wrong_key_profile_does_not_change_row() {
let (_dir, store) = store();
let owner = SigningKey::generate(&mut OsRng);
let owner_pk = owner.verifying_key().to_bytes();
store.upsert_friend(&owner_pk, "old.onion", None).unwrap();
let first = profile::sign(&owner.to_bytes(), "real", "", "", 2_000_000_000).unwrap();
assert!(store.apply_profile(&owner_pk, &first).unwrap());
let other = SigningKey::generate(&mut OsRng);
let spoof = profile::sign(&other.to_bytes(), "evil", "no", "", 2_000_000_001).unwrap();
assert!(!store.apply_profile(&owner_pk, &spoof).unwrap());
let got = store.friend_profile(&owner_pk).unwrap().unwrap();
assert_eq!(got.display_name, "real");
}
#[test]
fn stale_ts_profile_does_not_change_row() {
let (_dir, store) = store();
let sk = SigningKey::generate(&mut OsRng);
let pk = sk.verifying_key().to_bytes();
store.upsert_friend(&pk, "old.onion", None).unwrap();
let newer = profile::sign(&sk.to_bytes(), "new", "", "", 100).unwrap();
assert!(store.apply_profile(&pk, &newer).unwrap());
let stale = profile::sign(&sk.to_bytes(), "old", "", "", 50).unwrap();
assert!(!store.apply_profile(&pk, &stale).unwrap());
assert_eq!(
store.friend_profile(&pk).unwrap().unwrap().display_name,
"new"
);
}
#[test]
fn unknown_pubkey_profile_is_ignored() {
let (_dir, store) = store();
let sk = SigningKey::generate(&mut OsRng);
let pk = sk.verifying_key().to_bytes();
let prf = profile::sign(&sk.to_bytes(), "ghost", "", "", 2_000_000_000).unwrap();
assert!(!store.apply_profile(&pk, &prf).unwrap());
assert!(store.friend_profile(&pk).unwrap().is_none());
assert_eq!(store.friend_count().unwrap(), 0);
}
#[test]
fn bio_over_512_rejected() {
let bio = "x".repeat(513);
let sk = [7u8; 32];
assert!(profile::sign(&sk, "n", &bio, "", 1).is_err());
let mut out = b"prf n\n".to_vec();
out.extend_from_slice(bio.as_bytes());
out.extend_from_slice(b"\n\n1\n");
out.extend_from_slice("aa".repeat(64).as_bytes());
assert!(profile::decode(&out).is_none());
}
#[test]
fn newline_in_name_rejected() {
let sk = [7u8; 32];
assert!(profile::sign(&sk, "bad\nname", "bio", "", 1).is_err());
assert!(profile::decode(b"prf bad\nname\nbio\n\n1\n00").is_none());
}
#[test]
fn chat_is_not_profile() {
assert!(profile::decode(b"hello wire").is_none());
}
const MAINNET_STD: &str = "4AdUndXHHZ6cfufTMvppY6JwXNouMBzSkbLYfpAV5Usx3skxNgYeYTRj5UzqtReoS44qo9mtmXCqY45DJ852K5Jv2684Rge";
#[test]
fn self_profile_roundtrip() {
let (_dir, store) = store();
store.set_self_profile("me", "a bio", MAINNET_STD).unwrap();
let got = store.self_profile().unwrap();
assert_eq!(got.display_name, "me");
assert_eq!(got.bio, "a bio");
assert_eq!(got.xmr_addr, MAINNET_STD);
assert!(got.updated_at > 0);
store.set_self_profile("", "", "").unwrap();
let empty = store.self_profile().unwrap();
assert_eq!(empty.display_name, "");
assert_eq!(empty.xmr_addr, "");
assert!(empty.updated_at >= got.updated_at);
}
#[test]
fn self_profile_rejects_short_xmr_addr() {
let (_dir, store) = store();
assert!(store.set_self_profile("me", "a bio", "4abc").is_err());
assert!(profile::validate("me", "a bio", "4abc").is_err());
assert!(profile::validate("me", "a bio", "").is_ok());
assert!(profile::validate("me", "a bio", MAINNET_STD).is_ok());
}
#[test]
fn slash_profile_who_and_wipe_still_parse() {
assert_eq!(parse_cmd("/profile"), Some(SlashCmd::Profile));
assert_eq!(parse_cmd(" /profile "), Some(SlashCmd::Profile));
assert_eq!(parse_cmd("/who"), Some(SlashCmd::Who));
assert_eq!(parse_cmd("/who\n"), Some(SlashCmd::Who));
assert_eq!(parse_cmd("/wipe"), Some(SlashCmd::Wipe(WipeKind::Messages)));
assert_eq!(parse_slash("/wipe"), Some(WipeKind::Messages));
assert_eq!(parse_slash("/wipe-all"), Some(WipeKind::All));
assert_eq!(parse_slash("/profile"), None);
assert_eq!(parse_cmd("/rotate"), None);
}
#[test]
fn profile_save_empty_name_ok_no_confirm_phrase() {
let saved = onionwire::tui::ProfileEditor::new("", "", "").on_enter();
assert_eq!(saved.display_name, "");
assert_eq!(saved.bio, "");
assert_eq!(saved.xmr_addr, "");
}
#[test]
fn who_empty_says_no_profile_yet() {
let t = onionwire::tui::who_overlay_text("abcd", "x.onion", None);
assert!(t.contains("no profile yet"));
assert!(t.contains("abcd"));
assert!(t.contains("x.onion"));
}

View file

@ -15,9 +15,10 @@ struct TempHome {
impl TempHome {
fn new() -> Self {
let dir = tempfile::tempdir().expect("tempdir");
let guard = ENV_LOCK.lock().expect("env lock");
let guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
unsafe {
std::env::set_var("ONIONWIRE_HOME", dir.path());
std::env::set_var("ONIONWIRE_STORE_PASSPHRASE", "onionwire-test");
}
Self {
_dir: dir,
@ -30,12 +31,43 @@ impl Drop for TempHome {
fn drop(&mut self) {
unsafe {
std::env::remove_var("ONIONWIRE_HOME");
std::env::remove_var("ONIONWIRE_STORE_PASSPHRASE");
}
}
}
fn v3onion(tag: &str) -> String {
format!("{tag}.onion")
let mut addr = vec![b'a'; 56];
let bytes: Vec<u8> = tag
.bytes()
.map(|b| match b {
b'a'..=b'z' | b'2'..=b'7' => b,
_ => b'a',
})
.collect();
let n = bytes.len().min(56);
addr[..n].copy_from_slice(&bytes[..n]);
format!("{}.onion", String::from_utf8(addr).expect("base32"))
}
fn invite_fields(raw: &str) -> (String, String, String, String) {
let rest = raw.strip_prefix("onionwire:v1:").expect("prefix");
let mut k = String::new();
let mut o = String::new();
let mut spk = String::new();
let mut sig = String::new();
for part in rest.split(':') {
if let Some(v) = part.strip_prefix("k=") {
k = v.to_string();
} else if let Some(v) = part.strip_prefix("o=") {
o = v.to_string();
} else if let Some(v) = part.strip_prefix("spk=") {
spk = v.to_string();
} else if let Some(v) = part.strip_prefix("sig=") {
sig = v.to_string();
}
}
(k, o, spk, sig)
}
#[test]
@ -90,19 +122,6 @@ fn bad_sig_is_err() {
assert!(qr::decode(&format!("{head}:sig={flipped}")).is_err());
}
#[test]
fn unicode_qr_renders() {
let _home = TempHome::new();
let store = Store::open().expect("open");
let me = store.self_identity().expect("self");
let raw = qr::encode(&me.identity_sk, &v3onion("x"), &[1u8; 32]).expect("encode");
let art = qr::render_unicode(&raw).expect("render");
assert!(
art.contains('█') || art.contains('▀') || art.contains('▄') || art.contains('▌'),
"expected unicode blocks, got {art:?}"
);
}
#[test]
fn same_k_rescan_updates_onion_not_duplicate() {
let _home = TempHome::new();
@ -130,3 +149,73 @@ fn unknown_k_decode_does_not_insert() {
let _p = qr::decode(&raw).unwrap();
assert_eq!(store.friend_count().unwrap(), 0);
}
#[test]
fn shifted_o_spk_is_err() {
let _home = TempHome::new();
let store = Store::open().expect("open");
let me = store.self_identity().expect("self");
let onion = v3onion("honest");
let spk = [0xab; 32];
let raw = qr::encode(&me.identity_sk, &onion, &spk).expect("encode");
let (k, o, spk_hex, sig) = invite_fields(&raw);
assert_eq!(spk_hex.len(), 64);
let mutant = format!(
"onionwire:v1:k={k}:o={o}{}:spk={}:sig={sig}",
&spk_hex[..8],
&spk_hex[8..]
);
assert!(
qr::decode(&mutant).is_err(),
"shifted o/spk must not verify"
);
}
#[test]
fn encode_rejects_spk_not_32() {
let _home = TempHome::new();
let store = Store::open().expect("open");
let me = store.self_identity().expect("self");
let onion = v3onion("spk");
assert!(qr::encode(&me.identity_sk, &onion, &[1u8; 31]).is_err());
assert!(qr::encode(&me.identity_sk, &onion, &[1u8; 33]).is_err());
assert!(qr::encode(&me.identity_sk, &onion, &[]).is_err());
}
#[test]
fn encode_rejects_garbage_onion() {
let _home = TempHome::new();
let store = Store::open().expect("open");
let me = store.self_identity().expect("self");
let spk = [2u8; 32];
assert!(qr::encode(&me.identity_sk, "not-an-onion", &spk).is_err());
assert!(qr::encode(&me.identity_sk, "alice.onion", &spk).is_err());
assert!(qr::encode(&me.identity_sk, &format!("{}.onion", "A".repeat(56)), &spk).is_err());
}
#[test]
fn decode_rejects_spk_len_and_garbage_onion() {
let _home = TempHome::new();
let store = Store::open().expect("open");
let me = store.self_identity().expect("self");
let raw = qr::encode(&me.identity_sk, &v3onion("ok"), &[3u8; 32]).expect("encode");
let (k, o, spk, sig) = invite_fields(&raw);
let short_spk = format!("onionwire:v1:k={k}:o={o}:spk={}:sig={sig}", &spk[..62]);
assert!(qr::decode(&short_spk).is_err());
let garbage_o = format!("onionwire:v1:k={k}:o=nope.onion:spk={spk}:sig={sig}");
assert!(qr::decode(&garbage_o).is_err());
}
#[test]
fn huge_hex_is_err_quickly() {
let huge = format!(
"onionwire:v1:k={}:o=x:spk=yy:sig=zz",
"aa".repeat(1024 * 1024)
);
let t = std::time::Instant::now();
assert!(qr::decode(&huge).is_err());
assert!(
t.elapsed() < std::time::Duration::from_millis(250),
"decode must fail closed before a multi-MB alloc"
);
}

28
tests/ratelimit.rs Normal file
View file

@ -0,0 +1,28 @@
//! Incoming rend accepts: 30 / 60s, burst 10.
use std::time::{Duration, Instant};
use onionwire::ratelimit::TokenBucket;
#[test]
fn burst_allows_then_denies() {
let t0 = Instant::now();
let mut b = TokenBucket::new(30, Duration::from_secs(60), 10);
for _ in 0..10 {
assert!(b.try_acquire_at(t0), "burst of 10 must pass");
}
assert!(!b.try_acquire_at(t0), "11th in the burst must drop");
}
#[test]
fn refill_one_token_after_two_seconds() {
let t0 = Instant::now();
let mut b = TokenBucket::new(30, Duration::from_secs(60), 10);
for _ in 0..10 {
assert!(b.try_acquire_at(t0));
}
// 30 tokens / 60s = 0.5/s → 2s yields one token.
let t1 = t0 + Duration::from_secs(2);
assert!(b.try_acquire_at(t1));
assert!(!b.try_acquire_at(t1));
}

View file

@ -14,7 +14,7 @@ fn pk(tag: u8) -> [u8; 32] {
fn store() -> (tempfile::TempDir, Store) {
let dir = tempfile::tempdir().expect("tempdir");
let store = Store::open_at(dir.path()).expect("open");
let store = Store::open_at_with_passphrase(dir.path(), "onionwire-test").expect("open");
(dir, store)
}
@ -126,7 +126,7 @@ fn rotate_prompt_text_matches_spec() {
assert!(t.contains("Rotate onion address?"));
assert!(t.contains("Your identity key stays the same."));
assert!(t.contains("Online friends get a signed location update."));
assert!(t.contains("Offline friends CANNOT find you until they rescan your QR."));
assert!(t.contains("Offline friends CANNOT find you until they F3-paste your new invite."));
assert!(t.contains("Type ROTATE to confirm"));
assert!(t.contains("Esc to cancel"));
}

View file

@ -23,6 +23,9 @@ async fn rotate_changes_onion_not_identity_and_notifies_online_peer() {
let carol_home = root.path().join("carol");
eprintln!("starting alice + bob…");
unsafe {
std::env::set_var("ONIONWIRE_STORE_PASSPHRASE", "onionwire-test");
}
let (alice, bob) = tokio::join!(
Node::start(alice_home.clone()),
Node::start(bob_home.clone())
@ -35,7 +38,7 @@ async fn rotate_changes_onion_not_identity_and_notifies_online_peer() {
alice.add_friend_from_qr(&b_qr).expect("alice adds bob");
bob.add_friend_from_qr(&a_qr).expect("bob adds alice");
let carol = Store::open_at(&carol_home).expect("carol store");
let carol = Store::open_at_with_passphrase(&carol_home, "onionwire-test").expect("carol store");
let a_pk = alice.identity_pk();
carol
.upsert_friend(&a_pk, &alice.onion(), Some("alice"))

45
tests/send.rs Normal file
View file

@ -0,0 +1,45 @@
//! Composer Enter must send chat to the selected friend, not only slash commands.
use onionwire::tui::{composer_enter, ComposerAction, SlashCmd, WipeKind};
#[test]
fn enter_plain_text_is_send() {
assert_eq!(
composer_enter("hello wire"),
Some(ComposerAction::Send("hello wire".into()))
);
}
#[test]
fn enter_trims_but_sends() {
assert_eq!(
composer_enter(" hi there "),
Some(ComposerAction::Send("hi there".into()))
);
}
#[test]
fn enter_empty_does_nothing() {
assert_eq!(composer_enter(""), None);
assert_eq!(composer_enter(" "), None);
}
#[test]
fn enter_slash_cmds_are_not_chat() {
assert_eq!(
composer_enter("/wipe"),
Some(ComposerAction::Cmd(SlashCmd::Wipe(WipeKind::Messages)))
);
assert_eq!(
composer_enter("/who"),
Some(ComposerAction::Cmd(SlashCmd::Who))
);
}
#[test]
fn enter_unknown_slash_is_not_chat() {
assert_eq!(
composer_enter("/nope"),
Some(ComposerAction::UnknownSlash("/nope".into()))
);
}

View file

@ -20,6 +20,7 @@ impl TempHome {
let guard = ENV_LOCK.lock().expect("env lock");
unsafe {
std::env::set_var("ONIONWIRE_HOME", dir.path());
std::env::set_var("ONIONWIRE_STORE_PASSPHRASE", "onionwire-test");
}
Self { dir, _guard: guard }
}
@ -33,6 +34,7 @@ impl Drop for TempHome {
fn drop(&mut self) {
unsafe {
std::env::remove_var("ONIONWIRE_HOME");
std::env::remove_var("ONIONWIRE_STORE_PASSPHRASE");
}
}
}
@ -53,11 +55,14 @@ fn first_run_creates_0700_dirs_and_self_row() {
let store = Store::open().expect("open");
let arti = home.path().join("arti");
let cache = home.path().join("cache");
let db = home.path().join("onionwire.db");
assert!(arti.is_dir(), "arti dir");
assert!(cache.is_dir(), "cache dir");
assert!(db.is_file(), "onionwire.db");
assert_eq!(mode(home.path()), 0o700);
assert_eq!(mode(&arti), 0o700);
assert_eq!(mode(&cache), 0o700);
let me = store.self_identity().expect("self");
assert_eq!(me.identity_pk.len(), 32);
@ -121,8 +126,8 @@ fn unknown_pubkey_inserts_new_row() {
fn open_at_two_homes_are_independent() {
let a = tempfile::tempdir().expect("a");
let b = tempfile::tempdir().expect("b");
let sa = onionwire::Store::open_at(a.path()).expect("open a");
let sb = onionwire::Store::open_at(b.path()).expect("open b");
let sa = onionwire::Store::open_at_with_passphrase(a.path(), "a-pass").expect("open a");
let sb = onionwire::Store::open_at_with_passphrase(b.path(), "b-pass").expect("open b");
let ia = sa.self_identity().unwrap();
let ib = sb.self_identity().unwrap();
assert_ne!(ia.identity_pk, ib.identity_pk);
@ -163,3 +168,160 @@ fn friend_prekey_lookup() {
assert_eq!(f.pubkey, pk(1));
assert_eq!(f.prekey, spk);
}
#[test]
fn open_uses_wal_journal() {
let dir = tempfile::tempdir().expect("tempdir");
let store = Store::open_at_with_passphrase(dir.path(), "onionwire-test").expect("open");
assert_eq!(store.journal_mode().unwrap().to_ascii_lowercase(), "wal");
}
#[test]
fn corrupt_db_fails_closed() {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::write(dir.path().join("onionwire.db"), b"not a sqlite database").unwrap();
assert!(Store::open_at_with_passphrase(dir.path(), "onionwire-test").is_err());
}
fn home_contains_bytes(home: &Path, needle: &[u8]) -> bool {
let Ok(rd) = fs::read_dir(home) else {
return false;
};
for ent in rd.flatten() {
let name = ent.file_name();
let name = name.to_string_lossy();
if !name.starts_with("onionwire.db") {
continue;
}
let Ok(bytes) = fs::read(ent.path()) else {
continue;
};
if bytes.windows(needle.len()).any(|w| w == needle) {
return true;
}
}
false
}
#[test]
fn empty_passphrase_is_rejected() {
let dir = tempfile::tempdir().expect("tempdir");
let err = match Store::open_at_with_passphrase(dir.path(), "") {
Ok(_) => panic!("empty passphrase should fail"),
Err(e) => e,
};
assert!(
err.to_string().to_ascii_lowercase().contains("empty"),
"got {err}"
);
}
#[test]
fn append_message_db_file_does_not_contain_plaintext() {
let dir = tempfile::tempdir().expect("tempdir");
let store = Store::open_at_with_passphrase(dir.path(), "correct-horse").expect("open");
store.upsert_friend(&pk(1), "b.onion", None).unwrap();
let needle = b"needle-plaintext-xyzzy-at-rest";
store.append_message(&pk(1), "out", needle).unwrap();
let msgs = store.list_messages(&pk(1)).unwrap();
assert_eq!(msgs.len(), 1);
assert_eq!(msgs[0].plaintext, needle);
drop(store);
assert!(
!home_contains_bytes(dir.path(), needle),
"sqlite files must not contain chat plaintext"
);
}
#[test]
fn wrong_passphrase_cannot_open_or_list() {
let dir = tempfile::tempdir().expect("tempdir");
let store = Store::open_at_with_passphrase(dir.path(), "right-pass").expect("open");
store.upsert_friend(&pk(1), "b.onion", None).unwrap();
store
.append_message(&pk(1), "in", b"secret-chat-body")
.unwrap();
drop(store);
let err = match Store::open_at_with_passphrase(dir.path(), "wrong-pass") {
Ok(_) => panic!("wrong passphrase should fail"),
Err(e) => e,
};
let msg = err.to_string().to_ascii_lowercase();
assert!(
msg.contains("passphrase") || msg.contains("decrypt") || msg.contains("wrong"),
"got {err}"
);
}
#[test]
fn reopen_with_same_passphrase_decrypts() {
let dir = tempfile::tempdir().expect("tempdir");
let store = Store::open_at_with_passphrase(dir.path(), "same-pass").expect("open");
store.upsert_friend(&pk(1), "b.onion", None).unwrap();
store.append_message(&pk(1), "out", b"hello again").unwrap();
drop(store);
let store = Store::open_at_with_passphrase(dir.path(), "same-pass").expect("reopen");
let msgs = store.list_messages(&pk(1)).unwrap();
assert_eq!(msgs[0].plaintext, b"hello again");
}
#[test]
fn env_passphrase_skips_prompt() {
let _g = ENV_LOCK.lock().expect("env lock");
unsafe {
std::env::set_var("ONIONWIRE_STORE_PASSPHRASE", "from-env");
}
let mut prompted = false;
let got = onionwire::resolve_store_passphrase(|_| {
prompted = true;
Ok("from-tty".into())
});
unsafe {
std::env::remove_var("ONIONWIRE_STORE_PASSPHRASE");
}
assert_eq!(got.unwrap(), "from-env");
assert!(!prompted);
}
#[test]
fn empty_env_passphrase_is_rejected_without_prompt() {
let _g = ENV_LOCK.lock().expect("env lock");
unsafe {
std::env::set_var("ONIONWIRE_STORE_PASSPHRASE", "");
}
let mut prompted = false;
let err = onionwire::resolve_store_passphrase(|_| {
prompted = true;
Ok("from-tty".into())
})
.unwrap_err();
unsafe {
std::env::remove_var("ONIONWIRE_STORE_PASSPHRASE");
}
assert!(err.to_string().contains("empty"), "got {err}");
assert!(!prompted);
}
#[test]
fn missing_env_reads_secret_and_strips_newline() {
let _g = ENV_LOCK.lock().expect("env lock");
unsafe {
std::env::remove_var("ONIONWIRE_STORE_PASSPHRASE");
}
let got = onionwire::resolve_store_passphrase(|prompt| {
assert!(prompt.contains("store passphrase"), "prompt {prompt}");
Ok("secret-from-tty\n".into())
})
.unwrap();
assert_eq!(got, "secret-from-tty");
}
#[test]
fn missing_env_empty_secret_is_rejected() {
let _g = ENV_LOCK.lock().expect("env lock");
unsafe {
std::env::remove_var("ONIONWIRE_STORE_PASSPHRASE");
}
let err = onionwire::resolve_store_passphrase(|_| Ok(String::new())).unwrap_err();
assert!(err.to_string().contains("empty"), "got {err}");
}

View file

@ -6,53 +6,22 @@
use std::sync::Arc;
use std::time::{Duration, Instant};
use arti_client::config::TorClientConfigBuilder;
use arti_client::{DormantMode, TorClient, TorClientConfig};
use arti_client::DormantMode;
use futures::StreamExt;
use futures::io::{AsyncReadExt, AsyncWriteExt};
use safelog::DisplayRedacted;
use onionwire::hs::{self, Client, HS_PORT};
use tor_cell::relaycell::msg::Connected;
use tor_hsservice::status::State;
use tor_hsservice::{HsNickname, OnionServiceConfig, RunningOnionService, handle_rend_requests};
use tor_rtcompat::PreferredRuntime;
use tor_hsservice::{RunningOnionService, handle_rend_requests};
const PING: &[u8] = b"ping";
const HS_PORT: u16 = 80;
const BOOTSTRAP_LOG: &str = "info";
type Client = Arc<TorClient<PreferredRuntime>>;
fn client_config(state_dir: &std::path::Path, cache_dir: &std::path::Path) -> TorClientConfig {
std::fs::create_dir_all(state_dir).expect("state dir");
std::fs::create_dir_all(cache_dir).expect("cache dir");
let mut builder = TorClientConfigBuilder::from_directories(state_dir, cache_dir);
// Temp dirs sit under $TMP; skip fs-mistrust on the parent tree.
builder.storage().permissions().dangerously_trust_everyone();
builder.build().expect("TorClientConfig")
}
async fn bootstrapped(state_dir: &std::path::Path, cache_dir: &std::path::Path) -> Client {
let cfg = client_config(state_dir, cache_dir);
TorClient::create_bootstrapped(cfg)
hs::bootstrapped(state_dir, cache_dir)
.await
.expect("Arti bootstrap failed — fail closed, no C-tor fallback")
}
fn hs_config(nickname: &str) -> OnionServiceConfig {
let nickname = HsNickname::new(nickname.to_string()).expect("HsNickname");
OnionServiceConfig::builder()
.nickname(nickname)
.build()
.expect("OnionServiceConfig")
}
fn onion_string(svc: &RunningOnionService) -> String {
let id = svc
.onion_address()
.expect("onion identity missing from keystore");
id.display_unredacted().to_string()
}
fn spawn_echo(
rend: impl futures::Stream<Item = tor_hsservice::RendRequest> + Send + 'static,
) -> tokio::task::JoinHandle<()> {
@ -79,41 +48,15 @@ async fn launch_echo(
nickname: &str,
) -> (Arc<RunningOnionService>, tokio::task::JoinHandle<()>, String) {
let launched = client
.launch_onion_service(hs_config(nickname))
.launch_onion_service(hs::hs_config(nickname).expect("hs_config"))
.expect("launch_onion_service")
.expect("onion service disabled in config — fail closed");
let (svc, rend) = launched;
let onion = onion_string(&svc);
let onion = hs::onion_string(&svc).expect("onion_string");
let echo = spawn_echo(rend);
(svc, echo, onion)
}
async fn wait_until_published(svc: &RunningOnionService, label: &str) {
let deadline = Instant::now() + Duration::from_secs(180);
let mut events = svc.status_events();
loop {
let st = svc.status();
eprintln!("{label} hs status: {:?}", st.state());
match st.state() {
State::Running | State::DegradedReachable => return,
State::Broken => {
panic!("{label}: onion service broken: {:?}", st.current_problem())
}
_ => {}
}
if Instant::now() >= deadline {
panic!(
"{label}: onion service did not publish within 180s: {:?}",
st.state()
);
}
tokio::select! {
_ = events.next() => {}
_ = tokio::time::sleep(Duration::from_secs(2)) => {}
}
}
}
async fn echo_ping(client: &Client, onion: &str) -> Result<(), String> {
let mut stream = client
.connect((onion, HS_PORT))
@ -181,8 +124,12 @@ async fn two_node_byte_pipe_restart_and_dormant() {
eprintln!("bob onion={bob_onion}");
assert_ne!(alice_onion, bob_onion, "separate HS identities");
wait_until_published(&alice_svc, "alice").await;
wait_until_published(&bob_svc, "bob").await;
hs::wait_until_published(&alice, &alice_svc, &alice_onion)
.await
.expect("alice publish");
hs::wait_until_published(&bob, &bob_svc, &bob_onion)
.await
.expect("bob publish");
eprintln!("echo ping alice → bob");
echo_ping_retry(&alice, &bob_onion, Duration::from_secs(180)).await;

View file

@ -1,8 +1,9 @@
//! Main-screen chrome helpers: ASCII wordmark, compact fallback, help text.
use onionwire::qr;
use onionwire::tui::{
banner_for_width, compact_banner, help_overlay_text, main_footer_hints, onion_glyph,
status_footer, wordmark_banner, Pane,
Pane, banner_for_width, compact_banner, draw_share, help_overlay_text, main_footer_hints,
onion_glyph, status_footer, wordmark_banner,
};
#[test]
@ -38,7 +39,22 @@ fn onion_glyph_is_nonempty() {
fn help_overlay_lists_core_bindings() {
let help = help_overlay_text();
assert!(!help.is_empty());
for needle in ["Tab", "F2", "F3", "F4", "Ctrl-Q", "?"] {
for needle in [
"Tab",
"F2",
"F3",
"F4",
"F5",
"send chat",
"/profile",
"/pay",
"/tip",
"/file",
"/backup",
"/restore",
"Ctrl-Q",
"?",
] {
assert!(help.contains(needle), "help overlay missing {needle:?}");
}
for line in help.lines() {
@ -61,13 +77,13 @@ fn banner_for_width_collapses_when_narrow() {
#[test]
fn chrome_renders_at_80x24_and_120x40() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use ratatui::layout::{Constraint, Layout};
use ratatui::widgets::Paragraph;
use ratatui::Terminal;
let fp = "abcdef0123456789";
let onion = "abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopq.onion";
let onion = "abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopqrstuvwx.onion";
for (w, h) in [(80u16, 24u16), (120, 40)] {
let backend = TestBackend::new(w, h);
let mut terminal = Terminal::new(backend).expect("terminal");
@ -109,7 +125,7 @@ fn chrome_renders_at_80x24_and_120x40() {
#[test]
fn main_footer_keeps_help_at_80_and_120() {
let fp = "abcdef0123456789deadbeef";
let onion = "abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopq.onion";
let onion = "abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopqrstuvwx.onion";
let hints = main_footer_hints();
assert!(hints.contains("? help"));
for w in [80u16, 120] {
@ -154,3 +170,41 @@ fn pane_focus_cycles_roster_chat_composer() {
assert_eq!(Pane::Chat.prev(), Pane::Roster);
assert_eq!(Pane::Composer.prev(), Pane::Chat);
}
#[test]
fn restore_terminal_is_callable_without_panic() {
onionwire::tui::restore_terminal();
}
#[test]
fn share_screen_shows_invite_not_qr_at_80x24() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let sk = [7u8; 32];
let onion = "abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopqrstuvwx.onion";
let payload = qr::encode(&sk, onion, &[1u8; 32]).expect("encode");
assert!(payload.starts_with("onionwire:v1:"));
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).expect("terminal");
terminal.draw(|f| draw_share(f, &payload)).expect("draw");
let buf = terminal.backend().buffer();
let mut screen = String::new();
for y in 0..24u16 {
for x in 0..80u16 {
screen.push_str(buf[(x, y)].symbol());
}
screen.push('\n');
}
assert!(
screen.contains("onionwire:v1:"),
"share screen missing invite prefix:\n{screen}"
);
for ch in ['█', '▀', '▄'] {
assert!(
!screen.contains(ch),
"share screen still has QR block {ch:?}:\n{screen}"
);
}
}

View file

@ -21,6 +21,9 @@ async fn alice_sends_hello_wire_bob_sqlite_has_plaintext() {
let bob_home = root.path().join("bob");
eprintln!("starting alice + bob nodes…");
unsafe {
std::env::set_var("ONIONWIRE_STORE_PASSPHRASE", "onionwire-test");
}
let (alice, bob) = tokio::join!(
Node::start(alice_home.clone()),
Node::start(bob_home.clone())

279
tests/wallet.rs Normal file
View file

@ -0,0 +1,279 @@
//! M8: optional monero-wallet-rpc JSON client. Mock TCP only — no live monerod.
use ed25519_dalek::SigningKey;
use onionwire::pay;
use onionwire::wallet::{self, TransferRow, Wallet};
use onionwire::{PaymentWrite, Store};
use rand::rngs::OsRng;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
fn row(txid: &str, amount: &str, address: &str) -> TransferRow {
TransferRow {
txid: txid.into(),
amount: amount.into(),
address: address.into(),
}
}
fn xmr_addr() -> String {
// Same documented mainnet standard as tests/pay.rs — F4 checksums this.
"4AdUndXHHZ6cfufTMvppY6JwXNouMBzSkbLYfpAV5Usx3skxNgYeYTRj5UzqtReoS44qo9mtmXCqY45DJ852K5Jv2684Rge"
.to_string()
}
fn json_rpc_ok(result: &str) -> String {
let body = format!(r#"{{"jsonrpc":"2.0","id":"0","result":{result}}}"#);
format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
)
}
#[test]
fn disabled_create_address_is_not_configured() {
let w = Wallet::disabled();
let err = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap()
.block_on(w.create_address())
.unwrap_err();
assert!(err.to_string().contains("not configured"), "got {err}");
}
#[test]
fn refuse_url_without_credentials() {
let err = Wallet::from_url("http://127.0.0.1:18083").unwrap_err();
let msg = err.to_string().to_ascii_lowercase();
assert!(
msg.contains("credential") || msg.contains("login") || msg.contains("user"),
"got {err}"
);
}
#[test]
fn refuse_non_loopback_non_onion_host() {
let err = Wallet::from_url("http://ow:secret@example.com:18083").unwrap_err();
assert!(
err.to_string().to_ascii_lowercase().contains("host")
|| err.to_string().contains("loopback")
|| err.to_string().contains("onion"),
"got {err}"
);
}
#[test]
fn refuse_onion_rpc_url() {
let err = Wallet::from_url(
"http://ow:s3cretPASS@abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopqrstuvwxyz.onion:18083",
)
.unwrap_err();
let msg = err.to_string();
assert!(msg.to_ascii_lowercase().contains("onion"), "got {err}");
assert!(
!msg.contains("s3cretPASS"),
"password leaked in error: {err}"
);
}
#[test]
fn password_absent_from_url_errors() {
let err = Wallet::from_url("http://ow:s3cretPASS@example.com:18083").unwrap_err();
assert!(
!err.to_string().contains("s3cretPASS"),
"password leaked in error: {err}"
);
let err = Wallet::from_url("http://ow:s3cretPASS@").unwrap_err();
assert!(
!err.to_string().contains("s3cretPASS"),
"password leaked in error: {err}"
);
}
#[tokio::test]
async fn mock_get_address_parses_string() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let canned = json_rpc_ok(
r#"{"address":"4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"}"#,
);
tokio::spawn(serve_digest_then(listener, canned));
let w = Wallet::from_url(&format!("http://ow:secret@127.0.0.1:{}", addr.port())).unwrap();
let got = w.get_address().await.expect("get_address");
assert!(got.starts_with('4'), "got {got}");
}
#[tokio::test]
async fn mock_create_address_and_transfer() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let canned = json_rpc_ok(
r#"{"address":"8BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB","tx_hash":"abc123"}"#,
);
tokio::spawn(serve_digest_then(listener, canned.clone()));
let w = Wallet::from_url(&format!("http://ow:secret@127.0.0.1:{}", addr.port())).unwrap();
let created = w.create_address().await.expect("create_address");
assert!(created.starts_with('8'));
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(serve_digest_then(listener, canned));
let w = Wallet::from_url(&format!("http://ow:secret@127.0.0.1:{}", addr.port())).unwrap();
let txid = w
.transfer("8BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", 1)
.await
.expect("transfer");
assert_eq!(txid, "abc123");
}
#[tokio::test]
async fn mock_get_transfers_matches_txid() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let canned = json_rpc_ok(
r#"{"in":[{"txid":"deadbeef","amount":5,"address":"8BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"}],"pending":[]}"#,
);
tokio::spawn(serve_digest_then(listener, canned));
let w = Wallet::from_url(&format!("http://ow:secret@127.0.0.1:{}", addr.port())).unwrap();
let rows = w.get_transfers().await.expect("get_transfers");
assert!(wallet::transfers_match(
&rows,
"deadbeef",
"5",
"8BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"
));
assert!(!wallet::transfers_match(&rows, "nope", "1", "nope"));
}
#[test]
fn transfers_match_txid_only_wrong_amount_or_addr_is_false() {
let addr = xmr_addr();
let rows = [row("deadbeef", "5", &addr)];
assert!(!wallet::transfers_match(&rows, "deadbeef", "99", &addr));
let other = format!("8{}", "C".repeat(94));
assert!(!wallet::transfers_match(&rows, "deadbeef", "5", &other));
}
#[test]
fn transfers_match_address_and_amount_wrong_txid_is_false() {
let addr = xmr_addr();
let rows = [row("deadbeef", "5", &addr)];
assert!(!wallet::transfers_match(&rows, "cafebabe", "5", &addr));
}
#[test]
fn transfers_match_honest_triple_is_true() {
let addr = xmr_addr();
let rows = [row("deadbeef", "5", &addr)];
assert!(wallet::transfers_match(&rows, "deadbeef", "5", &addr));
}
#[test]
fn transfers_match_empty_field_is_false() {
let addr = xmr_addr();
let rows = [row("deadbeef", "5", &addr)];
assert!(!wallet::transfers_match(&rows, "", "5", &addr));
assert!(!wallet::transfers_match(&rows, "deadbeef", "", &addr));
assert!(!wallet::transfers_match(&rows, "deadbeef", "5", ""));
let empty = [row("", "", "")];
assert!(!wallet::transfers_match(&empty, "", "", ""));
}
/// ingest_receipt inserts verified=0, then mark_verified iff transfers_match.
/// A Noise-signed rcp that cites an unrelated wallet row must stay unverified.
#[test]
fn ingest_signed_receipt_mismatched_wallet_history_stays_unverified() {
let dir = tempfile::tempdir().expect("tempdir");
let store = Store::open_at_with_passphrase(dir.path(), "onionwire-test").expect("open");
let sk = SigningKey::generate(&mut OsRng);
let pk = sk.verifying_key().to_bytes();
store.upsert_friend(&pk, "peer.onion", None).unwrap();
let addr = xmr_addr();
let rcp = pay::sign_receipt(&sk.to_bytes(), "unrelated", "5", &addr, 1).unwrap();
assert!(pay::verify_receipt(&pk, &rcp));
let id = store
.insert_payment(
&pk,
PaymentWrite {
dir: "in",
kind: "receipt",
amount_atomic: &rcp.amount_atomic,
address: &rcp.address,
memo: "",
txid: Some(&rcp.txid),
verified: false,
},
)
.unwrap();
let history = [row("unrelated", "99", &format!("8{}", "C".repeat(94)))];
if wallet::transfers_match(&history, &rcp.txid, &rcp.amount_atomic, &rcp.address) {
store.mark_verified(id).unwrap();
}
let rows = store.list_payments(&pk).unwrap();
assert!(!rows[0].verified);
}
#[tokio::test]
async fn oversized_rpc_response_is_err() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let huge = vec![b'A'; 2 * 1024 * 1024];
tokio::spawn(async move {
let (mut sock, _) = listener.accept().await.expect("accept");
let mut buf = vec![0u8; 4096];
let _ = sock.read(&mut buf).await;
sock.write_all(b"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nConnection: close\r\n\r\n")
.await
.expect("hdr");
sock.write_all(&huge).await.expect("body");
});
let w = Wallet::from_url(&format!("http://ow:secret@127.0.0.1:{}", addr.port())).unwrap();
let err = w.get_address().await.unwrap_err();
let msg = err.to_string().to_ascii_lowercase();
assert!(
msg.contains("large") || msg.contains("size") || msg.contains("cap"),
"got {err}"
);
assert!(
!err.to_string().contains("secret"),
"password leaked: {err}"
);
}
fn digest_401() -> String {
"HTTP/1.1 401 Unauthorized\r\nWWW-Authenticate: Digest realm=\"monero-rpc\", nonce=\"abcnonce\", qop=\"auth\", algorithm=MD5\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".into()
}
async fn serve_digest_then(listener: TcpListener, ok: String) {
let (mut sock, _) = listener.accept().await.expect("accept");
let mut buf = vec![0u8; 8192];
let _ = sock.read(&mut buf).await;
sock.write_all(digest_401().as_bytes()).await.expect("401");
drop(sock);
let (mut sock, _) = listener.accept().await.expect("accept2");
buf.fill(0);
let n = sock.read(&mut buf).await.unwrap_or(0);
let req = String::from_utf8_lossy(&buf[..n]);
assert!(
req.contains("Authorization: Digest"),
"missing digest auth: {req}"
);
assert!(req.contains("username=\"ow\""), "missing user: {req}");
assert!(req.contains("response=\""), "missing response: {req}");
sock.write_all(ok.as_bytes()).await.expect("200");
}
#[tokio::test]
async fn mock_digest_auth_accepted() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let canned = json_rpc_ok(
r#"{"address":"4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"}"#,
);
tokio::spawn(serve_digest_then(listener, canned));
let w = Wallet::from_url(&format!("http://ow:s3cretPASS@127.0.0.1:{}", addr.port())).unwrap();
let got = w.get_address().await.expect("get_address");
assert!(got.starts_with('4'), "got {got}");
}

View file

@ -1,7 +1,13 @@
//! M5: wipe messages (keep identity + friends); wipe-all is a new person.
use onionwire::Store;
use onionwire::tui::{WipeDecision, WipeKind, WipePrompt, parse_slash, wipe_screen_text};
use onionwire::tui::{
parse_slash, quit_screen_text, wipe_screen_text, QuitDecision, QuitPrompt, WipeDecision,
WipeKind, WipePrompt,
};
use onionwire::{PaymentWrite, Store};
// Official mainnet standard from Monero docs (same fixture as tests/pay.rs).
const MAINNET_STD: &str = "4AdUndXHHZ6cfufTMvppY6JwXNouMBzSkbLYfpAV5Usx3skxNgYeYTRj5UzqtReoS44qo9mtmXCqY45DJ852K5Jv2684Rge";
fn pk(tag: u8) -> [u8; 32] {
let mut k = [0u8; 32];
@ -19,7 +25,7 @@ fn db_contains(home: &std::path::Path, needle: &[u8]) -> bool {
#[test]
fn wipe_clears_messages_keeps_self_and_friends() {
let dir = tempfile::tempdir().expect("tempdir");
let store = Store::open_at(dir.path()).expect("open");
let store = Store::open_at_with_passphrase(dir.path(), "onionwire-test").expect("open");
let me = store.self_identity().unwrap();
store
.upsert_friend(&pk(1), "a.onion", Some("alice"))
@ -47,11 +53,58 @@ fn wipe_clears_messages_keeps_self_and_friends() {
);
}
#[test]
fn wipe_clears_payments_keeps_self_and_friends() {
let dir = tempfile::tempdir().expect("tempdir");
let store = Store::open_at_with_passphrase(dir.path(), "onionwire-test").expect("open");
let me = store.self_identity().unwrap();
store
.upsert_friend(&pk(1), "a.onion", Some("alice"))
.unwrap();
store
.append_message(&pk(1), "out", b"secret-log-line-xyz")
.unwrap();
store
.insert_payment(
&pk(1),
PaymentWrite {
dir: "out",
kind: "receipt",
amount_atomic: "1000000000000",
address: MAINNET_STD,
memo: "counterparty-memo-xyz",
txid: Some("aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899"),
verified: true,
},
)
.unwrap();
assert_eq!(store.list_messages(&pk(1)).unwrap().len(), 1);
assert_eq!(store.list_payments(&pk(1)).unwrap().len(), 1);
store.wipe_messages().expect("wipe");
assert!(store.list_messages(&pk(1)).unwrap().is_empty());
assert!(
store.list_payments(&pk(1)).unwrap().is_empty(),
"wipe must drop payments, not only chat"
);
assert_eq!(store.friend_count().unwrap(), 1);
let f = store.get_friend(&pk(1)).unwrap().expect("friend");
assert_eq!(f.petname.as_deref(), Some("alice"));
let me2 = store.self_identity().unwrap();
assert_eq!(me.identity_pk, me2.identity_pk);
drop(store);
assert!(
!db_contains(dir.path(), b"counterparty-memo-xyz"),
"wipe must not leave payment memo in the db file"
);
}
#[test]
fn wipe_all_removes_dir_so_next_open_is_new_identity() {
let dir = tempfile::tempdir().expect("tempdir");
let home = dir.path().join("ow");
let store = Store::open_at(&home).expect("open");
let store = Store::open_at_with_passphrase(&home, "onionwire-test").expect("open");
let old_pk = store.self_identity().unwrap().identity_pk;
store.upsert_friend(&pk(1), "a.onion", None).unwrap();
store.append_message(&pk(1), "out", b"gone").unwrap();
@ -61,7 +114,7 @@ fn wipe_all_removes_dir_so_next_open_is_new_identity() {
Store::wipe_all(&home).expect("wipe-all");
assert!(!home.exists(), "data dir gone");
let store2 = Store::open_at(&home).expect("reopen");
let store2 = Store::open_at_with_passphrase(&home, "onionwire-test").expect("reopen");
let new_pk = store2.self_identity().unwrap().identity_pk;
assert_ne!(old_pk, new_pk, "new identity key = new person");
assert_eq!(store2.friend_count().unwrap(), 0);
@ -105,8 +158,9 @@ fn wipe_all_requires_typing_wipeall() {
#[test]
fn wipe_prompt_text_matches_spec() {
let m = wipe_screen_text(WipeKind::Messages);
assert!(m.contains("Wipe message log?"));
assert!(m.contains("Wipe chat and payments history?"));
assert!(m.contains("Identity key and friends stay."));
assert!(m.contains("Not a forensic erase."));
assert!(m.contains("Type WIPE to confirm"));
assert!(m.contains("Esc to cancel"));
let a = wipe_screen_text(WipeKind::All);
@ -115,3 +169,39 @@ fn wipe_prompt_text_matches_spec() {
assert!(a.contains("Type WIPEALL to confirm"));
assert!(a.contains("Esc to cancel"));
}
#[test]
fn quit_requires_clear_or_quit_then_enter() {
let mut p = QuitPrompt::new();
assert_eq!(p.on_esc(), QuitDecision::Cancel);
assert_eq!(
p.on_char('\n'),
QuitDecision::Pending,
"Enter alone must not quit"
);
for c in "CLEA".chars() {
assert_eq!(p.on_char(c), QuitDecision::Pending);
}
assert_eq!(p.on_char('R'), QuitDecision::Pending, "CLEAR without Enter");
assert_eq!(p.on_char('\n'), QuitDecision::ClearAndQuit);
let mut q = QuitPrompt::new();
for c in "QUIT".chars() {
assert_eq!(q.on_char(c), QuitDecision::Pending);
}
assert_eq!(q.on_char('\n'), QuitDecision::Quit);
}
#[test]
fn quit_prompt_text_matches_spec() {
let t = quit_screen_text();
assert!(
t.contains("message history can be cleared")
|| t.contains("Message history can be cleared")
);
assert!(t.contains("identity") && t.contains("friends"));
assert!(t.contains("not") && t.contains("/wipe-all"));
assert!(t.contains("CLEAR"));
assert!(t.contains("QUIT"));
assert!(t.contains("Esc"));
}