Compare commits

..

31 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
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
68 changed files with 11508 additions and 175 deletions

View file

@ -8,12 +8,18 @@ on:
pull_request: pull_request:
workflow_dispatch: 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: env:
CARGO_TERM_COLOR: never CARGO_TERM_COLOR: never
RUST_IMAGE: rust:1.91-bookworm RUST_IMAGE: rust:1.91-bookworm
CARGO_REGISTRY_VOLUME: onionwire-cargo-registry CARGO_REGISTRY_VOLUME: onionwire-cargo-registry
CARGO_TARGET_VOLUME: onionwire-target-ci CARGO_TARGET_VOLUME: onionwire-target-ci
BUILD_CONTAINER: onionwire-ci BUILD_CONTAINER: onionwire-ci-${{ github.run_id }}
jobs: jobs:
test: test:

View file

@ -22,7 +22,7 @@ env:
RUST_IMAGE: rust:1.91-bookworm RUST_IMAGE: rust:1.91-bookworm
CARGO_REGISTRY_VOLUME: onionwire-cargo-registry CARGO_REGISTRY_VOLUME: onionwire-cargo-registry
CARGO_TARGET_VOLUME: onionwire-target-aarch64 CARGO_TARGET_VOLUME: onionwire-target-aarch64
BUILD_CONTAINER: onionwire-release-build BUILD_CONTAINER: onionwire-release-build-${{ github.run_id }}
jobs: jobs:
aarch64: aarch64:

14
.gitignore vendored
View file

@ -1,3 +1,17 @@
/target /target
/dist /dist
/.worktrees/ /.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/

86
Cargo.lock generated
View file

