Compare commits

..

No commits in common. "main" and "wt/t_8b24e067" have entirely different histories.

32 changed files with 128 additions and 1594 deletions

View file

@ -8,18 +8,12 @@ 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-${{ github.run_id }} BUILD_CONTAINER: onionwire-ci
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-${{ github.run_id }} BUILD_CONTAINER: onionwire-release-build
jobs: jobs:
aarch64: aarch64:

37
Cargo.lock generated
View file

@ -2359,16 +2359,6 @@ 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"
@ -2629,22 +2619,18 @@ dependencies = [
[[package]] [[package]]
name = "onionwire" name = "onionwire"
version = "0.2.1" version = "0.2.0"
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",
@ -3383,17 +3369,6 @@ 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"
@ -3415,16 +3390,6 @@ 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"

View file

@ -1,6 +1,6 @@
[package] [package]
name = "onionwire" name = "onionwire"
version = "0.2.1" version = "0.2.0"
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."
@ -43,10 +43,6 @@ 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 (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. On start you are prompted for a store passphrase (or set `ONIONWIRE_STORE_PASSPHRASE`). Empty passphrase is rejected; a wrong passphrase does not open chat. Then you should see `onionwire: bootstrapping Arti…` on stderr. Directory bootstrap is usually under a minute; the onion is ready once a probe connect works (combined Arti status may still say Bootstrapping). Fail closed at 360s. Data lives in `ONIONWIRE_HOME` if set, otherwise `~/.local/share/onionwire/` (`onionwire.db` + Arti state, mode 0700). First open creates an ed25519 identity key. That key **is** you.
Then `F2` to share your invite, `F3` to paste a friends. Mouse-select the `onionwire:v1:…` line to copy. Highlight them in the roster, type in the composer, Enter to send. Fail closed: if their onion is down, send fails — no outbox. Then `F2` to share your invite, `F3` to paste a friends. Mouse-select the `onionwire:v1:…` line to copy.
## 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 | Send chat to the selected friend, or run a `/command` | | Enter | Run `/wipe`, `/wipe-all`, `/profile`, `/who`, `/pay`, `/tip`, `/backup`, `/restore` from the composer |
| `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,31 +156,19 @@ 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`. The wallet **must** use `--rpc-login`; OnionWire refuses an open RPC (HTTP 200 without a Digest challenge) and refuses URLs with no credentials. OnionWire is not a wallet. Optional JSON-RPC to a user-hosted `monero-wallet-rpc`:
```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 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. Loopback or `.onion` only, HTTP, 5s timeout. Unset → chat still works; `/pay` and `/tip` say so.
- `/pay <xmr> [memo]` — invoice (we want to receive). Uses a wallet subaddress if RPC is up, else the profile `xmr_addr`. - `/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.
@ -196,14 +184,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 ` / `fil ` 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 ` 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`. 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` — confirm by typing `WIPE`. Overwrites the message log and `VACUUM`s. Identity key and friends stay.
- `/wipe-all` — confirm by typing `WIPEALL`. Deletes the data dir. Next start is a **new person** (new identity key). Same disk caveat. 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). Esc cancels. Nothing is wiped without confirm.
## Uninstall ## Uninstall
@ -227,7 +215,7 @@ 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` 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. The message key is **not** wrapped with `identity_sk` (that key is in the same file). sqlcipher is out of v1. `/wipe` overwrites message bodies and vacuums; `/wipe-all` deletes the data dir. Ctrl-Q can clear history on the way out (`CLEAR`) without becoming a new person.
Threat model: [`docs/THREAT_MODEL.md`](docs/THREAT_MODEL.md). Threat model: [`docs/THREAT_MODEL.md`](docs/THREAT_MODEL.md).

View file

@ -2507,16 +2507,6 @@ 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"
@ -2760,21 +2750,18 @@ dependencies = [
[[package]] [[package]]
name = "onionwire" name = "onionwire"
version = "0.2.1" version = "0.2.0"
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",
"sha3 0.10.9",
"snow", "snow",
"tokio", "tokio",
"tor-cell", "tor-cell",
@ -2789,7 +2776,6 @@ version = "0.1.0"
dependencies = [ dependencies = [
"arti-client", "arti-client",
"onionwire", "onionwire",
"rustls",
"thiserror 2.0.20", "thiserror 2.0.20",
"tokio", "tokio",
"uniffi", "uniffi",
@ -3486,17 +3472,6 @@ 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"
@ -3518,16 +3493,6 @@ 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"
@ -3588,7 +3553,6 @@ checksum = "6725596c3f2c3a0aef021139e145d4eafe314a6623e4680ca83852b2c67ab2ba"
dependencies = [ dependencies = [
"log", "log",
"once_cell", "once_cell",
"ring",
"rustls-pki-types", "rustls-pki-types",
"rustls-webpki", "rustls-webpki",
"subtle", "subtle",

View file

@ -32,17 +32,6 @@ arti-client = { version = "0.46", default-features = false, features = [
"static-sqlite", "static-sqlite",
] } ] }
tokio = { version = "1", features = ["rt-multi-thread", "time"] } 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"] } uniffi = { version = "0.32", features = ["cli", "tokio"] }
thiserror = "2" thiserror = "2"

View file

@ -17,38 +17,13 @@
//! no TUI/ratatui type leaks into the AAR. //! no TUI/ratatui type leaks into the AAR.
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::{Arc, OnceLock}; use std::sync::Arc;
use onionwire::node::Node; use onionwire::node::Node;
use onionwire::qr; use onionwire::qr;
uniffi::setup_scaffolding!(); 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)] #[derive(Debug, thiserror::Error, uniffi::Error)]
#[uniffi(flat_error)] #[uniffi(flat_error)]
pub enum WireError { pub enum WireError {
@ -113,10 +88,6 @@ pub struct Wire {
/// that is minutes, not seconds. Call it off the main thread. /// that is minutes, not seconds. Call it off the main thread.
#[uniffi::export(async_runtime = "tokio")] #[uniffi::export(async_runtime = "tokio")]
pub async fn open_wire(home: String, passphrase: String) -> WResult<Arc<Wire>> { 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) let node = Node::start_with_passphrase(PathBuf::from(home), &passphrase)
.await .await
.map_err(WireError::new)?; .map_err(WireError::new)?;

View file

@ -1,30 +0,0 @@
//! `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

@ -1,45 +0,0 @@
//! 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

@ -18,7 +18,7 @@ A signed `loc` frame (`onion`, `ts`, `sig`) rewrites a friends locator **only
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. Chat bodies in sqlite are ChaCha20-Poly1305 (`nonce || ciphertext` in the `messages.plaintext` column) with AAD `owmsg1 || friend_id_le64 || dir || 0x00 || row_id_le64`. Swapping ciphertext between rows fails closed. A random 32-byte data key is wrapped with Argon2id (same params as identity backup) from a non-empty passphrase. Salt + wrapped key live in `store_meta`. Unlock is fail-closed: wrong or empty passphrase does not open chat. Empty-AAD v0.2 blobs are rewrapped once on unlock; `list_messages` never falls back to empty AAD.
Identity secret key, friend public keys, and locators remain plaintext in the same db. The message key is not wrapped with `identity_sk` (that key is already on disk). A seized laptop still yields who you talk to and your identity unless you add OS/FDE. sqlcipher is out of v1. `/wipe` deletes chat and payments history (overwrite message bodies, `VACUUM`, WAL checkpoint); roster and identity stay. It is not a forensic erase — SSD wear-leveling can keep copies. `/wipe-all` deletes the data dir (new identity); same disk caveat. 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.
## 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 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. OnionWire never holds spend keys. Optional `ONIONWIRE_WALLET_RPC` talks HTTP to a user-hosted `monero-wallet-rpc`. 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

View file

@ -8,7 +8,6 @@ pub enum Kind {
Invoice, Invoice,
Receipt, Receipt,
Ping, Ping,
File,
Drop, Drop,
} }
@ -28,9 +27,6 @@ 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()

View file

@ -1,387 +0,0 @@
//! 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,6 +1,5 @@
//! 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};
@ -9,7 +8,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::{HsId, HsNickname, OnionServiceConfig, RunningOnionService}; use tor_hsservice::{HsNickname, OnionServiceConfig, RunningOnionService};
use tor_rtcompat::PreferredRuntime; use tor_rtcompat::PreferredRuntime;
pub const HS_PORT: u16 = 80; pub const HS_PORT: u16 = 80;
@ -29,24 +28,9 @@ 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 {
mkdir_700(state_dir); std::fs::create_dir_all(state_dir).expect("state dir");
mkdir_700(cache_dir); std::fs::create_dir_all(cache_dir).expect("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
@ -100,8 +84,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,6 +1,5 @@
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;
@ -14,7 +13,4 @@ mod store;
pub mod tui; pub mod tui;
pub mod wallet; pub mod wallet;
pub use store::{ pub use store::{Friend, FriendProfile, Message, Payment, PaymentWrite, SelfIdentity, Store};
Friend, FriendProfile, Message, Payment, PaymentWrite, SelfIdentity, Store,
resolve_store_passphrase,
};

View file

@ -1,4 +1,5 @@
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");
@ -34,8 +35,7 @@ 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 = onionwire::resolve_store_passphrase(|p| rpassword::prompt_password(p)) let pass = store_passphrase()?;
.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,3 +48,22 @@ 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,7 +10,6 @@ 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;
@ -47,7 +46,6 @@ 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 {
@ -77,7 +75,6 @@ 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),
@ -87,7 +84,6 @@ 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.
@ -96,7 +92,7 @@ impl Node {
_svc: Arc::clone(&svc), _svc: Arc::clone(&svc),
rend, rend,
}); });
hs::wait_until_published(&node.client, &svc, &onion).await?; hs::wait_until_published(&node.client, &svc, &onion, &onion).await?;
node.store node.store
.lock() .lock()
.map_err(|e| e.to_string())? .map_err(|e| e.to_string())?
@ -448,7 +444,7 @@ impl Node {
_svc: Arc::clone(&svc), _svc: Arc::clone(&svc),
rend, rend,
}); });
hs::wait_until_published(&self.client, &svc, &onion).await?; hs::wait_until_published(&self.client, &svc, &onion, &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())?;
@ -485,25 +481,6 @@ 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())?;
@ -643,41 +620,10 @@ 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,5 +1,4 @@
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>;
@ -36,72 +35,19 @@ 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 raw = decode_monero_b58(addr).ok_or_else(|| Error("invalid Monero address".into()))?; let ok = match addr.as_bytes().first() {
if raw.len() != 69 && raw.len() != 77 { Some(b'4') if addr.len() == 95 || addr.len() == 106 => true,
return Err(Error("invalid Monero address".into())); Some(b'8') if addr.len() == 95 => true,
}
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 { if ok && !addr.contains('\n') {
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,9 +125,6 @@ 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

@ -117,16 +117,12 @@ 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;
@ -752,17 +748,14 @@ impl Store {
Ok(out) Ok(out)
} }
/// Drop chat + payments history. Identity + friends stay. /// Overwrite message bodies, delete rows, VACUUM. 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(())
} }
@ -831,26 +824,6 @@ 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();

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 send chat to selected friend, or a /command\n\ Enter /wipe /wipe-all /profile /who /pay /tip\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,7 +77,6 @@ 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\
@ -164,29 +163,6 @@ 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)]
@ -308,11 +284,6 @@ 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
} }
@ -347,9 +318,8 @@ 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 chat and payments history?\n\ "Wipe message log?\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 => {
@ -722,8 +692,6 @@ 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(),
@ -749,8 +717,8 @@ impl App {
self.composer.pop(); self.composer.pop();
} }
} }
KeyCode::Enter => match composer_enter(&self.composer) { KeyCode::Enter => match parse_cmd(&self.composer) {
Some(ComposerAction::Cmd(SlashCmd::Wipe(kind))) => { Some(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(),
@ -758,23 +726,23 @@ impl App {
}; };
self.screen = Screen::Wipe { kind, prompt }; self.screen = Screen::Wipe { kind, prompt };
} }
Some(ComposerAction::Cmd(SlashCmd::Profile)) => { Some(SlashCmd::Profile) => {
self.composer.clear(); self.composer.clear();
open_profile = true; open_profile = true;
} }
Some(ComposerAction::Cmd(SlashCmd::Who)) => { Some(SlashCmd::Who) => {
self.composer.clear(); self.composer.clear();
self.who_open = true; self.who_open = true;
} }
Some(ComposerAction::Cmd(SlashCmd::Pay { atomic, memo })) => { Some(SlashCmd::Pay { atomic, memo }) => {
self.composer.clear(); self.composer.clear();
pay_cmd = Some((atomic, memo)); pay_cmd = Some((atomic, memo));
} }
Some(ComposerAction::Cmd(SlashCmd::Tip { atomic, memo })) => { Some(SlashCmd::Tip { atomic, memo }) => {
self.composer.clear(); self.composer.clear();
tip_cmd = Some((atomic, memo)); tip_cmd = Some((atomic, memo));
} }
Some(ComposerAction::Cmd(SlashCmd::Backup { path })) => { Some(SlashCmd::Backup { path }) => {
self.composer.clear(); self.composer.clear();
self.screen = Screen::ConfirmKeys { self.screen = Screen::ConfirmKeys {
kind: BackupKind::Backup, kind: BackupKind::Backup,
@ -782,7 +750,7 @@ impl App {
prompt: BackupPrompt::backup(), prompt: BackupPrompt::backup(),
}; };
} }
Some(ComposerAction::Cmd(SlashCmd::Restore { path })) => { Some(SlashCmd::Restore { path }) => {
self.composer.clear(); self.composer.clear();
self.screen = Screen::ConfirmKeys { self.screen = Screen::ConfirmKeys {
kind: BackupKind::Restore, kind: BackupKind::Restore,
@ -790,18 +758,6 @@ 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) => {
@ -955,12 +911,6 @@ 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)
} }
@ -1212,36 +1162,6 @@ 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();
@ -1373,7 +1293,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 /file".to_string() "/wipe /wipe-all /profile /who /pay /tip".to_string()
} else { } else {
self.composer.clone() self.composer.clone()
}; };

View file

@ -1,6 +1,5 @@
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;
@ -17,25 +16,11 @@ impl std::fmt::Display for Error {
impl std::error::Error for Error {} impl std::error::Error for Error {}
#[derive(Clone)] #[derive(Debug, 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)]
@ -59,28 +44,20 @@ 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() => { Ok(s) if !s.trim().is_empty() => match Self::from_url(s.trim()) {
let login = std::env::var("ONIONWIRE_WALLET_RPC_LOGIN") Ok(w) => w,
.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, None)?), endpoint: Some(parse_http_url(url)?),
}) })
} }
@ -149,26 +126,15 @@ impl Wallet {
"params": params, "params": params,
}) })
.to_string(); .to_string();
let raw = post_timeout(ep, &build_req(ep, &body, None)).await?; let host_hdr = host_header(&ep.host, ep.port);
let status = http_status(&raw).unwrap_or(0); let req = format!(
if status == 200 { "POST {} HTTP/1.1\r\nHost: {host_hdr}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
return Err(Error( ep.path,
"wallet RPC requires digest auth (open RPC refused)".into(), body.len()
)); );
} let raw = tokio::time::timeout(RPC_TIMEOUT, http_post(ep, req.as_bytes()))
if status != 401 { .await
return parse_json_rpc(&raw); .map_err(|_| Error("wallet RPC timed out".into()))??;
}
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)
} }
} }
@ -181,36 +147,13 @@ pub fn transfers_match(rows: &[TransferRow], txid: &str, amount: &str, address:
.any(|r| r.txid == txid && r.amount == amount && r.address == address) .any(|r| r.txid == txid && r.amount == amount && r.address == address)
} }
fn credentials_required() -> Error { fn parse_http_url(url: &str) -> Result<Endpoint> {
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()),
@ -221,21 +164,10 @@ fn parse_http_url(url: &str, extra_login: Option<&str>) -> Result<Endpoint> {
path path
}; };
let (host, port) = parse_hostport(hostport)?; let (host, port) = parse_hostport(hostport)?;
if host.trim().to_ascii_lowercase().ends_with(".onion") {
return Err(Error(
"wallet RPC over .onion is not supported (loopback only; no Arti dial)".into(),
));
}
if !allowed_host(&host) { if !allowed_host(&host) {
return Err(Error("wallet RPC host must be loopback".into())); return Err(Error("wallet RPC host must be loopback or .onion".into()));
} }
Ok(Endpoint { Ok(Endpoint { host, port, path })
host,
port,
path,
user,
pass,
})
} }
fn parse_hostport(hostport: &str) -> Result<(String, u16)> { fn parse_hostport(hostport: &str) -> Result<(String, u16)> {
@ -268,6 +200,9 @@ 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;
} }
@ -284,31 +219,6 @@ 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
@ -318,117 +228,13 @@ 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();
let mut tmp = [0u8; 8192]; stream
loop { .read_to_end(&mut buf)
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

View file

@ -1,133 +0,0 @@
//! 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,6 +1,5 @@
//! 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;
@ -68,30 +67,3 @@ 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,20 +12,16 @@ 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 {
MAINNET_STD.to_string() format!("4{}", "A".repeat(94))
} }
fn addr_sub() -> String { fn addr_sub() -> String {
MAINNET_SUB.to_string() format!("8{}", "B".repeat(94))
}
fn addr_integrated() -> String {
format!("4{}", "C".repeat(105))
} }
fn payw<'a>( fn payw<'a>(
@ -50,20 +46,12 @@ fn payw<'a>(
#[test] #[test]
fn valid_and_invalid_xmr_addresses() { fn valid_and_invalid_xmr_addresses() {
assert!(pay::check_address(MAINNET_STD).is_ok()); assert!(pay::check_address(&addr_std()).is_ok());
assert!(pay::check_address(MAINNET_SUB).is_ok()); assert!(pay::check_address(&addr_sub()).is_ok());
assert!(pay::check_address(MAINNET_INT).is_ok()); assert!(pay::check_address(&addr_integrated()).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!("{MAINNET_STD}\n")).is_err()); assert!(pay::check_address(&format!("5{}", "A".repeat(94))).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,33 +108,21 @@ 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", MAINNET_STD).unwrap(); store.set_self_profile("me", "a bio", "4abc").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, MAINNET_STD); assert_eq!(got.xmr_addr, "4abc");
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

@ -1,45 +0,0 @@
//! 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,14 +55,11 @@ 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);
@ -264,64 +261,3 @@ 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) hs::wait_until_published(&alice, &alice_svc, &alice_onion, "alice")
.await .await
.expect("alice publish"); .expect("alice publish");
hs::wait_until_published(&bob, &bob_svc, &bob_onion) hs::wait_until_published(&bob, &bob_svc, &bob_onion, "bob")
.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::{
Pane, banner_for_width, compact_banner, draw_share, help_overlay_text, main_footer_hints, banner_for_width, compact_banner, draw_share, help_overlay_text, main_footer_hints,
onion_glyph, status_footer, wordmark_banner, onion_glyph, status_footer, wordmark_banner, Pane,
}; };
#[test] #[test]
@ -40,19 +40,7 @@ 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", "Tab", "F2", "F3", "F4", "F5", "/profile", "/pay", "/tip", "/backup", "/restore", "Ctrl-Q",
"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:?}");
@ -77,10 +65,10 @@ 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 = "abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopqrstuvwx.onion"; let onion = "abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopqrstuvwx.onion";
@ -178,8 +166,8 @@ 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::Terminal;
use ratatui::backend::TestBackend; use ratatui::backend::TestBackend;
use ratatui::Terminal;
let sk = [7u8; 32]; let sk = [7u8; 32];
let onion = "abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopqrstuvwx.onion"; let onion = "abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopqrstuvwx.onion";

View file

@ -17,9 +17,7 @@ fn row(txid: &str, amount: &str, address: &str) -> TransferRow {
} }
fn xmr_addr() -> String { fn xmr_addr() -> String {
// Same documented mainnet standard as tests/pay.rs — F4 checksums this. format!("8{}", "B".repeat(94))
"4AdUndXHHZ6cfufTMvppY6JwXNouMBzSkbLYfpAV5Usx3skxNgYeYTRj5UzqtReoS44qo9mtmXCqY45DJ852K5Jv2684Rge"
.to_string()
} }
fn json_rpc_ok(result: &str) -> String { fn json_rpc_ok(result: &str) -> String {
@ -30,6 +28,13 @@ 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();
@ -42,19 +47,9 @@ 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://ow:secret@example.com:18083").unwrap_err(); let err = Wallet::from_url("http://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")
@ -63,34 +58,6 @@ 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();
@ -98,8 +65,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_digest_then(listener, canned)); tokio::spawn(serve_once(listener, canned));
let w = Wallet::from_url(&format!("http://ow:secret@127.0.0.1:{}", addr.port())).unwrap(); let w = Wallet::from_url(&format!("http://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}");
} }
@ -111,15 +78,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_digest_then(listener, canned.clone())); tokio::spawn(serve_once(listener, canned.clone()));
let w = Wallet::from_url(&format!("http://ow:secret@127.0.0.1:{}", addr.port())).unwrap(); let w = Wallet::from_url(&format!("http://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_digest_then(listener, canned)); tokio::spawn(serve_once(listener, canned));
let w = Wallet::from_url(&format!("http://ow:secret@127.0.0.1:{}", addr.port())).unwrap(); let w = Wallet::from_url(&format!("http://127.0.0.1:{}", addr.port())).unwrap();
let txid = w let txid = w
.transfer("8BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", 1) .transfer("8BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", 1)
.await .await
@ -134,8 +101,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_digest_then(listener, canned)); tokio::spawn(serve_once(listener, canned));
let w = Wallet::from_url(&format!("http://ow:secret@127.0.0.1:{}", addr.port())).unwrap(); let w = Wallet::from_url(&format!("http://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,
@ -213,67 +180,3 @@ fn ingest_signed_receipt_mismatched_wallet_history_stays_unverified() {
let rows = store.list_payments(&pk).unwrap(); let rows = store.list_payments(&pk).unwrap();
assert!(!rows[0].verified); 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,13 +1,10 @@
//! 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::{
parse_slash, quit_screen_text, wipe_screen_text, QuitDecision, QuitPrompt, WipeDecision, QuitDecision, QuitPrompt, WipeDecision, WipeKind, WipePrompt, parse_slash, quit_screen_text,
WipeKind, WipePrompt, wipe_screen_text,
}; };
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];
@ -53,53 +50,6 @@ 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");
@ -158,9 +108,8 @@ 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 chat and payments history?")); assert!(m.contains("Wipe message log?"));
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);