diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 0eff673..e4690a9 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -8,12 +8,18 @@ on: pull_request: workflow_dispatch: +# One cargo job on the Pi at a time. Overlapping PR+main runs shared the +# container name `onionwire-ci` (docker Conflict) and OOM-killed with 137. +concurrency: + group: onionwire-ci-pi + cancel-in-progress: false + env: CARGO_TERM_COLOR: never RUST_IMAGE: rust:1.91-bookworm CARGO_REGISTRY_VOLUME: onionwire-cargo-registry CARGO_TARGET_VOLUME: onionwire-target-ci - BUILD_CONTAINER: onionwire-ci + BUILD_CONTAINER: onionwire-ci-${{ github.run_id }} jobs: test: diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index ac4d184..881c867 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -22,7 +22,7 @@ env: RUST_IMAGE: rust:1.91-bookworm CARGO_REGISTRY_VOLUME: onionwire-cargo-registry CARGO_TARGET_VOLUME: onionwire-target-aarch64 - BUILD_CONTAINER: onionwire-release-build + BUILD_CONTAINER: onionwire-release-build-${{ github.run_id }} jobs: aarch64: diff --git a/Cargo.lock b/Cargo.lock index 67ba8a4..d44b5c5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" @@ -2626,6 +2636,7 @@ dependencies = [ "chacha20poly1305", "ed25519-dalek", "futures", + "md-5", "rand 0.8.8", "ratatui", "rusqlite", diff --git a/Cargo.toml b/Cargo.toml index 342d15c..3d82721 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,6 +44,7 @@ serde_json = "1" argon2 = "0.5" chacha20poly1305 = "0.10" sha3 = "0.10" +md-5 = "0.10" [dev-dependencies] tempfile = "3" diff --git a/README.md b/README.md index e80dab3..f6f3054 100644 --- a/README.md +++ b/README.md @@ -162,13 +162,17 @@ If a peer’s onion is down, send fails. v1 has no outbox, no retry queue, no DH ## 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 [memo]` — invoice (we want to receive). Uses a wallet subaddress if RPC is up, else the profile `xmr_addr`. - `/tip [memo]` — pay the selected friend’s profile address, then send a signed `rcp`. Incoming receipts stay unverified until RPC `get_transfers` matches. @@ -190,8 +194,8 @@ Treat the backup file like the sqlite db. 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 +219,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). diff --git a/docs/THREAT_MODEL.md b/docs/THREAT_MODEL.md index 8e86980..e8372a5 100644 --- a/docs/THREAT_MODEL.md +++ b/docs/THREAT_MODEL.md @@ -18,7 +18,7 @@ A signed `loc` frame (`onion`, `ts`, `sig`) rewrites a friend’s 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 user’s 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 user’s wallet policy. ## Backup file is the identity diff --git a/src/hs.rs b/src/hs.rs index 019f311..159e53d 100644 --- a/src/hs.rs +++ b/src/hs.rs @@ -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>; +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::() { + 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; diff --git a/src/node.rs b/src/node.rs index 92c49c5..4a8efac 100644 --- a/src/node.rs +++ b/src/node.rs @@ -92,7 +92,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 +444,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())?; diff --git a/src/store.rs b/src/store.rs index adc1228..c2ad26f 100644 --- a/src/store.rs +++ b/src/store.rs @@ -4,9 +4,9 @@ use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; use ed25519_dalek::SigningKey; -use rand::RngCore; use rand::rngs::OsRng; -use rusqlite::{Connection, OptionalExtension, params}; +use rand::RngCore; +use rusqlite::{params, Connection, OptionalExtension}; use x25519_dalek::{PublicKey as X25519Public, StaticSecret}; pub type Result = std::result::Result; @@ -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(()) } diff --git a/src/tui.rs b/src/tui.rs index 8efd685..7d5c00f 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -318,8 +318,9 @@ pub fn parse_slash(raw: &str) -> Option { 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 => { diff --git a/src/wallet.rs b/src/wallet.rs index e7cc081..9dd574f 100644 --- a/src/wallet.rs +++ b/src/wallet.rs @@ -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", &"") + .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, - Err(e) => { - eprintln!("ONIONWIRE_WALLET_RPC: {e}"); - Self::disabled() + 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 { 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 { +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 { 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 { 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 { 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> { + 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> { 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> { .await .map_err(|e| Error(format!("wallet write: {e}")))?; let mut buf = Vec::new(); - stream - .read_to_end(&mut buf) - .await - .map_err(|e| Error(format!("wallet read: {e}")))?; + 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 { + 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 { + 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 { + 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 { + 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 { let text = std::str::from_utf8(raw).map_err(|_| Error("wallet RPC not UTF-8".into()))?; let (head, body) = text diff --git a/tests/hs.rs b/tests/hs.rs index bfa70eb..334620c 100644 --- a/tests/hs.rs +++ b/tests/hs.rs @@ -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); +} diff --git a/tests/store.rs b/tests/store.rs index 33f4a89..bf993eb 100644 --- a/tests/store.rs +++ b/tests/store.rs @@ -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); diff --git a/tests/tor_hs.rs b/tests/tor_hs.rs index 1851183..d5b12bc 100644 --- a/tests/tor_hs.rs +++ b/tests/tor_hs.rs @@ -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"); diff --git a/tests/wallet.rs b/tests/wallet.rs index 2747e0f..097c009 100644 --- a/tests/wallet.rs +++ b/tests/wallet.rs @@ -17,7 +17,9 @@ fn row(txid: &str, amount: &str, address: &str) -> TransferRow { } fn xmr_addr() -> String { - format!("8{}", "B".repeat(94)) + // Same documented mainnet standard as tests/pay.rs — F4 checksums this. + "4AdUndXHHZ6cfufTMvppY6JwXNouMBzSkbLYfpAV5Usx3skxNgYeYTRj5UzqtReoS44qo9mtmXCqY45DJ852K5Jv2684Rge" + .to_string() } fn json_rpc_ok(result: &str) -> String { @@ -28,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(); @@ -47,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") @@ -58,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(); @@ -65,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}"); } @@ -78,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 @@ -101,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, @@ -180,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}"); +} diff --git a/tests/wipe.rs b/tests/wipe.rs index 2cc25ab..61245cf 100644 --- a/tests/wipe.rs +++ b/tests/wipe.rs @@ -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);