@ -1557,6 +1557,17 @@ dependencies = [
"syn 3.0.5", "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]] [[package]]
name = "futures-sink" name = "futures-sink"
version = "0.3.34" version = "0.3.34"
@ -2348,6 +2359,16 @@ dependencies = [
"regex-automata", "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]] [[package]]
name = "memchr" name = "memchr"
version = "2.8.3" version = "2.8.3"
@ -2608,18 +2629,22 @@ dependencies = [
[[package]] [[package]]
name = "onionwire" name = "onionwire"
version = "0.2.0" version = "0.2.1"
dependencies = [ dependencies = [
"argon2", "argon2",
"arti-client", "arti-client",
"chacha20poly1305", "chacha20poly1305",
"ed25519-dalek", "ed25519-dalek",
"futures", "futures",
"md-5",
"rand 0.8.8", "rand 0.8.8",
"ratatui", "ratatui",
"rpassword",
"rusqlite", "rusqlite",
"safelog", "safelog",
"serde_json", "serde_json",
"sha2",
"sha3 0.10.9",
"snow", "snow",
"tempfile", "tempfile",
"tokio", "tokio",
@ -3358,6 +3383,17 @@ dependencies = [
"windows-sys 0.52.0", "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]] [[package]]
name = "rsa" name = "rsa"
version = "0.9.10" version = "0.9.10"
@ -3379,6 +3415,16 @@ dependencies = [
"zeroize", "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]] [[package]]
name = "rusqlite" name = "rusqlite"
version = "0.36.0" version = "0.36.0"
@ -3425,6 +3471,40 @@ dependencies = [
"windows-sys 0.61.2", "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]] [[package]]
name = "rustversion" name = "rustversion"
version = "1.0.23" version = "1.0.23"
@ -5338,11 +5418,15 @@ dependencies = [
"dyn-clone", "dyn-clone",
"educe", "educe",
"futures", "futures",
"futures-rustls",
"hex", "hex",
"libc", "libc",
"native-tls", "native-tls",
"paste", "paste",
"pin-project", "pin-project",
"rustls",
"rustls-pki-types",
"rustls-webpki",
"socket2", "socket2",
"thiserror 2.0.20", "thiserror 2.0.20",
"tokio", "tokio",

View file

@ -1,6 +1,6 @@
[package] [package]
name = "onionwire" name = "onionwire"
version = "0.2.0" version = "0.2.1"
edition = "2024" edition = "2024"
rust-version = "1.91" rust-version = "1.91"
description = "Lean Tor messenger: Arti in-process, identity=pubkey, onion=locator. No XMPP." description = "Lean Tor messenger: Arti in-process, identity=pubkey, onion=locator. No XMPP."
@ -8,8 +8,26 @@ license = "MIT"
publish = false publish = false
repository = "https://github.com/sirius0xdev/onionwire" 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] [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" futures = "0.3"
safelog = "0.9" safelog = "0.9"
ed25519-dalek = { version = "2", features = ["rand_core"] } ed25519-dalek = { version = "2", features = ["rand_core"] }
@ -25,6 +43,10 @@ snow = "0.10"
serde_json = "1" serde_json = "1"
argon2 = "0.5" argon2 = "0.5"
chacha20poly1305 = "0.10" chacha20poly1305 = "0.10"
sha3 = "0.10"
sha2 = "0.10"
md-5 = "0.10"
rpassword = "7"
[dev-dependencies] [dev-dependencies]
tempfile = "3" tempfile = "3"

View file

@ -108,9 +108,9 @@ onionwire
or `cargo run --release`. or `cargo run --release`.
On start you are prompted for a store passphrase (or set `ONIONWIRE_STORE_PASSPHRASE`). Empty passphrase is rejected; a wrong passphrase does not open chat. Then you should see `onionwire: bootstrapping Arti…` on stderr. Directory bootstrap is usually under a minute; the onion is ready once a probe connect works (combined Arti status may still say Bootstrapping). Fail closed at 360s. Data lives in `ONIONWIRE_HOME` if set, otherwise `~/.local/share/onionwire/` (`onionwire.db` + Arti state, mode 0700). First open creates an ed25519 identity key. That key **is** you. 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. 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 ## Friends are keys
@ -131,7 +131,7 @@ Focus starts on the composer so typing works immediately. `Tab` cycles panes; `j
| `F3` | Paste a friends invite | | `F3` | Paste a friends invite |
| `F4` | Rotate **onion** (locator only) | | `F4` | Rotate **onion** (locator only) |
| `F5` | Selected friends profile (`/who`) | | `F5` | Selected friends profile (`/who`) |
| Enter | Run `/wipe`, `/wipe-all`, `/profile`, `/who`, `/pay`, `/tip`, `/backup`, `/restore` from the composer | | Enter | Send chat to the selected friend, or run a `/command` |
| `Esc` | Close overlay / back to Main / clear composer | | `Esc` | Close overlay / back to Main / clear composer |
| `Ctrl-Q` | Quit: type `CLEAR`+Enter to wipe history, `QUIT`+Enter to leave it, Esc to stay | | `Ctrl-Q` | Quit: type `CLEAR`+Enter to wipe history, `QUIT`+Enter to leave it, Esc to stay |
@ -156,19 +156,31 @@ Give that string to a friend. They `F3` paste it (`(o) paste invite`). Unknown `
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. 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
`/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. `/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 ## Monero sidecar
OnionWire is not a wallet. Optional JSON-RPC to a user-hosted `monero-wallet-rpc`: 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 ```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=http://127.0.0.1:18083
export ONIONWIRE_WALLET_RPC_LOGIN=onionwire:secret
``` ```
Loopback or `.onion` only, HTTP, 5s timeout. Unset → chat still works; `/pay` and `/tip` say so. 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`. - `/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. - `/tip <xmr> [memo]` — pay the selected friends profile address, then send a signed `rcp`. Incoming receipts stay unverified until RPC `get_transfers` matches.
@ -184,14 +196,14 @@ Treat the backup file like the sqlite db.
## Mixed versions ## Mixed versions
0.1.2 peers store unknown plaintext as chat. A 0.2 sender of `prf ` / `inv ` / `rcp ` will leave a garbage line on an un-upgraded peer. Upgrade both sides. The Noise handshake is unchanged. 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 ## Wipe
Composer (bottom of the roster screen): Composer (bottom of the roster screen):
- `/wipe` — confirm by typing `WIPE`. Overwrites the message log and `VACUUM`s. Identity key and friends stay. - `/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). Esc cancels. Nothing is wiped without confirm. - `/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 ## Uninstall
@ -208,17 +220,44 @@ If you set `ONIONWIRE_HOME`, delete that directory instead.
## Seized laptop ## Seized laptop
Chat bodies in sqlite are ChaCha20-Poly1305 (`nonce || ciphertext` in `messages.plaintext`), wrapped by a passphrase-derived Argon2id key. A disk grep of `onionwire.db` must not yield the message log. 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): Still plaintext on disk (unless you add OS/FDE):
- your identity secret key (`self.identity_sk`) - your identity secret key (`self.identity_sk`)
- friend public keys and current locators - friend public keys and current locators
The message key is **not** wrapped with `identity_sk` (that key is in the same file). sqlcipher is out of v1. `/wipe` overwrites message bodies and vacuums; `/wipe-all` deletes the data dir. Ctrl-Q can clear history on the way out (`CLEAR`) without becoming a new person. 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). Threat model: [`docs/THREAT_MODEL.md`](docs/THREAT_MODEL.md).
## Android (SDK + APK)
Same repository, same protocol, separate product from the Linux TUI. An Android
peer and a Linux peer that exchange invites interoperate — one protocol, not two.
- **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
# one-time: sdkmanager platform 36 / build-tools 36.0.0 / ndk 28.2.13676358,
# rustup target add aarch64-linux-android, cargo install cargo-ndk
scripts/build-android-local.sh # -> dist/, with .sha256 files
PUBLISH_TAG=v0.3.0 scripts/build-android-local.sh # + upload to that release
```
Every asset has a matching `.sha256` so `sha256sum -c` works. The APK is
**debug-signed** and is for sideloading, not for Play. Full build notes, the
`minSdk 26` rationale, the 16 KB page-size check and the signing story are in
[`android/README.md`](android/README.md).
## Releases (maintainers) ## Releases (maintainers)
Tags trigger Forgejo Actions on the self-hosted Pi runner. The x86_64 asset has Tags trigger Forgejo Actions on the self-hosted Pi runner. The x86_64 asset has
@ -249,6 +288,7 @@ 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/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-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). - [`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 Do not run `cargo publish`; `publish = false`. CI needs the repo secret

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"

View file

@ -16,9 +16,9 @@ A signed `loc` frame (`onion`, `ts`, `sig`) rewrites a friends locator **only
## Message bodies are encrypted at rest; keys are not ## Message bodies are encrypted at rest; keys are not
Chat bodies in sqlite are ChaCha20-Poly1305 (`nonce || ciphertext` in the `messages.plaintext` column). 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. 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` overwrites message bodies and vacuums; `/wipe-all` deletes the data dir. 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 ## Fail closed
@ -30,7 +30,7 @@ A signed `prf` frame is shown to people who already have a session with you. App
## Monero sidecar is not a wallet ## Monero sidecar is not a wallet
OnionWire never holds spend keys. Optional `ONIONWIRE_WALLET_RPC` talks HTTP to a user-hosted `monero-wallet-rpc`. Never trust a `rcp` frame without RPC confirmation (`verified` stays 0). Subaddress reuse is the users wallet policy. 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 file is the identity

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

View file

@ -1,7 +1,7 @@
//! Encrypted identity backup. Onion (locator) is not included. //! Encrypted identity backup. Onion (locator) is not included.
use argon2::{Algorithm, Argon2, Params, Version}; use argon2::{Algorithm, Argon2, Params, Version};
use chacha20poly1305::aead::{Aead, AeadCore, KeyInit, OsRng}; use chacha20poly1305::aead::{Aead, AeadCore, KeyInit, OsRng, Payload};
use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce}; use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce};
use rand::RngCore; use rand::RngCore;
@ -95,11 +95,21 @@ pub fn open(passphrase: &str, blob: &[u8]) -> Result<BackupKeys, String> {
BackupKeys::from_bytes(&pt) BackupKeys::from_bytes(&pt)
} }
pub(crate) fn aead_encrypt(key: &[u8; 32], plaintext: &[u8]) -> Result<Vec<u8>, String> { 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 cipher = ChaCha20Poly1305::new(Key::from_slice(key));
let nonce = ChaCha20Poly1305::generate_nonce(&mut OsRng); let nonce = ChaCha20Poly1305::generate_nonce(&mut OsRng);
let ct = cipher let ct = cipher
.encrypt(&nonce, plaintext) .encrypt(
&nonce,
Payload {
msg: plaintext,
aad,
},
)
.map_err(|_| "encrypt failed".to_string())?; .map_err(|_| "encrypt failed".to_string())?;
let mut out = Vec::with_capacity(NONCE_LEN + ct.len()); let mut out = Vec::with_capacity(NONCE_LEN + ct.len());
out.extend_from_slice(&nonce); out.extend_from_slice(&nonce);
@ -107,13 +117,19 @@ pub(crate) fn aead_encrypt(key: &[u8; 32], plaintext: &[u8]) -> Result<Vec<u8>,
Ok(out) Ok(out)
} }
pub(crate) fn aead_decrypt(key: &[u8; 32], blob: &[u8]) -> Result<Vec<u8>, String> { pub(crate) fn aead_decrypt(key: &[u8; 32], blob: &[u8], aad: &[u8]) -> Result<Vec<u8>, String> {
if blob.len() < NONCE_LEN + 16 { if blob.len() < NONCE_LEN + 16 {
return Err("ciphertext length".into()); return Err("ciphertext length".into());
} }
let nonce = Nonce::from_slice(&blob[..NONCE_LEN]); let nonce = Nonce::from_slice(&blob[..NONCE_LEN]);
let cipher = ChaCha20Poly1305::new(Key::from_slice(key)); let cipher = ChaCha20Poly1305::new(Key::from_slice(key));
cipher cipher
.decrypt(nonce, &blob[NONCE_LEN..]) .decrypt(
nonce,
Payload {
msg: &blob[NONCE_LEN..],
aad,
},
)
.map_err(|_| "wrong passphrase or corrupt".to_string()) .map_err(|_| "wrong passphrase or corrupt".to_string())
} }

View file

@ -8,6 +8,7 @@ pub enum Kind {
Invoice, Invoice,
Receipt, Receipt,
Ping, Ping,
File,
Drop, Drop,
} }
@ -27,6 +28,9 @@ pub fn classify(pt: &[u8]) -> Kind {
if pt.starts_with(b"png ") { if pt.starts_with(b"png ") {
return Kind::Ping; return Kind::Ping;
} }
if pt.starts_with(b"fil ") {
return Kind::File;
}
if pt.len() >= 4 if pt.len() >= 4
&& pt[0].is_ascii_lowercase() && pt[0].is_ascii_lowercase()
&& pt[1].is_ascii_lowercase() && pt[1].is_ascii_lowercase()

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). //! In-process Arti onion-service helpers (no C-tor).
use std::os::unix::fs::PermissionsExt;
use std::sync::Arc; use std::sync::Arc;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
@ -8,7 +9,7 @@ use arti_client::{TorClient, TorClientConfig};
use futures::StreamExt; use futures::StreamExt;
use safelog::DisplayRedacted; use safelog::DisplayRedacted;
use tor_hsservice::status::State; use tor_hsservice::status::State;
use tor_hsservice::{HsNickname, OnionServiceConfig, RunningOnionService}; use tor_hsservice::{HsId, HsNickname, OnionServiceConfig, RunningOnionService};
use tor_rtcompat::PreferredRuntime; use tor_rtcompat::PreferredRuntime;
pub const HS_PORT: u16 = 80; pub const HS_PORT: u16 = 80;
@ -28,9 +29,24 @@ const PROBE_TIMEOUT: Duration = Duration::from_secs(12);
pub type Client = Arc<TorClient<PreferredRuntime>>; 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 { 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"); mkdir_700(state_dir);
std::fs::create_dir_all(cache_dir).expect("cache dir"); mkdir_700(cache_dir);
let mut builder = TorClientConfigBuilder::from_directories(state_dir, cache_dir); let mut builder = TorClientConfigBuilder::from_directories(state_dir, cache_dir);
builder.storage().permissions().dangerously_trust_everyone(); builder.storage().permissions().dangerously_trust_everyone();
builder builder
@ -84,8 +100,8 @@ pub async fn wait_until_published(
client: &Client, client: &Client,
svc: &RunningOnionService, svc: &RunningOnionService,
onion: &str, onion: &str,
label: &str,
) -> Result<(), String> { ) -> Result<(), String> {
let label = log_label(onion);
let deadline = Instant::now() + PUBLISH_WAIT; let deadline = Instant::now() + PUBLISH_WAIT;
let mut events = svc.status_events(); let mut events = svc.status_events();
let mut next_probe = Instant::now() + PROBE_EVERY; let mut next_probe = Instant::now() + PROBE_EVERY;

View file

@ -1,5 +1,6 @@
pub mod backup; pub mod backup;
pub mod dispatch; pub mod dispatch;
pub mod file;
pub mod frame; pub mod frame;
pub mod hs; pub mod hs;
pub mod loc; pub mod loc;
@ -13,4 +14,7 @@ mod store;
pub mod tui; pub mod tui;
pub mod wallet; pub mod wallet;
pub use store::{Friend, FriendProfile, Message, Payment, PaymentWrite, SelfIdentity, Store}; pub use store::{
Friend, FriendProfile, Message, Payment, PaymentWrite, SelfIdentity, Store,
resolve_store_passphrase,
};

View file

@ -1,5 +1,4 @@
use onionwire::tui::AppExit; use onionwire::tui::AppExit;
use std::io::{self, Write};
fn print_help() { fn print_help() {
let v = env!("CARGO_PKG_VERSION"); let v = env!("CARGO_PKG_VERSION");
@ -35,7 +34,8 @@ async fn main() {
async fn boot() -> Result<(), String> { async fn boot() -> Result<(), String> {
let home = onionwire::Store::home_dir().map_err(|e| e.to_string())?; let home = onionwire::Store::home_dir().map_err(|e| e.to_string())?;
let pass = store_passphrase()?; let pass = onionwire::resolve_store_passphrase(|p| rpassword::prompt_password(p))
.map_err(|e| e.to_string())?;
eprintln!("onionwire: bootstrapping Arti…"); eprintln!("onionwire: bootstrapping Arti…");
let node = onionwire::node::Node::start_with_passphrase(home.clone(), &pass).await?; let node = onionwire::node::Node::start_with_passphrase(home.clone(), &pass).await?;
let handle = tokio::runtime::Handle::current(); let handle = tokio::runtime::Handle::current();
@ -48,22 +48,3 @@ async fn boot() -> Result<(), String> {
} }
Ok(()) Ok(())
} }
fn store_passphrase() -> Result<String, String> {
match std::env::var("ONIONWIRE_STORE_PASSPHRASE") {
Ok(p) if p.is_empty() => Err("empty passphrase".into()),
Ok(p) => Ok(p),
Err(_) => {
eprint!("onionwire: store passphrase: ");
let _ = io::stderr().flush();
let mut s = String::new();
io::stdin().read_line(&mut s).map_err(|e| e.to_string())?;
let s = s.trim_end_matches(['\n', '\r']).to_string();
if s.is_empty() {
Err("empty passphrase".into())
} else {
Ok(s)
}
}
}
}

View file

@ -10,6 +10,7 @@ use tor_cell::relaycell::msg::Connected;
use tor_hsservice::{RunningOnionService, handle_rend_requests}; use tor_hsservice::{RunningOnionService, handle_rend_requests};
use crate::dispatch::{self, Kind}; use crate::dispatch::{self, Kind};
use crate::file;
use crate::frame; use crate::frame;
use crate::hs::{self, Client, HS_PORT}; use crate::hs::{self, Client, HS_PORT};
use crate::loc; use crate::loc;
@ -46,6 +47,7 @@ pub struct Node {
keys: Mutex<Keys>, keys: Mutex<Keys>,
wallet: Wallet, wallet: Wallet,
incoming_limit: Mutex<TokenBucket>, incoming_limit: Mutex<TokenBucket>,
inbox: Mutex<file::Inbox>,
} }
impl Node { impl Node {
@ -75,6 +77,7 @@ impl Node {
.ok_or_else(|| "onion service disabled in config — fail closed".to_string())?; .ok_or_else(|| "onion service disabled in config — fail closed".to_string())?;
let (svc, rend) = launched; let (svc, rend) = launched;
let onion = hs::onion_string(&svc)?; let onion = hs::onion_string(&svc)?;
let inbox = file::Inbox::new(home.join("inbox"));
let node = Arc::new(Self { let node = Arc::new(Self {
home, home,
store: Mutex::new(store), store: Mutex::new(store),
@ -84,6 +87,7 @@ impl Node {
keys: Mutex::new(keys), keys: Mutex::new(keys),
wallet: Wallet::from_env(), wallet: Wallet::from_env(),
incoming_limit: Mutex::new(TokenBucket::default()), incoming_limit: Mutex::new(TokenBucket::default()),
inbox: Mutex::new(inbox),
}); });
// Accept rens before waiting so a reachability probe can succeed // Accept rens before waiting so a reachability probe can succeed
// while combined status is still Bootstrapping. // while combined status is still Bootstrapping.
@ -92,7 +96,7 @@ impl Node {
_svc: Arc::clone(&svc), _svc: Arc::clone(&svc),
rend, rend,
}); });
hs::wait_until_published(&node.client, &svc, &onion, &onion).await?; hs::wait_until_published(&node.client, &svc, &onion).await?;
node.store node.store
.lock() .lock()
.map_err(|e| e.to_string())? .map_err(|e| e.to_string())?
@ -201,6 +205,24 @@ impl Node {
.map_err(|e| e.to_string()) .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> { pub fn friend(&self, pubkey: &[u8]) -> Result<Friend, String> {
self.store self.store
.lock() .lock()
@ -426,7 +448,7 @@ impl Node {
_svc: Arc::clone(&svc), _svc: Arc::clone(&svc),
rend, rend,
}); });
hs::wait_until_published(&self.client, &svc, &onion, &onion).await?; hs::wait_until_published(&self.client, &svc, &onion).await?;
{ {
let store = self.store.lock().map_err(|e| e.to_string())?; let store = self.store.lock().map_err(|e| e.to_string())?;
store.set_onion(&onion).map_err(|e| e.to_string())?; store.set_onion(&onion).map_err(|e| e.to_string())?;
@ -463,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> { pub async fn send(&self, friend_pk: &[u8], plaintext: &[u8]) -> Result<(), String> {
let (onion, prekey) = { let (onion, prekey) = {
let store = self.store.lock().map_err(|e| e.to_string())?; let store = self.store.lock().map_err(|e| e.to_string())?;
@ -602,10 +643,41 @@ impl Node {
} }
Kind::Invoice => self.ingest_invoice(&sess.peer_identity, &pt).await, Kind::Invoice => self.ingest_invoice(&sess.peer_identity, &pt).await,
Kind::Receipt => self.ingest_receipt(&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(()), 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> { async fn ingest_invoice(&self, peer: &[u8], pt: &[u8]) -> Result<(), String> {
let Some(inv) = pay::decode_invoice(pt) else { let Some(inv) = pay::decode_invoice(pt) else {
return Ok(()); return Ok(());

View file

@ -1,4 +1,5 @@
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
use sha3::{Digest, Keccak256};
pub type Result<T> = std::result::Result<T, Error>; pub type Result<T> = std::result::Result<T, Error>;
@ -35,19 +36,72 @@ const INV_PREFIX: &[u8] = b"inv ";
const RCP_PREFIX: &[u8] = b"rcp "; const RCP_PREFIX: &[u8] = b"rcp ";
const PICONERO: u128 = 1_000_000_000_000; 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<()> { pub fn check_address(addr: &str) -> Result<()> {
let ok = match addr.as_bytes().first() { let raw = decode_monero_b58(addr).ok_or_else(|| Error("invalid Monero address".into()))?;
Some(b'4') if addr.len() == 95 || addr.len() == 106 => true, if raw.len() != 69 && raw.len() != 77 {
Some(b'8') if addr.len() == 95 => true, 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, _ => false,
}; };
if ok && !addr.contains('\n') { if ok {
Ok(()) Ok(())
} else { } else {
Err(Error("invalid Monero address".into())) 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> { pub fn parse_atomic(s: &str) -> Result<u128> {
if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) { if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) {
return Err(Error("amount must be decimal piconero".into())); return Err(Error("amount must be decimal piconero".into()));

View file

@ -125,6 +125,9 @@ fn check_fields(display_name: &str, bio: &str, xmr_addr: &str) -> Result<()> {
if display_name.contains('\n') || bio.contains('\n') || xmr_addr.contains('\n') { if display_name.contains('\n') || bio.contains('\n') || xmr_addr.contains('\n') {
return Err(Error("profile fields must not contain newlines".into())); 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(()) Ok(())
} }

View file

@ -20,30 +20,51 @@ pub struct QrPayload {
pub signed_prekey: Vec<u8>, 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> { 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 let sk_bytes: [u8; 32] = identity_sk
.try_into() .try_into()
.map_err(|_| Error("identity secret key must be 32 bytes".into()))?; .map_err(|_| Error("identity secret key must be 32 bytes".into()))?;
let sk = SigningKey::from_bytes(&sk_bytes); let sk = SigningKey::from_bytes(&sk_bytes);
let k = to_hex(&sk.verifying_key().to_bytes()); let k = to_hex(&sk.verifying_key().to_bytes());
let spk = to_hex(signed_prekey); 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()); let sig = to_hex(&sk.sign(&msg).to_bytes());
Ok(format!("onionwire:v1:k={k}:o={onion}:spk={spk}:sig={sig}")) Ok(format!("onionwire:v1:k={k}:o={onion}:spk={spk}:sig={sig}"))
} }
pub fn decode(raw: &str) -> Result<QrPayload> { 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)?; 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 pubkey = from_hex(&k)?;
let signed_prekey = from_hex(&spk)?; let signed_prekey = from_hex(&spk)?;
let sig_bytes = from_hex(&sig)?; 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 let pk_arr: [u8; 32] = pubkey
.as_slice() .as_slice()
.try_into() .try_into()
@ -54,9 +75,15 @@ pub fn decode(raw: &str) -> Result<QrPayload> {
.map_err(|_| Error("sig must be 64 bytes".into()))?; .map_err(|_| Error("sig must be 64 bytes".into()))?;
let vk = VerifyingKey::from_bytes(&pk_arr).map_err(|e| Error(e.to_string()))?; let vk = VerifyingKey::from_bytes(&pk_arr).map_err(|e| Error(e.to_string()))?;
let signature = Signature::from_bytes(&sig_arr); let signature = Signature::from_bytes(&sig_arr);
let msg = sign_msg(&k, &onion, &spk); let ok_v2 = vk
vk.verify(&msg, &signature) .verify(&sign_msg_v2(&k, &onion, &spk), &signature)
.map_err(|_| Error("bad signature".into()))?; .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 { Ok(QrPayload {
pubkey, pubkey,
onion, onion,
@ -64,7 +91,30 @@ pub fn decode(raw: &str) -> Result<QrPayload> {
}) })
} }
fn sign_msg(k: &str, onion: &str, spk: &str) -> Vec<u8> { 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 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()); let mut msg = Vec::with_capacity(k.len() + onion.len() + spk.len());
msg.extend_from_slice(k.as_bytes()); msg.extend_from_slice(k.as_bytes());
msg.extend_from_slice(onion.as_bytes()); msg.extend_from_slice(onion.as_bytes());
@ -118,7 +168,7 @@ fn to_hex(bytes: &[u8]) -> String {
} }
fn from_hex(s: &str) -> Result<Vec<u8>> { 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())); return Err(Error("invalid hex".into()));
} }
if !s.bytes().all(|c| c.is_ascii_hexdigit()) { if !s.bytes().all(|c| c.is_ascii_hexdigit()) {
@ -129,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()))) .map(|i| u8::from_str_radix(&s[i..i + 2], 16).map_err(|_| Error("invalid hex".into())))
.collect() .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]);
}
}

View file

@ -117,12 +117,16 @@ impl Store {
} }
mkdir_700(home)?; mkdir_700(home)?;
mkdir_700(&home.join("arti"))?; mkdir_700(&home.join("arti"))?;
mkdir_700(&home.join("cache"))?;
let db_path = home.join("onionwire.db"); let db_path = home.join("onionwire.db");
let conn = Connection::open(&db_path)?; let conn = Connection::open(&db_path)?;
let journal: String = conn.query_row("PRAGMA journal_mode = WAL", [], |row| row.get(0))?; let journal: String = conn.query_row("PRAGMA journal_mode = WAL", [], |row| row.get(0))?;
if !journal.eq_ignore_ascii_case("wal") { if !journal.eq_ignore_ascii_case("wal") {
return Err(Error(format!("journal_mode WAL failed: {journal}"))); 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( conn.execute_batch(
" "
PRAGMA foreign_keys = ON; PRAGMA foreign_keys = ON;
@ -270,12 +274,13 @@ impl Store {
match row { match row {
Some((salt, wrapped)) => { Some((salt, wrapped)) => {
let wrap_key = crate::backup::kdf(passphrase, &salt)?; let wrap_key = crate::backup::kdf(passphrase, &salt)?;
let raw = crate::backup::aead_decrypt(&wrap_key, &wrapped) let raw = crate::backup::aead_decrypt(&wrap_key, &wrapped, b"")
.map_err(|_| Error("wrong passphrase".into()))?; .map_err(|_| Error("wrong passphrase".into()))?;
if raw.len() != 32 { if raw.len() != 32 {
return Err(Error("wrapped message key length".into())); return Err(Error("wrapped message key length".into()));
} }
self.msg_key.copy_from_slice(&raw); self.msg_key.copy_from_slice(&raw);
self.rewrap_empty_aad_messages()?;
Ok(()) Ok(())
} }
None => { None => {
@ -284,7 +289,7 @@ impl Store {
let mut salt = [0u8; 16]; let mut salt = [0u8; 16];
OsRng.fill_bytes(&mut salt); OsRng.fill_bytes(&mut salt);
let wrap_key = crate::backup::kdf(passphrase, &salt)?; let wrap_key = crate::backup::kdf(passphrase, &salt)?;
let wrapped = crate::backup::aead_encrypt(&wrap_key, &data_key)?; let wrapped = crate::backup::aead_encrypt(&wrap_key, &data_key, b"")?;
self.conn.execute( self.conn.execute(
"INSERT INTO store_meta (id, kdf_salt, wrapped_key) VALUES (1, ?1, ?2)", "INSERT INTO store_meta (id, kdf_salt, wrapped_key) VALUES (1, ?1, ?2)",
params![salt.as_slice(), wrapped], params![salt.as_slice(), wrapped],
@ -297,17 +302,59 @@ impl Store {
} }
fn reencrypt_legacy_messages(&self) -> Result<()> { fn reencrypt_legacy_messages(&self) -> Result<()> {
let mut stmt = self.conn.prepare("SELECT id, plaintext FROM messages")?; let mut stmt = self
let rows: Vec<(i64, Vec<u8>)> = stmt .conn
.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))? .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<_, _>>()?; .collect::<std::result::Result<_, _>>()?;
drop(stmt); drop(stmt);
if rows.is_empty() { if rows.is_empty() {
return Ok(()); return Ok(());
} }
let tx = self.conn.unchecked_transaction()?; let tx = self.conn.unchecked_transaction()?;
for (id, plain) in rows { for (id, friend_id, dir, plain) in rows {
let blob = crate::backup::aead_encrypt(&self.msg_key, &plain)?; 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( tx.execute(
"UPDATE messages SET plaintext = ?1 WHERE id = ?2", "UPDATE messages SET plaintext = ?1 WHERE id = ?2",
params![blob, id], params![blob, id],
@ -385,6 +432,23 @@ impl Store {
.map_err(Into::into) .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<()> { pub fn upsert_friend(&self, pubkey: &[u8], onion: &str, petname: Option<&str>) -> Result<()> {
let now = unix_now(); let now = unix_now();
let fp = fingerprint(pubkey); let fp = fingerprint(pubkey);
@ -646,42 +710,59 @@ impl Store {
|row| row.get(0), |row| row.get(0),
) )
.map_err(|_| Error("friend not found".into()))?; .map_err(|_| Error("friend not found".into()))?;
let blob = crate::backup::aead_encrypt(&self.msg_key, plaintext)?; let tx = self.conn.unchecked_transaction()?;
self.conn.execute( tx.execute(
"INSERT INTO messages (friend_id, dir, plaintext, created_at) VALUES (?1, ?2, ?3, ?4)", "INSERT INTO messages (friend_id, dir, plaintext, created_at) VALUES (?1, ?2, x'', ?3)",
params![friend_id, dir, blob, unix_now()], 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(()) Ok(())
} }
pub fn list_messages(&self, friend_pk: &[u8]) -> Result<Vec<Message>> { pub fn list_messages(&self, friend_pk: &[u8]) -> Result<Vec<Message>> {
let mut stmt = self.conn.prepare( 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 JOIN friends f ON f.id = m.friend_id
WHERE f.pubkey = ?1 WHERE f.pubkey = ?1
ORDER BY m.id", ORDER BY m.id",
)?; )?;
let rows = stmt.query_map(params![friend_pk], |row| { let rows = stmt.query_map(params![friend_pk], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, Vec<u8>>(1)?)) Ok((
row.get::<_, i64>(0)?,
row.get::<_, i64>(1)?,
row.get::<_, String>(2)?,
row.get::<_, Vec<u8>>(3)?,
))
})?; })?;
let mut out = Vec::new(); let mut out = Vec::new();
for row in rows { for row in rows {
let (dir, blob) = row?; let (id, friend_id, dir, blob) = row?;
let plaintext = crate::backup::aead_decrypt(&self.msg_key, &blob) 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()))?; .map_err(|_| Error("message decrypt failed".into()))?;
out.push(Message { dir, plaintext }); out.push(Message { dir, plaintext });
} }
Ok(out) 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<()> { pub fn wipe_messages(&self) -> Result<()> {
self.conn.execute( self.conn.execute(
"UPDATE messages SET plaintext = zeroblob(length(plaintext))", "UPDATE messages SET plaintext = zeroblob(length(plaintext))",
[], [],
)?; )?;
self.conn.execute("DELETE FROM messages", [])?; self.conn.execute("DELETE FROM messages", [])?;
self.conn.execute("DELETE FROM payments", [])?;
self.conn.execute_batch("VACUUM")?; self.conn.execute_batch("VACUUM")?;
self.conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE)")?;
Ok(()) Ok(())
} }
@ -750,6 +831,26 @@ fn passphrase_from_env() -> Result<String> {
} }
} }
/// 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<()> { fn mkdir_700(path: &Path) -> Result<()> {
fs::create_dir_all(path)?; fs::create_dir_all(path)?;
let mut perms = fs::metadata(path)?.permissions(); let mut perms = fs::metadata(path)?.permissions();
@ -758,6 +859,17 @@ fn mkdir_700(path: &Path) -> Result<()> {
Ok(()) 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 { fn unix_now() -> i64 {
SystemTime::now() SystemTime::now()
.duration_since(UNIX_EPOCH) .duration_since(UNIX_EPOCH)
@ -773,6 +885,148 @@ fn fingerprint(pubkey: &[u8]) -> String {
mod at_rest { mod at_rest {
use super::*; 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] #[test]
fn first_unlock_reencrypts_legacy_plaintext_rows() { fn first_unlock_reencrypts_legacy_plaintext_rows() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();

View file

@ -68,7 +68,7 @@ OnionWire keys\n\
1 / 2 / 3 focus roster / chat / composer\n\ 1 / 2 / 3 focus roster / chat / composer\n\
j k or Up Down move or scroll focused pane\n\ j k or Up Down move or scroll focused pane\n\
g / G jump to top / bottom\n\ g / G jump to top / bottom\n\
Enter /wipe /wipe-all /profile /who /pay /tip\n\ Enter send chat to selected friend, or a /command\n\
Esc close overlay, go back, clear composer\n\ Esc close overlay, go back, clear composer\n\
F2 share invite\n\ F2 share invite\n\
F3 paste invite\n\ F3 paste invite\n\
@ -77,6 +77,7 @@ OnionWire keys\n\
/profile edit name, bio, Monero address\n\ /profile edit name, bio, Monero address\n\
/pay <xmr> [memo] invoice to receive\n\ /pay <xmr> [memo] invoice to receive\n\
/tip <xmr> [memo] pay selected friend\n\ /tip <xmr> [memo] pay selected friend\n\
/file /path send file to selected friend (1 MiB)\n\
/backup /path encrypted identity export\n\ /backup /path encrypted identity export\n\
/restore /path overwrite self keys\n\ /restore /path overwrite self keys\n\
? this help\n\ ? this help\n\
@ -163,6 +164,29 @@ pub enum SlashCmd {
Tip { atomic: String, memo: String }, Tip { atomic: String, memo: String },
Backup { path: String }, Backup { path: String },
Restore { path: String }, Restore { path: String },
File { path: String },
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ComposerAction {
Cmd(SlashCmd),
Send(String),
UnknownSlash(String),
}
/// Enter in the composer: slash command, chat send, or unknown `/cmd`.
pub fn composer_enter(raw: &str) -> Option<ComposerAction> {
let s = raw.trim();
if s.is_empty() {
return None;
}
if let Some(cmd) = parse_cmd(raw) {
return Some(ComposerAction::Cmd(cmd));
}
if s.starts_with('/') {
return Some(ComposerAction::UnknownSlash(s.to_string()));
}
Some(ComposerAction::Send(s.to_string()))
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
@ -284,6 +308,11 @@ pub fn parse_cmd(raw: &str) -> Option<SlashCmd> {
{ {
return parse_path_cmd(rest).map(|path| SlashCmd::Restore { path }); return parse_path_cmd(rest).map(|path| SlashCmd::Restore { path });
} }
if let Some(rest) = s.strip_prefix("/file")
&& (rest.is_empty() || rest.starts_with(char::is_whitespace))
{
return parse_path_cmd(rest).map(|path| SlashCmd::File { path });
}
None None
} }
@ -318,8 +347,9 @@ pub fn parse_slash(raw: &str) -> Option<WipeKind> {
pub fn wipe_screen_text(kind: WipeKind) -> &'static str { pub fn wipe_screen_text(kind: WipeKind) -> &'static str {
match kind { match kind {
WipeKind::Messages => { WipeKind::Messages => {
"Wipe message log?\n\ "Wipe chat and payments history?\n\
Identity key and friends stay.\n\ Identity key and friends stay.\n\
Not a forensic erase.\n\
Type WIPE to confirm Esc to cancel" Type WIPE to confirm Esc to cancel"
} }
WipeKind::All => { WipeKind::All => {
@ -692,6 +722,8 @@ impl App {
let mut open_profile = false; let mut open_profile = false;
let mut pay_cmd = None; let mut pay_cmd = None;
let mut tip_cmd = None; let mut tip_cmd = None;
let mut file_cmd = None;
let mut chat_send = None;
match &mut self.screen { match &mut self.screen {
Screen::Main => match key.code { Screen::Main => match key.code {
KeyCode::Tab => self.focus = self.focus.next(), KeyCode::Tab => self.focus = self.focus.next(),
@ -717,8 +749,8 @@ impl App {
self.composer.pop(); self.composer.pop();
} }
} }
KeyCode::Enter => match parse_cmd(&self.composer) { KeyCode::Enter => match composer_enter(&self.composer) {
Some(SlashCmd::Wipe(kind)) => { Some(ComposerAction::Cmd(SlashCmd::Wipe(kind))) => {
self.composer.clear(); self.composer.clear();
let prompt = match kind { let prompt = match kind {
WipeKind::Messages => WipePrompt::messages(), WipeKind::Messages => WipePrompt::messages(),
@ -726,23 +758,23 @@ impl App {
}; };
self.screen = Screen::Wipe { kind, prompt }; self.screen = Screen::Wipe { kind, prompt };
} }
Some(SlashCmd::Profile) => { Some(ComposerAction::Cmd(SlashCmd::Profile)) => {
self.composer.clear(); self.composer.clear();
open_profile = true; open_profile = true;
} }
Some(SlashCmd::Who) => { Some(ComposerAction::Cmd(SlashCmd::Who)) => {
self.composer.clear(); self.composer.clear();
self.who_open = true; self.who_open = true;
} }
Some(SlashCmd::Pay { atomic, memo }) => { Some(ComposerAction::Cmd(SlashCmd::Pay { atomic, memo })) => {
self.composer.clear(); self.composer.clear();
pay_cmd = Some((atomic, memo)); pay_cmd = Some((atomic, memo));
} }
Some(SlashCmd::Tip { atomic, memo }) => { Some(ComposerAction::Cmd(SlashCmd::Tip { atomic, memo })) => {
self.composer.clear(); self.composer.clear();
tip_cmd = Some((atomic, memo)); tip_cmd = Some((atomic, memo));
} }
Some(SlashCmd::Backup { path }) => { Some(ComposerAction::Cmd(SlashCmd::Backup { path })) => {
self.composer.clear(); self.composer.clear();
self.screen = Screen::ConfirmKeys { self.screen = Screen::ConfirmKeys {
kind: BackupKind::Backup, kind: BackupKind::Backup,
@ -750,7 +782,7 @@ impl App {
prompt: BackupPrompt::backup(), prompt: BackupPrompt::backup(),
}; };
} }
Some(SlashCmd::Restore { path }) => { Some(ComposerAction::Cmd(SlashCmd::Restore { path })) => {
self.composer.clear(); self.composer.clear();
self.screen = Screen::ConfirmKeys { self.screen = Screen::ConfirmKeys {
kind: BackupKind::Restore, kind: BackupKind::Restore,
@ -758,6 +790,18 @@ impl App {
prompt: BackupPrompt::restore(), prompt: BackupPrompt::restore(),
}; };
} }
Some(ComposerAction::Cmd(SlashCmd::File { path })) => {
self.composer.clear();
file_cmd = Some(path);
}
Some(ComposerAction::Send(text)) => {
self.composer.clear();
chat_send = Some(text);
}
Some(ComposerAction::UnknownSlash(cmd)) => {
self.composer.clear();
self.status_note = Some(format!("unknown command {cmd}"));
}
None => {} None => {}
}, },
KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => { KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
@ -911,6 +955,12 @@ impl App {
if let Some((atomic, memo)) = tip_cmd { if let Some((atomic, memo)) = tip_cmd {
self.send_tip(&atomic, &memo)?; self.send_tip(&atomic, &memo)?;
} }
if let Some(path) = file_cmd {
self.send_file(&path)?;
}
if let Some(text) = chat_send {
self.send_chat(&text)?;
}
Ok(None) Ok(None)
} }
@ -1162,6 +1212,36 @@ impl App {
Ok(()) Ok(())
} }
fn send_file(&mut self, path: &str) -> Result<(), String> {
let Some(friend) = self.friends.get(self.selected) else {
self.status_note = Some("no friend selected".into());
return Ok(());
};
let pk = friend.pubkey.clone();
self.status_note = Some("sending".into());
match self
.rt
.block_on(self.node.send_file(&pk, std::path::Path::new(path)))
{
Ok(name) => self.status_note = Some(format!("sent file {name}")),
Err(e) => self.status_note = Some(format!("send failed: {e}")),
}
Ok(())
}
fn send_chat(&mut self, text: &str) -> Result<(), String> {
let Some(friend) = self.friends.get(self.selected) else {
self.status_note = Some("no friend selected".into());
return Ok(());
};
let pk = friend.pubkey.clone();
match self.rt.block_on(self.node.send(&pk, text.as_bytes())) {
Ok(()) => self.status_note = Some("sent".into()),
Err(e) => self.status_note = Some(format!("send failed: {e}")),
}
Ok(())
}
fn who_body(&self) -> String { fn who_body(&self) -> String {
let Some(friend) = self.friends.get(self.selected) else { let Some(friend) = self.friends.get(self.selected) else {
return "no friend selected\n\nEsc closes".into(); return "no friend selected\n\nEsc closes".into();
@ -1293,7 +1373,7 @@ impl App {
f.render_widget(chat, panes[1]); f.render_widget(chat, panes[1]);
let cmd = if self.composer.is_empty() { let cmd = if self.composer.is_empty() {
"/wipe /wipe-all /profile /who /pay /tip".to_string() "/wipe /wipe-all /profile /who /pay /tip /file".to_string()
} else { } else {
self.composer.clone() self.composer.clone()
}; };

View file

@ -1,5 +1,6 @@
use std::time::Duration; use std::time::Duration;
use rand::RngCore;
use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream; use tokio::net::TcpStream;
@ -16,11 +17,25 @@ impl std::fmt::Display for Error {
impl std::error::Error for Error {} impl std::error::Error for Error {}
#[derive(Debug, Clone)] #[derive(Clone)]
struct Endpoint { struct Endpoint {
host: String, host: String,
port: u16, port: u16,
path: String, 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)] #[derive(Debug, Clone)]
@ -44,20 +59,28 @@ impl Wallet {
pub fn from_env() -> Self { pub fn from_env() -> Self {
match std::env::var("ONIONWIRE_WALLET_RPC") { match std::env::var("ONIONWIRE_WALLET_RPC") {
Ok(s) if !s.trim().is_empty() => match Self::from_url(s.trim()) { Ok(s) if !s.trim().is_empty() => {
Ok(w) => w, 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) => { Err(e) => {
eprintln!("ONIONWIRE_WALLET_RPC: {e}"); eprintln!("ONIONWIRE_WALLET_RPC: {e}");
Self::disabled() Self::disabled()
} }
}, }
}
_ => Self::disabled(), _ => Self::disabled(),
} }
} }
pub fn from_url(url: &str) -> Result<Self> { pub fn from_url(url: &str) -> Result<Self> {
Ok(Self { Ok(Self {
endpoint: Some(parse_http_url(url)?), endpoint: Some(parse_http_url(url, None)?),
}) })
} }
@ -126,35 +149,68 @@ impl Wallet {
"params": params, "params": params,
}) })
.to_string(); .to_string();
let host_hdr = host_header(&ep.host, ep.port); let raw = post_timeout(ep, &build_req(ep, &body, None)).await?;
let req = format!( let status = http_status(&raw).unwrap_or(0);
"POST {} HTTP/1.1\r\nHost: {host_hdr}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", if status == 200 {
ep.path, return Err(Error(
body.len() "wallet RPC requires digest auth (open RPC refused)".into(),
); ));
let raw = tokio::time::timeout(RPC_TIMEOUT, http_post(ep, req.as_bytes())) }
.await if status != 401 {
.map_err(|_| Error("wallet RPC timed out".into()))??; 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) parse_json_rpc(&raw)
} }
} }
pub fn transfers_match(rows: &[TransferRow], txid: &str, amount: &str, address: &str) -> bool { pub fn transfers_match(rows: &[TransferRow], txid: &str, amount: &str, address: &str) -> bool {
rows.iter().any(|r| { if txid.is_empty() || amount.is_empty() || address.is_empty() {
r.txid == txid return false;
|| (!address.is_empty() }
&& r.address == address rows.iter()
&& (amount.is_empty() || r.amount == amount)) .any(|r| r.txid == txid && r.amount == amount && r.address == address)
})
} }
fn parse_http_url(url: &str) -> Result<Endpoint> { 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 let rest = url
.strip_prefix("http://") .strip_prefix("http://")
.ok_or_else(|| Error("wallet RPC must be http:// (no TLS)".into()))?; .ok_or_else(|| Error("wallet RPC must be http:// (no TLS)".into()))?;
if rest.contains("://") { if rest.contains("://") {
return Err(Error("wallet RPC must be http:// (no TLS)".into())); 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('/') { let (hostport, path) = match rest.split_once('/') {
Some((hp, p)) => (hp, format!("/{p}")), Some((hp, p)) => (hp, format!("/{p}")),
None => (rest, "/json_rpc".into()), None => (rest, "/json_rpc".into()),
@ -165,10 +221,21 @@ fn parse_http_url(url: &str) -> Result<Endpoint> {
path path
}; };
let (host, port) = parse_hostport(hostport)?; let (host, port) = parse_hostport(hostport)?;
if !allowed_host(&host) { if host.trim().to_ascii_lowercase().ends_with(".onion") {
return Err(Error("wallet RPC host must be loopback or .onion".into())); return Err(Error(
"wallet RPC over .onion is not supported (loopback only; no Arti dial)".into(),
));
} }
Ok(Endpoint { host, port, path }) 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)> { fn parse_hostport(hostport: &str) -> Result<(String, u16)> {
@ -201,9 +268,6 @@ fn parse_port(p: &str) -> Result<u16> {
fn allowed_host(host: &str) -> bool { fn allowed_host(host: &str) -> bool {
let h = host.trim(); let h = host.trim();
if h.to_ascii_lowercase().ends_with(".onion") {
return true;
}
if h.eq_ignore_ascii_case("localhost") { if h.eq_ignore_ascii_case("localhost") {
return true; return true;
} }
@ -220,6 +284,31 @@ fn host_header(host: &str, port: u16) -> String {
} }
} }
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>> { async fn http_post(ep: &Endpoint, req: &[u8]) -> Result<Vec<u8>> {
let mut stream = TcpStream::connect((ep.host.as_str(), ep.port)) let mut stream = TcpStream::connect((ep.host.as_str(), ep.port))
.await .await
@ -229,13 +318,117 @@ async fn http_post(ep: &Endpoint, req: &[u8]) -> Result<Vec<u8>> {
.await .await
.map_err(|e| Error(format!("wallet write: {e}")))?; .map_err(|e| Error(format!("wallet write: {e}")))?;
let mut buf = Vec::new(); let mut buf = Vec::new();
stream let mut tmp = [0u8; 8192];
.read_to_end(&mut buf) loop {
let n = stream
.read(&mut tmp)
.await .await
.map_err(|e| Error(format!("wallet read: {e}")))?; .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) 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> { 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 text = std::str::from_utf8(raw).map_err(|_| Error("wallet RPC not UTF-8".into()))?;
let (head, body) = text let (head, body) = text

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);
}

View file

@ -1,5 +1,6 @@
//! HS publish wait and CBT floor — 180s fail-closed cuts a working HsDir upload. //! 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 std::time::Duration;
use onionwire::hs; use onionwire::hs;
@ -67,3 +68,30 @@ fn broken_or_shutdown_never_ready() {
assert!(!hs::hs_is_ready(State::Broken, true)); assert!(!hs::hs_is_ready(State::Broken, true));
assert!(!hs::hs_is_ready(State::Shutdown, 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);
}

View file

@ -12,16 +12,20 @@ fn store() -> (tempfile::TempDir, Store) {
(dir, store) (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 { fn addr_std() -> String {
format!("4{}", "A".repeat(94)) MAINNET_STD.to_string()
} }
fn addr_sub() -> String { fn addr_sub() -> String {
format!("8{}", "B".repeat(94)) MAINNET_SUB.to_string()
}
fn addr_integrated() -> String {
format!("4{}", "C".repeat(105))
} }
fn payw<'a>( fn payw<'a>(
@ -46,12 +50,20 @@ fn payw<'a>(
#[test] #[test]
fn valid_and_invalid_xmr_addresses() { fn valid_and_invalid_xmr_addresses() {
assert!(pay::check_address(&addr_std()).is_ok()); assert!(pay::check_address(MAINNET_STD).is_ok());
assert!(pay::check_address(&addr_sub()).is_ok()); assert!(pay::check_address(MAINNET_SUB).is_ok());
assert!(pay::check_address(&addr_integrated()).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!("4{}", "A".repeat(93))).is_err());
assert!(pay::check_address(&format!("8{}", "B".repeat(95))).is_err()); assert!(pay::check_address(&format!("8{}", "B".repeat(95))).is_err());
assert!(pay::check_address(&format!("5{}", "A".repeat(94))).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("").is_err());
assert!(pay::check_address("not-an-address").is_err()); assert!(pay::check_address("not-an-address").is_err());
} }

View file

@ -108,21 +108,33 @@ fn chat_is_not_profile() {
assert!(profile::decode(b"hello wire").is_none()); assert!(profile::decode(b"hello wire").is_none());
} }
const MAINNET_STD: &str = "4AdUndXHHZ6cfufTMvppY6JwXNouMBzSkbLYfpAV5Usx3skxNgYeYTRj5UzqtReoS44qo9mtmXCqY45DJ852K5Jv2684Rge";
#[test] #[test]
fn self_profile_roundtrip() { fn self_profile_roundtrip() {
let (_dir, store) = store(); let (_dir, store) = store();
store.set_self_profile("me", "a bio", "4abc").unwrap(); store.set_self_profile("me", "a bio", MAINNET_STD).unwrap();
let got = store.self_profile().unwrap(); let got = store.self_profile().unwrap();
assert_eq!(got.display_name, "me"); assert_eq!(got.display_name, "me");
assert_eq!(got.bio, "a bio"); assert_eq!(got.bio, "a bio");
assert_eq!(got.xmr_addr, "4abc"); assert_eq!(got.xmr_addr, MAINNET_STD);
assert!(got.updated_at > 0); assert!(got.updated_at > 0);
store.set_self_profile("", "", "").unwrap(); store.set_self_profile("", "", "").unwrap();
let empty = store.self_profile().unwrap(); let empty = store.self_profile().unwrap();
assert_eq!(empty.display_name, ""); assert_eq!(empty.display_name, "");
assert_eq!(empty.xmr_addr, "");
assert!(empty.updated_at >= got.updated_at); 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] #[test]
fn slash_profile_who_and_wipe_still_parse() { fn slash_profile_who_and_wipe_still_parse() {
assert_eq!(parse_cmd("/profile"), Some(SlashCmd::Profile)); assert_eq!(parse_cmd("/profile"), Some(SlashCmd::Profile));

View file

@ -2,8 +2,8 @@
use std::sync::{Mutex, MutexGuard}; use std::sync::{Mutex, MutexGuard};
use onionwire::Store;
use onionwire::qr; use onionwire::qr;
use onionwire::Store;
static ENV_LOCK: Mutex<()> = Mutex::new(()); static ENV_LOCK: Mutex<()> = Mutex::new(());
@ -15,7 +15,7 @@ struct TempHome {
impl TempHome { impl TempHome {
fn new() -> Self { fn new() -> Self {
let dir = tempfile::tempdir().expect("tempdir"); 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 { unsafe {
std::env::set_var("ONIONWIRE_HOME", dir.path()); std::env::set_var("ONIONWIRE_HOME", dir.path());
std::env::set_var("ONIONWIRE_STORE_PASSPHRASE", "onionwire-test"); std::env::set_var("ONIONWIRE_STORE_PASSPHRASE", "onionwire-test");
@ -37,7 +37,37 @@ impl Drop for TempHome {
} }
fn v3onion(tag: &str) -> String { 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] #[test]
@ -119,3 +149,73 @@ fn unknown_k_decode_does_not_insert() {
let _p = qr::decode(&raw).unwrap(); let _p = qr::decode(&raw).unwrap();
assert_eq!(store.friend_count().unwrap(), 0); 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"
);
}

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

@ -55,11 +55,14 @@ fn first_run_creates_0700_dirs_and_self_row() {
let store = Store::open().expect("open"); let store = Store::open().expect("open");
let arti = home.path().join("arti"); let arti = home.path().join("arti");
let cache = home.path().join("cache");
let db = home.path().join("onionwire.db"); let db = home.path().join("onionwire.db");
assert!(arti.is_dir(), "arti dir"); assert!(arti.is_dir(), "arti dir");
assert!(cache.is_dir(), "cache dir");
assert!(db.is_file(), "onionwire.db"); assert!(db.is_file(), "onionwire.db");
assert_eq!(mode(home.path()), 0o700); assert_eq!(mode(home.path()), 0o700);
assert_eq!(mode(&arti), 0o700); assert_eq!(mode(&arti), 0o700);
assert_eq!(mode(&cache), 0o700);
let me = store.self_identity().expect("self"); let me = store.self_identity().expect("self");
assert_eq!(me.identity_pk.len(), 32); assert_eq!(me.identity_pk.len(), 32);
@ -261,3 +264,64 @@ fn reopen_with_same_passphrase_decrypts() {
let msgs = store.list_messages(&pk(1)).unwrap(); let msgs = store.list_messages(&pk(1)).unwrap();
assert_eq!(msgs[0].plaintext, b"hello again"); 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

@ -124,10 +124,10 @@ async fn two_node_byte_pipe_restart_and_dormant() {
eprintln!("bob onion={bob_onion}"); eprintln!("bob onion={bob_onion}");
assert_ne!(alice_onion, bob_onion, "separate HS identities"); assert_ne!(alice_onion, bob_onion, "separate HS identities");
hs::wait_until_published(&alice, &alice_svc, &alice_onion, "alice") hs::wait_until_published(&alice, &alice_svc, &alice_onion)
.await .await
.expect("alice publish"); .expect("alice publish");
hs::wait_until_published(&bob, &bob_svc, &bob_onion, "bob") hs::wait_until_published(&bob, &bob_svc, &bob_onion)
.await .await
.expect("bob publish"); .expect("bob publish");

View file

@ -2,8 +2,8 @@
use onionwire::qr; use onionwire::qr;
use onionwire::tui::{ use onionwire::tui::{
banner_for_width, compact_banner, draw_share, help_overlay_text, main_footer_hints, Pane, banner_for_width, compact_banner, draw_share, help_overlay_text, main_footer_hints,
onion_glyph, status_footer, wordmark_banner, Pane, onion_glyph, status_footer, wordmark_banner,
}; };
#[test] #[test]
@ -40,7 +40,19 @@ fn help_overlay_lists_core_bindings() {
let help = help_overlay_text(); let help = help_overlay_text();
assert!(!help.is_empty()); assert!(!help.is_empty());
for needle in [ for needle in [
"Tab", "F2", "F3", "F4", "F5", "/profile", "/pay", "/tip", "/backup", "/restore", "Ctrl-Q", "Tab",
"F2",
"F3",
"F4",
"F5",
"send chat",
"/profile",
"/pay",
"/tip",
"/file",
"/backup",
"/restore",
"Ctrl-Q",
"?", "?",
] { ] {
assert!(help.contains(needle), "help overlay missing {needle:?}"); assert!(help.contains(needle), "help overlay missing {needle:?}");
@ -65,13 +77,13 @@ fn banner_for_width_collapses_when_narrow() {
#[test] #[test]
fn chrome_renders_at_80x24_and_120x40() { fn chrome_renders_at_80x24_and_120x40() {
use ratatui::Terminal;
use ratatui::backend::TestBackend; use ratatui::backend::TestBackend;
use ratatui::layout::{Constraint, Layout}; use ratatui::layout::{Constraint, Layout};
use ratatui::widgets::Paragraph; use ratatui::widgets::Paragraph;
use ratatui::Terminal;
let fp = "abcdef0123456789"; let fp = "abcdef0123456789";
let onion = "abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopq.onion"; let onion = "abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopqrstuvwx.onion";
for (w, h) in [(80u16, 24u16), (120, 40)] { for (w, h) in [(80u16, 24u16), (120, 40)] {
let backend = TestBackend::new(w, h); let backend = TestBackend::new(w, h);
let mut terminal = Terminal::new(backend).expect("terminal"); let mut terminal = Terminal::new(backend).expect("terminal");
@ -113,7 +125,7 @@ fn chrome_renders_at_80x24_and_120x40() {
#[test] #[test]
fn main_footer_keeps_help_at_80_and_120() { fn main_footer_keeps_help_at_80_and_120() {
let fp = "abcdef0123456789deadbeef"; let fp = "abcdef0123456789deadbeef";
let onion = "abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopq.onion"; let onion = "abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopqrstuvwx.onion";
let hints = main_footer_hints(); let hints = main_footer_hints();
assert!(hints.contains("? help")); assert!(hints.contains("? help"));
for w in [80u16, 120] { for w in [80u16, 120] {
@ -166,11 +178,11 @@ fn restore_terminal_is_callable_without_panic() {
#[test] #[test]
fn share_screen_shows_invite_not_qr_at_80x24() { fn share_screen_shows_invite_not_qr_at_80x24() {
use ratatui::backend::TestBackend;
use ratatui::Terminal; use ratatui::Terminal;
use ratatui::backend::TestBackend;
let sk = [7u8; 32]; let sk = [7u8; 32];
let onion = "abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopq.onion"; let onion = "abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopqrstuvwx.onion";
let payload = qr::encode(&sk, onion, &[1u8; 32]).expect("encode"); let payload = qr::encode(&sk, onion, &[1u8; 32]).expect("encode");
assert!(payload.starts_with("onionwire:v1:")); assert!(payload.starts_with("onionwire:v1:"));

View file

@ -1,9 +1,27 @@
//! M8: optional monero-wallet-rpc JSON client. Mock TCP only — no live monerod. //! M8: optional monero-wallet-rpc JSON client. Mock TCP only — no live monerod.
use onionwire::wallet::{self, Wallet}; 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::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener; 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 { fn json_rpc_ok(result: &str) -> String {
let body = format!(r#"{{"jsonrpc":"2.0","id":"0","result":{result}}}"#); let body = format!(r#"{{"jsonrpc":"2.0","id":"0","result":{result}}}"#);
format!( format!(
@ -12,13 +30,6 @@ fn json_rpc_ok(result: &str) -> String {
) )
} }
async fn serve_once(listener: TcpListener, response: String) {
let (mut sock, _) = listener.accept().await.expect("accept");
let mut buf = vec![0u8; 4096];
let _ = sock.read(&mut buf).await;
sock.write_all(response.as_bytes()).await.expect("write");
}
#[test] #[test]
fn disabled_create_address_is_not_configured() { fn disabled_create_address_is_not_configured() {
let w = Wallet::disabled(); let w = Wallet::disabled();
@ -31,9 +42,19 @@ fn disabled_create_address_is_not_configured() {
assert!(err.to_string().contains("not configured"), "got {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] #[test]
fn refuse_non_loopback_non_onion_host() { fn refuse_non_loopback_non_onion_host() {
let err = Wallet::from_url("http://example.com:18083").unwrap_err(); let err = Wallet::from_url("http://ow:secret@example.com:18083").unwrap_err();
assert!( assert!(
err.to_string().to_ascii_lowercase().contains("host") err.to_string().to_ascii_lowercase().contains("host")
|| err.to_string().contains("loopback") || err.to_string().contains("loopback")
@ -42,6 +63,34 @@ fn refuse_non_loopback_non_onion_host() {
); );
} }
#[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] #[tokio::test]
async fn mock_get_address_parses_string() { async fn mock_get_address_parses_string() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
@ -49,8 +98,8 @@ async fn mock_get_address_parses_string() {
let canned = json_rpc_ok( let canned = json_rpc_ok(
r#"{"address":"4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"}"#, r#"{"address":"4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"}"#,
); );
tokio::spawn(serve_once(listener, canned)); tokio::spawn(serve_digest_then(listener, canned));
let w = Wallet::from_url(&format!("http://127.0.0.1:{}", addr.port())).unwrap(); 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"); let got = w.get_address().await.expect("get_address");
assert!(got.starts_with('4'), "got {got}"); assert!(got.starts_with('4'), "got {got}");
} }
@ -62,15 +111,15 @@ async fn mock_create_address_and_transfer() {
let canned = json_rpc_ok( let canned = json_rpc_ok(
r#"{"address":"8BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB","tx_hash":"abc123"}"#, r#"{"address":"8BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB","tx_hash":"abc123"}"#,
); );
tokio::spawn(serve_once(listener, canned.clone())); tokio::spawn(serve_digest_then(listener, canned.clone()));
let w = Wallet::from_url(&format!("http://127.0.0.1:{}", addr.port())).unwrap(); 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"); let created = w.create_address().await.expect("create_address");
assert!(created.starts_with('8')); assert!(created.starts_with('8'));
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap(); let addr = listener.local_addr().unwrap();
tokio::spawn(serve_once(listener, canned)); tokio::spawn(serve_digest_then(listener, canned));
let w = Wallet::from_url(&format!("http://127.0.0.1:{}", addr.port())).unwrap(); let w = Wallet::from_url(&format!("http://ow:secret@127.0.0.1:{}", addr.port())).unwrap();
let txid = w let txid = w
.transfer("8BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", 1) .transfer("8BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", 1)
.await .await
@ -85,8 +134,8 @@ async fn mock_get_transfers_matches_txid() {
let canned = json_rpc_ok( let canned = json_rpc_ok(
r#"{"in":[{"txid":"deadbeef","amount":5,"address":"8BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"}],"pending":[]}"#, r#"{"in":[{"txid":"deadbeef","amount":5,"address":"8BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"}],"pending":[]}"#,
); );
tokio::spawn(serve_once(listener, canned)); tokio::spawn(serve_digest_then(listener, canned));
let w = Wallet::from_url(&format!("http://127.0.0.1:{}", addr.port())).unwrap(); 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"); let rows = w.get_transfers().await.expect("get_transfers");
assert!(wallet::transfers_match( assert!(wallet::transfers_match(
&rows, &rows,
@ -96,3 +145,135 @@ async fn mock_get_transfers_matches_txid() {
)); ));
assert!(!wallet::transfers_match(&rows, "nope", "1", "nope")); 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,10 +1,13 @@
//! M5: wipe messages (keep identity + friends); wipe-all is a new person. //! M5: wipe messages (keep identity + friends); wipe-all is a new person.
use onionwire::Store;
use onionwire::tui::{ use onionwire::tui::{
QuitDecision, QuitPrompt, WipeDecision, WipeKind, WipePrompt, parse_slash, quit_screen_text, parse_slash, quit_screen_text, wipe_screen_text, QuitDecision, QuitPrompt, WipeDecision,
wipe_screen_text, 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] { fn pk(tag: u8) -> [u8; 32] {
let mut k = [0u8; 32]; let mut k = [0u8; 32];
@ -50,6 +53,53 @@ 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] #[test]
fn wipe_all_removes_dir_so_next_open_is_new_identity() { fn wipe_all_removes_dir_so_next_open_is_new_identity() {
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
@ -108,8 +158,9 @@ fn wipe_all_requires_typing_wipeall() {
#[test] #[test]
fn wipe_prompt_text_matches_spec() { fn wipe_prompt_text_matches_spec() {
let m = wipe_screen_text(WipeKind::Messages); 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("Identity key and friends stay."));
assert!(m.contains("Not a forensic erase."));
assert!(m.contains("Type WIPE to confirm")); assert!(m.contains("Type WIPE to confirm"));
assert!(m.contains("Esc to cancel")); assert!(m.contains("Esc to cancel"));
let a = wipe_screen_text(WipeKind::All); let a = wipe_screen_text(WipeKind::All);