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
All checks were successful
ci / test (push) Successful in 2m45s
Reviewed-on: #12
This commit is contained in:
commit
f109223678
16 changed files with 489 additions and 70 deletions
|
|
@ -8,12 +8,18 @@ on:
|
||||||
pull_request:
|
pull_request:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
|
# One cargo job on the Pi at a time. Overlapping PR+main runs shared the
|
||||||
|
# container name `onionwire-ci` (docker Conflict) and OOM-killed with 137.
|
||||||
|
concurrency:
|
||||||
|
group: onionwire-ci-pi
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
env:
|
env:
|
||||||
CARGO_TERM_COLOR: never
|
CARGO_TERM_COLOR: never
|
||||||
RUST_IMAGE: rust:1.91-bookworm
|
RUST_IMAGE: rust:1.91-bookworm
|
||||||
CARGO_REGISTRY_VOLUME: onionwire-cargo-registry
|
CARGO_REGISTRY_VOLUME: onionwire-cargo-registry
|
||||||
CARGO_TARGET_VOLUME: onionwire-target-ci
|
CARGO_TARGET_VOLUME: onionwire-target-ci
|
||||||
BUILD_CONTAINER: onionwire-ci
|
BUILD_CONTAINER: onionwire-ci-${{ github.run_id }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ env:
|
||||||
RUST_IMAGE: rust:1.91-bookworm
|
RUST_IMAGE: rust:1.91-bookworm
|
||||||
CARGO_REGISTRY_VOLUME: onionwire-cargo-registry
|
CARGO_REGISTRY_VOLUME: onionwire-cargo-registry
|
||||||
CARGO_TARGET_VOLUME: onionwire-target-aarch64
|
CARGO_TARGET_VOLUME: onionwire-target-aarch64
|
||||||
BUILD_CONTAINER: onionwire-release-build
|
BUILD_CONTAINER: onionwire-release-build-${{ github.run_id }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
aarch64:
|
aarch64:
|
||||||
|
|
|
||||||
11
Cargo.lock
generated
11
Cargo.lock
generated
|
|
@ -2359,6 +2359,16 @@ dependencies = [
|
||||||
"regex-automata",
|
"regex-automata",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "md-5"
|
||||||
|
version = "0.10.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"digest 0.10.7",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "memchr"
|
name = "memchr"
|
||||||
version = "2.8.3"
|
version = "2.8.3"
|
||||||
|
|
@ -2626,6 +2636,7 @@ dependencies = [
|
||||||
"chacha20poly1305",
|
"chacha20poly1305",
|
||||||
"ed25519-dalek",
|
"ed25519-dalek",
|
||||||
"futures",
|
"futures",
|
||||||
|
"md-5",
|
||||||
"rand 0.8.8",
|
"rand 0.8.8",
|
||||||
"ratatui",
|
"ratatui",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ serde_json = "1"
|
||||||
argon2 = "0.5"
|
argon2 = "0.5"
|
||||||
chacha20poly1305 = "0.10"
|
chacha20poly1305 = "0.10"
|
||||||
sha3 = "0.10"
|
sha3 = "0.10"
|
||||||
|
md-5 = "0.10"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
|
|
|
||||||
14
README.md
14
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
|
## Monero sidecar
|
||||||
|
|
||||||
OnionWire is not a wallet. Optional JSON-RPC to a user-hosted `monero-wallet-rpc`:
|
OnionWire is not a wallet. Optional JSON-RPC to a user-hosted `monero-wallet-rpc`. The wallet **must** use `--rpc-login`; OnionWire refuses an open RPC (HTTP 200 without a Digest challenge) and refuses URLs with no credentials.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# monero-wallet-rpc --rpc-bind-ip 127.0.0.1 --rpc-bind-port 18083 --rpc-login onionwire:secret
|
||||||
|
export ONIONWIRE_WALLET_RPC=http://onionwire:secret@127.0.0.1:18083
|
||||||
|
# or keep the password out of the URL:
|
||||||
export ONIONWIRE_WALLET_RPC=http://127.0.0.1:18083
|
export ONIONWIRE_WALLET_RPC=http://127.0.0.1:18083
|
||||||
|
export ONIONWIRE_WALLET_RPC_LOGIN=onionwire:secret
|
||||||
```
|
```
|
||||||
|
|
||||||
Loopback or `.onion` only, HTTP, 5s timeout. Unset → chat still works; `/pay` and `/tip` say so.
|
Loopback only, HTTP Digest (RFC 2617, matching `--rpc-login`), 5s timeout, 1 MiB response cap. `.onion` RPC URLs are rejected (no Arti dial; do not point this at a remote wallet). Unset → chat still works; `/pay` and `/tip` say so. Do not log the RPC password.
|
||||||
|
|
||||||
- `/pay <xmr> [memo]` — invoice (we want to receive). Uses a wallet subaddress if RPC is up, else the profile `xmr_addr`.
|
- `/pay <xmr> [memo]` — invoice (we want to receive). Uses a wallet subaddress if RPC is up, else the profile `xmr_addr`.
|
||||||
- `/tip <xmr> [memo]` — pay the selected friend’s profile address, then send a signed `rcp`. Incoming receipts stay unverified until RPC `get_transfers` matches.
|
- `/tip <xmr> [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):
|
Composer (bottom of the roster screen):
|
||||||
|
|
||||||
- `/wipe` — confirm by typing `WIPE`. Overwrites the message log and `VACUUM`s. Identity key and friends stay.
|
- `/wipe` — confirm by typing `WIPE`. Chat and payments history gone (overwrite message bodies, drop `payments`, `VACUUM`, WAL checkpoint). Identity key and friends stay. Not a forensic erase (SSD wear-leveling). `/wipe-all` is the identity burn.
|
||||||
- `/wipe-all` — confirm by typing `WIPEALL`. Deletes the data dir. Next start is a **new person** (new identity key). Esc cancels. Nothing is wiped without confirm.
|
- `/wipe-all` — confirm by typing `WIPEALL`. Deletes the data dir. Next start is a **new person** (new identity key). Same disk caveat. Esc cancels. Nothing is wiped without confirm.
|
||||||
|
|
||||||
## Uninstall
|
## Uninstall
|
||||||
|
|
||||||
|
|
@ -215,7 +219,7 @@ Still plaintext on disk (unless you add OS/FDE):
|
||||||
- your identity secret key (`self.identity_sk`)
|
- your identity secret key (`self.identity_sk`)
|
||||||
- friend public keys and current locators
|
- friend public keys and current locators
|
||||||
|
|
||||||
The message key is **not** wrapped with `identity_sk` (that key is in the same file). sqlcipher is out of v1. `/wipe` overwrites message bodies and vacuums; `/wipe-all` deletes the data dir. Ctrl-Q can clear history on the way out (`CLEAR`) without becoming a new person.
|
The message key is **not** wrapped with `identity_sk` (that key is in the same file). sqlcipher is out of v1. `/wipe` deletes chat and payments history; it is not a forensic erase. Roster and identity stay. `/wipe-all` deletes the data dir (new identity). Ctrl-Q can clear history on the way out (`CLEAR`) without becoming a new person.
|
||||||
|
|
||||||
Threat model: [`docs/THREAT_MODEL.md`](docs/THREAT_MODEL.md).
|
Threat model: [`docs/THREAT_MODEL.md`](docs/THREAT_MODEL.md).
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
Chat bodies in sqlite are ChaCha20-Poly1305 (`nonce || ciphertext` in the `messages.plaintext` column) with AAD `owmsg1 || friend_id_le64 || dir || 0x00 || row_id_le64`. Swapping ciphertext between rows fails closed. A random 32-byte data key is wrapped with Argon2id (same params as identity backup) from a non-empty passphrase. Salt + wrapped key live in `store_meta`. Unlock is fail-closed: wrong or empty passphrase does not open chat. Empty-AAD v0.2 blobs are rewrapped once on unlock; `list_messages` never falls back to empty AAD.
|
||||||
|
|
||||||
Identity secret key, friend public keys, and locators remain plaintext in the same db. The message key is not wrapped with `identity_sk` (that key is already on disk). A seized laptop still yields who you talk to and your identity unless you add OS/FDE. sqlcipher is out of v1. `/wipe` overwrites message bodies and vacuums; `/wipe-all` deletes the data dir.
|
Identity secret key, friend public keys, and locators remain plaintext in the same db. The message key is not wrapped with `identity_sk` (that key is already on disk). A seized laptop still yields who you talk to and your identity unless you add OS/FDE. sqlcipher is out of v1. `/wipe` deletes chat and payments history (overwrite message bodies, `VACUUM`, WAL checkpoint); roster and identity stay. It is not a forensic erase — SSD wear-leveling can keep copies. `/wipe-all` deletes the data dir (new identity); same disk caveat.
|
||||||
|
|
||||||
## Fail closed
|
## Fail closed
|
||||||
|
|
||||||
|
|
@ -30,7 +30,7 @@ A signed `prf` frame is shown to people who already have a session with you. App
|
||||||
|
|
||||||
## Monero sidecar is not a wallet
|
## Monero sidecar is not a wallet
|
||||||
|
|
||||||
OnionWire never holds spend keys. Optional `ONIONWIRE_WALLET_RPC` talks HTTP to a user-hosted `monero-wallet-rpc`. 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
|
## Backup file is the identity
|
||||||
|
|
||||||
|
|
|
||||||
24
src/hs.rs
24
src/hs.rs
|
|
@ -1,5 +1,6 @@
|
||||||
//! In-process Arti onion-service helpers (no C-tor).
|
//! In-process Arti onion-service helpers (no C-tor).
|
||||||
|
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
|
@ -8,7 +9,7 @@ use arti_client::{TorClient, TorClientConfig};
|
||||||
use futures::StreamExt;
|
use futures::StreamExt;
|
||||||
use safelog::DisplayRedacted;
|
use safelog::DisplayRedacted;
|
||||||
use tor_hsservice::status::State;
|
use tor_hsservice::status::State;
|
||||||
use tor_hsservice::{HsNickname, OnionServiceConfig, RunningOnionService};
|
use tor_hsservice::{HsId, HsNickname, OnionServiceConfig, RunningOnionService};
|
||||||
use tor_rtcompat::PreferredRuntime;
|
use tor_rtcompat::PreferredRuntime;
|
||||||
|
|
||||||
pub const HS_PORT: u16 = 80;
|
pub const HS_PORT: u16 = 80;
|
||||||
|
|
@ -28,9 +29,24 @@ const PROBE_TIMEOUT: Duration = Duration::from_secs(12);
|
||||||
|
|
||||||
pub type Client = Arc<TorClient<PreferredRuntime>>;
|
pub type Client = Arc<TorClient<PreferredRuntime>>;
|
||||||
|
|
||||||
|
fn mkdir_700(path: &std::path::Path) {
|
||||||
|
std::fs::create_dir_all(path).expect("mkdir");
|
||||||
|
let mut perms = std::fs::metadata(path).expect("metadata").permissions();
|
||||||
|
perms.set_mode(0o700);
|
||||||
|
std::fs::set_permissions(path, perms).expect("chmod 0700");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Status/probe log label: safelog-redacted v3 onion, never the locator.
|
||||||
|
pub fn log_label(onion: &str) -> String {
|
||||||
|
match onion.parse::<HsId>() {
|
||||||
|
Ok(id) => id.display_redacted().to_string(),
|
||||||
|
Err(_) => safelog::sensitive(onion).to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn client_config(state_dir: &std::path::Path, cache_dir: &std::path::Path) -> TorClientConfig {
|
pub fn client_config(state_dir: &std::path::Path, cache_dir: &std::path::Path) -> TorClientConfig {
|
||||||
std::fs::create_dir_all(state_dir).expect("state dir");
|
mkdir_700(state_dir);
|
||||||
std::fs::create_dir_all(cache_dir).expect("cache dir");
|
mkdir_700(cache_dir);
|
||||||
let mut builder = TorClientConfigBuilder::from_directories(state_dir, cache_dir);
|
let mut builder = TorClientConfigBuilder::from_directories(state_dir, cache_dir);
|
||||||
builder.storage().permissions().dangerously_trust_everyone();
|
builder.storage().permissions().dangerously_trust_everyone();
|
||||||
builder
|
builder
|
||||||
|
|
@ -84,8 +100,8 @@ pub async fn wait_until_published(
|
||||||
client: &Client,
|
client: &Client,
|
||||||
svc: &RunningOnionService,
|
svc: &RunningOnionService,
|
||||||
onion: &str,
|
onion: &str,
|
||||||
label: &str,
|
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
|
let label = log_label(onion);
|
||||||
let deadline = Instant::now() + PUBLISH_WAIT;
|
let deadline = Instant::now() + PUBLISH_WAIT;
|
||||||
let mut events = svc.status_events();
|
let mut events = svc.status_events();
|
||||||
let mut next_probe = Instant::now() + PROBE_EVERY;
|
let mut next_probe = Instant::now() + PROBE_EVERY;
|
||||||
|
|
|
||||||
|
|
@ -92,7 +92,7 @@ impl Node {
|
||||||
_svc: Arc::clone(&svc),
|
_svc: Arc::clone(&svc),
|
||||||
rend,
|
rend,
|
||||||
});
|
});
|
||||||
hs::wait_until_published(&node.client, &svc, &onion, &onion).await?;
|
hs::wait_until_published(&node.client, &svc, &onion).await?;
|
||||||
node.store
|
node.store
|
||||||
.lock()
|
.lock()
|
||||||
.map_err(|e| e.to_string())?
|
.map_err(|e| e.to_string())?
|
||||||
|
|
@ -444,7 +444,7 @@ impl Node {
|
||||||
_svc: Arc::clone(&svc),
|
_svc: Arc::clone(&svc),
|
||||||
rend,
|
rend,
|
||||||
});
|
});
|
||||||
hs::wait_until_published(&self.client, &svc, &onion, &onion).await?;
|
hs::wait_until_published(&self.client, &svc, &onion).await?;
|
||||||
{
|
{
|
||||||
let store = self.store.lock().map_err(|e| e.to_string())?;
|
let store = self.store.lock().map_err(|e| e.to_string())?;
|
||||||
store.set_onion(&onion).map_err(|e| e.to_string())?;
|
store.set_onion(&onion).map_err(|e| e.to_string())?;
|
||||||
|
|
|
||||||
13
src/store.rs
13
src/store.rs
|
|
@ -4,9 +4,9 @@ use std::path::{Path, PathBuf};
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use ed25519_dalek::SigningKey;
|
use ed25519_dalek::SigningKey;
|
||||||
use rand::RngCore;
|
|
||||||
use rand::rngs::OsRng;
|
use rand::rngs::OsRng;
|
||||||
use rusqlite::{Connection, OptionalExtension, params};
|
use rand::RngCore;
|
||||||
|
use rusqlite::{params, Connection, OptionalExtension};
|
||||||
use x25519_dalek::{PublicKey as X25519Public, StaticSecret};
|
use x25519_dalek::{PublicKey as X25519Public, StaticSecret};
|
||||||
|
|
||||||
pub type Result<T> = std::result::Result<T, Error>;
|
pub type Result<T> = std::result::Result<T, Error>;
|
||||||
|
|
@ -117,12 +117,16 @@ impl Store {
|
||||||
}
|
}
|
||||||
mkdir_700(home)?;
|
mkdir_700(home)?;
|
||||||
mkdir_700(&home.join("arti"))?;
|
mkdir_700(&home.join("arti"))?;
|
||||||
|
mkdir_700(&home.join("cache"))?;
|
||||||
let db_path = home.join("onionwire.db");
|
let db_path = home.join("onionwire.db");
|
||||||
let conn = Connection::open(&db_path)?;
|
let conn = Connection::open(&db_path)?;
|
||||||
let journal: String = conn.query_row("PRAGMA journal_mode = WAL", [], |row| row.get(0))?;
|
let journal: String = conn.query_row("PRAGMA journal_mode = WAL", [], |row| row.get(0))?;
|
||||||
if !journal.eq_ignore_ascii_case("wal") {
|
if !journal.eq_ignore_ascii_case("wal") {
|
||||||
return Err(Error(format!("journal_mode WAL failed: {journal}")));
|
return Err(Error(format!("journal_mode WAL failed: {journal}")));
|
||||||
}
|
}
|
||||||
|
// Overwrite freed pages on DELETE. Flash wear-leveling can still keep copies;
|
||||||
|
// this is not a forensic / SSD crypto-shred.
|
||||||
|
conn.pragma_update(None, "secure_delete", "ON")?;
|
||||||
conn.execute_batch(
|
conn.execute_batch(
|
||||||
"
|
"
|
||||||
PRAGMA foreign_keys = ON;
|
PRAGMA foreign_keys = ON;
|
||||||
|
|
@ -748,14 +752,17 @@ impl Store {
|
||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Overwrite message bodies, delete rows, VACUUM. Identity + friends stay.
|
/// Drop chat + payments history. Identity + friends stay.
|
||||||
|
/// Not a forensic erase: SSD wear-leveling can keep copies.
|
||||||
pub fn wipe_messages(&self) -> Result<()> {
|
pub fn wipe_messages(&self) -> Result<()> {
|
||||||
self.conn.execute(
|
self.conn.execute(
|
||||||
"UPDATE messages SET plaintext = zeroblob(length(plaintext))",
|
"UPDATE messages SET plaintext = zeroblob(length(plaintext))",
|
||||||
[],
|
[],
|
||||||
)?;
|
)?;
|
||||||
self.conn.execute("DELETE FROM messages", [])?;
|
self.conn.execute("DELETE FROM messages", [])?;
|
||||||
|
self.conn.execute("DELETE FROM payments", [])?;
|
||||||
self.conn.execute_batch("VACUUM")?;
|
self.conn.execute_batch("VACUUM")?;
|
||||||
|
self.conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE)")?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -318,8 +318,9 @@ pub fn parse_slash(raw: &str) -> Option<WipeKind> {
|
||||||
pub fn wipe_screen_text(kind: WipeKind) -> &'static str {
|
pub fn wipe_screen_text(kind: WipeKind) -> &'static str {
|
||||||
match kind {
|
match kind {
|
||||||
WipeKind::Messages => {
|
WipeKind::Messages => {
|
||||||
"Wipe message log?\n\
|
"Wipe chat and payments history?\n\
|
||||||
Identity key and friends stay.\n\
|
Identity key and friends stay.\n\
|
||||||
|
Not a forensic erase.\n\
|
||||||
Type WIPE to confirm Esc to cancel"
|
Type WIPE to confirm Esc to cancel"
|
||||||
}
|
}
|
||||||
WipeKind::All => {
|
WipeKind::All => {
|
||||||
|
|
|
||||||
240
src/wallet.rs
240
src/wallet.rs
|
|
@ -1,5 +1,6 @@
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use rand::RngCore;
|
||||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
use tokio::net::TcpStream;
|
use tokio::net::TcpStream;
|
||||||
|
|
||||||
|
|
@ -16,11 +17,25 @@ impl std::fmt::Display for Error {
|
||||||
|
|
||||||
impl std::error::Error for Error {}
|
impl std::error::Error for Error {}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Clone)]
|
||||||
struct Endpoint {
|
struct Endpoint {
|
||||||
host: String,
|
host: String,
|
||||||
port: u16,
|
port: u16,
|
||||||
path: String,
|
path: String,
|
||||||
|
user: String,
|
||||||
|
pass: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for Endpoint {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.debug_struct("Endpoint")
|
||||||
|
.field("host", &self.host)
|
||||||
|
.field("port", &self.port)
|
||||||
|
.field("path", &self.path)
|
||||||
|
.field("user", &self.user)
|
||||||
|
.field("pass", &"<redacted>")
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
|
|
@ -44,20 +59,28 @@ impl Wallet {
|
||||||
|
|
||||||
pub fn from_env() -> Self {
|
pub fn from_env() -> Self {
|
||||||
match std::env::var("ONIONWIRE_WALLET_RPC") {
|
match std::env::var("ONIONWIRE_WALLET_RPC") {
|
||||||
Ok(s) if !s.trim().is_empty() => match Self::from_url(s.trim()) {
|
Ok(s) if !s.trim().is_empty() => {
|
||||||
Ok(w) => w,
|
let login = std::env::var("ONIONWIRE_WALLET_RPC_LOGIN")
|
||||||
|
.ok()
|
||||||
|
.map(|v| v.trim().to_string())
|
||||||
|
.filter(|v| !v.is_empty());
|
||||||
|
match parse_http_url(s.trim(), login.as_deref()) {
|
||||||
|
Ok(endpoint) => Self {
|
||||||
|
endpoint: Some(endpoint),
|
||||||
|
},
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("ONIONWIRE_WALLET_RPC: {e}");
|
eprintln!("ONIONWIRE_WALLET_RPC: {e}");
|
||||||
Self::disabled()
|
Self::disabled()
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
|
}
|
||||||
_ => Self::disabled(),
|
_ => Self::disabled(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn from_url(url: &str) -> Result<Self> {
|
pub fn from_url(url: &str) -> Result<Self> {
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
endpoint: Some(parse_http_url(url)?),
|
endpoint: Some(parse_http_url(url, None)?),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -126,15 +149,26 @@ impl Wallet {
|
||||||
"params": params,
|
"params": params,
|
||||||
})
|
})
|
||||||
.to_string();
|
.to_string();
|
||||||
let host_hdr = host_header(&ep.host, ep.port);
|
let raw = post_timeout(ep, &build_req(ep, &body, None)).await?;
|
||||||
let req = format!(
|
let status = http_status(&raw).unwrap_or(0);
|
||||||
"POST {} HTTP/1.1\r\nHost: {host_hdr}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
if status == 200 {
|
||||||
ep.path,
|
return Err(Error(
|
||||||
body.len()
|
"wallet RPC requires digest auth (open RPC refused)".into(),
|
||||||
);
|
));
|
||||||
let raw = tokio::time::timeout(RPC_TIMEOUT, http_post(ep, req.as_bytes()))
|
}
|
||||||
.await
|
if status != 401 {
|
||||||
.map_err(|_| Error("wallet RPC timed out".into()))??;
|
return parse_json_rpc(&raw);
|
||||||
|
}
|
||||||
|
let challenge = www_authenticate(&raw).ok_or_else(|| {
|
||||||
|
Error("wallet RPC digest required (--rpc-login / HTTP Digest)".into())
|
||||||
|
})?;
|
||||||
|
if !challenge.trim().to_ascii_lowercase().starts_with("digest") {
|
||||||
|
return Err(Error(
|
||||||
|
"wallet RPC digest required (--rpc-login / HTTP Digest)".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let auth = digest_authorization(ep, &challenge)?;
|
||||||
|
let raw = post_timeout(ep, &build_req(ep, &body, Some(&auth))).await?;
|
||||||
parse_json_rpc(&raw)
|
parse_json_rpc(&raw)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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)
|
.any(|r| r.txid == txid && r.amount == amount && r.address == address)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_http_url(url: &str) -> Result<Endpoint> {
|
fn credentials_required() -> Error {
|
||||||
|
Error("wallet RPC requires credentials (user:pass in URL or ONIONWIRE_WALLET_RPC_LOGIN)".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_login(login: &str) -> Result<(String, String)> {
|
||||||
|
let (user, pass) = login.split_once(':').ok_or_else(credentials_required)?;
|
||||||
|
if user.is_empty() || pass.is_empty() {
|
||||||
|
return Err(credentials_required());
|
||||||
|
}
|
||||||
|
Ok((user.to_string(), pass.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_http_url(url: &str, extra_login: Option<&str>) -> Result<Endpoint> {
|
||||||
let rest = url
|
let rest = url
|
||||||
.strip_prefix("http://")
|
.strip_prefix("http://")
|
||||||
.ok_or_else(|| Error("wallet RPC must be http:// (no TLS)".into()))?;
|
.ok_or_else(|| Error("wallet RPC must be http:// (no TLS)".into()))?;
|
||||||
if rest.contains("://") {
|
if rest.contains("://") {
|
||||||
return Err(Error("wallet RPC must be http:// (no TLS)".into()));
|
return Err(Error("wallet RPC must be http:// (no TLS)".into()));
|
||||||
}
|
}
|
||||||
|
let (userinfo, rest) = match rest.rsplit_once('@') {
|
||||||
|
Some((ui, hostpart)) => (Some(ui), hostpart),
|
||||||
|
None => (None, rest),
|
||||||
|
};
|
||||||
|
let (user, pass) = match userinfo {
|
||||||
|
Some(ui) => parse_login(ui)?,
|
||||||
|
None => match extra_login {
|
||||||
|
Some(login) => parse_login(login)?,
|
||||||
|
None => return Err(credentials_required()),
|
||||||
|
},
|
||||||
|
};
|
||||||
let (hostport, path) = match rest.split_once('/') {
|
let (hostport, path) = match rest.split_once('/') {
|
||||||
Some((hp, p)) => (hp, format!("/{p}")),
|
Some((hp, p)) => (hp, format!("/{p}")),
|
||||||
None => (rest, "/json_rpc".into()),
|
None => (rest, "/json_rpc".into()),
|
||||||
|
|
@ -164,10 +221,21 @@ fn parse_http_url(url: &str) -> Result<Endpoint> {
|
||||||
path
|
path
|
||||||
};
|
};
|
||||||
let (host, port) = parse_hostport(hostport)?;
|
let (host, port) = parse_hostport(hostport)?;
|
||||||
if !allowed_host(&host) {
|
if host.trim().to_ascii_lowercase().ends_with(".onion") {
|
||||||
return Err(Error("wallet RPC host must be loopback or .onion".into()));
|
return Err(Error(
|
||||||
|
"wallet RPC over .onion is not supported (loopback only; no Arti dial)".into(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
Ok(Endpoint { host, port, path })
|
if !allowed_host(&host) {
|
||||||
|
return Err(Error("wallet RPC host must be loopback".into()));
|
||||||
|
}
|
||||||
|
Ok(Endpoint {
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
path,
|
||||||
|
user,
|
||||||
|
pass,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_hostport(hostport: &str) -> Result<(String, u16)> {
|
fn parse_hostport(hostport: &str) -> Result<(String, u16)> {
|
||||||
|
|
@ -200,9 +268,6 @@ fn parse_port(p: &str) -> Result<u16> {
|
||||||
|
|
||||||
fn allowed_host(host: &str) -> bool {
|
fn allowed_host(host: &str) -> bool {
|
||||||
let h = host.trim();
|
let h = host.trim();
|
||||||
if h.to_ascii_lowercase().ends_with(".onion") {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if h.eq_ignore_ascii_case("localhost") {
|
if h.eq_ignore_ascii_case("localhost") {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -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>> {
|
async fn http_post(ep: &Endpoint, req: &[u8]) -> Result<Vec<u8>> {
|
||||||
let mut stream = TcpStream::connect((ep.host.as_str(), ep.port))
|
let mut stream = TcpStream::connect((ep.host.as_str(), ep.port))
|
||||||
.await
|
.await
|
||||||
|
|
@ -228,13 +318,117 @@ async fn http_post(ep: &Endpoint, req: &[u8]) -> Result<Vec<u8>> {
|
||||||
.await
|
.await
|
||||||
.map_err(|e| Error(format!("wallet write: {e}")))?;
|
.map_err(|e| Error(format!("wallet write: {e}")))?;
|
||||||
let mut buf = Vec::new();
|
let mut buf = Vec::new();
|
||||||
stream
|
let mut tmp = [0u8; 8192];
|
||||||
.read_to_end(&mut buf)
|
loop {
|
||||||
|
let n = stream
|
||||||
|
.read(&mut tmp)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| Error(format!("wallet read: {e}")))?;
|
.map_err(|e| Error(format!("wallet read: {e}")))?;
|
||||||
|
if n == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if buf.len().saturating_add(n) > MAX_RPC_BYTES {
|
||||||
|
return Err(Error("wallet RPC response too large".into()));
|
||||||
|
}
|
||||||
|
buf.extend_from_slice(&tmp[..n]);
|
||||||
|
}
|
||||||
Ok(buf)
|
Ok(buf)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn http_status(raw: &[u8]) -> Option<u16> {
|
||||||
|
let text = std::str::from_utf8(raw).ok()?;
|
||||||
|
let line = text.lines().next()?;
|
||||||
|
let mut parts = line.split_whitespace();
|
||||||
|
let _http = parts.next()?;
|
||||||
|
parts.next()?.parse().ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn www_authenticate(raw: &[u8]) -> Option<String> {
|
||||||
|
let text = std::str::from_utf8(raw).ok()?;
|
||||||
|
let (head, _) = text
|
||||||
|
.split_once("\r\n\r\n")
|
||||||
|
.or_else(|| text.split_once("\n\n"))?;
|
||||||
|
for line in head.lines().skip(1) {
|
||||||
|
let (k, v) = match line.split_once(':') {
|
||||||
|
Some(kv) => kv,
|
||||||
|
None => continue,
|
||||||
|
};
|
||||||
|
if k.eq_ignore_ascii_case("www-authenticate") {
|
||||||
|
return Some(v.trim().to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn digest_param(challenge: &str, key: &str) -> Option<String> {
|
||||||
|
let t = challenge.trim();
|
||||||
|
let rest = if t.len() >= 6 && t[..6].eq_ignore_ascii_case("digest") {
|
||||||
|
t[6..].trim()
|
||||||
|
} else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
for part in rest.split(',') {
|
||||||
|
let part = part.trim();
|
||||||
|
let (k, v) = match part.split_once('=') {
|
||||||
|
Some(kv) => kv,
|
||||||
|
None => continue,
|
||||||
|
};
|
||||||
|
if k.eq_ignore_ascii_case(key) {
|
||||||
|
return Some(v.trim().trim_matches('"').to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn md5_hex(s: &str) -> String {
|
||||||
|
use md5::{Digest, Md5};
|
||||||
|
hex_lower(&Md5::digest(s.as_bytes()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hex_lower(bytes: &[u8]) -> String {
|
||||||
|
const H: &[u8; 16] = b"0123456789abcdef";
|
||||||
|
let mut out = String::with_capacity(bytes.len() * 2);
|
||||||
|
for &b in bytes {
|
||||||
|
out.push(H[(b >> 4) as usize] as char);
|
||||||
|
out.push(H[(b & 0xf) as usize] as char);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn digest_authorization(ep: &Endpoint, challenge: &str) -> Result<String> {
|
||||||
|
if let Some(alg) = digest_param(challenge, "algorithm")
|
||||||
|
&& !alg.eq_ignore_ascii_case("MD5")
|
||||||
|
{
|
||||||
|
return Err(Error("wallet RPC digest algorithm not MD5".into()));
|
||||||
|
}
|
||||||
|
let realm = digest_param(challenge, "realm").unwrap_or_default();
|
||||||
|
let nonce = digest_param(challenge, "nonce")
|
||||||
|
.ok_or_else(|| Error("wallet RPC digest required (--rpc-login / HTTP Digest)".into()))?;
|
||||||
|
let qop = digest_param(challenge, "qop");
|
||||||
|
let ha1 = md5_hex(&format!("{}:{realm}:{}", ep.user, ep.pass));
|
||||||
|
let ha2 = md5_hex(&format!("POST:{}", ep.path));
|
||||||
|
let (qop_part, resp) = if qop
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|q| q.split(',').any(|x| x.trim() == "auth"))
|
||||||
|
{
|
||||||
|
let mut cnonce_bytes = [0u8; 8];
|
||||||
|
rand::thread_rng().fill_bytes(&mut cnonce_bytes);
|
||||||
|
let cnonce = hex_lower(&cnonce_bytes);
|
||||||
|
let nc = "00000001";
|
||||||
|
let response = md5_hex(&format!("{ha1}:{nonce}:{nc}:{cnonce}:auth:{ha2}"));
|
||||||
|
(
|
||||||
|
format!(", qop=auth, nc={nc}, cnonce=\"{cnonce}\""),
|
||||||
|
response,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(String::new(), md5_hex(&format!("{ha1}:{nonce}:{ha2}")))
|
||||||
|
};
|
||||||
|
Ok(format!(
|
||||||
|
"Digest username=\"{}\", realm=\"{realm}\", nonce=\"{nonce}\", uri=\"{}\", algorithm=MD5, response=\"{resp}\"{qop_part}",
|
||||||
|
ep.user, ep.path
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
fn parse_json_rpc(raw: &[u8]) -> Result<serde_json::Value> {
|
fn parse_json_rpc(raw: &[u8]) -> Result<serde_json::Value> {
|
||||||
let text = std::str::from_utf8(raw).map_err(|_| Error("wallet RPC not UTF-8".into()))?;
|
let text = std::str::from_utf8(raw).map_err(|_| Error("wallet RPC not UTF-8".into()))?;
|
||||||
let (head, body) = text
|
let (head, body) = text
|
||||||
|
|
|
||||||
28
tests/hs.rs
28
tests/hs.rs
|
|
@ -1,5 +1,6 @@
|
||||||
//! HS publish wait and CBT floor — 180s fail-closed cuts a working HsDir upload.
|
//! HS publish wait and CBT floor — 180s fail-closed cuts a working HsDir upload.
|
||||||
|
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use onionwire::hs;
|
use onionwire::hs;
|
||||||
|
|
@ -67,3 +68,30 @@ fn broken_or_shutdown_never_ready() {
|
||||||
assert!(!hs::hs_is_ready(State::Broken, true));
|
assert!(!hs::hs_is_ready(State::Broken, true));
|
||||||
assert!(!hs::hs_is_ready(State::Shutdown, true));
|
assert!(!hs::hs_is_ready(State::Shutdown, true));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hs_log_label_is_not_the_full_v3_onion() {
|
||||||
|
// Public v3 address; checksum is valid so HsId::from_str works.
|
||||||
|
let onion = "facebookwkhpilnemxj7asaniu7vnjjbiltxjqhye3mhbshg7kx5tfyd.onion";
|
||||||
|
let label = hs::log_label(onion);
|
||||||
|
assert_ne!(label, onion, "status/probe logs must not use the locator");
|
||||||
|
assert!(
|
||||||
|
!label.contains("facebookwkhpilnemxj7asaniu7vnjjbiltxjqhye3mhbshg7kx5tfyd"),
|
||||||
|
"log label leaked the onion body: {label}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
label.contains('…') || label.contains("[scrubbed]"),
|
||||||
|
"expected safelog redaction, got {label}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn client_config_mkdirs_state_and_cache_0700() {
|
||||||
|
let root = tempfile::tempdir().expect("tempdir");
|
||||||
|
let state = root.path().join("arti");
|
||||||
|
let cache = root.path().join("cache");
|
||||||
|
let _cfg = hs::client_config(&state, &cache);
|
||||||
|
let mode = |p: &std::path::Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777;
|
||||||
|
assert_eq!(mode(&state), 0o700);
|
||||||
|
assert_eq!(mode(&cache), 0o700);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -55,11 +55,14 @@ fn first_run_creates_0700_dirs_and_self_row() {
|
||||||
let store = Store::open().expect("open");
|
let store = Store::open().expect("open");
|
||||||
|
|
||||||
let arti = home.path().join("arti");
|
let arti = home.path().join("arti");
|
||||||
|
let cache = home.path().join("cache");
|
||||||
let db = home.path().join("onionwire.db");
|
let db = home.path().join("onionwire.db");
|
||||||
assert!(arti.is_dir(), "arti dir");
|
assert!(arti.is_dir(), "arti dir");
|
||||||
|
assert!(cache.is_dir(), "cache dir");
|
||||||
assert!(db.is_file(), "onionwire.db");
|
assert!(db.is_file(), "onionwire.db");
|
||||||
assert_eq!(mode(home.path()), 0o700);
|
assert_eq!(mode(home.path()), 0o700);
|
||||||
assert_eq!(mode(&arti), 0o700);
|
assert_eq!(mode(&arti), 0o700);
|
||||||
|
assert_eq!(mode(&cache), 0o700);
|
||||||
|
|
||||||
let me = store.self_identity().expect("self");
|
let me = store.self_identity().expect("self");
|
||||||
assert_eq!(me.identity_pk.len(), 32);
|
assert_eq!(me.identity_pk.len(), 32);
|
||||||
|
|
|
||||||
|
|
@ -124,10 +124,10 @@ async fn two_node_byte_pipe_restart_and_dormant() {
|
||||||
eprintln!("bob onion={bob_onion}");
|
eprintln!("bob onion={bob_onion}");
|
||||||
assert_ne!(alice_onion, bob_onion, "separate HS identities");
|
assert_ne!(alice_onion, bob_onion, "separate HS identities");
|
||||||
|
|
||||||
hs::wait_until_published(&alice, &alice_svc, &alice_onion, "alice")
|
hs::wait_until_published(&alice, &alice_svc, &alice_onion)
|
||||||
.await
|
.await
|
||||||
.expect("alice publish");
|
.expect("alice publish");
|
||||||
hs::wait_until_published(&bob, &bob_svc, &bob_onion, "bob")
|
hs::wait_until_published(&bob, &bob_svc, &bob_onion)
|
||||||
.await
|
.await
|
||||||
.expect("bob publish");
|
.expect("bob publish");
|
||||||
|
|
||||||
|
|
|
||||||
131
tests/wallet.rs
131
tests/wallet.rs
|
|
@ -17,7 +17,9 @@ fn row(txid: &str, amount: &str, address: &str) -> TransferRow {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn xmr_addr() -> String {
|
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 {
|
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]
|
#[test]
|
||||||
fn disabled_create_address_is_not_configured() {
|
fn disabled_create_address_is_not_configured() {
|
||||||
let w = Wallet::disabled();
|
let w = Wallet::disabled();
|
||||||
|
|
@ -47,9 +42,19 @@ fn disabled_create_address_is_not_configured() {
|
||||||
assert!(err.to_string().contains("not configured"), "got {err}");
|
assert!(err.to_string().contains("not configured"), "got {err}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn refuse_url_without_credentials() {
|
||||||
|
let err = Wallet::from_url("http://127.0.0.1:18083").unwrap_err();
|
||||||
|
let msg = err.to_string().to_ascii_lowercase();
|
||||||
|
assert!(
|
||||||
|
msg.contains("credential") || msg.contains("login") || msg.contains("user"),
|
||||||
|
"got {err}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn refuse_non_loopback_non_onion_host() {
|
fn refuse_non_loopback_non_onion_host() {
|
||||||
let err = Wallet::from_url("http://example.com:18083").unwrap_err();
|
let err = Wallet::from_url("http://ow:secret@example.com:18083").unwrap_err();
|
||||||
assert!(
|
assert!(
|
||||||
err.to_string().to_ascii_lowercase().contains("host")
|
err.to_string().to_ascii_lowercase().contains("host")
|
||||||
|| err.to_string().contains("loopback")
|
|| err.to_string().contains("loopback")
|
||||||
|
|
@ -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]
|
#[tokio::test]
|
||||||
async fn mock_get_address_parses_string() {
|
async fn mock_get_address_parses_string() {
|
||||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
|
@ -65,8 +98,8 @@ async fn mock_get_address_parses_string() {
|
||||||
let canned = json_rpc_ok(
|
let canned = json_rpc_ok(
|
||||||
r#"{"address":"4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"}"#,
|
r#"{"address":"4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"}"#,
|
||||||
);
|
);
|
||||||
tokio::spawn(serve_once(listener, canned));
|
tokio::spawn(serve_digest_then(listener, canned));
|
||||||
let w = Wallet::from_url(&format!("http://127.0.0.1:{}", addr.port())).unwrap();
|
let w = Wallet::from_url(&format!("http://ow:secret@127.0.0.1:{}", addr.port())).unwrap();
|
||||||
let got = w.get_address().await.expect("get_address");
|
let got = w.get_address().await.expect("get_address");
|
||||||
assert!(got.starts_with('4'), "got {got}");
|
assert!(got.starts_with('4'), "got {got}");
|
||||||
}
|
}
|
||||||
|
|
@ -78,15 +111,15 @@ async fn mock_create_address_and_transfer() {
|
||||||
let canned = json_rpc_ok(
|
let canned = json_rpc_ok(
|
||||||
r#"{"address":"8BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB","tx_hash":"abc123"}"#,
|
r#"{"address":"8BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB","tx_hash":"abc123"}"#,
|
||||||
);
|
);
|
||||||
tokio::spawn(serve_once(listener, canned.clone()));
|
tokio::spawn(serve_digest_then(listener, canned.clone()));
|
||||||
let w = Wallet::from_url(&format!("http://127.0.0.1:{}", addr.port())).unwrap();
|
let w = Wallet::from_url(&format!("http://ow:secret@127.0.0.1:{}", addr.port())).unwrap();
|
||||||
let created = w.create_address().await.expect("create_address");
|
let created = w.create_address().await.expect("create_address");
|
||||||
assert!(created.starts_with('8'));
|
assert!(created.starts_with('8'));
|
||||||
|
|
||||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
let addr = listener.local_addr().unwrap();
|
let addr = listener.local_addr().unwrap();
|
||||||
tokio::spawn(serve_once(listener, canned));
|
tokio::spawn(serve_digest_then(listener, canned));
|
||||||
let w = Wallet::from_url(&format!("http://127.0.0.1:{}", addr.port())).unwrap();
|
let w = Wallet::from_url(&format!("http://ow:secret@127.0.0.1:{}", addr.port())).unwrap();
|
||||||
let txid = w
|
let txid = w
|
||||||
.transfer("8BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", 1)
|
.transfer("8BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", 1)
|
||||||
.await
|
.await
|
||||||
|
|
@ -101,8 +134,8 @@ async fn mock_get_transfers_matches_txid() {
|
||||||
let canned = json_rpc_ok(
|
let canned = json_rpc_ok(
|
||||||
r#"{"in":[{"txid":"deadbeef","amount":5,"address":"8BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"}],"pending":[]}"#,
|
r#"{"in":[{"txid":"deadbeef","amount":5,"address":"8BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"}],"pending":[]}"#,
|
||||||
);
|
);
|
||||||
tokio::spawn(serve_once(listener, canned));
|
tokio::spawn(serve_digest_then(listener, canned));
|
||||||
let w = Wallet::from_url(&format!("http://127.0.0.1:{}", addr.port())).unwrap();
|
let w = Wallet::from_url(&format!("http://ow:secret@127.0.0.1:{}", addr.port())).unwrap();
|
||||||
let rows = w.get_transfers().await.expect("get_transfers");
|
let rows = w.get_transfers().await.expect("get_transfers");
|
||||||
assert!(wallet::transfers_match(
|
assert!(wallet::transfers_match(
|
||||||
&rows,
|
&rows,
|
||||||
|
|
@ -180,3 +213,67 @@ fn ingest_signed_receipt_mismatched_wallet_history_stays_unverified() {
|
||||||
let rows = store.list_payments(&pk).unwrap();
|
let rows = store.list_payments(&pk).unwrap();
|
||||||
assert!(!rows[0].verified);
|
assert!(!rows[0].verified);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn oversized_rpc_response_is_err() {
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let addr = listener.local_addr().unwrap();
|
||||||
|
let huge = vec![b'A'; 2 * 1024 * 1024];
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let (mut sock, _) = listener.accept().await.expect("accept");
|
||||||
|
let mut buf = vec![0u8; 4096];
|
||||||
|
let _ = sock.read(&mut buf).await;
|
||||||
|
sock.write_all(b"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nConnection: close\r\n\r\n")
|
||||||
|
.await
|
||||||
|
.expect("hdr");
|
||||||
|
sock.write_all(&huge).await.expect("body");
|
||||||
|
});
|
||||||
|
let w = Wallet::from_url(&format!("http://ow:secret@127.0.0.1:{}", addr.port())).unwrap();
|
||||||
|
let err = w.get_address().await.unwrap_err();
|
||||||
|
let msg = err.to_string().to_ascii_lowercase();
|
||||||
|
assert!(
|
||||||
|
msg.contains("large") || msg.contains("size") || msg.contains("cap"),
|
||||||
|
"got {err}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!err.to_string().contains("secret"),
|
||||||
|
"password leaked: {err}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn digest_401() -> String {
|
||||||
|
"HTTP/1.1 401 Unauthorized\r\nWWW-Authenticate: Digest realm=\"monero-rpc\", nonce=\"abcnonce\", qop=\"auth\", algorithm=MD5\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".into()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn serve_digest_then(listener: TcpListener, ok: String) {
|
||||||
|
let (mut sock, _) = listener.accept().await.expect("accept");
|
||||||
|
let mut buf = vec![0u8; 8192];
|
||||||
|
let _ = sock.read(&mut buf).await;
|
||||||
|
sock.write_all(digest_401().as_bytes()).await.expect("401");
|
||||||
|
drop(sock);
|
||||||
|
|
||||||
|
let (mut sock, _) = listener.accept().await.expect("accept2");
|
||||||
|
buf.fill(0);
|
||||||
|
let n = sock.read(&mut buf).await.unwrap_or(0);
|
||||||
|
let req = String::from_utf8_lossy(&buf[..n]);
|
||||||
|
assert!(
|
||||||
|
req.contains("Authorization: Digest"),
|
||||||
|
"missing digest auth: {req}"
|
||||||
|
);
|
||||||
|
assert!(req.contains("username=\"ow\""), "missing user: {req}");
|
||||||
|
assert!(req.contains("response=\""), "missing response: {req}");
|
||||||
|
sock.write_all(ok.as_bytes()).await.expect("200");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn mock_digest_auth_accepted() {
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let addr = listener.local_addr().unwrap();
|
||||||
|
let canned = json_rpc_ok(
|
||||||
|
r#"{"address":"4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"}"#,
|
||||||
|
);
|
||||||
|
tokio::spawn(serve_digest_then(listener, canned));
|
||||||
|
let w = Wallet::from_url(&format!("http://ow:s3cretPASS@127.0.0.1:{}", addr.port())).unwrap();
|
||||||
|
let got = w.get_address().await.expect("get_address");
|
||||||
|
assert!(got.starts_with('4'), "got {got}");
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,13 @@
|
||||||
//! M5: wipe messages (keep identity + friends); wipe-all is a new person.
|
//! M5: wipe messages (keep identity + friends); wipe-all is a new person.
|
||||||
|
|
||||||
use onionwire::Store;
|
|
||||||
use onionwire::tui::{
|
use onionwire::tui::{
|
||||||
QuitDecision, QuitPrompt, WipeDecision, WipeKind, WipePrompt, parse_slash, quit_screen_text,
|
parse_slash, quit_screen_text, wipe_screen_text, QuitDecision, QuitPrompt, WipeDecision,
|
||||||
wipe_screen_text,
|
WipeKind, WipePrompt,
|
||||||
};
|
};
|
||||||
|
use onionwire::{PaymentWrite, Store};
|
||||||
|
|
||||||
|
// Official mainnet standard from Monero docs (same fixture as tests/pay.rs).
|
||||||
|
const MAINNET_STD: &str = "4AdUndXHHZ6cfufTMvppY6JwXNouMBzSkbLYfpAV5Usx3skxNgYeYTRj5UzqtReoS44qo9mtmXCqY45DJ852K5Jv2684Rge";
|
||||||
|
|
||||||
fn pk(tag: u8) -> [u8; 32] {
|
fn pk(tag: u8) -> [u8; 32] {
|
||||||
let mut k = [0u8; 32];
|
let mut k = [0u8; 32];
|
||||||
|
|
@ -50,6 +53,53 @@ fn wipe_clears_messages_keeps_self_and_friends() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wipe_clears_payments_keeps_self_and_friends() {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let store = Store::open_at_with_passphrase(dir.path(), "onionwire-test").expect("open");
|
||||||
|
let me = store.self_identity().unwrap();
|
||||||
|
store
|
||||||
|
.upsert_friend(&pk(1), "a.onion", Some("alice"))
|
||||||
|
.unwrap();
|
||||||
|
store
|
||||||
|
.append_message(&pk(1), "out", b"secret-log-line-xyz")
|
||||||
|
.unwrap();
|
||||||
|
store
|
||||||
|
.insert_payment(
|
||||||
|
&pk(1),
|
||||||
|
PaymentWrite {
|
||||||
|
dir: "out",
|
||||||
|
kind: "receipt",
|
||||||
|
amount_atomic: "1000000000000",
|
||||||
|
address: MAINNET_STD,
|
||||||
|
memo: "counterparty-memo-xyz",
|
||||||
|
txid: Some("aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899"),
|
||||||
|
verified: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(store.list_messages(&pk(1)).unwrap().len(), 1);
|
||||||
|
assert_eq!(store.list_payments(&pk(1)).unwrap().len(), 1);
|
||||||
|
|
||||||
|
store.wipe_messages().expect("wipe");
|
||||||
|
|
||||||
|
assert!(store.list_messages(&pk(1)).unwrap().is_empty());
|
||||||
|
assert!(
|
||||||
|
store.list_payments(&pk(1)).unwrap().is_empty(),
|
||||||
|
"wipe must drop payments, not only chat"
|
||||||
|
);
|
||||||
|
assert_eq!(store.friend_count().unwrap(), 1);
|
||||||
|
let f = store.get_friend(&pk(1)).unwrap().expect("friend");
|
||||||
|
assert_eq!(f.petname.as_deref(), Some("alice"));
|
||||||
|
let me2 = store.self_identity().unwrap();
|
||||||
|
assert_eq!(me.identity_pk, me2.identity_pk);
|
||||||
|
drop(store);
|
||||||
|
assert!(
|
||||||
|
!db_contains(dir.path(), b"counterparty-memo-xyz"),
|
||||||
|
"wipe must not leave payment memo in the db file"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn wipe_all_removes_dir_so_next_open_is_new_identity() {
|
fn wipe_all_removes_dir_so_next_open_is_new_identity() {
|
||||||
let dir = tempfile::tempdir().expect("tempdir");
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
|
@ -108,8 +158,9 @@ fn wipe_all_requires_typing_wipeall() {
|
||||||
#[test]
|
#[test]
|
||||||
fn wipe_prompt_text_matches_spec() {
|
fn wipe_prompt_text_matches_spec() {
|
||||||
let m = wipe_screen_text(WipeKind::Messages);
|
let m = wipe_screen_text(WipeKind::Messages);
|
||||||
assert!(m.contains("Wipe message log?"));
|
assert!(m.contains("Wipe chat and payments history?"));
|
||||||
assert!(m.contains("Identity key and friends stay."));
|
assert!(m.contains("Identity key and friends stay."));
|
||||||
|
assert!(m.contains("Not a forensic erase."));
|
||||||
assert!(m.contains("Type WIPE to confirm"));
|
assert!(m.contains("Type WIPE to confirm"));
|
||||||
assert!(m.contains("Esc to cancel"));
|
assert!(m.contains("Esc to cancel"));
|
||||||
let a = wipe_screen_text(WipeKind::All);
|
let a = wipe_screen_text(WipeKind::All);
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue