Compare commits
31 commits
wt/t_b7176
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| af7d69ace8 | |||
|
|
0ea6fd68c4 | ||
|
|
c4ccca7e8c | ||
| c141eb45b8 | |||
|
|
d2d3b0c827 | ||
|
|
411194a8b2 | ||
| 389d8f16c4 | |||
| b354632552 | |||
|
|
c5019f1737 | ||
| 2abb9af4ad | |||
|
|
51e47ff90c | ||
| f109223678 | |||
|
|
08ced86a34 | ||
|
|
39e7b3bf16 | ||
|
|
424002c0f5 | ||
|
|
45c105de8a | ||
|
|
125aafb816 | ||
|
|
bfd28fce6b | ||
| b3c5b78f25 | |||
| 2041302da7 | |||
|
|
666b9d2e86 | ||
| a56029b750 | |||
| deee357772 | |||
|
|
68da7fec3b | ||
| b1427041c2 | |||
|
|
f75c434db7 | ||
| bdea9578e4 | |||
|
|
1a465581c2 | ||
| 0dfd807b10 | |||
| 95b0fa4dee | |||
|
|
dfa4ea8f39 |
36 changed files with 2317 additions and 176 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
37
Cargo.lock
generated
37
Cargo.lock
generated
|
|
@ -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,22 @@ 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",
|
||||
"tokio",
|
||||
|
|
@ -3369,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"
|
||||
|
|
@ -3390,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"
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
@ -43,6 +43,10 @@ snow = "0.10"
|
|||
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"
|
||||
|
|
|
|||
32
README.md
32
README.md
|
|
@ -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 friend’s. Mouse-select the `onionwire:v1:…` line to copy.
|
||||
Then `F2` to share your invite, `F3` to paste a friend’s. 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 friend’s invite |
|
||||
| `F4` | Rotate **onion** (locator only) |
|
||||
| `F5` | Selected friend’s 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 peer’s 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 friend’s 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
|
||||
|
||||
|
|
@ -208,14 +220,14 @@ If you set `ONIONWIRE_HOME`, delete that directory instead.
|
|||
|
||||
## Seized laptop
|
||||
|
||||
Chat bodies in sqlite are ChaCha20-Poly1305 (`nonce || ciphertext` in `messages.plaintext`), wrapped by a passphrase-derived Argon2id key. A disk grep of `onionwire.db` must not yield the message log.
|
||||
Chat bodies in sqlite are ChaCha20-Poly1305 (`nonce || ciphertext` in `messages.plaintext`) with AAD bound to `friend_id`, `dir`, and row id, wrapped by a passphrase-derived Argon2id key. A disk grep of `onionwire.db` must not yield the message log. Empty-AAD v0.2 blobs are rewrapped once on unlock.
|
||||
|
||||
Still plaintext on disk (unless you add OS/FDE):
|
||||
|
||||
- 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).
|
||||
|
||||
|
|
|
|||
38
crates/onionwire-sdk/Cargo.lock
generated
38
crates/onionwire-sdk/Cargo.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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)?;
|
||||
|
|
|
|||
30
crates/onionwire-sdk/tests/init_provider.rs
Normal file
30
crates/onionwire-sdk/tests/init_provider.rs
Normal 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();
|
||||
}
|
||||
45
crates/onionwire-sdk/tests/provider_resolution.rs
Normal file
45
crates/onionwire-sdk/tests/provider_resolution.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
194
docs/SECURITY_AUDIT.md
Normal file
194
docs/SECURITY_AUDIT.md
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
# OnionWire Security Audit
|
||||
Commit: 2b42864ebaaef2aab64e66055ee482c525180f63 (`2b42864`)
|
||||
Baseline: `cargo test --locked` **pass** (100 passed, 3 ignored), `cargo clippy --locked --all-targets -- -D warnings` **pass**
|
||||
Auditor: rust-dev (no access to running hidden services / live Monero wallet)
|
||||
Tree: worktree `wt/t_d85060fb` at `/home/lancelot/Projects/onionwire/.worktrees/t_d85060fb`
|
||||
Remote: `origin/main` = same SHA (`https://forgejo.siriusdevops.com/sirius/onionwire.git`)
|
||||
|
||||
Ignored tests (`needs live Tor network`): `rotate_hs`, `tor_hs`, `two_node`. Not re-run.
|
||||
|
||||
## Severity key
|
||||
Critical = remote key compromise or plaintext disclosure
|
||||
High = local key/plaintext disclosure, authn bypass, or payment forgery
|
||||
Medium = DoS, nonce/IV weakness, metadata leak
|
||||
Low = hygiene, error-path leakage, docs mismatch
|
||||
Info = observation
|
||||
|
||||
## Findings
|
||||
|
||||
### F1 — Incoming receipt `verified=1` on unrelated wallet history [High]
|
||||
Location: `src/wallet.rs:142-148`, used at `src/node.rs:667-683`
|
||||
|
||||
Evidence: confirmation is not “this txid paid this amount to this address”.
|
||||
|
||||
```142:148:src/wallet.rs
|
||||
pub fn transfers_match(rows: &[TransferRow], txid: &str, amount: &str, address: &str) -> bool {
|
||||
rows.iter().any(|r| {
|
||||
r.txid == txid
|
||||
|| (!address.is_empty()
|
||||
&& r.address == address
|
||||
&& (amount.is_empty() || r.amount == amount))
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
`ingest_receipt` verifies the **ed25519** on the `rcp` frame (so the *friend* signed it), inserts `verified: false`, then flips `verified` if `transfers_match` is true (`src/node.rs:671-682`). Replica of that predicate against two wallet rows `{txid:aaa111, amount:1000, addr:4AAA…}` and `{txid:bbb222, amount:5, addr:8BBB…}`:
|
||||
|
||||
```
|
||||
txid-only match (wrong amount+addr): True
|
||||
addr+amount match (wrong txid): True
|
||||
honest miss: False
|
||||
```
|
||||
|
||||
Impact: a friend who completed Noise IK can send a signed receipt for an arbitrary amount/address and get the TUI line `[receipt] N XMR` (verified) if *either* (a) `txid` appears anywhere in `get_transfers` `in`/`pending`, or (b) some inbound row already has that address and amount. That is payment forgery against the local “verified” bit. It is not a third-party wire injection: the frame still has to decrypt under the pinned session. `docs/THREAT_MODEL.md:33` says “Never trust a `rcp` frame without RPC confirmation (`verified` stays 0)” — the code *does* promote `verified`, and the RPC check does not bind amount+address+txid together. Tests encode the store default (`tests/pay.rs` `incoming_receipt_is_not_verified`) but never exercise `transfers_match` against mismatched amount.
|
||||
|
||||
Fix: require `txid == row.txid && amount == row.amount && address == row.address` (and reject empty fields). Do not OR. Keep `verified=0` if RPC is down.
|
||||
|
||||
### F2 — Chat AEAD has empty AAD; ciphertext rows are interchangeable [Medium]
|
||||
Location: `src/backup.rs:98-108` (`aead_encrypt`), `src/store.rs:649` / `670`
|
||||
|
||||
Evidence: bodies are `nonce || ChaCha20-Poly1305(key, nonce, pt)` with no associated data. The same 32-byte `msg_key` wraps every row. A DB writer who cannot open the passphrase can still swap `messages.plaintext` blobs. Throwaway against this tree (`/tmp/ow-audit-repro`, `CARGO_TARGET_DIR` = this worktree `target`):
|
||||
|
||||
```
|
||||
SWAP: alice sees "secret-for-bob"
|
||||
SWAP: bob sees "secret-for-alice"
|
||||
```
|
||||
|
||||
Both `list_messages` calls returned `Ok`; Poly1305 verified. `dir` / `friend_id` / `id` / `created_at` are plaintext columns and are not in the MAC.
|
||||
|
||||
Impact: anyone with write access to `onionwire.db` (same uid, stolen unlocked file, or a bug that writes sqlite) can reattribute ciphertext across friends and in/out without the passphrase. This is *not* remote plaintext disclosure. Identity secret keys are already plaintext in `self` (threat model says so); this is extra: the body encryption does not bind a row to its owner. Nonces are 96-bit random per `generate_nonce` — reuse across restarts/`wipe-all` (new key) / first-unlock rewrap is not the failure mode here.
|
||||
|
||||
Fix: encrypt as `Aead::encrypt` with AAD = `friend_id || dir || row_id` (or a committed header), or include those fields in the plaintext that is MACed. Reject decrypt if AAD does not match the row.
|
||||
|
||||
### F3 — Invite `sig` is over concatenated strings, not length-prefixed fields [Medium]
|
||||
Location: `src/qr.rs:67-72` (`sign_msg`), `src/qr.rs:36-64` (`decode`)
|
||||
|
||||
Evidence: `sig` covers `k.as_bytes() || onion.as_bytes() || spk.as_bytes()` with no delimiters or lengths. `k` is 64 hex chars after the 32-byte check, so it cannot shift. `o` and `spk` can. Encode a real v3 onion + 32-byte `spk`, then move the first 8 hex chars of `spk` onto `o`, keep `k` and `sig`. `qr::decode` **accepts** the mutant:
|
||||
|
||||
```
|
||||
CONCAT: mutated invite accepted
|
||||
CONCAT: onion_changed=true
|
||||
CONCAT: onion=abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopqrstuvwxyz234567.onionabababab
|
||||
CONCAT: spk_len=28 (want 32)
|
||||
CONCAT: pubkey_unchanged=true
|
||||
```
|
||||
|
||||
`decode` does not require `signed_prekey.len()==32` or a v3 onion. `Node::add_friend_payload` then `upsert_friend` (same `k` **replaces** locator) and `set_friend_prekey`. Duplicate/unknown fields are rejected (`src/qr.rs:85-106`); all four fields are required. Empty `sig` fails `from_hex`. This is not a classic steal-the-identity concat: `k` stays the signer.
|
||||
|
||||
Impact: a mutated invite still verifies under the real identity key. F3-paste (or a same-`k` rescan) can poison `friends.onion` / `prekey` for that pubkey. The shifted onion is not an arbitrary attacker HS (you can only append a hex prefix of the original `spk`), so this is roster integrity / availability, not a silent MITM. Handshake then fails (`prekey` length ≠ 32 at `node.rs:388`). No panic on this path. `from_hex` has **no size cap**: a 4,000,000-char hex string decoded to 2,000,000 bytes in 0.34s in CPython; `decode` allocates that before `pubkey.len()!=32` rejects.
|
||||
|
||||
Fix: sign a domain-separated encoding (`k` || `0x00` || `o` || `0x00` || `spk`, or length prefixes). Reject `spk` ≠ 32 bytes and onion ≠ v3. Cap invite length before `from_hex` (a few KiB).
|
||||
|
||||
### F4 — Monero address check is prefix+length, not checksum [Medium]
|
||||
Location: `src/pay.rs:38-48`; tests *require* the junk form to pass (`tests/pay.rs:15-50`)
|
||||
|
||||
Evidence:
|
||||
|
||||
```38:48:src/pay.rs
|
||||
pub fn check_address(addr: &str) -> Result<()> {
|
||||
let ok = match addr.as_bytes().first() {
|
||||
Some(b'4') if addr.len() == 95 || addr.len() == 106 => true,
|
||||
Some(b'8') if addr.len() == 95 => true,
|
||||
_ => false,
|
||||
};
|
||||
if ok && !addr.contains('\n') {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(Error("invalid Monero address".into()))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`4` + `'A' * 94` is accepted (len 95). No network byte, no Keccak checksum, no alphabet check. `profile::check_fields` (`src/profile.rs:118-128`) does not call `check_address` at all — `xmr_addr` may be `"4abc"` (`tests/profile.rs`). `/tip` does call `check_address` on the stored profile address (`src/node.rs:331`). `/pay` invoices are signed over that address (`src/node.rs:290`).
|
||||
|
||||
Impact: OnionWire will persist and sign invoices/receipts for strings no Monero wallet should pay. A live `monero-wallet-rpc` will usually reject checksum failures on `transfer`, so this is not by itself silent theft. It *is* a local fail-open: garbage becomes a signed `inv`/`rcp` payload and a payments row. Combined with F1, a verified receipt can cite such an address.
|
||||
|
||||
Fix: decode base58, check network prefix + checksum. Empty profile `xmr_addr` stays allowed; non-empty must pass the same check.
|
||||
|
||||
### F5 — Wallet RPC is unauthenticated HTTP and can mark receipts from its full transfer list [Medium]
|
||||
Location: `src/wallet.rs:151-171`, `223-237`, `117-138`; README documents `ONIONWIRE_WALLET_RPC=http://127.0.0.1:18083`
|
||||
|
||||
Evidence: URL parser requires `http://` (no TLS), host must be loopback or `.onion` (`allowed_host`, tested in `tests/wallet.rs` `refuse_non_loopback_non_onion_host`). There is no `user:pass` / Digest / header. `http_post` is raw `TcpStream` + `read_to_end` with a 5s timeout, no body size cap. `.onion` hosts are allowed but `TcpStream::connect((onion, port))` does **not** go through Arti — so an onion RPC URL fail-closes at connect (not examined live). `eprintln!("ONIONWIRE_WALLET_RPC: {e}")` prints the parse error, not the URL, on bad env.
|
||||
|
||||
Impact: the documented operator setup is “HTTP to loopback, no login”. Any local process that can reach that port can `transfer` (spend) and `get_transfers` (the same list F1 trusts). OnionWire never holds spend keys (threat model — true); it also never authenticates to the process that does. This is local, not remote, if the operator actually bound loopback. Code cannot express `--rpc-login`.
|
||||
|
||||
Fix: require digest (or a unix socket). Refuse URLs without credentials. Cap RPC read size. If onion RPC is a goal, dial it through the Arti client, not `TcpStream`.
|
||||
|
||||
### F6 — `/wipe` does not touch payments; sqlite is not `secure_delete` [Low]
|
||||
Location: `src/store.rs:677-685`, `122-125`
|
||||
|
||||
Evidence: `wipe_messages` overwrites `messages.plaintext` with `zeroblob(length)`, `DELETE FROM messages`, `VACUUM`. No `PRAGMA secure_delete`. No `DELETE FROM payments`. WAL is required (`journal_mode = WAL` fail-closed). Threat model (`docs/THREAT_MODEL.md:21`) says “`/wipe` overwrites message bodies and vacuums” — that part matches. It does not say payments go away; they do not. Identity / friend pubkeys / onions stay plaintext (disclosed, not a finding). `onionwire.db` itself is never `chmod 0600`; the home and `arti/` dirs are `0700` after `create_dir_all` (`src/store.rs:753-758`, `tests/store.rs:61-62`). Backup files *are* `0o600` at create (`src/node.rs:145-150`).
|
||||
|
||||
Impact: `/wipe` is not a forensic erase (SSD wear-leveling, WAL snapshots, payments table, roster). A seized disk after `/wipe` still has who you pay and who you talk to. `/wipe-all` is `remove_dir_all` — same disk caveat.
|
||||
|
||||
Fix: if `/wipe` should mean “chat history gone”, also drop `payments` (and checkpoint WAL). Document that `/wipe` is not crypto-shred. Optional `secure_delete` is still not a guarantee on flash.
|
||||
|
||||
### F7 — HS publish logs the onion; `dangerously_trust_everyone` is Arti-only [Low]
|
||||
Location: `src/hs.rs:31-36`, `src/hs.rs:95`, `src/node.rs:95`
|
||||
|
||||
Evidence: `wait_until_published(..., &onion, &onion)` uses the unredacted onion as `label`. `eprintln!("{label} hs status: {state:?}")` and probe lines go to stderr. `onion_string` uses `display_unredacted` (`src/hs.rs:70`) — required to persist the locator; the leak is the log. `client_config` calls `builder.storage().permissions().dangerously_trust_everyone()` after `create_dir_all` on the Arti state/cache paths. That API is fs-mistrust for **Arti’s** directories, not sqlite. `Store::open_at_with_passphrase` `mkdir_700`s `home` and `home/arti` first; `home/cache` is created by Arti’s `create_dir_all` without `0700`. `cbtmintimeout` / `cbtinitialtimeout` = 20s is a circuit-build floor (perf / publish reliability), not an auth bypass.
|
||||
|
||||
Impact: journald/script logs contain the current v3 locator. Arti state/cache may be created `0755` until something else tightens them; sqlite lives under the `0700` home. A world-readable Arti cache is descriptor/consensus metadata, not chat bodies.
|
||||
|
||||
Fix: log a redacted onion (safelog). `mkdir_700` the cache dir before `client_config`. Keep `dangerously_trust_everyone` scoped to Arti storage; do not reuse it for `onionwire.db`.
|
||||
|
||||
### F8 — Threat model overstates receipt verification and omits F1–F3 [Low]
|
||||
Location: `docs/THREAT_MODEL.md:17-21`, `:33`, `:39-41`
|
||||
|
||||
Evidence: TM correctly describes live-only send, loc rules, identity-vs-locator, passphrase-wrapped message key, plaintext identity/roster, experimental Arti, global 30/60s burst-10 token bucket, backup = identity. It claims RPC confirmation keeps `verified` at 0 unless the chain view agrees — F1 shows the matcher is not that. It does not mention empty AEAD AAD, invite concat, shape-only XMR addresses, or env passphrase (`ONIONWIRE_STORE_PASSPHRASE` in `src/store.rs:745-750`, visible in `/proc/<pid>/environ`).
|
||||
|
||||
Impact: an operator who treats TM as the capability list will believe “verified receipt ⇒ wallet saw that payment”.
|
||||
|
||||
Fix: either implement F1’s conjunctive match or change the sentence to “incoming `rcp` is displayed; `verified` is best-effort and must not be trusted in v0.2”.
|
||||
|
||||
## Verified correct
|
||||
|
||||
- Noise pattern is actually `Noise_IK_25519_ChaChaPoly_BLAKE2s` with prologue `onionwire-v1` (`src/session.rs:8-9, 111-122`). Initiator sets `remote_public_key` to the QR/roster x25519 prekey (`src/session.rs:189`). Responder takes remote static from snow (`get_remote_static`) and looks up the friend (`src/session.rs:255-260`, `src/node.rs:550-555`).
|
||||
- Mutual identity proofs are `ed25519_sign(handshake_hash)` with the 32-byte pubkey prefix; `verify_proof` calls `VerifyingKey::verify` (`src/session.rs:289-310`). Initiator compares proof prefix to the **pinned** identity (`src/session.rs:219-222`); responder to the roster id (`src/session.rs:273-276`). Mismatch is `Error::mismatch` → send hard-fails (`src/node.rs:490`).
|
||||
- Transport nonces are snow `TransportState` counters (Noise spec: increment, reject reuse). Application code does not set ChaCha nonces on the wire. Session keys include ephemeral DH → compromise of long-term static does not decrypt **past** transport; it does allow impersonation **forward**. That is IK, not a bug.
|
||||
- Loc frames: `apply_loc` verifies ed25519 against the **session** `peer_identity`, requires the pubkey already in `friends`, and requires `ts > onion_updated_at` (`src/store.rs:452-480`, `src/loc.rs:38-55`, `src/node.rs:561-568`). A peer cannot silently move you to an onion they control unless they hold that identity key (Noise proof + loc sig). An old loc with smaller `ts` cannot rewind after a later rotate. (F3 invite paste can still overwrite locator; that is out-of-band.)
|
||||
- Unknown typed prefixes (`xyz `) are `Kind::Drop`, not fatal (`src/dispatch.rs:30-38`). Unparseable loc/inv/rcp are ignored (`src/node.rs:562`, `610-611`, `643-644`). Handshake/frame errors print `incoming: {e}` and the rend task ends; the accept loop continues (`src/node.rs:723-729`).
|
||||
- Frames are length-prefixed, `MAX_FRAME = 65535`, checked **before** allocating the body (`src/frame.rs:5, 63-67`).
|
||||
- Token bucket is **one global** `TokenBucket::default()` = 30 tokens / 60s, burst 10 (`src/ratelimit.rs:41-44`, `src/node.rs:86, 712-720`). Matches TM; one flood can starve every friend (availability, not auth).
|
||||
- Backup: Argon2id v0x13, `m=19456` KiB, `t=2`, `p=1` (`src/backup.rs:52-53`) = OWASP 2023 minimum. Per-export 16-byte salt + 12-byte random nonce in the file. `open` AEAD-fails with a single error before `replace_identity_keys` (`src/backup.rs:91-95`, `src/node.rs:155-168`). Onion is not in the blob (`tests/backup.rs`). Wrong passphrase does not write keys.
|
||||
- First-run message key: 32 random bytes, wrapped with the same KDF/AEAD, stored in `store_meta` (`src/store.rs:281-293`). Empty passphrase refused. Integrity check fail-closed (`src/store.rs:191-194`).
|
||||
- Amounts on the pay path are decimal **integer piconero** (`src/pay.rs:51-59`); `xmr_to_atomic` pads a ≤12-digit fraction without float (`src/pay.rs:62-85`). `/tip` then `parse`s to `u64` for RPC (`src/node.rs:332-334`).
|
||||
- Incoming `inv`/`rcp` are verified against **session peer identity**, not a field inside the frame (`src/node.rs:613`, `646`).
|
||||
- Locked product decisions (no server/XMPP/MAM/DHT, live-only send, identity=pubkey, onion=locator, no dual-host grace) match the code. Not findings.
|
||||
|
||||
## Not examined / out of scope
|
||||
|
||||
- Live hidden-service reachability, IPT/HsDir, and two-node Tor tests (ignored; no HS from this auditor).
|
||||
- Live `monero-wallet-rpc` (auth defaults, `get_transfers` JSON shape vs `json_amount`, unlock/spend confirm).
|
||||
- snow 0.10 internals beyond the `Builder`/`TransportState` API used here (constant-time, rekey at 2^64).
|
||||
- Arti keystore encryption at rest, fs-mistrust semantics of `dangerously_trust_everyone` beyond “it is called on Arti storage”.
|
||||
- Timing of Argon2 / ed25519 verify (failed backup passphrase is one error string; KDF still runs).
|
||||
- TUI rendering of hostile chat (ratatui text; no HTML).
|
||||
- Traffic analysis / HS existence (TM already declines that).
|
||||
- `cargo audit`: **not installed** (`which cargo-audit` empty). Lockfile inspected by hand; no RustSec lookup was executed against this `Cargo.lock`.
|
||||
|
||||
## Dependencies (Cargo.lock)
|
||||
|
||||
| Crate | Lock version | Cargo.toml |
|
||||
|---|---|---|
|
||||
| snow | 0.10.0 | `0.10` |
|
||||
| chacha20poly1305 | 0.10.1 | `0.10` |
|
||||
| argon2 | 0.5.3 | `0.5` |
|
||||
| ed25519-dalek | 2.2.0 | `2` |
|
||||
| x25519-dalek | 2.0.1 | `2` |
|
||||
| rusqlite | 0.36.0 | `0.36` (bundled) |
|
||||
| arti-client | 0.46.0 | `0.46` + `onion-service-client` + `onion-service-service` |
|
||||
| tor-hsservice | 0.46.0 | `0.46` |
|
||||
|
||||
Caret reqs are not `=`; `cargo update` can move 0.10.x / 0.46.x without a Cargo.toml edit. `--locked` CI is the real pin. Arti onion services are still experimental upstream (TM + onionwire skill); this tree fail-closes, no C-tor fallback (`src/hs.rs:55`).
|
||||
|
||||
## Open questions for Lance
|
||||
|
||||
- Is a “verified” receipt allowed to mean anything in v0.2, or should the UI only ever show “unverified” until F1 is conjunctive and covered by a test?
|
||||
- Invite encoding: length-prefix / `0x00` separators now, or wait for `onionwire:v2` (v1 strings stay in the wild)?
|
||||
- Store passphrase: keep `ONIONWIRE_STORE_PASSPHRASE` (proc-visible) or prompt / kernel keyring?
|
||||
- Wallet RPC: document “loopback + `--rpc-login` you type into a wrapper”, or teach OnionWire digest?
|
||||
|
||||
## Stop / go (auditor, not a ship decision)
|
||||
|
||||
Go for **friends-only chat** under the written seizure model (identity keys plaintext; bodies encrypted; Tor relays are not a server). **Do not** treat `verified` receipts as money moved. **Do not** treat F3-paste of a string you did not copy yourself as an integrity-checked locator. No Critical remote key/plaintext bug found in this revision.
|
||||
|
|
@ -16,9 +16,9 @@ A signed `loc` frame (`onion`, `ts`, `sig`) rewrites a friend’s locator **only
|
|||
|
||||
## Message bodies are encrypted at rest; keys are not
|
||||
|
||||
Chat bodies in sqlite are ChaCha20-Poly1305 (`nonce || ciphertext` in the `messages.plaintext` column). A random 32-byte data key is wrapped with Argon2id (same params as identity backup) from a non-empty passphrase. Salt + wrapped key live in `store_meta`. Unlock is fail-closed: wrong or empty passphrase does not open chat.
|
||||
Chat bodies in sqlite are ChaCha20-Poly1305 (`nonce || ciphertext` in the `messages.plaintext` column) with AAD `owmsg1 || friend_id_le64 || dir || 0x00 || row_id_le64`. Swapping ciphertext between rows fails closed. A random 32-byte data key is wrapped with Argon2id (same params as identity backup) from a non-empty passphrase. Salt + wrapped key live in `store_meta`. Unlock is fail-closed: wrong or empty passphrase does not open chat. Empty-AAD v0.2 blobs are rewrapped once on unlock; `list_messages` never falls back to empty AAD.
|
||||
|
||||
Identity secret key, friend public keys, and locators remain plaintext in the same db. The message key is not wrapped with `identity_sk` (that key is already on disk). A seized laptop still yields who you talk to and your identity unless you add OS/FDE. sqlcipher is out of v1. `/wipe` overwrites message bodies and vacuums; `/wipe-all` deletes the data dir.
|
||||
Identity secret key, friend public keys, and locators remain plaintext in the same db. The message key is not wrapped with `identity_sk` (that key is already on disk). A seized laptop still yields who you talk to and your identity unless you add OS/FDE. sqlcipher is out of v1. `/wipe` deletes chat and payments history (overwrite message bodies, `VACUUM`, WAL checkpoint); roster and identity stay. It is not a forensic erase — SSD wear-leveling can keep copies. `/wipe-all` deletes the data dir (new identity); same disk caveat.
|
||||
|
||||
## Fail closed
|
||||
|
||||
|
|
@ -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`. Never trust a `rcp` frame without RPC confirmation (`verified` stays 0). 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
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
//! Encrypted identity backup. Onion (locator) is not included.
|
||||
|
||||
use argon2::{Algorithm, Argon2, Params, Version};
|
||||
use chacha20poly1305::aead::{Aead, AeadCore, KeyInit, OsRng};
|
||||
use chacha20poly1305::aead::{Aead, AeadCore, KeyInit, OsRng, Payload};
|
||||
use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce};
|
||||
use rand::RngCore;
|
||||
|
||||
|
|
@ -95,11 +95,21 @@ pub fn open(passphrase: &str, blob: &[u8]) -> Result<BackupKeys, String> {
|
|||
BackupKeys::from_bytes(&pt)
|
||||
}
|
||||
|
||||
pub(crate) fn aead_encrypt(key: &[u8; 32], plaintext: &[u8]) -> Result<Vec<u8>, String> {
|
||||
pub(crate) fn aead_encrypt(
|
||||
key: &[u8; 32],
|
||||
plaintext: &[u8],
|
||||
aad: &[u8],
|
||||
) -> Result<Vec<u8>, String> {
|
||||
let cipher = ChaCha20Poly1305::new(Key::from_slice(key));
|
||||
let nonce = ChaCha20Poly1305::generate_nonce(&mut OsRng);
|
||||
let ct = cipher
|
||||
.encrypt(&nonce, plaintext)
|
||||
.encrypt(
|
||||
&nonce,
|
||||
Payload {
|
||||
msg: plaintext,
|
||||
aad,
|
||||
},
|
||||
)
|
||||
.map_err(|_| "encrypt failed".to_string())?;
|
||||
let mut out = Vec::with_capacity(NONCE_LEN + ct.len());
|
||||
out.extend_from_slice(&nonce);
|
||||
|
|
@ -107,13 +117,19 @@ pub(crate) fn aead_encrypt(key: &[u8; 32], plaintext: &[u8]) -> Result<Vec<u8>,
|
|||
Ok(out)
|
||||
}
|
||||
|
||||
pub(crate) fn aead_decrypt(key: &[u8; 32], blob: &[u8]) -> Result<Vec<u8>, String> {
|
||||
pub(crate) fn aead_decrypt(key: &[u8; 32], blob: &[u8], aad: &[u8]) -> Result<Vec<u8>, String> {
|
||||
if blob.len() < NONCE_LEN + 16 {
|
||||
return Err("ciphertext length".into());
|
||||
}
|
||||
let nonce = Nonce::from_slice(&blob[..NONCE_LEN]);
|
||||
let cipher = ChaCha20Poly1305::new(Key::from_slice(key));
|
||||
cipher
|
||||
.decrypt(nonce, &blob[NONCE_LEN..])
|
||||
.decrypt(
|
||||
nonce,
|
||||
Payload {
|
||||
msg: &blob[NONCE_LEN..],
|
||||
aad,
|
||||
},
|
||||
)
|
||||
.map_err(|_| "wrong passphrase or corrupt".to_string())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
387
src/file.rs
Normal 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()
|
||||
}
|
||||
24
src/hs.rs
24
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<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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
23
src/main.rs
23
src/main.rs
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
58
src/node.rs
58
src/node.rs
|
|
@ -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(());
|
||||
|
|
|
|||
62
src/pay.rs
62
src/pay.rs
|
|
@ -1,4 +1,5 @@
|
|||
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
|
||||
use sha3::{Digest, Keccak256};
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
|
|
@ -35,19 +36,72 @@ const INV_PREFIX: &[u8] = b"inv ";
|
|||
const RCP_PREFIX: &[u8] = b"rcp ";
|
||||
const PICONERO: u128 = 1_000_000_000_000;
|
||||
|
||||
/// Bitcoin-style alphabet; Monero encodes 8-byte blocks (11 chars), not raw base58.
|
||||
const B58: &[u8] = b"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
|
||||
const B58_ENC_LEN: [usize; 9] = [0, 2, 3, 5, 6, 7, 9, 10, 11];
|
||||
|
||||
pub fn check_address(addr: &str) -> Result<()> {
|
||||
let ok = match addr.as_bytes().first() {
|
||||
Some(b'4') if addr.len() == 95 || addr.len() == 106 => true,
|
||||
Some(b'8') if addr.len() == 95 => true,
|
||||
let raw = decode_monero_b58(addr).ok_or_else(|| Error("invalid Monero address".into()))?;
|
||||
if raw.len() != 69 && raw.len() != 77 {
|
||||
return Err(Error("invalid Monero address".into()));
|
||||
}
|
||||
let (payload, ck) = raw.split_at(raw.len() - 4);
|
||||
let hash = Keccak256::digest(payload);
|
||||
if hash.as_slice().get(..4) != Some(ck) {
|
||||
return Err(Error("invalid Monero address".into()));
|
||||
}
|
||||
let ok = match (payload[0], raw.len()) {
|
||||
(18 | 24 | 42 | 36, 69) => true, // mainnet/stagenet standard + subaddress
|
||||
(19 | 25, 77) => true, // mainnet/stagenet integrated
|
||||
_ => false,
|
||||
};
|
||||
if ok && !addr.contains('\n') {
|
||||
if ok {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(Error("invalid Monero address".into()))
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_monero_b58(addr: &str) -> Option<Vec<u8>> {
|
||||
let bytes = addr.as_bytes();
|
||||
if bytes.is_empty() || !bytes.iter().all(|b| B58.contains(b)) {
|
||||
return None;
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
let rest = bytes.len() - i;
|
||||
let (enc_len, dec_len) = if rest >= 11 {
|
||||
(11, 8)
|
||||
} else {
|
||||
let dec_len = B58_ENC_LEN.iter().position(|&n| n == rest)?;
|
||||
(rest, dec_len)
|
||||
};
|
||||
out.extend_from_slice(&decode_b58_block(&bytes[i..i + enc_len], dec_len)?);
|
||||
i += enc_len;
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn decode_b58_block(enc: &[u8], out_len: usize) -> Option<Vec<u8>> {
|
||||
let mut acc: u128 = 0;
|
||||
for &c in enc {
|
||||
let d = B58.iter().position(|&a| a == c)? as u128;
|
||||
acc = acc.checked_mul(58)?.checked_add(d)?;
|
||||
}
|
||||
let max = if out_len >= 16 {
|
||||
return None;
|
||||
} else if out_len == 0 {
|
||||
0
|
||||
} else {
|
||||
(1u128 << (8 * out_len)) - 1
|
||||
};
|
||||
if acc > max {
|
||||
return None;
|
||||
}
|
||||
Some(acc.to_be_bytes()[16 - out_len..].to_vec())
|
||||
}
|
||||
|
||||
pub fn parse_atomic(s: &str) -> Result<u128> {
|
||||
if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) {
|
||||
return Err(Error("amount must be decimal piconero".into()));
|
||||
|
|
|
|||
|
|
@ -125,6 +125,9 @@ fn check_fields(display_name: &str, bio: &str, xmr_addr: &str) -> Result<()> {
|
|||
if display_name.contains('\n') || bio.contains('\n') || xmr_addr.contains('\n') {
|
||||
return Err(Error("profile fields must not contain newlines".into()));
|
||||
}
|
||||
if !xmr_addr.is_empty() {
|
||||
crate::pay::check_address(xmr_addr).map_err(|e| Error(e.to_string()))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
|
|||
98
src/qr.rs
98
src/qr.rs
|
|
@ -20,30 +20,51 @@ pub struct QrPayload {
|
|||
pub signed_prekey: Vec<u8>,
|
||||
}
|
||||
|
||||
/// `onionwire:v1:k={pubkey_hex}:o={v3onion}:spk={signed_prekey}:sig={sign(k||o||spk)}`
|
||||
/// Cap invite text before hex decode. Honest v1 is a few hundred bytes.
|
||||
const MAX_INVITE: usize = 4096;
|
||||
const HEX_K: usize = 64;
|
||||
const HEX_SPK: usize = 64;
|
||||
const HEX_SIG: usize = 128;
|
||||
|
||||
/// `onionwire:v1:k={pubkey_hex}:o={v3onion}:spk={signed_prekey}:sig={sign(v2)}`
|
||||
pub fn encode(identity_sk: &[u8], onion: &str, signed_prekey: &[u8]) -> Result<String> {
|
||||
if signed_prekey.len() != 32 {
|
||||
return Err(Error("spk must be 32 bytes".into()));
|
||||
}
|
||||
if !is_v3_onion(onion) {
|
||||
return Err(Error("o must be a v3 onion".into()));
|
||||
}
|
||||
let sk_bytes: [u8; 32] = identity_sk
|
||||
.try_into()
|
||||
.map_err(|_| Error("identity secret key must be 32 bytes".into()))?;
|
||||
let sk = SigningKey::from_bytes(&sk_bytes);
|
||||
let k = to_hex(&sk.verifying_key().to_bytes());
|
||||
let spk = to_hex(signed_prekey);
|
||||
let msg = sign_msg(&k, onion, &spk);
|
||||
let msg = sign_msg_v2(&k, onion, &spk);
|
||||
let sig = to_hex(&sk.sign(&msg).to_bytes());
|
||||
Ok(format!("onionwire:v1:k={k}:o={onion}:spk={spk}:sig={sig}"))
|
||||
}
|
||||
|
||||
pub fn decode(raw: &str) -> Result<QrPayload> {
|
||||
if raw.len() > MAX_INVITE {
|
||||
return Err(Error("invite too long".into()));
|
||||
}
|
||||
let (k, onion, spk, sig) = parse_fields(raw)?;
|
||||
if !is_hex_len(&k, HEX_K) {
|
||||
return Err(Error("k must be 32 bytes".into()));
|
||||
}
|
||||
if !is_hex_len(&spk, HEX_SPK) {
|
||||
return Err(Error("spk must be 32 bytes".into()));
|
||||
}
|
||||
if !is_hex_len(&sig, HEX_SIG) {
|
||||
return Err(Error("sig must be 64 bytes".into()));
|
||||
}
|
||||
if !is_v3_onion(&onion) {
|
||||
return Err(Error("o must be a v3 onion".into()));
|
||||
}
|
||||
let pubkey = from_hex(&k)?;
|
||||
let signed_prekey = from_hex(&spk)?;
|
||||
let sig_bytes = from_hex(&sig)?;
|
||||
if pubkey.len() != 32 {
|
||||
return Err(Error("k must be 32 bytes".into()));
|
||||
}
|
||||
if sig_bytes.len() != 64 {
|
||||
return Err(Error("sig must be 64 bytes".into()));
|
||||
}
|
||||
let pk_arr: [u8; 32] = pubkey
|
||||
.as_slice()
|
||||
.try_into()
|
||||
|
|
@ -54,9 +75,15 @@ pub fn decode(raw: &str) -> Result<QrPayload> {
|
|||
.map_err(|_| Error("sig must be 64 bytes".into()))?;
|
||||
let vk = VerifyingKey::from_bytes(&pk_arr).map_err(|e| Error(e.to_string()))?;
|
||||
let signature = Signature::from_bytes(&sig_arr);
|
||||
let msg = sign_msg(&k, &onion, &spk);
|
||||
vk.verify(&msg, &signature)
|
||||
.map_err(|_| Error("bad signature".into()))?;
|
||||
let ok_v2 = vk
|
||||
.verify(&sign_msg_v2(&k, &onion, &spk), &signature)
|
||||
.is_ok();
|
||||
let ok_v1 = vk
|
||||
.verify(&sign_msg_v1(&k, &onion, &spk), &signature)
|
||||
.is_ok();
|
||||
if !ok_v2 && !ok_v1 {
|
||||
return Err(Error("bad signature".into()));
|
||||
}
|
||||
Ok(QrPayload {
|
||||
pubkey,
|
||||
onion,
|
||||
|
|
@ -64,7 +91,30 @@ pub fn decode(raw: &str) -> Result<QrPayload> {
|
|||
})
|
||||
}
|
||||
|
||||
fn sign_msg(k: &str, onion: &str, spk: &str) -> Vec<u8> {
|
||||
fn is_v3_onion(s: &str) -> bool {
|
||||
let Some(addr) = s.strip_suffix(".onion") else {
|
||||
return false;
|
||||
};
|
||||
addr.len() == 56 && addr.bytes().all(|b| matches!(b, b'a'..=b'z' | b'2'..=b'7'))
|
||||
}
|
||||
|
||||
fn is_hex_len(s: &str, n: usize) -> bool {
|
||||
s.len() == n && s.bytes().all(|c| c.is_ascii_hexdigit())
|
||||
}
|
||||
|
||||
fn sign_msg_v2(k: &str, onion: &str, spk: &str) -> Vec<u8> {
|
||||
let mut msg = Vec::with_capacity(19 + 3 + k.len() + onion.len() + spk.len());
|
||||
msg.extend_from_slice(b"onionwire-invite-v1");
|
||||
msg.push(0);
|
||||
msg.extend_from_slice(k.as_bytes());
|
||||
msg.push(0);
|
||||
msg.extend_from_slice(onion.as_bytes());
|
||||
msg.push(0);
|
||||
msg.extend_from_slice(spk.as_bytes());
|
||||
msg
|
||||
}
|
||||
|
||||
fn sign_msg_v1(k: &str, onion: &str, spk: &str) -> Vec<u8> {
|
||||
let mut msg = Vec::with_capacity(k.len() + onion.len() + spk.len());
|
||||
msg.extend_from_slice(k.as_bytes());
|
||||
msg.extend_from_slice(onion.as_bytes());
|
||||
|
|
@ -118,7 +168,7 @@ fn to_hex(bytes: &[u8]) -> String {
|
|||
}
|
||||
|
||||
fn from_hex(s: &str) -> Result<Vec<u8>> {
|
||||
if s.is_empty() || !s.len().is_multiple_of(2) {
|
||||
if s.len() > HEX_SIG || s.is_empty() || !s.len().is_multiple_of(2) {
|
||||
return Err(Error("invalid hex".into()));
|
||||
}
|
||||
if !s.bytes().all(|c| c.is_ascii_hexdigit()) {
|
||||
|
|
@ -129,3 +179,25 @@ fn from_hex(s: &str) -> Result<Vec<u8>> {
|
|||
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).map_err(|_| Error("invalid hex".into())))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn v3() -> String {
|
||||
format!("{}.onion", "a".repeat(56))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn well_formed_v1_concat_still_decodes() {
|
||||
let sk = SigningKey::from_bytes(&[7u8; 32]);
|
||||
let k = to_hex(&sk.verifying_key().to_bytes());
|
||||
let onion = v3();
|
||||
let spk = to_hex(&[9u8; 32]);
|
||||
let sig = to_hex(&sk.sign(&sign_msg_v1(&k, &onion, &spk)).to_bytes());
|
||||
let raw = format!("onionwire:v1:k={k}:o={onion}:spk={spk}:sig={sig}");
|
||||
let p = decode(&raw).expect("legacy concat");
|
||||
assert_eq!(p.onion, onion);
|
||||
assert_eq!(p.signed_prekey, vec![9u8; 32]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
269
src/store.rs
269
src/store.rs
|
|
@ -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;
|
||||
|
|
@ -270,12 +274,13 @@ impl Store {
|
|||
match row {
|
||||
Some((salt, wrapped)) => {
|
||||
let wrap_key = crate::backup::kdf(passphrase, &salt)?;
|
||||
let raw = crate::backup::aead_decrypt(&wrap_key, &wrapped)
|
||||
let raw = crate::backup::aead_decrypt(&wrap_key, &wrapped, b"")
|
||||
.map_err(|_| Error("wrong passphrase".into()))?;
|
||||
if raw.len() != 32 {
|
||||
return Err(Error("wrapped message key length".into()));
|
||||
}
|
||||
self.msg_key.copy_from_slice(&raw);
|
||||
self.rewrap_empty_aad_messages()?;
|
||||
Ok(())
|
||||
}
|
||||
None => {
|
||||
|
|
@ -284,7 +289,7 @@ impl Store {
|
|||
let mut salt = [0u8; 16];
|
||||
OsRng.fill_bytes(&mut salt);
|
||||
let wrap_key = crate::backup::kdf(passphrase, &salt)?;
|
||||
let wrapped = crate::backup::aead_encrypt(&wrap_key, &data_key)?;
|
||||
let wrapped = crate::backup::aead_encrypt(&wrap_key, &data_key, b"")?;
|
||||
self.conn.execute(
|
||||
"INSERT INTO store_meta (id, kdf_salt, wrapped_key) VALUES (1, ?1, ?2)",
|
||||
params![salt.as_slice(), wrapped],
|
||||
|
|
@ -297,17 +302,59 @@ impl Store {
|
|||
}
|
||||
|
||||
fn reencrypt_legacy_messages(&self) -> Result<()> {
|
||||
let mut stmt = self.conn.prepare("SELECT id, plaintext FROM messages")?;
|
||||
let rows: Vec<(i64, Vec<u8>)> = stmt
|
||||
.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
|
||||
let mut stmt = self
|
||||
.conn
|
||||
.prepare("SELECT id, friend_id, dir, plaintext FROM messages")?;
|
||||
let rows: Vec<(i64, i64, String, Vec<u8>)> = stmt
|
||||
.query_map([], |row| {
|
||||
Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
|
||||
})?
|
||||
.collect::<std::result::Result<_, _>>()?;
|
||||
drop(stmt);
|
||||
if rows.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let tx = self.conn.unchecked_transaction()?;
|
||||
for (id, plain) in rows {
|
||||
let blob = crate::backup::aead_encrypt(&self.msg_key, &plain)?;
|
||||
for (id, friend_id, dir, plain) in rows {
|
||||
let aad = message_aad(friend_id, &dir, id);
|
||||
let blob = crate::backup::aead_encrypt(&self.msg_key, &plain, &aad)?;
|
||||
tx.execute(
|
||||
"UPDATE messages SET plaintext = ?1 WHERE id = ?2",
|
||||
params![blob, id],
|
||||
)?;
|
||||
}
|
||||
tx.commit()?;
|
||||
let _ = self.conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE)");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// One-shot: empty-AAD v0.2 blobs → row-bound AAD. list_messages never falls back.
|
||||
fn rewrap_empty_aad_messages(&self) -> Result<()> {
|
||||
let mut stmt = self
|
||||
.conn
|
||||
.prepare("SELECT id, friend_id, dir, plaintext FROM messages")?;
|
||||
let rows: Vec<(i64, i64, String, Vec<u8>)> = stmt
|
||||
.query_map([], |row| {
|
||||
Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
|
||||
})?
|
||||
.collect::<std::result::Result<_, _>>()?;
|
||||
drop(stmt);
|
||||
let mut updates = Vec::new();
|
||||
for (id, friend_id, dir, blob) in rows {
|
||||
let aad = message_aad(friend_id, &dir, id);
|
||||
if crate::backup::aead_decrypt(&self.msg_key, &blob, &aad).is_ok() {
|
||||
continue;
|
||||
}
|
||||
if let Ok(pt) = crate::backup::aead_decrypt(&self.msg_key, &blob, b"") {
|
||||
let new_blob = crate::backup::aead_encrypt(&self.msg_key, &pt, &aad)?;
|
||||
updates.push((id, new_blob));
|
||||
}
|
||||
}
|
||||
if updates.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let tx = self.conn.unchecked_transaction()?;
|
||||
for (id, blob) in updates {
|
||||
tx.execute(
|
||||
"UPDATE messages SET plaintext = ?1 WHERE id = ?2",
|
||||
params![blob, id],
|
||||
|
|
@ -663,42 +710,59 @@ impl Store {
|
|||
|row| row.get(0),
|
||||
)
|
||||
.map_err(|_| Error("friend not found".into()))?;
|
||||
let blob = crate::backup::aead_encrypt(&self.msg_key, plaintext)?;
|
||||
self.conn.execute(
|
||||
"INSERT INTO messages (friend_id, dir, plaintext, created_at) VALUES (?1, ?2, ?3, ?4)",
|
||||
params![friend_id, dir, blob, unix_now()],
|
||||
let tx = self.conn.unchecked_transaction()?;
|
||||
tx.execute(
|
||||
"INSERT INTO messages (friend_id, dir, plaintext, created_at) VALUES (?1, ?2, x'', ?3)",
|
||||
params![friend_id, dir, unix_now()],
|
||||
)?;
|
||||
let row_id = tx.last_insert_rowid();
|
||||
let aad = message_aad(friend_id, dir, row_id);
|
||||
let blob = crate::backup::aead_encrypt(&self.msg_key, plaintext, &aad)?;
|
||||
tx.execute(
|
||||
"UPDATE messages SET plaintext = ?1 WHERE id = ?2",
|
||||
params![blob, row_id],
|
||||
)?;
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn list_messages(&self, friend_pk: &[u8]) -> Result<Vec<Message>> {
|
||||
let mut stmt = self.conn.prepare(
|
||||
"SELECT m.dir, m.plaintext FROM messages m
|
||||
"SELECT m.id, m.friend_id, m.dir, m.plaintext FROM messages m
|
||||
JOIN friends f ON f.id = m.friend_id
|
||||
WHERE f.pubkey = ?1
|
||||
ORDER BY m.id",
|
||||
)?;
|
||||
let rows = stmt.query_map(params![friend_pk], |row| {
|
||||
Ok((row.get::<_, String>(0)?, row.get::<_, Vec<u8>>(1)?))
|
||||
Ok((
|
||||
row.get::<_, i64>(0)?,
|
||||
row.get::<_, i64>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
row.get::<_, Vec<u8>>(3)?,
|
||||
))
|
||||
})?;
|
||||
let mut out = Vec::new();
|
||||
for row in rows {
|
||||
let (dir, blob) = row?;
|
||||
let plaintext = crate::backup::aead_decrypt(&self.msg_key, &blob)
|
||||
let (id, friend_id, dir, blob) = row?;
|
||||
let aad = message_aad(friend_id, &dir, id);
|
||||
let plaintext = crate::backup::aead_decrypt(&self.msg_key, &blob, &aad)
|
||||
.map_err(|_| Error("message decrypt failed".into()))?;
|
||||
out.push(Message { dir, plaintext });
|
||||
}
|
||||
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(())
|
||||
}
|
||||
|
||||
|
|
@ -767,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();
|
||||
|
|
@ -775,6 +859,17 @@ fn mkdir_700(path: &Path) -> Result<()> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// AAD: `owmsg1` || friend_id_le64 || dir || 0x00 || row_id_le64
|
||||
fn message_aad(friend_id: i64, dir: &str, row_id: i64) -> Vec<u8> {
|
||||
let mut aad = Vec::with_capacity(6 + 8 + dir.len() + 1 + 8);
|
||||
aad.extend_from_slice(b"owmsg1");
|
||||
aad.extend_from_slice(&friend_id.to_le_bytes());
|
||||
aad.extend_from_slice(dir.as_bytes());
|
||||
aad.push(0);
|
||||
aad.extend_from_slice(&row_id.to_le_bytes());
|
||||
aad
|
||||
}
|
||||
|
||||
fn unix_now() -> i64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
|
|
@ -790,6 +885,148 @@ fn fingerprint(pubkey: &[u8]) -> String {
|
|||
mod at_rest {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn swapped_ciphertext_does_not_show_other_friends_body() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let store = Store::open_at_with_passphrase(dir.path(), "aad-pass").unwrap();
|
||||
let alice = [1u8; 32];
|
||||
let bob = [2u8; 32];
|
||||
store.upsert_friend(&alice, "a.onion", None).unwrap();
|
||||
store.upsert_friend(&bob, "b.onion", None).unwrap();
|
||||
store
|
||||
.append_message(&alice, "out", b"secret-for-alice")
|
||||
.unwrap();
|
||||
store
|
||||
.append_message(&bob, "out", b"secret-for-bob")
|
||||
.unwrap();
|
||||
|
||||
let blobs: Vec<(i64, Vec<u8>)> = {
|
||||
let mut stmt = store
|
||||
.conn
|
||||
.prepare("SELECT id, plaintext FROM messages ORDER BY id")
|
||||
.unwrap();
|
||||
stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))
|
||||
.unwrap()
|
||||
.collect::<rusqlite::Result<Vec<_>>>()
|
||||
.unwrap()
|
||||
};
|
||||
assert_eq!(blobs.len(), 2);
|
||||
store
|
||||
.conn
|
||||
.execute(
|
||||
"UPDATE messages SET plaintext = ?1 WHERE id = ?2",
|
||||
params![&blobs[1].1, blobs[0].0],
|
||||
)
|
||||
.unwrap();
|
||||
store
|
||||
.conn
|
||||
.execute(
|
||||
"UPDATE messages SET plaintext = ?1 WHERE id = ?2",
|
||||
params![&blobs[0].1, blobs[1].0],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
if let Ok(msgs) = store.list_messages(&alice) {
|
||||
assert!(
|
||||
msgs.iter()
|
||||
.all(|m| m.plaintext.as_slice() != b"secret-for-bob"),
|
||||
"alice saw bob's body after ciphertext swap"
|
||||
);
|
||||
}
|
||||
if let Ok(msgs) = store.list_messages(&bob) {
|
||||
assert!(
|
||||
msgs.iter()
|
||||
.all(|m| m.plaintext.as_slice() != b"secret-for-alice"),
|
||||
"bob saw alice's body after ciphertext swap"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_aad_rows_rewrap_on_unlock_then_swap_fails() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let store = Store::open_at_with_passphrase(dir.path(), "rewrap-pass").unwrap();
|
||||
let alice = [1u8; 32];
|
||||
let bob = [2u8; 32];
|
||||
store.upsert_friend(&alice, "a.onion", None).unwrap();
|
||||
store.upsert_friend(&bob, "b.onion", None).unwrap();
|
||||
store
|
||||
.append_message(&alice, "out", b"secret-for-alice")
|
||||
.unwrap();
|
||||
store
|
||||
.append_message(&bob, "out", b"secret-for-bob")
|
||||
.unwrap();
|
||||
let ids: Vec<i64> = {
|
||||
let mut stmt = store
|
||||
.conn
|
||||
.prepare("SELECT id FROM messages ORDER BY id")
|
||||
.unwrap();
|
||||
stmt.query_map([], |r| r.get(0))
|
||||
.unwrap()
|
||||
.collect::<rusqlite::Result<Vec<_>>>()
|
||||
.unwrap()
|
||||
};
|
||||
let a_blob = crate::backup::aead_encrypt(&store.msg_key, b"secret-for-alice", b"").unwrap();
|
||||
let b_blob = crate::backup::aead_encrypt(&store.msg_key, b"secret-for-bob", b"").unwrap();
|
||||
store
|
||||
.conn
|
||||
.execute(
|
||||
"UPDATE messages SET plaintext = ?1 WHERE id = ?2",
|
||||
params![a_blob, ids[0]],
|
||||
)
|
||||
.unwrap();
|
||||
store
|
||||
.conn
|
||||
.execute(
|
||||
"UPDATE messages SET plaintext = ?1 WHERE id = ?2",
|
||||
params![b_blob, ids[1]],
|
||||
)
|
||||
.unwrap();
|
||||
drop(store);
|
||||
|
||||
let store = Store::open_at_with_passphrase(dir.path(), "rewrap-pass").unwrap();
|
||||
assert_eq!(
|
||||
store.list_messages(&alice).unwrap()[0].plaintext,
|
||||
b"secret-for-alice"
|
||||
);
|
||||
assert_eq!(
|
||||
store.list_messages(&bob).unwrap()[0].plaintext,
|
||||
b"secret-for-bob"
|
||||
);
|
||||
|
||||
let blobs: Vec<(i64, Vec<u8>)> = {
|
||||
let mut stmt = store
|
||||
.conn
|
||||
.prepare("SELECT id, plaintext FROM messages ORDER BY id")
|
||||
.unwrap();
|
||||
stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))
|
||||
.unwrap()
|
||||
.collect::<rusqlite::Result<Vec<_>>>()
|
||||
.unwrap()
|
||||
};
|
||||
store
|
||||
.conn
|
||||
.execute(
|
||||
"UPDATE messages SET plaintext = ?1 WHERE id = ?2",
|
||||
params![&blobs[1].1, blobs[0].0],
|
||||
)
|
||||
.unwrap();
|
||||
store
|
||||
.conn
|
||||
.execute(
|
||||
"UPDATE messages SET plaintext = ?1 WHERE id = ?2",
|
||||
params![&blobs[0].1, blobs[1].0],
|
||||
)
|
||||
.unwrap();
|
||||
if let Ok(msgs) = store.list_messages(&alice) {
|
||||
assert!(
|
||||
msgs.iter()
|
||||
.all(|m| m.plaintext.as_slice() != b"secret-for-bob"),
|
||||
"alice saw bob's body after post-rewrap swap"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_unlock_reencrypts_legacy_plaintext_rows() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
|
|
|||
102
src/tui.rs
102
src/tui.rs
|
|
@ -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()
|
||||
};
|
||||
|
|
|
|||
251
src/wallet.rs
251
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", &"<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,35 +149,68 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn transfers_match(rows: &[TransferRow], txid: &str, amount: &str, address: &str) -> bool {
|
||||
rows.iter().any(|r| {
|
||||
r.txid == txid
|
||||
|| (!address.is_empty()
|
||||
&& r.address == address
|
||||
&& (amount.is_empty() || r.amount == amount))
|
||||
})
|
||||
if txid.is_empty() || amount.is_empty() || address.is_empty() {
|
||||
return false;
|
||||
}
|
||||
rows.iter()
|
||||
.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()),
|
||||
|
|
@ -165,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)> {
|
||||
|
|
@ -201,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;
|
||||
}
|
||||
|
|
@ -220,6 +284,31 @@ fn host_header(host: &str, port: u16) -> String {
|
|||
}
|
||||
}
|
||||
|
||||
const MAX_RPC_BYTES: usize = 1024 * 1024;
|
||||
|
||||
async fn post_timeout(ep: &Endpoint, req: &str) -> Result<Vec<u8>> {
|
||||
tokio::time::timeout(RPC_TIMEOUT, http_post(ep, req.as_bytes()))
|
||||
.await
|
||||
.map_err(|_| Error("wallet RPC timed out".into()))?
|
||||
}
|
||||
|
||||
fn build_req(ep: &Endpoint, body: &str, authorization: Option<&str>) -> String {
|
||||
let host_hdr = host_header(&ep.host, ep.port);
|
||||
let mut req = format!(
|
||||
"POST {} HTTP/1.1\r\nHost: {host_hdr}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n",
|
||||
ep.path,
|
||||
body.len()
|
||||
);
|
||||
if let Some(auth) = authorization {
|
||||
req.push_str("Authorization: ");
|
||||
req.push_str(auth);
|
||||
req.push_str("\r\n");
|
||||
}
|
||||
req.push_str("\r\n");
|
||||
req.push_str(body);
|
||||
req
|
||||
}
|
||||
|
||||
async fn http_post(ep: &Endpoint, req: &[u8]) -> Result<Vec<u8>> {
|
||||
let mut stream = TcpStream::connect((ep.host.as_str(), ep.port))
|
||||
.await
|
||||
|
|
@ -229,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
133
tests/file.rs
Normal 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);
|
||||
}
|
||||
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.
|
||||
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
32
tests/pay.rs
32
tests/pay.rs
|
|
@ -12,16 +12,20 @@ fn store() -> (tempfile::TempDir, Store) {
|
|||
(dir, store)
|
||||
}
|
||||
|
||||
// Official mainnet standard from Monero docs (not live RPC).
|
||||
const MAINNET_STD: &str = "4AdUndXHHZ6cfufTMvppY6JwXNouMBzSkbLYfpAV5Usx3skxNgYeYTRj5UzqtReoS44qo9mtmXCqY45DJ852K5Jv2684Rge";
|
||||
// Same documented spend/view keys, mainnet subaddress (0x2A) and integrated (0x13 + 8 zero pid).
|
||||
const MAINNET_SUB: &str = "8BTd81B7syWcfufTMvppY6JwXNouMBzSkbLYfpAV5Usx3skxNgYeYTRj5UzqtReoS44qo9mtmXCqY45DJ852K5Jv25pnJx6";
|
||||
const MAINNET_INT: &str = "4LL9oSLmtpccfufTMvppY6JwXNouMBzSkbLYfpAV5Usx3skxNgYeYTRj5UzqtReoS44qo9mtmXCqY45DJ852K5Jv2WK48GNSUQf17NLRTG";
|
||||
// Same keys, stagenet standard (0x18).
|
||||
const STAGENET_STD: &str = "5AqWsUSEwACcfufTMvppY6JwXNouMBzSkbLYfpAV5Usx3skxNgYeYTRj5UzqtReoS44qo9mtmXCqY45DJ852K5Jv23X7tqA";
|
||||
|
||||
fn addr_std() -> String {
|
||||
format!("4{}", "A".repeat(94))
|
||||
MAINNET_STD.to_string()
|
||||
}
|
||||
|
||||
fn addr_sub() -> String {
|
||||
format!("8{}", "B".repeat(94))
|
||||
}
|
||||
|
||||
fn addr_integrated() -> String {
|
||||
format!("4{}", "C".repeat(105))
|
||||
MAINNET_SUB.to_string()
|
||||
}
|
||||
|
||||
fn payw<'a>(
|
||||
|
|
@ -46,12 +50,20 @@ fn payw<'a>(
|
|||
|
||||
#[test]
|
||||
fn valid_and_invalid_xmr_addresses() {
|
||||
assert!(pay::check_address(&addr_std()).is_ok());
|
||||
assert!(pay::check_address(&addr_sub()).is_ok());
|
||||
assert!(pay::check_address(&addr_integrated()).is_ok());
|
||||
assert!(pay::check_address(MAINNET_STD).is_ok());
|
||||
assert!(pay::check_address(MAINNET_SUB).is_ok());
|
||||
assert!(pay::check_address(MAINNET_INT).is_ok());
|
||||
assert!(pay::check_address(STAGENET_STD).is_ok());
|
||||
// prefix+length junk that the old checker accepted
|
||||
assert!(pay::check_address(&format!("4{}", "A".repeat(94))).is_err());
|
||||
assert!(pay::check_address(&format!("8{}", "B".repeat(94))).is_err());
|
||||
assert!(pay::check_address(&format!("4{}", "C".repeat(105))).is_err());
|
||||
assert!(pay::check_address(&format!("4{}", "A".repeat(93))).is_err());
|
||||
assert!(pay::check_address(&format!("8{}", "B".repeat(95))).is_err());
|
||||
assert!(pay::check_address(&format!("5{}", "A".repeat(94))).is_err());
|
||||
assert!(pay::check_address(&format!("{MAINNET_STD}\n")).is_err());
|
||||
let mut bad_ck = MAINNET_STD.to_string();
|
||||
bad_ck.replace_range(94..95, "f");
|
||||
assert!(pay::check_address(&bad_ck).is_err());
|
||||
assert!(pay::check_address("").is_err());
|
||||
assert!(pay::check_address("not-an-address").is_err());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -108,21 +108,33 @@ fn chat_is_not_profile() {
|
|||
assert!(profile::decode(b"hello wire").is_none());
|
||||
}
|
||||
|
||||
const MAINNET_STD: &str = "4AdUndXHHZ6cfufTMvppY6JwXNouMBzSkbLYfpAV5Usx3skxNgYeYTRj5UzqtReoS44qo9mtmXCqY45DJ852K5Jv2684Rge";
|
||||
|
||||
#[test]
|
||||
fn self_profile_roundtrip() {
|
||||
let (_dir, store) = store();
|
||||
store.set_self_profile("me", "a bio", "4abc").unwrap();
|
||||
store.set_self_profile("me", "a bio", MAINNET_STD).unwrap();
|
||||
let got = store.self_profile().unwrap();
|
||||
assert_eq!(got.display_name, "me");
|
||||
assert_eq!(got.bio, "a bio");
|
||||
assert_eq!(got.xmr_addr, "4abc");
|
||||
assert_eq!(got.xmr_addr, MAINNET_STD);
|
||||
assert!(got.updated_at > 0);
|
||||
store.set_self_profile("", "", "").unwrap();
|
||||
let empty = store.self_profile().unwrap();
|
||||
assert_eq!(empty.display_name, "");
|
||||
assert_eq!(empty.xmr_addr, "");
|
||||
assert!(empty.updated_at >= got.updated_at);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn self_profile_rejects_short_xmr_addr() {
|
||||
let (_dir, store) = store();
|
||||
assert!(store.set_self_profile("me", "a bio", "4abc").is_err());
|
||||
assert!(profile::validate("me", "a bio", "4abc").is_err());
|
||||
assert!(profile::validate("me", "a bio", "").is_ok());
|
||||
assert!(profile::validate("me", "a bio", MAINNET_STD).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slash_profile_who_and_wipe_still_parse() {
|
||||
assert_eq!(parse_cmd("/profile"), Some(SlashCmd::Profile));
|
||||
|
|
|
|||
106
tests/qr.rs
106
tests/qr.rs
|
|
@ -2,8 +2,8 @@
|
|||
|
||||
use std::sync::{Mutex, MutexGuard};
|
||||
|
||||
use onionwire::Store;
|
||||
use onionwire::qr;
|
||||
use onionwire::Store;
|
||||
|
||||
static ENV_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
|
|
@ -15,7 +15,7 @@ struct TempHome {
|
|||
impl TempHome {
|
||||
fn new() -> Self {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let guard = ENV_LOCK.lock().expect("env lock");
|
||||
let guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
unsafe {
|
||||
std::env::set_var("ONIONWIRE_HOME", dir.path());
|
||||
std::env::set_var("ONIONWIRE_STORE_PASSPHRASE", "onionwire-test");
|
||||
|
|
@ -37,7 +37,37 @@ impl Drop for TempHome {
|
|||
}
|
||||
|
||||
fn v3onion(tag: &str) -> String {
|
||||
format!("{tag}.onion")
|
||||
let mut addr = vec![b'a'; 56];
|
||||
let bytes: Vec<u8> = tag
|
||||
.bytes()
|
||||
.map(|b| match b {
|
||||
b'a'..=b'z' | b'2'..=b'7' => b,
|
||||
_ => b'a',
|
||||
})
|
||||
.collect();
|
||||
let n = bytes.len().min(56);
|
||||
addr[..n].copy_from_slice(&bytes[..n]);
|
||||
format!("{}.onion", String::from_utf8(addr).expect("base32"))
|
||||
}
|
||||
|
||||
fn invite_fields(raw: &str) -> (String, String, String, String) {
|
||||
let rest = raw.strip_prefix("onionwire:v1:").expect("prefix");
|
||||
let mut k = String::new();
|
||||
let mut o = String::new();
|
||||
let mut spk = String::new();
|
||||
let mut sig = String::new();
|
||||
for part in rest.split(':') {
|
||||
if let Some(v) = part.strip_prefix("k=") {
|
||||
k = v.to_string();
|
||||
} else if let Some(v) = part.strip_prefix("o=") {
|
||||
o = v.to_string();
|
||||
} else if let Some(v) = part.strip_prefix("spk=") {
|
||||
spk = v.to_string();
|
||||
} else if let Some(v) = part.strip_prefix("sig=") {
|
||||
sig = v.to_string();
|
||||
}
|
||||
}
|
||||
(k, o, spk, sig)
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -119,3 +149,73 @@ fn unknown_k_decode_does_not_insert() {
|
|||
let _p = qr::decode(&raw).unwrap();
|
||||
assert_eq!(store.friend_count().unwrap(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shifted_o_spk_is_err() {
|
||||
let _home = TempHome::new();
|
||||
let store = Store::open().expect("open");
|
||||
let me = store.self_identity().expect("self");
|
||||
let onion = v3onion("honest");
|
||||
let spk = [0xab; 32];
|
||||
let raw = qr::encode(&me.identity_sk, &onion, &spk).expect("encode");
|
||||
let (k, o, spk_hex, sig) = invite_fields(&raw);
|
||||
assert_eq!(spk_hex.len(), 64);
|
||||
let mutant = format!(
|
||||
"onionwire:v1:k={k}:o={o}{}:spk={}:sig={sig}",
|
||||
&spk_hex[..8],
|
||||
&spk_hex[8..]
|
||||
);
|
||||
assert!(
|
||||
qr::decode(&mutant).is_err(),
|
||||
"shifted o/spk must not verify"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encode_rejects_spk_not_32() {
|
||||
let _home = TempHome::new();
|
||||
let store = Store::open().expect("open");
|
||||
let me = store.self_identity().expect("self");
|
||||
let onion = v3onion("spk");
|
||||
assert!(qr::encode(&me.identity_sk, &onion, &[1u8; 31]).is_err());
|
||||
assert!(qr::encode(&me.identity_sk, &onion, &[1u8; 33]).is_err());
|
||||
assert!(qr::encode(&me.identity_sk, &onion, &[]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encode_rejects_garbage_onion() {
|
||||
let _home = TempHome::new();
|
||||
let store = Store::open().expect("open");
|
||||
let me = store.self_identity().expect("self");
|
||||
let spk = [2u8; 32];
|
||||
assert!(qr::encode(&me.identity_sk, "not-an-onion", &spk).is_err());
|
||||
assert!(qr::encode(&me.identity_sk, "alice.onion", &spk).is_err());
|
||||
assert!(qr::encode(&me.identity_sk, &format!("{}.onion", "A".repeat(56)), &spk).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_rejects_spk_len_and_garbage_onion() {
|
||||
let _home = TempHome::new();
|
||||
let store = Store::open().expect("open");
|
||||
let me = store.self_identity().expect("self");
|
||||
let raw = qr::encode(&me.identity_sk, &v3onion("ok"), &[3u8; 32]).expect("encode");
|
||||
let (k, o, spk, sig) = invite_fields(&raw);
|
||||
let short_spk = format!("onionwire:v1:k={k}:o={o}:spk={}:sig={sig}", &spk[..62]);
|
||||
assert!(qr::decode(&short_spk).is_err());
|
||||
let garbage_o = format!("onionwire:v1:k={k}:o=nope.onion:spk={spk}:sig={sig}");
|
||||
assert!(qr::decode(&garbage_o).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn huge_hex_is_err_quickly() {
|
||||
let huge = format!(
|
||||
"onionwire:v1:k={}:o=x:spk=yy:sig=zz",
|
||||
"aa".repeat(1024 * 1024)
|
||||
);
|
||||
let t = std::time::Instant::now();
|
||||
assert!(qr::decode(&huge).is_err());
|
||||
assert!(
|
||||
t.elapsed() < std::time::Duration::from_millis(250),
|
||||
"decode must fail closed before a multi-MB alloc"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
45
tests/send.rs
Normal file
45
tests/send.rs
Normal 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()))
|
||||
);
|
||||
}
|
||||
|
|
@ -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}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
||||
|
|
|
|||
|
|
@ -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,13 +77,13 @@ 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 = "abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopq.onion";
|
||||
let onion = "abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopqrstuvwx.onion";
|
||||
for (w, h) in [(80u16, 24u16), (120, 40)] {
|
||||
let backend = TestBackend::new(w, h);
|
||||
let mut terminal = Terminal::new(backend).expect("terminal");
|
||||
|
|
@ -113,7 +125,7 @@ fn chrome_renders_at_80x24_and_120x40() {
|
|||
#[test]
|
||||
fn main_footer_keeps_help_at_80_and_120() {
|
||||
let fp = "abcdef0123456789deadbeef";
|
||||
let onion = "abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopq.onion";
|
||||
let onion = "abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopqrstuvwx.onion";
|
||||
let hints = main_footer_hints();
|
||||
assert!(hints.contains("? help"));
|
||||
for w in [80u16, 120] {
|
||||
|
|
@ -166,11 +178,11 @@ 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 = "abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopq.onion";
|
||||
let onion = "abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopqrstuvwx.onion";
|
||||
let payload = qr::encode(&sk, onion, &[1u8; 32]).expect("encode");
|
||||
assert!(payload.starts_with("onionwire:v1:"));
|
||||
|
||||
|
|
|
|||
215
tests/wallet.rs
215
tests/wallet.rs
|
|
@ -1,9 +1,27 @@
|
|||
//! M8: optional monero-wallet-rpc JSON client. Mock TCP only — no live monerod.
|
||||
|
||||
use onionwire::wallet::{self, Wallet};
|
||||
use ed25519_dalek::SigningKey;
|
||||
use onionwire::pay;
|
||||
use onionwire::wallet::{self, TransferRow, Wallet};
|
||||
use onionwire::{PaymentWrite, Store};
|
||||
use rand::rngs::OsRng;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
fn row(txid: &str, amount: &str, address: &str) -> TransferRow {
|
||||
TransferRow {
|
||||
txid: txid.into(),
|
||||
amount: amount.into(),
|
||||
address: address.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn xmr_addr() -> String {
|
||||
// Same documented mainnet standard as tests/pay.rs — F4 checksums this.
|
||||
"4AdUndXHHZ6cfufTMvppY6JwXNouMBzSkbLYfpAV5Usx3skxNgYeYTRj5UzqtReoS44qo9mtmXCqY45DJ852K5Jv2684Rge"
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn json_rpc_ok(result: &str) -> String {
|
||||
let body = format!(r#"{{"jsonrpc":"2.0","id":"0","result":{result}}}"#);
|
||||
format!(
|
||||
|
|
@ -12,13 +30,6 @@ fn json_rpc_ok(result: &str) -> String {
|
|||
)
|
||||
}
|
||||
|
||||
async fn serve_once(listener: TcpListener, response: String) {
|
||||
let (mut sock, _) = listener.accept().await.expect("accept");
|
||||
let mut buf = vec![0u8; 4096];
|
||||
let _ = sock.read(&mut buf).await;
|
||||
sock.write_all(response.as_bytes()).await.expect("write");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_create_address_is_not_configured() {
|
||||
let w = Wallet::disabled();
|
||||
|
|
@ -31,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")
|
||||
|
|
@ -42,6 +63,34 @@ fn refuse_non_loopback_non_onion_host() {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuse_onion_rpc_url() {
|
||||
let err = Wallet::from_url(
|
||||
"http://ow:s3cretPASS@abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopqrstuvwxyz.onion:18083",
|
||||
)
|
||||
.unwrap_err();
|
||||
let msg = err.to_string();
|
||||
assert!(msg.to_ascii_lowercase().contains("onion"), "got {err}");
|
||||
assert!(
|
||||
!msg.contains("s3cretPASS"),
|
||||
"password leaked in error: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn password_absent_from_url_errors() {
|
||||
let err = Wallet::from_url("http://ow:s3cretPASS@example.com:18083").unwrap_err();
|
||||
assert!(
|
||||
!err.to_string().contains("s3cretPASS"),
|
||||
"password leaked in error: {err}"
|
||||
);
|
||||
let err = Wallet::from_url("http://ow:s3cretPASS@").unwrap_err();
|
||||
assert!(
|
||||
!err.to_string().contains("s3cretPASS"),
|
||||
"password leaked in error: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mock_get_address_parses_string() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
|
|
@ -49,8 +98,8 @@ async fn mock_get_address_parses_string() {
|
|||
let canned = json_rpc_ok(
|
||||
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}");
|
||||
}
|
||||
|
|
@ -62,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
|
||||
|
|
@ -85,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,
|
||||
|
|
@ -96,3 +145,135 @@ async fn mock_get_transfers_matches_txid() {
|
|||
));
|
||||
assert!(!wallet::transfers_match(&rows, "nope", "1", "nope"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transfers_match_txid_only_wrong_amount_or_addr_is_false() {
|
||||
let addr = xmr_addr();
|
||||
let rows = [row("deadbeef", "5", &addr)];
|
||||
assert!(!wallet::transfers_match(&rows, "deadbeef", "99", &addr));
|
||||
let other = format!("8{}", "C".repeat(94));
|
||||
assert!(!wallet::transfers_match(&rows, "deadbeef", "5", &other));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transfers_match_address_and_amount_wrong_txid_is_false() {
|
||||
let addr = xmr_addr();
|
||||
let rows = [row("deadbeef", "5", &addr)];
|
||||
assert!(!wallet::transfers_match(&rows, "cafebabe", "5", &addr));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transfers_match_honest_triple_is_true() {
|
||||
let addr = xmr_addr();
|
||||
let rows = [row("deadbeef", "5", &addr)];
|
||||
assert!(wallet::transfers_match(&rows, "deadbeef", "5", &addr));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transfers_match_empty_field_is_false() {
|
||||
let addr = xmr_addr();
|
||||
let rows = [row("deadbeef", "5", &addr)];
|
||||
assert!(!wallet::transfers_match(&rows, "", "5", &addr));
|
||||
assert!(!wallet::transfers_match(&rows, "deadbeef", "", &addr));
|
||||
assert!(!wallet::transfers_match(&rows, "deadbeef", "5", ""));
|
||||
let empty = [row("", "", "")];
|
||||
assert!(!wallet::transfers_match(&empty, "", "", ""));
|
||||
}
|
||||
|
||||
/// ingest_receipt inserts verified=0, then mark_verified iff transfers_match.
|
||||
/// A Noise-signed rcp that cites an unrelated wallet row must stay unverified.
|
||||
#[test]
|
||||
fn ingest_signed_receipt_mismatched_wallet_history_stays_unverified() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let store = Store::open_at_with_passphrase(dir.path(), "onionwire-test").expect("open");
|
||||
let sk = SigningKey::generate(&mut OsRng);
|
||||
let pk = sk.verifying_key().to_bytes();
|
||||
store.upsert_friend(&pk, "peer.onion", None).unwrap();
|
||||
let addr = xmr_addr();
|
||||
let rcp = pay::sign_receipt(&sk.to_bytes(), "unrelated", "5", &addr, 1).unwrap();
|
||||
assert!(pay::verify_receipt(&pk, &rcp));
|
||||
let id = store
|
||||
.insert_payment(
|
||||
&pk,
|
||||
PaymentWrite {
|
||||
dir: "in",
|
||||
kind: "receipt",
|
||||
amount_atomic: &rcp.amount_atomic,
|
||||
address: &rcp.address,
|
||||
memo: "",
|
||||
txid: Some(&rcp.txid),
|
||||
verified: false,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let history = [row("unrelated", "99", &format!("8{}", "C".repeat(94)))];
|
||||
if wallet::transfers_match(&history, &rcp.txid, &rcp.amount_atomic, &rcp.address) {
|
||||
store.mark_verified(id).unwrap();
|
||||
}
|
||||
let rows = store.list_payments(&pk).unwrap();
|
||||
assert!(!rows[0].verified);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn oversized_rpc_response_is_err() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let huge = vec![b'A'; 2 * 1024 * 1024];
|
||||
tokio::spawn(async move {
|
||||
let (mut sock, _) = listener.accept().await.expect("accept");
|
||||
let mut buf = vec![0u8; 4096];
|
||||
let _ = sock.read(&mut buf).await;
|
||||
sock.write_all(b"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nConnection: close\r\n\r\n")
|
||||
.await
|
||||
.expect("hdr");
|
||||
sock.write_all(&huge).await.expect("body");
|
||||
});
|
||||
let w = Wallet::from_url(&format!("http://ow:secret@127.0.0.1:{}", addr.port())).unwrap();
|
||||
let err = w.get_address().await.unwrap_err();
|
||||
let msg = err.to_string().to_ascii_lowercase();
|
||||
assert!(
|
||||
msg.contains("large") || msg.contains("size") || msg.contains("cap"),
|
||||
"got {err}"
|
||||
);
|
||||
assert!(
|
||||
!err.to_string().contains("secret"),
|
||||
"password leaked: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
fn digest_401() -> String {
|
||||
"HTTP/1.1 401 Unauthorized\r\nWWW-Authenticate: Digest realm=\"monero-rpc\", nonce=\"abcnonce\", qop=\"auth\", algorithm=MD5\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".into()
|
||||
}
|
||||
|
||||
async fn serve_digest_then(listener: TcpListener, ok: String) {
|
||||
let (mut sock, _) = listener.accept().await.expect("accept");
|
||||
let mut buf = vec![0u8; 8192];
|
||||
let _ = sock.read(&mut buf).await;
|
||||
sock.write_all(digest_401().as_bytes()).await.expect("401");
|
||||
drop(sock);
|
||||
|
||||
let (mut sock, _) = listener.accept().await.expect("accept2");
|
||||
buf.fill(0);
|
||||
let n = sock.read(&mut buf).await.unwrap_or(0);
|
||||
let req = String::from_utf8_lossy(&buf[..n]);
|
||||
assert!(
|
||||
req.contains("Authorization: Digest"),
|
||||
"missing digest auth: {req}"
|
||||
);
|
||||
assert!(req.contains("username=\"ow\""), "missing user: {req}");
|
||||
assert!(req.contains("response=\""), "missing response: {req}");
|
||||
sock.write_all(ok.as_bytes()).await.expect("200");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mock_digest_auth_accepted() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let canned = json_rpc_ok(
|
||||
r#"{"address":"4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"}"#,
|
||||
);
|
||||
tokio::spawn(serve_digest_then(listener, canned));
|
||||
let w = Wallet::from_url(&format!("http://ow:s3cretPASS@127.0.0.1:{}", addr.port())).unwrap();
|
||||
let got = w.get_address().await.expect("get_address");
|
||||
assert!(got.starts_with('4'), "got {got}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue