Compare commits

..

17 commits

Author SHA1 Message Date
af7d69ace8 Merge pull request 'feat(tui): fail-closed /file transfer (1 MiB)' (#16) from feat/tui-file-transfer into main
Some checks are pending
ci / test (push) Waiting to run
Reviewed-on: #16
2026-09-11 04:52:55 +00:00
Sirius DevOps
0ea6fd68c4
[verified] feat(tui): fail-closed /file transfer (1 MiB)
Some checks are pending
ci / test (pull_request) Waiting to run
Typed fil frames chunk a local file to the selected friend over the
existing one-shot pipe. Receive-side 1 MiB cap, basename-only names,
hash check before rename, never overwrite. Chat shows [file] name only.
2026-09-10 23:32:06 -04:00
Sirius DevOps
c4ccca7e8c
feat(tui): Enter sends chat to the selected friend
Composer Enter only ran slash commands, so typed messages were dropped.
Wire Node::send, keep /commands, reject unknown /foo as not-chat.
2026-09-10 23:07:22 -04:00
c141eb45b8 Merge pull request 'fix(sdk): enable rustls' ring provider — APK died at 'Failed to start / Could not determine the process-level CryptoProvider'' (#15) from wt/t_dcb8565a into main
All checks were successful
ci / test (push) Successful in 4m5s
Reviewed-on: #15
2026-09-10 22:35:31 -04:00
Sirius DevOps
d2d3b0c827
fix(sdk): enable rustls' ring provider and install it in the init path
All checks were successful
ci / test (pull_request) Successful in 3m30s
GREEN. rustls 0.23 selects its CryptoProvider from its own 'ring' /
'aws-lc-rs' crate features. Arti reaches rustls through tor-rtcompat with
default-features = false and nothing in this workspace turned a provider
feature on, so rustls was compiled with zero providers and the first TLS
config built from the process default panicked:

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

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

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

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

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

Evidence:
  cargo test --release --test provider_resolution
    before: panicked at rustls-0.23.44/src/crypto/mod.rs:249 (device message)
    after:  1 passed
  cargo tree -f '{p} [{f}]' -p rustls
    rustls v0.23.44 [log,logging,ring,std,tls12]
2026-09-10 22:03:40 -04:00
Sirius DevOps
411194a8b2
test(sdk): reproduce rustls CryptoProvider panic from the device
RED. The APK builds, installs and opens, but the first rustls config built
from the process default panics one call deep inside Arti's rustls backend:

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

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

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

The SDK lockfile is resynced with the root Cargo.toml on main (onionwire
0.2.1, rpassword, md-5, rtoolbox) in the same commit so the test build is
against a consistent lock.
2026-09-10 22:02:02 -04:00
389d8f16c4 Merge pull request 'fix(cli): hide store passphrase like a password prompt' (#14) from feat/hidden-store-passphrase into main
All checks were successful
ci / test (push) Successful in 3m32s
Reviewed-on: #14
2026-09-10 21:18:46 -04:00
b354632552 Merge branch 'main' into feat/hidden-store-passphrase
All checks were successful
ci / test (pull_request) Successful in 3m44s
2026-09-10 21:18:32 -04:00
Sirius DevOps
c5019f1737
fix(cli): hide store passphrase like a password prompt
All checks were successful
ci / test (pull_request) Successful in 4m54s
stdin read_line echoed the passphrase. rpassword disables tty echo.
ONIONWIRE_STORE_PASSPHRASE still skips the prompt.
2026-09-10 21:10:50 -04:00
2abb9af4ad Merge pull request 'chore: bump crate version to 0.2.1' (#13) from feat/v0.2.1 into main
All checks were successful
ci / test (push) Successful in 5m10s
Reviewed-on: #13
2026-09-10 21:08:20 -04:00
Sirius DevOps
51e47ff90c
chore: bump crate version to 0.2.1
All checks were successful
release / aarch64 (push) Successful in 5m19s
ci / test (pull_request) Successful in 5m34s
Tag and --version have to move together. Ships the post-v0.2.0 main
line: security audit F1–F7, Pi CI serialize, Android SDK already on main.
2026-09-10 20:52:24 -04:00
f109223678 Merge pull request 'fix: security audit F5–F7 + Pi CI races (bundle)' (#12) from feat/audit-fixes-bundle into main
All checks were successful
ci / test (push) Successful in 2m45s
Reviewed-on: #12
2026-09-10 20:41:40 -04:00
Sirius DevOps
08ced86a34
Merge F5 wallet digest RPC onto audit bundle
All checks were successful
ci / test (pull_request) Successful in 3m33s
Keep sha3 (F4 checksums) and md-5 (HTTP Digest). Threat model: digest login
plus conjunctive verified receipts. Wallet tests: F1 matcher fixtures plus
F5 digest/cap coverage.
2026-09-10 20:36:10 -04:00
Sirius DevOps
39e7b3bf16
Merge remote-tracking branch 'origin/wt/t_792593f3' into feat/audit-fixes-bundle 2026-09-10 20:33:50 -04:00
Sirius DevOps
424002c0f5
[verified] fix(hs): redact onion in publish logs; cache 0700
All checks were successful
ci / test (pull_request) Successful in 3m0s
wait_until_published now logs HsId via safelog, not the locator.
Store and client_config mkdir Arti cache/state 0700. Arti
dangerously_trust_everyone stays on storage only.
2026-09-10 20:30:18 -04:00
Sirius DevOps
45c105de8a
fix(store): drop payments on /wipe; document non-forensic
All checks were successful
ci / test (pull_request) Successful in 3m1s
/wipe now deletes payments as well as chat, checkpoints WAL, and
enables sqlite secure_delete (flash still not crypto-shred).
2026-09-10 20:22:31 -04:00
Sirius DevOps
125aafb816
[verified] feat(wallet): require RPC digest login and cap reads
All checks were successful
ci / test (pull_request) Successful in 3m8s
Refuse unauthenticated ONIONWIRE_WALLET_RPC, speak HTTP Digest like
monero-wallet-rpc --rpc-login, cap responses at 1 MiB, and reject
.onion RPC URLs instead of half-dialing them over TCP.
2026-09-10 20:17:25 -04:00
26 changed files with 1485 additions and 110 deletions

36
Cargo.lock generated
View file

@ -2359,6 +2359,16 @@ dependencies = [
"regex-automata",
]
[[package]]
name = "md-5"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
dependencies = [
"cfg-if",
"digest 0.10.7",
]
[[package]]
name = "memchr"
version = "2.8.3"
@ -2619,18 +2629,21 @@ dependencies = [
[[package]]
name = "onionwire"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"argon2",
"arti-client",
"chacha20poly1305",
"ed25519-dalek",
"futures",
"md-5",
"rand 0.8.8",
"ratatui",
"rpassword",
"rusqlite",
"safelog",
"serde_json",
"sha2",
"sha3 0.10.9",
"snow",
"tempfile",
@ -3370,6 +3383,17 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "rpassword"
version = "7.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2da316a15f47e3d053de9cb2c439650bd8fa4aaeb9365f2e5f27f492ff73c196"
dependencies = [
"libc",
"rtoolbox",
"windows-sys 0.61.2",
]
[[package]]
name = "rsa"
version = "0.9.10"
@ -3391,6 +3415,16 @@ dependencies = [
"zeroize",
]
[[package]]
name = "rtoolbox"
version = "0.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a1efe12a1469752d0e6ff5ebec0b6ef4924cc5c4c71046b0ec730040535819d"
dependencies = [
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "rusqlite"
version = "0.36.0"

View file

@ -1,6 +1,6 @@
[package]
name = "onionwire"
version = "0.2.0"
version = "0.2.1"
edition = "2024"
rust-version = "1.91"
description = "Lean Tor messenger: Arti in-process, identity=pubkey, onion=locator. No XMPP."
@ -44,6 +44,9 @@ serde_json = "1"
argon2 = "0.5"
chacha20poly1305 = "0.10"
sha3 = "0.10"
sha2 = "0.10"
md-5 = "0.10"
rpassword = "7"
[dev-dependencies]
tempfile = "3"

View file

@ -108,9 +108,9 @@ onionwire
or `cargo run --release`.
On start you are prompted for a store passphrase (or set `ONIONWIRE_STORE_PASSPHRASE`). Empty passphrase is rejected; a wrong passphrase does not open chat. Then you should see `onionwire: bootstrapping Arti…` on stderr. Directory bootstrap is usually under a minute; the onion is ready once a probe connect works (combined Arti status may still say Bootstrapping). Fail closed at 360s. Data lives in `ONIONWIRE_HOME` if set, otherwise `~/.local/share/onionwire/` (`onionwire.db` + Arti state, mode 0700). First open creates an ed25519 identity key. That key **is** you.
On start you are prompted for a store passphrase (echo off, like other CLI passwords) or set `ONIONWIRE_STORE_PASSPHRASE`. Empty passphrase is rejected; a wrong passphrase does not open chat. Then you should see `onionwire: bootstrapping Arti…` on stderr. Directory bootstrap is usually under a minute; the onion is ready once a probe connect works (combined Arti status may still say Bootstrapping). Fail closed at 360s. Data lives in `ONIONWIRE_HOME` if set, otherwise `~/.local/share/onionwire/` (`onionwire.db` + Arti state, mode 0700). First open creates an ed25519 identity key. That key **is** you.
Then `F2` to share your invite, `F3` to paste a friends. Mouse-select the `onionwire:v1:…` line to copy.
Then `F2` to share your invite, `F3` to paste a friends. Mouse-select the `onionwire:v1:…` line to copy. Highlight them in the roster, type in the composer, Enter to send. Fail closed: if their onion is down, send fails — no outbox.
## Friends are keys
@ -131,7 +131,7 @@ Focus starts on the composer so typing works immediately. `Tab` cycles panes; `j
| `F3` | Paste a friends invite |
| `F4` | Rotate **onion** (locator only) |
| `F5` | Selected friends profile (`/who`) |
| Enter | Run `/wipe`, `/wipe-all`, `/profile`, `/who`, `/pay`, `/tip`, `/backup`, `/restore` from the composer |
| Enter | Send chat to the selected friend, or run a `/command` |
| `Esc` | Close overlay / back to Main / clear composer |
| `Ctrl-Q` | Quit: type `CLEAR`+Enter to wipe history, `QUIT`+Enter to leave it, Esc to stay |
@ -156,19 +156,31 @@ Give that string to a friend. They `F3` paste it (`(o) paste invite`). Unknown `
If a peers onion is down, send fails. v1 has no outbox, no retry queue, no DHT, no name server. There is still no hosted chat server.
## File transfer
`/file /path` sends a local file to the selected friend. Both must be online.
Cap 1 MiB on send and receive. Fail closed: a bad chunk or hash mismatch
deletes the partial (never overwrite). Files land in
`$ONIONWIRE_HOME/inbox/<fingerprint>/`. Chat shows `[file] name (N bytes)`,
never raw frames. No outbox, no resume, no images in the TUI.
## Profile
`/profile` edits your friend-visible display name, bio, and optional Monero address (64 / 512 byte limits, no images). Enter saves and one-shot sends a signed `prf` frame to the selected friend. `F5` or `/who` shows their last signed profile. There is no directory: unknown pubkeys are ignored.
## Monero sidecar
OnionWire is not a wallet. Optional JSON-RPC to a user-hosted `monero-wallet-rpc`:
OnionWire is not a wallet. Optional JSON-RPC to a user-hosted `monero-wallet-rpc`. The wallet **must** use `--rpc-login`; OnionWire refuses an open RPC (HTTP 200 without a Digest challenge) and refuses URLs with no credentials.
```bash
# monero-wallet-rpc --rpc-bind-ip 127.0.0.1 --rpc-bind-port 18083 --rpc-login onionwire:secret
export ONIONWIRE_WALLET_RPC=http://onionwire:secret@127.0.0.1:18083
# or keep the password out of the URL:
export ONIONWIRE_WALLET_RPC=http://127.0.0.1:18083
export ONIONWIRE_WALLET_RPC_LOGIN=onionwire:secret
```
Loopback or `.onion` only, HTTP, 5s timeout. Unset → chat still works; `/pay` and `/tip` say so.
Loopback only, HTTP Digest (RFC 2617, matching `--rpc-login`), 5s timeout, 1 MiB response cap. `.onion` RPC URLs are rejected (no Arti dial; do not point this at a remote wallet). Unset → chat still works; `/pay` and `/tip` say so. Do not log the RPC password.
- `/pay <xmr> [memo]` — invoice (we want to receive). Uses a wallet subaddress if RPC is up, else the profile `xmr_addr`.
- `/tip <xmr> [memo]` — pay the selected friends profile address, then send a signed `rcp`. Incoming receipts stay unverified until RPC `get_transfers` matches.
@ -184,14 +196,14 @@ Treat the backup file like the sqlite db.
## Mixed versions
0.1.2 peers store unknown plaintext as chat. A 0.2 sender of `prf ` / `inv ` / `rcp ` will leave a garbage line on an un-upgraded peer. Upgrade both sides. The Noise handshake is unchanged.
0.1.2 peers store unknown plaintext as chat. A 0.2 sender of `prf ` / `inv ` / `rcp ` / `fil ` will leave a garbage line on an un-upgraded peer. Upgrade both sides. The Noise handshake is unchanged.
## Wipe
Composer (bottom of the roster screen):
- `/wipe` — confirm by typing `WIPE`. Overwrites the message log and `VACUUM`s. Identity key and friends stay.
- `/wipe-all` — confirm by typing `WIPEALL`. Deletes the data dir. Next start is a **new person** (new identity key). Esc cancels. Nothing is wiped without confirm.
- `/wipe` — confirm by typing `WIPE`. Chat and payments history gone (overwrite message bodies, drop `payments`, `VACUUM`, WAL checkpoint). Identity key and friends stay. Not a forensic erase (SSD wear-leveling). `/wipe-all` is the identity burn.
- `/wipe-all` — confirm by typing `WIPEALL`. Deletes the data dir. Next start is a **new person** (new identity key). Same disk caveat. Esc cancels. Nothing is wiped without confirm.
## Uninstall
@ -215,7 +227,7 @@ Still plaintext on disk (unless you add OS/FDE):
- your identity secret key (`self.identity_sk`)
- friend public keys and current locators
The message key is **not** wrapped with `identity_sk` (that key is in the same file). sqlcipher is out of v1. `/wipe` overwrites message bodies and vacuums; `/wipe-all` deletes the data dir. Ctrl-Q can clear history on the way out (`CLEAR`) without becoming a new person.
The message key is **not** wrapped with `identity_sk` (that key is in the same file). sqlcipher is out of v1. `/wipe` deletes chat and payments history; it is not a forensic erase. Roster and identity stay. `/wipe-all` deletes the data dir (new identity). Ctrl-Q can clear history on the way out (`CLEAR`) without becoming a new person.
Threat model: [`docs/THREAT_MODEL.md`](docs/THREAT_MODEL.md).

View file

@ -2507,6 +2507,16 @@ dependencies = [
"regex-automata",
]
[[package]]
name = "md-5"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
dependencies = [
"cfg-if",
"digest 0.10.7",
]
[[package]]
name = "memchr"
version = "2.8.3"
@ -2750,18 +2760,21 @@ dependencies = [
[[package]]
name = "onionwire"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"argon2",
"arti-client",
"chacha20poly1305",
"ed25519-dalek",
"futures",
"md-5",
"rand 0.8.8",
"ratatui",
"rpassword",
"rusqlite",
"safelog",
"serde_json",
"sha3 0.10.9",
"snow",
"tokio",
"tor-cell",
@ -2776,6 +2789,7 @@ version = "0.1.0"
dependencies = [
"arti-client",
"onionwire",
"rustls",
"thiserror 2.0.20",
"tokio",
"uniffi",
@ -3472,6 +3486,17 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "rpassword"
version = "7.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2da316a15f47e3d053de9cb2c439650bd8fa4aaeb9365f2e5f27f492ff73c196"
dependencies = [
"libc",
"rtoolbox",
"windows-sys 0.61.2",
]
[[package]]
name = "rsa"
version = "0.9.10"
@ -3493,6 +3518,16 @@ dependencies = [
"zeroize",
]
[[package]]
name = "rtoolbox"
version = "0.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a1efe12a1469752d0e6ff5ebec0b6ef4924cc5c4c71046b0ec730040535819d"
dependencies = [
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "rusqlite"
version = "0.36.0"
@ -3553,6 +3588,7 @@ checksum = "6725596c3f2c3a0aef021139e145d4eafe314a6623e4680ca83852b2c67ab2ba"
dependencies = [
"log",
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",

View file

@ -32,6 +32,17 @@ arti-client = { version = "0.46", default-features = false, features = [
"static-sqlite",
] }
tokio = { version = "1", features = ["rt-multi-thread", "time"] }
# rustls 0.23 resolves its provider from its OWN `ring` / `aws-lc-rs` features,
# never from whichever crypto crates happen to be linked in the graph. Arti
# pulls rustls in through `tor-rtcompat` with `default-features = false`, so
# without this line rustls compiles with zero providers and the first TLS config
# built from the process default fails at runtime — the "Failed to start /
# Could not automatically determine the process-level CryptoProvider" screen.
#
# `ring`, not `aws-lc-rs`: aws-lc-rs needs CMake and a C toolchain for the NDK
# and fights the Android cross-compile. The `ring` crate was already in the
# graph (via `snow`, for Noise) but that is a different thing entirely.
rustls = { version = "0.23", default-features = false, features = ["ring"] }
uniffi = { version = "0.32", features = ["cli", "tokio"] }
thiserror = "2"

View file

@ -17,13 +17,38 @@
//! no TUI/ratatui type leaks into the AAR.
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::{Arc, OnceLock};
use onionwire::node::Node;
use onionwire::qr;
uniffi::setup_scaffolding!();
/// Install rustls's process-level default `CryptoProvider`, exactly once.
///
/// Belt and braces. `Cargo.toml` pins `rustls` with the `ring` feature, which
/// is what actually fixes the missing-provider failure: with it, rustls
/// resolves a provider from crate features on its own. This function exists
/// because the cdylib is loaded into a process we do not own — an explicitly
/// installed provider makes the SDK independent of how the surrounding app's
/// feature graph happens to resolve rustls.
///
/// Idempotent and cheap after the first call (`OnceLock` short-circuits it).
/// An `Err` means some provider is already installed process-wide, which is the
/// outcome we want, so it is deliberately ignored — including the case of a
/// consumer that installed `aws-lc-rs` itself. A genuine *conflict* — both
/// provider features compiled in — is a build-graph bug, not something to
/// paper over here; `Cargo.toml` enables `ring` only.
///
/// Not exported over UniFFI: it is Rust-side plumbing, not part of the Kotlin
/// surface.
pub fn install_crypto_provider() {
static INSTALLED: OnceLock<()> = OnceLock::new();
INSTALLED.get_or_init(|| {
let _ = rustls::crypto::ring::default_provider().install_default();
});
}
#[derive(Debug, thiserror::Error, uniffi::Error)]
#[uniffi(flat_error)]
pub enum WireError {
@ -88,6 +113,10 @@ pub struct Wire {
/// that is minutes, not seconds. Call it off the main thread.
#[uniffi::export(async_runtime = "tokio")]
pub async fn open_wire(home: String, passphrase: String) -> WResult<Arc<Wire>> {
// Before anything that can build a TLS config: Arti's rustls backend dies
// with "Could not automatically determine the process-level CryptoProvider"
// if no provider is resolvable. See `install_crypto_provider`.
install_crypto_provider();
let node = Node::start_with_passphrase(PathBuf::from(home), &passphrase)
.await
.map_err(WireError::new)?;

View file

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

View file

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

View file

@ -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.
Identity secret key, friend public keys, and locators remain plaintext in the same db. The message key is not wrapped with `identity_sk` (that key is already on disk). A seized laptop still yields who you talk to and your identity unless you add OS/FDE. sqlcipher is out of v1. `/wipe` overwrites message bodies and vacuums; `/wipe-all` deletes the data dir.
Identity secret key, friend public keys, and locators remain plaintext in the same db. The message key is not wrapped with `identity_sk` (that key is already on disk). A seized laptop still yields who you talk to and your identity unless you add OS/FDE. sqlcipher is out of v1. `/wipe` deletes chat and payments history (overwrite message bodies, `VACUUM`, WAL checkpoint); roster and identity stay. It is not a forensic erase — SSD wear-leveling can keep copies. `/wipe-all` deletes the data dir (new identity); same disk caveat.
## Fail closed
@ -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
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.
OnionWire never holds spend keys. Optional `ONIONWIRE_WALLET_RPC` talks HTTP Digest to a user-hosted `monero-wallet-rpc` on loopback (`--rpc-login` required; open RPC is refused). A Noise friend can sign any `rcp`; the signature proves who sent the claim, not that a payment happened. `verified=1` only after a conjunctive RPC match: one `get_transfers` row with the same non-empty `txid`, `amount`, and `address`. Incoming `rcp` stays `verified=0` if RPC is down, errors, or no exact row. Subaddress reuse is the users wallet policy.
## Backup file is the identity

View file

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

387
src/file.rs Normal file
View file

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

View file

@ -1,5 +1,6 @@
//! In-process Arti onion-service helpers (no C-tor).
use std::os::unix::fs::PermissionsExt;
use std::sync::Arc;
use std::time::{Duration, Instant};
@ -8,7 +9,7 @@ use arti_client::{TorClient, TorClientConfig};
use futures::StreamExt;
use safelog::DisplayRedacted;
use tor_hsservice::status::State;
use tor_hsservice::{HsNickname, OnionServiceConfig, RunningOnionService};
use tor_hsservice::{HsId, HsNickname, OnionServiceConfig, RunningOnionService};
use tor_rtcompat::PreferredRuntime;
pub const HS_PORT: u16 = 80;
@ -28,9 +29,24 @@ const PROBE_TIMEOUT: Duration = Duration::from_secs(12);
pub type Client = Arc<TorClient<PreferredRuntime>>;
fn mkdir_700(path: &std::path::Path) {
std::fs::create_dir_all(path).expect("mkdir");
let mut perms = std::fs::metadata(path).expect("metadata").permissions();
perms.set_mode(0o700);
std::fs::set_permissions(path, perms).expect("chmod 0700");
}
/// Status/probe log label: safelog-redacted v3 onion, never the locator.
pub fn log_label(onion: &str) -> String {
match onion.parse::<HsId>() {
Ok(id) => id.display_redacted().to_string(),
Err(_) => safelog::sensitive(onion).to_string(),
}
}
pub fn client_config(state_dir: &std::path::Path, cache_dir: &std::path::Path) -> TorClientConfig {
std::fs::create_dir_all(state_dir).expect("state dir");
std::fs::create_dir_all(cache_dir).expect("cache dir");
mkdir_700(state_dir);
mkdir_700(cache_dir);
let mut builder = TorClientConfigBuilder::from_directories(state_dir, cache_dir);
builder.storage().permissions().dangerously_trust_everyone();
builder
@ -84,8 +100,8 @@ pub async fn wait_until_published(
client: &Client,
svc: &RunningOnionService,
onion: &str,
label: &str,
) -> Result<(), String> {
let label = log_label(onion);
let deadline = Instant::now() + PUBLISH_WAIT;
let mut events = svc.status_events();
let mut next_probe = Instant::now() + PROBE_EVERY;

View file

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

View file

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

View file

@ -10,6 +10,7 @@ use tor_cell::relaycell::msg::Connected;
use tor_hsservice::{RunningOnionService, handle_rend_requests};
use crate::dispatch::{self, Kind};
use crate::file;
use crate::frame;
use crate::hs::{self, Client, HS_PORT};
use crate::loc;
@ -46,6 +47,7 @@ pub struct Node {
keys: Mutex<Keys>,
wallet: Wallet,
incoming_limit: Mutex<TokenBucket>,
inbox: Mutex<file::Inbox>,
}
impl Node {
@ -75,6 +77,7 @@ impl Node {
.ok_or_else(|| "onion service disabled in config — fail closed".to_string())?;
let (svc, rend) = launched;
let onion = hs::onion_string(&svc)?;
let inbox = file::Inbox::new(home.join("inbox"));
let node = Arc::new(Self {
home,
store: Mutex::new(store),
@ -84,6 +87,7 @@ impl Node {
keys: Mutex::new(keys),
wallet: Wallet::from_env(),
incoming_limit: Mutex::new(TokenBucket::default()),
inbox: Mutex::new(inbox),
});
// Accept rens before waiting so a reachability probe can succeed
// while combined status is still Bootstrapping.
@ -92,7 +96,7 @@ impl Node {
_svc: Arc::clone(&svc),
rend,
});
hs::wait_until_published(&node.client, &svc, &onion, &onion).await?;
hs::wait_until_published(&node.client, &svc, &onion).await?;
node.store
.lock()
.map_err(|e| e.to_string())?
@ -444,7 +448,7 @@ impl Node {
_svc: Arc::clone(&svc),
rend,
});
hs::wait_until_published(&self.client, &svc, &onion, &onion).await?;
hs::wait_until_published(&self.client, &svc, &onion).await?;
{
let store = self.store.lock().map_err(|e| e.to_string())?;
store.set_onion(&onion).map_err(|e| e.to_string())?;
@ -481,6 +485,25 @@ impl Node {
})
}
/// One-shot file to a friend. Fail closed; no outbox, no resume.
pub async fn send_file(&self, friend_pk: &[u8], path: &Path) -> Result<String, String> {
let (name, bytes) = file::read_limited(path).map_err(|e| e.to_string())?;
let chunks = file::chunks(&name, &bytes).map_err(|e| e.to_string())?;
for chunk in &chunks {
self.send_once(friend_pk, &file::encode(chunk)).await?;
}
self.store
.lock()
.map_err(|e| e.to_string())?
.append_message(
friend_pk,
"out",
file::chat_line(&name, bytes.len()).as_bytes(),
)
.map_err(|e| e.to_string())?;
Ok(name)
}
pub async fn send(&self, friend_pk: &[u8], plaintext: &[u8]) -> Result<(), String> {
let (onion, prekey) = {
let store = self.store.lock().map_err(|e| e.to_string())?;
@ -620,10 +643,41 @@ impl Node {
}
Kind::Invoice => self.ingest_invoice(&sess.peer_identity, &pt).await,
Kind::Receipt => self.ingest_receipt(&sess.peer_identity, &pt).await,
Kind::File => self.ingest_file(&sess.peer_identity, &pt),
Kind::Ping | Kind::Drop => Ok(()),
}
}
fn ingest_file(&self, peer: &[u8], pt: &[u8]) -> Result<(), String> {
let Some(chunk) = file::decode(pt) else {
return Ok(());
};
let fp: String = peer.iter().map(|b| format!("{b:02x}")).collect();
let done = {
let mut inbox = self.inbox.lock().map_err(|e| e.to_string())?;
match inbox.ingest(&fp, &chunk) {
Ok(p) => p,
Err(e) => {
eprintln!("fil dropped ({e})");
return Ok(());
}
}
};
if let Some(path) = done {
let nbytes = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0) as usize;
let name = path
.file_name()
.and_then(|s| s.to_str())
.unwrap_or(&chunk.filename);
self.store
.lock()
.map_err(|e| e.to_string())?
.append_message(peer, "in", file::chat_line(name, nbytes).as_bytes())
.map_err(|e| e.to_string())?;
}
Ok(())
}
async fn ingest_invoice(&self, peer: &[u8], pt: &[u8]) -> Result<(), String> {
let Some(inv) = pay::decode_invoice(pt) else {
return Ok(());

View file

@ -117,12 +117,16 @@ impl Store {
}
mkdir_700(home)?;
mkdir_700(&home.join("arti"))?;
mkdir_700(&home.join("cache"))?;
let db_path = home.join("onionwire.db");
let conn = Connection::open(&db_path)?;
let journal: String = conn.query_row("PRAGMA journal_mode = WAL", [], |row| row.get(0))?;
if !journal.eq_ignore_ascii_case("wal") {
return Err(Error(format!("journal_mode WAL failed: {journal}")));
}
// Overwrite freed pages on DELETE. Flash wear-leveling can still keep copies;
// this is not a forensic / SSD crypto-shred.
conn.pragma_update(None, "secure_delete", "ON")?;
conn.execute_batch(
"
PRAGMA foreign_keys = ON;
@ -748,14 +752,17 @@ impl Store {
Ok(out)
}
/// Overwrite message bodies, delete rows, VACUUM. Identity + friends stay.
/// Drop chat + payments history. Identity + friends stay.
/// Not a forensic erase: SSD wear-leveling can keep copies.
pub fn wipe_messages(&self) -> Result<()> {
self.conn.execute(
"UPDATE messages SET plaintext = zeroblob(length(plaintext))",
[],
)?;
self.conn.execute("DELETE FROM messages", [])?;
self.conn.execute("DELETE FROM payments", [])?;
self.conn.execute_batch("VACUUM")?;
self.conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE)")?;
Ok(())
}
@ -824,6 +831,26 @@ fn passphrase_from_env() -> Result<String> {
}
}
/// Env `ONIONWIRE_STORE_PASSPHRASE` if set (non-empty). Otherwise `read_secret`
/// (TTY, no echo). Empty values fail closed.
pub fn resolve_store_passphrase(
read_secret: impl FnOnce(&str) -> std::io::Result<String>,
) -> Result<String> {
match std::env::var("ONIONWIRE_STORE_PASSPHRASE") {
Ok(p) if p.is_empty() => Err(Error("empty passphrase".into())),
Ok(p) => Ok(p),
Err(_) => {
let s = read_secret("onionwire: store passphrase: ")?;
let s = s.trim_end_matches(['\n', '\r']).to_string();
if s.is_empty() {
Err(Error("empty passphrase".into()))
} else {
Ok(s)
}
}
}
}
fn mkdir_700(path: &Path) -> Result<()> {
fs::create_dir_all(path)?;
let mut perms = fs::metadata(path)?.permissions();

View file

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

View file

@ -1,5 +1,6 @@
use std::time::Duration;
use rand::RngCore;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
@ -16,11 +17,25 @@ impl std::fmt::Display for Error {
impl std::error::Error for Error {}
#[derive(Debug, Clone)]
#[derive(Clone)]
struct Endpoint {
host: String,
port: u16,
path: String,
user: String,
pass: String,
}
impl std::fmt::Debug for Endpoint {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Endpoint")
.field("host", &self.host)
.field("port", &self.port)
.field("path", &self.path)
.field("user", &self.user)
.field("pass", &"<redacted>")
.finish()
}
}
#[derive(Debug, Clone)]
@ -44,20 +59,28 @@ impl Wallet {
pub fn from_env() -> Self {
match std::env::var("ONIONWIRE_WALLET_RPC") {
Ok(s) if !s.trim().is_empty() => match Self::from_url(s.trim()) {
Ok(w) => w,
Ok(s) if !s.trim().is_empty() => {
let login = std::env::var("ONIONWIRE_WALLET_RPC_LOGIN")
.ok()
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty());
match parse_http_url(s.trim(), login.as_deref()) {
Ok(endpoint) => Self {
endpoint: Some(endpoint),
},
Err(e) => {
eprintln!("ONIONWIRE_WALLET_RPC: {e}");
Self::disabled()
}
},
}
}
_ => Self::disabled(),
}
}
pub fn from_url(url: &str) -> Result<Self> {
Ok(Self {
endpoint: Some(parse_http_url(url)?),
endpoint: Some(parse_http_url(url, None)?),
})
}
@ -126,15 +149,26 @@ impl Wallet {
"params": params,
})
.to_string();
let host_hdr = host_header(&ep.host, ep.port);
let req = format!(
"POST {} HTTP/1.1\r\nHost: {host_hdr}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
ep.path,
body.len()
);
let raw = tokio::time::timeout(RPC_TIMEOUT, http_post(ep, req.as_bytes()))
.await
.map_err(|_| Error("wallet RPC timed out".into()))??;
let raw = post_timeout(ep, &build_req(ep, &body, None)).await?;
let status = http_status(&raw).unwrap_or(0);
if status == 200 {
return Err(Error(
"wallet RPC requires digest auth (open RPC refused)".into(),
));
}
if status != 401 {
return parse_json_rpc(&raw);
}
let challenge = www_authenticate(&raw).ok_or_else(|| {
Error("wallet RPC digest required (--rpc-login / HTTP Digest)".into())
})?;
if !challenge.trim().to_ascii_lowercase().starts_with("digest") {
return Err(Error(
"wallet RPC digest required (--rpc-login / HTTP Digest)".into(),
));
}
let auth = digest_authorization(ep, &challenge)?;
let raw = post_timeout(ep, &build_req(ep, &body, Some(&auth))).await?;
parse_json_rpc(&raw)
}
}
@ -147,13 +181,36 @@ pub fn transfers_match(rows: &[TransferRow], txid: &str, amount: &str, address:
.any(|r| r.txid == txid && r.amount == amount && r.address == address)
}
fn parse_http_url(url: &str) -> Result<Endpoint> {
fn credentials_required() -> Error {
Error("wallet RPC requires credentials (user:pass in URL or ONIONWIRE_WALLET_RPC_LOGIN)".into())
}
fn parse_login(login: &str) -> Result<(String, String)> {
let (user, pass) = login.split_once(':').ok_or_else(credentials_required)?;
if user.is_empty() || pass.is_empty() {
return Err(credentials_required());
}
Ok((user.to_string(), pass.to_string()))
}
fn parse_http_url(url: &str, extra_login: Option<&str>) -> Result<Endpoint> {
let rest = url
.strip_prefix("http://")
.ok_or_else(|| Error("wallet RPC must be http:// (no TLS)".into()))?;
if rest.contains("://") {
return Err(Error("wallet RPC must be http:// (no TLS)".into()));
}
let (userinfo, rest) = match rest.rsplit_once('@') {
Some((ui, hostpart)) => (Some(ui), hostpart),
None => (None, rest),
};
let (user, pass) = match userinfo {
Some(ui) => parse_login(ui)?,
None => match extra_login {
Some(login) => parse_login(login)?,
None => return Err(credentials_required()),
},
};
let (hostport, path) = match rest.split_once('/') {
Some((hp, p)) => (hp, format!("/{p}")),
None => (rest, "/json_rpc".into()),
@ -164,10 +221,21 @@ fn parse_http_url(url: &str) -> Result<Endpoint> {
path
};
let (host, port) = parse_hostport(hostport)?;
if !allowed_host(&host) {
return Err(Error("wallet RPC host must be loopback or .onion".into()));
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(),
));
}
Ok(Endpoint { host, port, path })
if !allowed_host(&host) {
return Err(Error("wallet RPC host must be loopback".into()));
}
Ok(Endpoint {
host,
port,
path,
user,
pass,
})
}
fn parse_hostport(hostport: &str) -> Result<(String, u16)> {
@ -200,9 +268,6 @@ fn parse_port(p: &str) -> Result<u16> {
fn allowed_host(host: &str) -> bool {
let h = host.trim();
if h.to_ascii_lowercase().ends_with(".onion") {
return true;
}
if h.eq_ignore_ascii_case("localhost") {
return true;
}
@ -219,6 +284,31 @@ fn host_header(host: &str, port: u16) -> String {
}
}
const MAX_RPC_BYTES: usize = 1024 * 1024;
async fn post_timeout(ep: &Endpoint, req: &str) -> Result<Vec<u8>> {
tokio::time::timeout(RPC_TIMEOUT, http_post(ep, req.as_bytes()))
.await
.map_err(|_| Error("wallet RPC timed out".into()))?
}
fn build_req(ep: &Endpoint, body: &str, authorization: Option<&str>) -> String {
let host_hdr = host_header(&ep.host, ep.port);
let mut req = format!(
"POST {} HTTP/1.1\r\nHost: {host_hdr}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n",
ep.path,
body.len()
);
if let Some(auth) = authorization {
req.push_str("Authorization: ");
req.push_str(auth);
req.push_str("\r\n");
}
req.push_str("\r\n");
req.push_str(body);
req
}
async fn http_post(ep: &Endpoint, req: &[u8]) -> Result<Vec<u8>> {
let mut stream = TcpStream::connect((ep.host.as_str(), ep.port))
.await
@ -228,13 +318,117 @@ async fn http_post(ep: &Endpoint, req: &[u8]) -> Result<Vec<u8>> {
.await
.map_err(|e| Error(format!("wallet write: {e}")))?;
let mut buf = Vec::new();
stream
.read_to_end(&mut buf)
let mut tmp = [0u8; 8192];
loop {
let n = stream
.read(&mut tmp)
.await
.map_err(|e| Error(format!("wallet read: {e}")))?;
if n == 0 {
break;
}
if buf.len().saturating_add(n) > MAX_RPC_BYTES {
return Err(Error("wallet RPC response too large".into()));
}
buf.extend_from_slice(&tmp[..n]);
}
Ok(buf)
}
fn http_status(raw: &[u8]) -> Option<u16> {
let text = std::str::from_utf8(raw).ok()?;
let line = text.lines().next()?;
let mut parts = line.split_whitespace();
let _http = parts.next()?;
parts.next()?.parse().ok()
}
fn www_authenticate(raw: &[u8]) -> Option<String> {
let text = std::str::from_utf8(raw).ok()?;
let (head, _) = text
.split_once("\r\n\r\n")
.or_else(|| text.split_once("\n\n"))?;
for line in head.lines().skip(1) {
let (k, v) = match line.split_once(':') {
Some(kv) => kv,
None => continue,
};
if k.eq_ignore_ascii_case("www-authenticate") {
return Some(v.trim().to_string());
}
}
None
}
fn digest_param(challenge: &str, key: &str) -> Option<String> {
let t = challenge.trim();
let rest = if t.len() >= 6 && t[..6].eq_ignore_ascii_case("digest") {
t[6..].trim()
} else {
return None;
};
for part in rest.split(',') {
let part = part.trim();
let (k, v) = match part.split_once('=') {
Some(kv) => kv,
None => continue,
};
if k.eq_ignore_ascii_case(key) {
return Some(v.trim().trim_matches('"').to_string());
}
}
None
}
fn md5_hex(s: &str) -> String {
use md5::{Digest, Md5};
hex_lower(&Md5::digest(s.as_bytes()))
}
fn hex_lower(bytes: &[u8]) -> String {
const H: &[u8; 16] = b"0123456789abcdef";
let mut out = String::with_capacity(bytes.len() * 2);
for &b in bytes {
out.push(H[(b >> 4) as usize] as char);
out.push(H[(b & 0xf) as usize] as char);
}
out
}
fn digest_authorization(ep: &Endpoint, challenge: &str) -> Result<String> {
if let Some(alg) = digest_param(challenge, "algorithm")
&& !alg.eq_ignore_ascii_case("MD5")
{
return Err(Error("wallet RPC digest algorithm not MD5".into()));
}
let realm = digest_param(challenge, "realm").unwrap_or_default();
let nonce = digest_param(challenge, "nonce")
.ok_or_else(|| Error("wallet RPC digest required (--rpc-login / HTTP Digest)".into()))?;
let qop = digest_param(challenge, "qop");
let ha1 = md5_hex(&format!("{}:{realm}:{}", ep.user, ep.pass));
let ha2 = md5_hex(&format!("POST:{}", ep.path));
let (qop_part, resp) = if qop
.as_deref()
.is_some_and(|q| q.split(',').any(|x| x.trim() == "auth"))
{
let mut cnonce_bytes = [0u8; 8];
rand::thread_rng().fill_bytes(&mut cnonce_bytes);
let cnonce = hex_lower(&cnonce_bytes);
let nc = "00000001";
let response = md5_hex(&format!("{ha1}:{nonce}:{nc}:{cnonce}:auth:{ha2}"));
(
format!(", qop=auth, nc={nc}, cnonce=\"{cnonce}\""),
response,
)
} else {
(String::new(), md5_hex(&format!("{ha1}:{nonce}:{ha2}")))
};
Ok(format!(
"Digest username=\"{}\", realm=\"{realm}\", nonce=\"{nonce}\", uri=\"{}\", algorithm=MD5, response=\"{resp}\"{qop_part}",
ep.user, ep.path
))
}
fn parse_json_rpc(raw: &[u8]) -> Result<serde_json::Value> {
let text = std::str::from_utf8(raw).map_err(|_| Error("wallet RPC not UTF-8".into()))?;
let (head, body) = text

133
tests/file.rs Normal file
View file

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

View file

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

45
tests/send.rs Normal file
View file

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

View file

@ -55,11 +55,14 @@ fn first_run_creates_0700_dirs_and_self_row() {
let store = Store::open().expect("open");
let arti = home.path().join("arti");
let cache = home.path().join("cache");
let db = home.path().join("onionwire.db");
assert!(arti.is_dir(), "arti dir");
assert!(cache.is_dir(), "cache dir");
assert!(db.is_file(), "onionwire.db");
assert_eq!(mode(home.path()), 0o700);
assert_eq!(mode(&arti), 0o700);
assert_eq!(mode(&cache), 0o700);
let me = store.self_identity().expect("self");
assert_eq!(me.identity_pk.len(), 32);
@ -261,3 +264,64 @@ fn reopen_with_same_passphrase_decrypts() {
let msgs = store.list_messages(&pk(1)).unwrap();
assert_eq!(msgs[0].plaintext, b"hello again");
}
#[test]
fn env_passphrase_skips_prompt() {
let _g = ENV_LOCK.lock().expect("env lock");
unsafe {
std::env::set_var("ONIONWIRE_STORE_PASSPHRASE", "from-env");
}
let mut prompted = false;
let got = onionwire::resolve_store_passphrase(|_| {
prompted = true;
Ok("from-tty".into())
});
unsafe {
std::env::remove_var("ONIONWIRE_STORE_PASSPHRASE");
}
assert_eq!(got.unwrap(), "from-env");
assert!(!prompted);
}
#[test]
fn empty_env_passphrase_is_rejected_without_prompt() {
let _g = ENV_LOCK.lock().expect("env lock");
unsafe {
std::env::set_var("ONIONWIRE_STORE_PASSPHRASE", "");
}
let mut prompted = false;
let err = onionwire::resolve_store_passphrase(|_| {
prompted = true;
Ok("from-tty".into())
})
.unwrap_err();
unsafe {
std::env::remove_var("ONIONWIRE_STORE_PASSPHRASE");
}
assert!(err.to_string().contains("empty"), "got {err}");
assert!(!prompted);
}
#[test]
fn missing_env_reads_secret_and_strips_newline() {
let _g = ENV_LOCK.lock().expect("env lock");
unsafe {
std::env::remove_var("ONIONWIRE_STORE_PASSPHRASE");
}
let got = onionwire::resolve_store_passphrase(|prompt| {
assert!(prompt.contains("store passphrase"), "prompt {prompt}");
Ok("secret-from-tty\n".into())
})
.unwrap();
assert_eq!(got, "secret-from-tty");
}
#[test]
fn missing_env_empty_secret_is_rejected() {
let _g = ENV_LOCK.lock().expect("env lock");
unsafe {
std::env::remove_var("ONIONWIRE_STORE_PASSPHRASE");
}
let err = onionwire::resolve_store_passphrase(|_| Ok(String::new())).unwrap_err();
assert!(err.to_string().contains("empty"), "got {err}");
}

View file

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

View file

@ -2,8 +2,8 @@
use onionwire::qr;
use onionwire::tui::{
banner_for_width, compact_banner, draw_share, help_overlay_text, main_footer_hints,
onion_glyph, status_footer, wordmark_banner, Pane,
Pane, banner_for_width, compact_banner, draw_share, help_overlay_text, main_footer_hints,
onion_glyph, status_footer, wordmark_banner,
};
#[test]
@ -40,7 +40,19 @@ fn help_overlay_lists_core_bindings() {
let help = help_overlay_text();
assert!(!help.is_empty());
for needle in [
"Tab", "F2", "F3", "F4", "F5", "/profile", "/pay", "/tip", "/backup", "/restore", "Ctrl-Q",
"Tab",
"F2",
"F3",
"F4",
"F5",
"send chat",
"/profile",
"/pay",
"/tip",
"/file",
"/backup",
"/restore",
"Ctrl-Q",
"?",
] {
assert!(help.contains(needle), "help overlay missing {needle:?}");
@ -65,10 +77,10 @@ fn banner_for_width_collapses_when_narrow() {
#[test]
fn chrome_renders_at_80x24_and_120x40() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use ratatui::layout::{Constraint, Layout};
use ratatui::widgets::Paragraph;
use ratatui::Terminal;
let fp = "abcdef0123456789";
let onion = "abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopqrstuvwx.onion";
@ -166,8 +178,8 @@ fn restore_terminal_is_callable_without_panic() {
#[test]
fn share_screen_shows_invite_not_qr_at_80x24() {
use ratatui::backend::TestBackend;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let sk = [7u8; 32];
let onion = "abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopqrstuvwx.onion";

View file

@ -30,13 +30,6 @@ fn json_rpc_ok(result: &str) -> String {
)
}
async fn serve_once(listener: TcpListener, response: String) {
let (mut sock, _) = listener.accept().await.expect("accept");
let mut buf = vec![0u8; 4096];
let _ = sock.read(&mut buf).await;
sock.write_all(response.as_bytes()).await.expect("write");
}
#[test]
fn disabled_create_address_is_not_configured() {
let w = Wallet::disabled();
@ -49,9 +42,19 @@ fn disabled_create_address_is_not_configured() {
assert!(err.to_string().contains("not configured"), "got {err}");
}
#[test]
fn refuse_url_without_credentials() {
let err = Wallet::from_url("http://127.0.0.1:18083").unwrap_err();
let msg = err.to_string().to_ascii_lowercase();
assert!(
msg.contains("credential") || msg.contains("login") || msg.contains("user"),
"got {err}"
);
}
#[test]
fn refuse_non_loopback_non_onion_host() {
let err = Wallet::from_url("http://example.com:18083").unwrap_err();
let err = Wallet::from_url("http://ow:secret@example.com:18083").unwrap_err();
assert!(
err.to_string().to_ascii_lowercase().contains("host")
|| err.to_string().contains("loopback")
@ -60,6 +63,34 @@ fn refuse_non_loopback_non_onion_host() {
);
}
#[test]
fn refuse_onion_rpc_url() {
let err = Wallet::from_url(
"http://ow:s3cretPASS@abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopqrstuvwxyz.onion:18083",
)
.unwrap_err();
let msg = err.to_string();
assert!(msg.to_ascii_lowercase().contains("onion"), "got {err}");
assert!(
!msg.contains("s3cretPASS"),
"password leaked in error: {err}"
);
}
#[test]
fn password_absent_from_url_errors() {
let err = Wallet::from_url("http://ow:s3cretPASS@example.com:18083").unwrap_err();
assert!(
!err.to_string().contains("s3cretPASS"),
"password leaked in error: {err}"
);
let err = Wallet::from_url("http://ow:s3cretPASS@").unwrap_err();
assert!(
!err.to_string().contains("s3cretPASS"),
"password leaked in error: {err}"
);
}
#[tokio::test]
async fn mock_get_address_parses_string() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
@ -67,8 +98,8 @@ async fn mock_get_address_parses_string() {
let canned = json_rpc_ok(
r#"{"address":"4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"}"#,
);
tokio::spawn(serve_once(listener, canned));
let w = Wallet::from_url(&format!("http://127.0.0.1:{}", addr.port())).unwrap();
tokio::spawn(serve_digest_then(listener, canned));
let w = Wallet::from_url(&format!("http://ow:secret@127.0.0.1:{}", addr.port())).unwrap();
let got = w.get_address().await.expect("get_address");
assert!(got.starts_with('4'), "got {got}");
}
@ -80,15 +111,15 @@ async fn mock_create_address_and_transfer() {
let canned = json_rpc_ok(
r#"{"address":"8BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB","tx_hash":"abc123"}"#,
);
tokio::spawn(serve_once(listener, canned.clone()));
let w = Wallet::from_url(&format!("http://127.0.0.1:{}", addr.port())).unwrap();
tokio::spawn(serve_digest_then(listener, canned.clone()));
let w = Wallet::from_url(&format!("http://ow:secret@127.0.0.1:{}", addr.port())).unwrap();
let created = w.create_address().await.expect("create_address");
assert!(created.starts_with('8'));
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(serve_once(listener, canned));
let w = Wallet::from_url(&format!("http://127.0.0.1:{}", addr.port())).unwrap();
tokio::spawn(serve_digest_then(listener, canned));
let w = Wallet::from_url(&format!("http://ow:secret@127.0.0.1:{}", addr.port())).unwrap();
let txid = w
.transfer("8BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", 1)
.await
@ -103,8 +134,8 @@ async fn mock_get_transfers_matches_txid() {
let canned = json_rpc_ok(
r#"{"in":[{"txid":"deadbeef","amount":5,"address":"8BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"}],"pending":[]}"#,
);
tokio::spawn(serve_once(listener, canned));
let w = Wallet::from_url(&format!("http://127.0.0.1:{}", addr.port())).unwrap();
tokio::spawn(serve_digest_then(listener, canned));
let w = Wallet::from_url(&format!("http://ow:secret@127.0.0.1:{}", addr.port())).unwrap();
let rows = w.get_transfers().await.expect("get_transfers");
assert!(wallet::transfers_match(
&rows,
@ -182,3 +213,67 @@ fn ingest_signed_receipt_mismatched_wallet_history_stays_unverified() {
let rows = store.list_payments(&pk).unwrap();
assert!(!rows[0].verified);
}
#[tokio::test]
async fn oversized_rpc_response_is_err() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let huge = vec![b'A'; 2 * 1024 * 1024];
tokio::spawn(async move {
let (mut sock, _) = listener.accept().await.expect("accept");
let mut buf = vec![0u8; 4096];
let _ = sock.read(&mut buf).await;
sock.write_all(b"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nConnection: close\r\n\r\n")
.await
.expect("hdr");
sock.write_all(&huge).await.expect("body");
});
let w = Wallet::from_url(&format!("http://ow:secret@127.0.0.1:{}", addr.port())).unwrap();
let err = w.get_address().await.unwrap_err();
let msg = err.to_string().to_ascii_lowercase();
assert!(
msg.contains("large") || msg.contains("size") || msg.contains("cap"),
"got {err}"
);
assert!(
!err.to_string().contains("secret"),
"password leaked: {err}"
);
}
fn digest_401() -> String {
"HTTP/1.1 401 Unauthorized\r\nWWW-Authenticate: Digest realm=\"monero-rpc\", nonce=\"abcnonce\", qop=\"auth\", algorithm=MD5\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".into()
}
async fn serve_digest_then(listener: TcpListener, ok: String) {
let (mut sock, _) = listener.accept().await.expect("accept");
let mut buf = vec![0u8; 8192];
let _ = sock.read(&mut buf).await;
sock.write_all(digest_401().as_bytes()).await.expect("401");
drop(sock);
let (mut sock, _) = listener.accept().await.expect("accept2");
buf.fill(0);
let n = sock.read(&mut buf).await.unwrap_or(0);
let req = String::from_utf8_lossy(&buf[..n]);
assert!(
req.contains("Authorization: Digest"),
"missing digest auth: {req}"
);
assert!(req.contains("username=\"ow\""), "missing user: {req}");
assert!(req.contains("response=\""), "missing response: {req}");
sock.write_all(ok.as_bytes()).await.expect("200");
}
#[tokio::test]
async fn mock_digest_auth_accepted() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let canned = json_rpc_ok(
r#"{"address":"4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"}"#,
);
tokio::spawn(serve_digest_then(listener, canned));
let w = Wallet::from_url(&format!("http://ow:s3cretPASS@127.0.0.1:{}", addr.port())).unwrap();
let got = w.get_address().await.expect("get_address");
assert!(got.starts_with('4'), "got {got}");
}

View file

@ -1,10 +1,13 @@
//! M5: wipe messages (keep identity + friends); wipe-all is a new person.
use onionwire::Store;
use onionwire::tui::{
QuitDecision, QuitPrompt, WipeDecision, WipeKind, WipePrompt, parse_slash, quit_screen_text,
wipe_screen_text,
parse_slash, quit_screen_text, wipe_screen_text, QuitDecision, QuitPrompt, WipeDecision,
WipeKind, WipePrompt,
};
use onionwire::{PaymentWrite, Store};
// Official mainnet standard from Monero docs (same fixture as tests/pay.rs).
const MAINNET_STD: &str = "4AdUndXHHZ6cfufTMvppY6JwXNouMBzSkbLYfpAV5Usx3skxNgYeYTRj5UzqtReoS44qo9mtmXCqY45DJ852K5Jv2684Rge";
fn pk(tag: u8) -> [u8; 32] {
let mut k = [0u8; 32];
@ -50,6 +53,53 @@ fn wipe_clears_messages_keeps_self_and_friends() {
);
}
#[test]
fn wipe_clears_payments_keeps_self_and_friends() {
let dir = tempfile::tempdir().expect("tempdir");
let store = Store::open_at_with_passphrase(dir.path(), "onionwire-test").expect("open");
let me = store.self_identity().unwrap();
store
.upsert_friend(&pk(1), "a.onion", Some("alice"))
.unwrap();
store
.append_message(&pk(1), "out", b"secret-log-line-xyz")
.unwrap();
store
.insert_payment(
&pk(1),
PaymentWrite {
dir: "out",
kind: "receipt",
amount_atomic: "1000000000000",
address: MAINNET_STD,
memo: "counterparty-memo-xyz",
txid: Some("aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899"),
verified: true,
},
)
.unwrap();
assert_eq!(store.list_messages(&pk(1)).unwrap().len(), 1);
assert_eq!(store.list_payments(&pk(1)).unwrap().len(), 1);
store.wipe_messages().expect("wipe");
assert!(store.list_messages(&pk(1)).unwrap().is_empty());
assert!(
store.list_payments(&pk(1)).unwrap().is_empty(),
"wipe must drop payments, not only chat"
);
assert_eq!(store.friend_count().unwrap(), 1);
let f = store.get_friend(&pk(1)).unwrap().expect("friend");
assert_eq!(f.petname.as_deref(), Some("alice"));
let me2 = store.self_identity().unwrap();
assert_eq!(me.identity_pk, me2.identity_pk);
drop(store);
assert!(
!db_contains(dir.path(), b"counterparty-memo-xyz"),
"wipe must not leave payment memo in the db file"
);
}
#[test]
fn wipe_all_removes_dir_so_next_open_is_new_identity() {
let dir = tempfile::tempdir().expect("tempdir");
@ -108,8 +158,9 @@ fn wipe_all_requires_typing_wipeall() {
#[test]
fn wipe_prompt_text_matches_spec() {
let m = wipe_screen_text(WipeKind::Messages);
assert!(m.contains("Wipe message log?"));
assert!(m.contains("Wipe chat and payments history?"));
assert!(m.contains("Identity key and friends stay."));
assert!(m.contains("Not a forensic erase."));
assert!(m.contains("Type WIPE to confirm"));
assert!(m.contains("Esc to cancel"));
let a = wipe_screen_text(WipeKind::All);