Compare commits

..

31 commits

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

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

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

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

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

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

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

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

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

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

The SDK lockfile is resynced with the root Cargo.toml on main (onionwire
0.2.1, rpassword, md-5, rtoolbox) in the same commit so the test build is
against a consistent lock.
2026-09-10 22:02:02 -04:00
389d8f16c4 Merge pull request 'fix(cli): hide store passphrase like a password prompt' (#14) from feat/hidden-store-passphrase into main
All checks were successful
ci / test (push) Successful in 3m32s
Reviewed-on: #14
2026-09-10 21:18:46 -04:00
b354632552 Merge branch 'main' into feat/hidden-store-passphrase
All checks were successful
ci / test (pull_request) Successful in 3m44s
2026-09-10 21:18:32 -04:00
Sirius DevOps
c5019f1737
fix(cli): hide store passphrase like a password prompt
All checks were successful
ci / test (pull_request) Successful in 4m54s
stdin read_line echoed the passphrase. rpassword disables tty echo.
ONIONWIRE_STORE_PASSPHRASE still skips the prompt.
2026-09-10 21:10:50 -04:00
2abb9af4ad Merge pull request 'chore: bump crate version to 0.2.1' (#13) from feat/v0.2.1 into main
All checks were successful
ci / test (push) Successful in 5m10s
Reviewed-on: #13
2026-09-10 21:08:20 -04:00
Sirius DevOps
51e47ff90c
chore: bump crate version to 0.2.1
All checks were successful
release / aarch64 (push) Successful in 5m19s
ci / test (pull_request) Successful in 5m34s
Tag and --version have to move together. Ships the post-v0.2.0 main
line: security audit F1–F7, Pi CI serialize, Android SDK already on main.
2026-09-10 20:52:24 -04:00
f109223678 Merge pull request 'fix: security audit F5–F7 + Pi CI races (bundle)' (#12) from feat/audit-fixes-bundle into main
All checks were successful
ci / test (push) Successful in 2m45s
Reviewed-on: #12
2026-09-10 20:41:40 -04:00
Sirius DevOps
08ced86a34
Merge F5 wallet digest RPC onto audit bundle
All checks were successful
ci / test (pull_request) Successful in 3m33s
Keep sha3 (F4 checksums) and md-5 (HTTP Digest). Threat model: digest login
plus conjunctive verified receipts. Wallet tests: F1 matcher fixtures plus
F5 digest/cap coverage.
2026-09-10 20:36:10 -04:00
Sirius DevOps
39e7b3bf16
Merge remote-tracking branch 'origin/wt/t_792593f3' into feat/audit-fixes-bundle 2026-09-10 20:33:50 -04:00
Sirius DevOps
424002c0f5
[verified] fix(hs): redact onion in publish logs; cache 0700
All checks were successful
ci / test (pull_request) Successful in 3m0s
wait_until_published now logs HsId via safelog, not the locator.
Store and client_config mkdir Arti cache/state 0700. Arti
dangerously_trust_everyone stays on storage only.
2026-09-10 20:30:18 -04:00
Sirius DevOps
45c105de8a
fix(store): drop payments on /wipe; document non-forensic
All checks were successful
ci / test (pull_request) Successful in 3m1s
/wipe now deletes payments as well as chat, checkpoints WAL, and
enables sqlite secure_delete (flash still not crypto-shred).
2026-09-10 20:22:31 -04:00
Sirius DevOps
125aafb816
[verified] feat(wallet): require RPC digest login and cap reads
All checks were successful
ci / test (pull_request) Successful in 3m8s
Refuse unauthenticated ONIONWIRE_WALLET_RPC, speak HTTP Digest like
monero-wallet-rpc --rpc-login, cap responses at 1 MiB, and reject
.onion RPC URLs instead of half-dialing them over TCP.
2026-09-10 20:17:25 -04:00
Sirius DevOps
bfd28fce6b
fix(ci): serialize Pi jobs; valid XMR fixture after F4
All checks were successful
ci / test (pull_request) Successful in 2m49s
Overlapping Actions used a fixed docker name onionwire-ci (Conflict)
and OOM-killed with 137. Unique run_id names + one-at-a-time
concurrency. F1 ingest test still used 8+B*94 which F4 checksums reject.
2026-09-10 20:12:41 -04:00
b3c5b78f25 Merge pull request 'fix(pay): checksum Monero addresses (base58 + keccak)' (#7) from wt/t_45d30b55 into main
Some checks failed
ci / test (push) Failing after 3m13s
Reviewed-on: #7
2026-09-10 20:03:10 -04:00
2041302da7 Merge branch 'main' into wt/t_45d30b55
Some checks failed
ci / test (pull_request) Failing after 12s
2026-09-10 20:02:59 -04:00
Sirius DevOps
666b9d2e86
fix(pay): checksum Monero addresses (base58 + keccak)
All checks were successful
ci / test (pull_request) Successful in 3m16s
Reject prefix+length junk so /pay, /tip, and profile save cannot
sign or store garbage xmr_addr. Empty profile address stays allowed.
2026-09-10 20:01:45 -04:00
a56029b750 Merge pull request 'fix(qr): bind invite sig fields; cap decode' (#6) from wt/t_8b24e067 into main
All checks were successful
ci / test (push) Successful in 3m15s
Reviewed-on: #6
2026-09-10 19:52:12 -04:00
deee357772 Merge branch 'main' into wt/t_8b24e067
Some checks failed
ci / test (pull_request) Failing after 24s
2026-09-10 19:51:58 -04:00
Sirius DevOps
68da7fec3b
[verified] fix(qr): bind invite sig fields; cap decode
Some checks failed
ci / test (pull_request) Failing after 54s
Reject non-32-byte spk, non-v3 onion, and invites over 4KiB before
hex alloc. Sign onionwire-invite-v1 with 0x00 separators; verify that
first. Well-formed v1 concat still verifies after field checks.
2026-09-10 19:52:44 -04:00
b1427041c2 Merge pull request 'fix(store): bind chat AEAD to row AAD' (#5) from wt/t_69a6b3f9 into main
All checks were successful
ci / test (push) Successful in 3m4s
Reviewed-on: #5
2026-09-10 19:41:57 -04:00
Sirius DevOps
f75c434db7
[verified] fix(store): bind chat AEAD to row AAD
Some checks failed
ci / test (pull_request) Failing after 2m59s
ChaCha20-Poly1305 at rest now MACs owmsg1||friend_id_le64||dir||0x00||row_id_le64.
Swapped ciphertext fails closed. Empty-AAD v0.2 blobs rewrap once on unlock;
list_messages never falls back to empty AAD.
2026-09-10 19:41:00 -04:00
bdea9578e4 Merge pull request 'fix(wallet): conjunctive receipt verify' (#4) from wt/t_70fc1f81 into main
All checks were successful
ci / test (push) Successful in 2m56s
Reviewed-on: #4
2026-09-10 19:28:14 -04:00
Sirius DevOps
1a465581c2
[verified] fix(wallet): conjunctive receipt verify
Some checks failed
ci / test (pull_request) Failing after 35s
Require non-empty txid AND amount AND address against one
get_transfers row. Signed rcp alone never sets verified=1.
2026-09-10 19:29:39 -04:00
0dfd807b10 Merge pull request 'docs: security audit of 2b42864 (report only)' (#3) from wt/t_d85060fb into main
All checks were successful
ci / test (push) Successful in 3m8s
Reviewed-on: #3
2026-09-10 19:16:58 -04:00
95b0fa4dee Merge pull request 'feat(android): SDK AAR (UniFFI) + Compose APK, Arti rustls feature split' (#2) from wt/t_b7176a1b into main
All checks were successful
ci / test (push) Successful in 3m3s
Reviewed-on: #2
2026-09-10 19:16:44 -04:00
Sirius DevOps
dfa4ea8f39
docs: security audit of 2b42864 (report only)
All checks were successful
ci / test (pull_request) Successful in 2m57s
Evidence-backed audit at docs/SECURITY_AUDIT.md. No src/ changes.
2026-09-10 18:54:40 -04:00
36 changed files with 2317 additions and 176 deletions

View file

@ -8,12 +8,18 @@ on:
pull_request: pull_request:
workflow_dispatch: workflow_dispatch:
# One cargo job on the Pi at a time. Overlapping PR+main runs shared the
# container name `onionwire-ci` (docker Conflict) and OOM-killed with 137.
concurrency:
group: onionwire-ci-pi
cancel-in-progress: false
env: env:
CARGO_TERM_COLOR: never CARGO_TERM_COLOR: never
RUST_IMAGE: rust:1.91-bookworm RUST_IMAGE: rust:1.91-bookworm
CARGO_REGISTRY_VOLUME: onionwire-cargo-registry CARGO_REGISTRY_VOLUME: onionwire-cargo-registry
CARGO_TARGET_VOLUME: onionwire-target-ci CARGO_TARGET_VOLUME: onionwire-target-ci
BUILD_CONTAINER: onionwire-ci BUILD_CONTAINER: onionwire-ci-${{ github.run_id }}
jobs: jobs:
test: test:

View file

@ -22,7 +22,7 @@ env:
RUST_IMAGE: rust:1.91-bookworm RUST_IMAGE: rust:1.91-bookworm
CARGO_REGISTRY_VOLUME: onionwire-cargo-registry CARGO_REGISTRY_VOLUME: onionwire-cargo-registry
CARGO_TARGET_VOLUME: onionwire-target-aarch64 CARGO_TARGET_VOLUME: onionwire-target-aarch64
BUILD_CONTAINER: onionwire-release-build BUILD_CONTAINER: onionwire-release-build-${{ github.run_id }}
jobs: jobs:
aarch64: aarch64:

37
Cargo.lock generated
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

194
docs/SECURITY_AUDIT.md Normal file
View 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 **Artis** directories, not sqlite. `Store::open_at_with_passphrase` `mkdir_700`s `home` and `home/arti` first; `home/cache` is created by Artis `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 F1F3 [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 F1s 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.

View file

@ -16,9 +16,9 @@ A signed `loc` frame (`onion`, `ts`, `sig`) rewrites a friends locator **only
## Message bodies are encrypted at rest; keys are not ## 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 ## Fail closed
@ -30,7 +30,7 @@ A signed `prf` frame is shown to people who already have a session with you. App
## Monero sidecar is not a wallet ## Monero sidecar is not a wallet
OnionWire never holds spend keys. Optional `ONIONWIRE_WALLET_RPC` talks HTTP to a user-hosted `monero-wallet-rpc`. Never trust a `rcp` frame without RPC confirmation (`verified` stays 0). Subaddress reuse is the users wallet policy. OnionWire never holds spend keys. Optional `ONIONWIRE_WALLET_RPC` talks HTTP Digest to a user-hosted `monero-wallet-rpc` on loopback (`--rpc-login` required; open RPC is refused). A Noise friend can sign any `rcp`; the signature proves who sent the claim, not that a payment happened. `verified=1` only after a conjunctive RPC match: one `get_transfers` row with the same non-empty `txid`, `amount`, and `address`. Incoming `rcp` stays `verified=0` if RPC is down, errors, or no exact row. Subaddress reuse is the users wallet policy.
## Backup file is the identity ## Backup file is the identity

View file

@ -1,7 +1,7 @@
//! Encrypted identity backup. Onion (locator) is not included. //! Encrypted identity backup. Onion (locator) is not included.
use argon2::{Algorithm, Argon2, Params, Version}; 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 chacha20poly1305::{ChaCha20Poly1305, Key, Nonce};
use rand::RngCore; use rand::RngCore;
@ -95,11 +95,21 @@ pub fn open(passphrase: &str, blob: &[u8]) -> Result<BackupKeys, String> {
BackupKeys::from_bytes(&pt) 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 cipher = ChaCha20Poly1305::new(Key::from_slice(key));
let nonce = ChaCha20Poly1305::generate_nonce(&mut OsRng); let nonce = ChaCha20Poly1305::generate_nonce(&mut OsRng);
let ct = cipher let ct = cipher
.encrypt(&nonce, plaintext) .encrypt(
&nonce,
Payload {
msg: plaintext,
aad,
},
)
.map_err(|_| "encrypt failed".to_string())?; .map_err(|_| "encrypt failed".to_string())?;
let mut out = Vec::with_capacity(NONCE_LEN + ct.len()); let mut out = Vec::with_capacity(NONCE_LEN + ct.len());
out.extend_from_slice(&nonce); out.extend_from_slice(&nonce);
@ -107,13 +117,19 @@ pub(crate) fn aead_encrypt(key: &[u8; 32], plaintext: &[u8]) -> Result<Vec<u8>,
Ok(out) 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 { if blob.len() < NONCE_LEN + 16 {
return Err("ciphertext length".into()); return Err("ciphertext length".into());
} }
let nonce = Nonce::from_slice(&blob[..NONCE_LEN]); let nonce = Nonce::from_slice(&blob[..NONCE_LEN]);
let cipher = ChaCha20Poly1305::new(Key::from_slice(key)); let cipher = ChaCha20Poly1305::new(Key::from_slice(key));
cipher cipher
.decrypt(nonce, &blob[NONCE_LEN..]) .decrypt(
nonce,
Payload {
msg: &blob[NONCE_LEN..],
aad,
},
)
.map_err(|_| "wrong passphrase or corrupt".to_string()) .map_err(|_| "wrong passphrase or corrupt".to_string())
} }

View file

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

387
src/file.rs Normal file
View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,4 +1,5 @@
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
use sha3::{Digest, Keccak256};
pub type Result<T> = std::result::Result<T, Error>; pub type Result<T> = std::result::Result<T, Error>;
@ -35,19 +36,72 @@ const INV_PREFIX: &[u8] = b"inv ";
const RCP_PREFIX: &[u8] = b"rcp "; const RCP_PREFIX: &[u8] = b"rcp ";
const PICONERO: u128 = 1_000_000_000_000; const PICONERO: u128 = 1_000_000_000_000;
/// Bitcoin-style alphabet; Monero encodes 8-byte blocks (11 chars), not raw base58.
const B58: &[u8] = b"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
const B58_ENC_LEN: [usize; 9] = [0, 2, 3, 5, 6, 7, 9, 10, 11];
pub fn check_address(addr: &str) -> Result<()> { pub fn check_address(addr: &str) -> Result<()> {
let ok = match addr.as_bytes().first() { let raw = decode_monero_b58(addr).ok_or_else(|| Error("invalid Monero address".into()))?;
Some(b'4') if addr.len() == 95 || addr.len() == 106 => true, if raw.len() != 69 && raw.len() != 77 {
Some(b'8') if addr.len() == 95 => true, 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, _ => false,
}; };
if ok && !addr.contains('\n') { if ok {
Ok(()) Ok(())
} else { } else {
Err(Error("invalid Monero address".into())) Err(Error("invalid Monero address".into()))
} }
} }
fn decode_monero_b58(addr: &str) -> Option<Vec<u8>> {
let bytes = addr.as_bytes();
if bytes.is_empty() || !bytes.iter().all(|b| B58.contains(b)) {
return None;
}
let mut out = Vec::new();
let mut i = 0;
while i < bytes.len() {
let rest = bytes.len() - i;
let (enc_len, dec_len) = if rest >= 11 {
(11, 8)
} else {
let dec_len = B58_ENC_LEN.iter().position(|&n| n == rest)?;
(rest, dec_len)
};
out.extend_from_slice(&decode_b58_block(&bytes[i..i + enc_len], dec_len)?);
i += enc_len;
}
Some(out)
}
fn decode_b58_block(enc: &[u8], out_len: usize) -> Option<Vec<u8>> {
let mut acc: u128 = 0;
for &c in enc {
let d = B58.iter().position(|&a| a == c)? as u128;
acc = acc.checked_mul(58)?.checked_add(d)?;
}
let max = if out_len >= 16 {
return None;
} else if out_len == 0 {
0
} else {
(1u128 << (8 * out_len)) - 1
};
if acc > max {
return None;
}
Some(acc.to_be_bytes()[16 - out_len..].to_vec())
}
pub fn parse_atomic(s: &str) -> Result<u128> { pub fn parse_atomic(s: &str) -> Result<u128> {
if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) { if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) {
return Err(Error("amount must be decimal piconero".into())); return Err(Error("amount must be decimal piconero".into()));

View file

@ -125,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') { if display_name.contains('\n') || bio.contains('\n') || xmr_addr.contains('\n') {
return Err(Error("profile fields must not contain newlines".into())); return Err(Error("profile fields must not contain newlines".into()));
} }
if !xmr_addr.is_empty() {
crate::pay::check_address(xmr_addr).map_err(|e| Error(e.to_string()))?;
}
Ok(()) Ok(())
} }

View file

@ -20,30 +20,51 @@ pub struct QrPayload {
pub signed_prekey: Vec<u8>, 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> { 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 let sk_bytes: [u8; 32] = identity_sk
.try_into() .try_into()
.map_err(|_| Error("identity secret key must be 32 bytes".into()))?; .map_err(|_| Error("identity secret key must be 32 bytes".into()))?;
let sk = SigningKey::from_bytes(&sk_bytes); let sk = SigningKey::from_bytes(&sk_bytes);
let k = to_hex(&sk.verifying_key().to_bytes()); let k = to_hex(&sk.verifying_key().to_bytes());
let spk = to_hex(signed_prekey); 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()); let sig = to_hex(&sk.sign(&msg).to_bytes());
Ok(format!("onionwire:v1:k={k}:o={onion}:spk={spk}:sig={sig}")) Ok(format!("onionwire:v1:k={k}:o={onion}:spk={spk}:sig={sig}"))
} }
pub fn decode(raw: &str) -> Result<QrPayload> { 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)?; 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 pubkey = from_hex(&k)?;
let signed_prekey = from_hex(&spk)?; let signed_prekey = from_hex(&spk)?;
let sig_bytes = from_hex(&sig)?; 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 let pk_arr: [u8; 32] = pubkey
.as_slice() .as_slice()
.try_into() .try_into()
@ -54,9 +75,15 @@ pub fn decode(raw: &str) -> Result<QrPayload> {
.map_err(|_| Error("sig must be 64 bytes".into()))?; .map_err(|_| Error("sig must be 64 bytes".into()))?;
let vk = VerifyingKey::from_bytes(&pk_arr).map_err(|e| Error(e.to_string()))?; let vk = VerifyingKey::from_bytes(&pk_arr).map_err(|e| Error(e.to_string()))?;
let signature = Signature::from_bytes(&sig_arr); let signature = Signature::from_bytes(&sig_arr);
let msg = sign_msg(&k, &onion, &spk); let ok_v2 = vk
vk.verify(&msg, &signature) .verify(&sign_msg_v2(&k, &onion, &spk), &signature)
.map_err(|_| Error("bad signature".into()))?; .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 { Ok(QrPayload {
pubkey, pubkey,
onion, 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()); let mut msg = Vec::with_capacity(k.len() + onion.len() + spk.len());
msg.extend_from_slice(k.as_bytes()); msg.extend_from_slice(k.as_bytes());
msg.extend_from_slice(onion.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>> { 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())); return Err(Error("invalid hex".into()));
} }
if !s.bytes().all(|c| c.is_ascii_hexdigit()) { 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()))) .map(|i| u8::from_str_radix(&s[i..i + 2], 16).map_err(|_| Error("invalid hex".into())))
.collect() .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]);
}
}

View file

@ -117,12 +117,16 @@ impl Store {
} }
mkdir_700(home)?; mkdir_700(home)?;
mkdir_700(&home.join("arti"))?; mkdir_700(&home.join("arti"))?;
mkdir_700(&home.join("cache"))?;
let db_path = home.join("onionwire.db"); let db_path = home.join("onionwire.db");
let conn = Connection::open(&db_path)?; let conn = Connection::open(&db_path)?;
let journal: String = conn.query_row("PRAGMA journal_mode = WAL", [], |row| row.get(0))?; let journal: String = conn.query_row("PRAGMA journal_mode = WAL", [], |row| row.get(0))?;
if !journal.eq_ignore_ascii_case("wal") { if !journal.eq_ignore_ascii_case("wal") {
return Err(Error(format!("journal_mode WAL failed: {journal}"))); return Err(Error(format!("journal_mode WAL failed: {journal}")));
} }
// Overwrite freed pages on DELETE. Flash wear-leveling can still keep copies;
// this is not a forensic / SSD crypto-shred.
conn.pragma_update(None, "secure_delete", "ON")?;
conn.execute_batch( conn.execute_batch(
" "
PRAGMA foreign_keys = ON; PRAGMA foreign_keys = ON;
@ -270,12 +274,13 @@ impl Store {
match row { match row {
Some((salt, wrapped)) => { Some((salt, wrapped)) => {
let wrap_key = crate::backup::kdf(passphrase, &salt)?; 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()))?; .map_err(|_| Error("wrong passphrase".into()))?;
if raw.len() != 32 { if raw.len() != 32 {
return Err(Error("wrapped message key length".into())); return Err(Error("wrapped message key length".into()));
} }
self.msg_key.copy_from_slice(&raw); self.msg_key.copy_from_slice(&raw);
self.rewrap_empty_aad_messages()?;
Ok(()) Ok(())
} }
None => { None => {
@ -284,7 +289,7 @@ impl Store {
let mut salt = [0u8; 16]; let mut salt = [0u8; 16];
OsRng.fill_bytes(&mut salt); OsRng.fill_bytes(&mut salt);
let wrap_key = crate::backup::kdf(passphrase, &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( self.conn.execute(
"INSERT INTO store_meta (id, kdf_salt, wrapped_key) VALUES (1, ?1, ?2)", "INSERT INTO store_meta (id, kdf_salt, wrapped_key) VALUES (1, ?1, ?2)",
params![salt.as_slice(), wrapped], params![salt.as_slice(), wrapped],
@ -297,17 +302,59 @@ impl Store {
} }
fn reencrypt_legacy_messages(&self) -> Result<()> { fn reencrypt_legacy_messages(&self) -> Result<()> {
let mut stmt = self.conn.prepare("SELECT id, plaintext FROM messages")?; let mut stmt = self
let rows: Vec<(i64, Vec<u8>)> = stmt .conn
.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))? .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<_, _>>()?; .collect::<std::result::Result<_, _>>()?;
drop(stmt); drop(stmt);
if rows.is_empty() { if rows.is_empty() {
return Ok(()); return Ok(());
} }
let tx = self.conn.unchecked_transaction()?; let tx = self.conn.unchecked_transaction()?;
for (id, plain) in rows { for (id, friend_id, dir, plain) in rows {
let blob = crate::backup::aead_encrypt(&self.msg_key, &plain)?; 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( tx.execute(
"UPDATE messages SET plaintext = ?1 WHERE id = ?2", "UPDATE messages SET plaintext = ?1 WHERE id = ?2",
params![blob, id], params![blob, id],
@ -663,42 +710,59 @@ impl Store {
|row| row.get(0), |row| row.get(0),
) )
.map_err(|_| Error("friend not found".into()))?; .map_err(|_| Error("friend not found".into()))?;
let blob = crate::backup::aead_encrypt(&self.msg_key, plaintext)?; let tx = self.conn.unchecked_transaction()?;
self.conn.execute( tx.execute(
"INSERT INTO messages (friend_id, dir, plaintext, created_at) VALUES (?1, ?2, ?3, ?4)", "INSERT INTO messages (friend_id, dir, plaintext, created_at) VALUES (?1, ?2, x'', ?3)",
params![friend_id, dir, blob, unix_now()], 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(()) Ok(())
} }
pub fn list_messages(&self, friend_pk: &[u8]) -> Result<Vec<Message>> { pub fn list_messages(&self, friend_pk: &[u8]) -> Result<Vec<Message>> {
let mut stmt = self.conn.prepare( 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 JOIN friends f ON f.id = m.friend_id
WHERE f.pubkey = ?1 WHERE f.pubkey = ?1
ORDER BY m.id", ORDER BY m.id",
)?; )?;
let rows = stmt.query_map(params![friend_pk], |row| { 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(); let mut out = Vec::new();
for row in rows { for row in rows {
let (dir, blob) = row?; let (id, friend_id, dir, blob) = row?;
let plaintext = crate::backup::aead_decrypt(&self.msg_key, &blob) 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()))?; .map_err(|_| Error("message decrypt failed".into()))?;
out.push(Message { dir, plaintext }); out.push(Message { dir, plaintext });
} }
Ok(out) Ok(out)
} }
/// Overwrite message bodies, delete rows, VACUUM. Identity + friends stay. /// Drop chat + payments history. Identity + friends stay.
/// Not a forensic erase: SSD wear-leveling can keep copies.
pub fn wipe_messages(&self) -> Result<()> { pub fn wipe_messages(&self) -> Result<()> {
self.conn.execute( self.conn.execute(
"UPDATE messages SET plaintext = zeroblob(length(plaintext))", "UPDATE messages SET plaintext = zeroblob(length(plaintext))",
[], [],
)?; )?;
self.conn.execute("DELETE FROM messages", [])?; self.conn.execute("DELETE FROM messages", [])?;
self.conn.execute("DELETE FROM payments", [])?;
self.conn.execute_batch("VACUUM")?; self.conn.execute_batch("VACUUM")?;
self.conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE)")?;
Ok(()) Ok(())
} }
@ -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<()> { fn mkdir_700(path: &Path) -> Result<()> {
fs::create_dir_all(path)?; fs::create_dir_all(path)?;
let mut perms = fs::metadata(path)?.permissions(); let mut perms = fs::metadata(path)?.permissions();
@ -775,6 +859,17 @@ fn mkdir_700(path: &Path) -> Result<()> {
Ok(()) 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 { fn unix_now() -> i64 {
SystemTime::now() SystemTime::now()
.duration_since(UNIX_EPOCH) .duration_since(UNIX_EPOCH)
@ -790,6 +885,148 @@ fn fingerprint(pubkey: &[u8]) -> String {
mod at_rest { mod at_rest {
use super::*; 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] #[test]
fn first_unlock_reencrypts_legacy_plaintext_rows() { fn first_unlock_reencrypts_legacy_plaintext_rows() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();

View file

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

View file

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

133
tests/file.rs Normal file
View file

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

View file

@ -1,5 +1,6 @@
//! HS publish wait and CBT floor — 180s fail-closed cuts a working HsDir upload. //! HS publish wait and CBT floor — 180s fail-closed cuts a working HsDir upload.
use std::os::unix::fs::PermissionsExt;
use std::time::Duration; use std::time::Duration;
use onionwire::hs; use onionwire::hs;
@ -67,3 +68,30 @@ fn broken_or_shutdown_never_ready() {
assert!(!hs::hs_is_ready(State::Broken, true)); assert!(!hs::hs_is_ready(State::Broken, true));
assert!(!hs::hs_is_ready(State::Shutdown, true)); assert!(!hs::hs_is_ready(State::Shutdown, true));
} }
#[test]
fn hs_log_label_is_not_the_full_v3_onion() {
// Public v3 address; checksum is valid so HsId::from_str works.
let onion = "facebookwkhpilnemxj7asaniu7vnjjbiltxjqhye3mhbshg7kx5tfyd.onion";
let label = hs::log_label(onion);
assert_ne!(label, onion, "status/probe logs must not use the locator");
assert!(
!label.contains("facebookwkhpilnemxj7asaniu7vnjjbiltxjqhye3mhbshg7kx5tfyd"),
"log label leaked the onion body: {label}"
);
assert!(
label.contains('…') || label.contains("[scrubbed]"),
"expected safelog redaction, got {label}"
);
}
#[test]
fn client_config_mkdirs_state_and_cache_0700() {
let root = tempfile::tempdir().expect("tempdir");
let state = root.path().join("arti");
let cache = root.path().join("cache");
let _cfg = hs::client_config(&state, &cache);
let mode = |p: &std::path::Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777;
assert_eq!(mode(&state), 0o700);
assert_eq!(mode(&cache), 0o700);
}

View file

@ -12,16 +12,20 @@ fn store() -> (tempfile::TempDir, Store) {
(dir, store) (dir, store)
} }
// Official mainnet standard from Monero docs (not live RPC).
const MAINNET_STD: &str = "4AdUndXHHZ6cfufTMvppY6JwXNouMBzSkbLYfpAV5Usx3skxNgYeYTRj5UzqtReoS44qo9mtmXCqY45DJ852K5Jv2684Rge";
// Same documented spend/view keys, mainnet subaddress (0x2A) and integrated (0x13 + 8 zero pid).
const MAINNET_SUB: &str = "8BTd81B7syWcfufTMvppY6JwXNouMBzSkbLYfpAV5Usx3skxNgYeYTRj5UzqtReoS44qo9mtmXCqY45DJ852K5Jv25pnJx6";
const MAINNET_INT: &str = "4LL9oSLmtpccfufTMvppY6JwXNouMBzSkbLYfpAV5Usx3skxNgYeYTRj5UzqtReoS44qo9mtmXCqY45DJ852K5Jv2WK48GNSUQf17NLRTG";
// Same keys, stagenet standard (0x18).
const STAGENET_STD: &str = "5AqWsUSEwACcfufTMvppY6JwXNouMBzSkbLYfpAV5Usx3skxNgYeYTRj5UzqtReoS44qo9mtmXCqY45DJ852K5Jv23X7tqA";
fn addr_std() -> String { fn addr_std() -> String {
format!("4{}", "A".repeat(94)) MAINNET_STD.to_string()
} }
fn addr_sub() -> String { fn addr_sub() -> String {
format!("8{}", "B".repeat(94)) MAINNET_SUB.to_string()
}
fn addr_integrated() -> String {
format!("4{}", "C".repeat(105))
} }
fn payw<'a>( fn payw<'a>(
@ -46,12 +50,20 @@ fn payw<'a>(
#[test] #[test]
fn valid_and_invalid_xmr_addresses() { fn valid_and_invalid_xmr_addresses() {
assert!(pay::check_address(&addr_std()).is_ok()); assert!(pay::check_address(MAINNET_STD).is_ok());
assert!(pay::check_address(&addr_sub()).is_ok()); assert!(pay::check_address(MAINNET_SUB).is_ok());
assert!(pay::check_address(&addr_integrated()).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!("4{}", "A".repeat(93))).is_err());
assert!(pay::check_address(&format!("8{}", "B".repeat(95))).is_err()); assert!(pay::check_address(&format!("8{}", "B".repeat(95))).is_err());
assert!(pay::check_address(&format!("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("").is_err());
assert!(pay::check_address("not-an-address").is_err()); assert!(pay::check_address("not-an-address").is_err());
} }

View file

@ -108,21 +108,33 @@ fn chat_is_not_profile() {
assert!(profile::decode(b"hello wire").is_none()); assert!(profile::decode(b"hello wire").is_none());
} }
const MAINNET_STD: &str = "4AdUndXHHZ6cfufTMvppY6JwXNouMBzSkbLYfpAV5Usx3skxNgYeYTRj5UzqtReoS44qo9mtmXCqY45DJ852K5Jv2684Rge";
#[test] #[test]
fn self_profile_roundtrip() { fn self_profile_roundtrip() {
let (_dir, store) = store(); let (_dir, store) = store();
store.set_self_profile("me", "a bio", "4abc").unwrap(); store.set_self_profile("me", "a bio", MAINNET_STD).unwrap();
let got = store.self_profile().unwrap(); let got = store.self_profile().unwrap();
assert_eq!(got.display_name, "me"); assert_eq!(got.display_name, "me");
assert_eq!(got.bio, "a bio"); assert_eq!(got.bio, "a bio");
assert_eq!(got.xmr_addr, "4abc"); assert_eq!(got.xmr_addr, MAINNET_STD);
assert!(got.updated_at > 0); assert!(got.updated_at > 0);
store.set_self_profile("", "", "").unwrap(); store.set_self_profile("", "", "").unwrap();
let empty = store.self_profile().unwrap(); let empty = store.self_profile().unwrap();
assert_eq!(empty.display_name, ""); assert_eq!(empty.display_name, "");
assert_eq!(empty.xmr_addr, "");
assert!(empty.updated_at >= got.updated_at); assert!(empty.updated_at >= got.updated_at);
} }
#[test]
fn self_profile_rejects_short_xmr_addr() {
let (_dir, store) = store();
assert!(store.set_self_profile("me", "a bio", "4abc").is_err());
assert!(profile::validate("me", "a bio", "4abc").is_err());
assert!(profile::validate("me", "a bio", "").is_ok());
assert!(profile::validate("me", "a bio", MAINNET_STD).is_ok());
}
#[test] #[test]
fn slash_profile_who_and_wipe_still_parse() { fn slash_profile_who_and_wipe_still_parse() {
assert_eq!(parse_cmd("/profile"), Some(SlashCmd::Profile)); assert_eq!(parse_cmd("/profile"), Some(SlashCmd::Profile));

View file

@ -2,8 +2,8 @@
use std::sync::{Mutex, MutexGuard}; use std::sync::{Mutex, MutexGuard};
use onionwire::Store;
use onionwire::qr; use onionwire::qr;
use onionwire::Store;
static ENV_LOCK: Mutex<()> = Mutex::new(()); static ENV_LOCK: Mutex<()> = Mutex::new(());
@ -15,7 +15,7 @@ struct TempHome {
impl TempHome { impl TempHome {
fn new() -> Self { fn new() -> Self {
let dir = tempfile::tempdir().expect("tempdir"); 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 { unsafe {
std::env::set_var("ONIONWIRE_HOME", dir.path()); std::env::set_var("ONIONWIRE_HOME", dir.path());
std::env::set_var("ONIONWIRE_STORE_PASSPHRASE", "onionwire-test"); std::env::set_var("ONIONWIRE_STORE_PASSPHRASE", "onionwire-test");
@ -37,7 +37,37 @@ impl Drop for TempHome {
} }
fn v3onion(tag: &str) -> String { 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] #[test]
@ -119,3 +149,73 @@ fn unknown_k_decode_does_not_insert() {
let _p = qr::decode(&raw).unwrap(); let _p = qr::decode(&raw).unwrap();
assert_eq!(store.friend_count().unwrap(), 0); 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
View file

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

View file

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

View file

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

View file

@ -2,8 +2,8 @@
use onionwire::qr; use onionwire::qr;
use onionwire::tui::{ use onionwire::tui::{
banner_for_width, compact_banner, draw_share, help_overlay_text, main_footer_hints, Pane, banner_for_width, compact_banner, draw_share, help_overlay_text, main_footer_hints,
onion_glyph, status_footer, wordmark_banner, Pane, onion_glyph, status_footer, wordmark_banner,
}; };
#[test] #[test]
@ -40,7 +40,19 @@ fn help_overlay_lists_core_bindings() {
let help = help_overlay_text(); let help = help_overlay_text();
assert!(!help.is_empty()); assert!(!help.is_empty());
for needle in [ for needle in [
"Tab", "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:?}"); assert!(help.contains(needle), "help overlay missing {needle:?}");
@ -65,13 +77,13 @@ fn banner_for_width_collapses_when_narrow() {
#[test] #[test]
fn chrome_renders_at_80x24_and_120x40() { fn chrome_renders_at_80x24_and_120x40() {
use ratatui::Terminal;
use ratatui::backend::TestBackend; use ratatui::backend::TestBackend;
use ratatui::layout::{Constraint, Layout}; use ratatui::layout::{Constraint, Layout};
use ratatui::widgets::Paragraph; use ratatui::widgets::Paragraph;
use ratatui::Terminal;
let fp = "abcdef0123456789"; let fp = "abcdef0123456789";
let onion = "abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopq.onion"; let onion = "abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopqrstuvwx.onion";
for (w, h) in [(80u16, 24u16), (120, 40)] { for (w, h) in [(80u16, 24u16), (120, 40)] {
let backend = TestBackend::new(w, h); let backend = TestBackend::new(w, h);
let mut terminal = Terminal::new(backend).expect("terminal"); let mut terminal = Terminal::new(backend).expect("terminal");
@ -113,7 +125,7 @@ fn chrome_renders_at_80x24_and_120x40() {
#[test] #[test]
fn main_footer_keeps_help_at_80_and_120() { fn main_footer_keeps_help_at_80_and_120() {
let fp = "abcdef0123456789deadbeef"; let fp = "abcdef0123456789deadbeef";
let onion = "abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopq.onion"; let onion = "abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopqrstuvwx.onion";
let hints = main_footer_hints(); let hints = main_footer_hints();
assert!(hints.contains("? help")); assert!(hints.contains("? help"));
for w in [80u16, 120] { for w in [80u16, 120] {
@ -166,11 +178,11 @@ fn restore_terminal_is_callable_without_panic() {
#[test] #[test]
fn share_screen_shows_invite_not_qr_at_80x24() { fn share_screen_shows_invite_not_qr_at_80x24() {
use ratatui::backend::TestBackend;
use ratatui::Terminal; use ratatui::Terminal;
use ratatui::backend::TestBackend;
let sk = [7u8; 32]; let sk = [7u8; 32];
let onion = "abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopq.onion"; let onion = "abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopqrstuvwx.onion";
let payload = qr::encode(&sk, onion, &[1u8; 32]).expect("encode"); let payload = qr::encode(&sk, onion, &[1u8; 32]).expect("encode");
assert!(payload.starts_with("onionwire:v1:")); assert!(payload.starts_with("onionwire:v1:"));

View file

@ -1,9 +1,27 @@
//! M8: optional monero-wallet-rpc JSON client. Mock TCP only — no live monerod. //! 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::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener; 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 { fn json_rpc_ok(result: &str) -> String {
let body = format!(r#"{{"jsonrpc":"2.0","id":"0","result":{result}}}"#); let body = format!(r#"{{"jsonrpc":"2.0","id":"0","result":{result}}}"#);
format!( 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] #[test]
fn disabled_create_address_is_not_configured() { fn disabled_create_address_is_not_configured() {
let w = Wallet::disabled(); let w = Wallet::disabled();
@ -31,9 +42,19 @@ fn disabled_create_address_is_not_configured() {
assert!(err.to_string().contains("not configured"), "got {err}"); assert!(err.to_string().contains("not configured"), "got {err}");
} }
#[test]
fn refuse_url_without_credentials() {
let err = Wallet::from_url("http://127.0.0.1:18083").unwrap_err();
let msg = err.to_string().to_ascii_lowercase();
assert!(
msg.contains("credential") || msg.contains("login") || msg.contains("user"),
"got {err}"
);
}
#[test] #[test]
fn refuse_non_loopback_non_onion_host() { fn refuse_non_loopback_non_onion_host() {
let err = Wallet::from_url("http://example.com:18083").unwrap_err(); let err = Wallet::from_url("http://ow:secret@example.com:18083").unwrap_err();
assert!( assert!(
err.to_string().to_ascii_lowercase().contains("host") err.to_string().to_ascii_lowercase().contains("host")
|| err.to_string().contains("loopback") || err.to_string().contains("loopback")
@ -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] #[tokio::test]
async fn mock_get_address_parses_string() { async fn mock_get_address_parses_string() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
@ -49,8 +98,8 @@ async fn mock_get_address_parses_string() {
let canned = json_rpc_ok( let canned = json_rpc_ok(
r#"{"address":"4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"}"#, r#"{"address":"4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"}"#,
); );
tokio::spawn(serve_once(listener, canned)); tokio::spawn(serve_digest_then(listener, canned));
let w = Wallet::from_url(&format!("http://127.0.0.1:{}", addr.port())).unwrap(); let w = Wallet::from_url(&format!("http://ow:secret@127.0.0.1:{}", addr.port())).unwrap();
let got = w.get_address().await.expect("get_address"); let got = w.get_address().await.expect("get_address");
assert!(got.starts_with('4'), "got {got}"); assert!(got.starts_with('4'), "got {got}");
} }
@ -62,15 +111,15 @@ async fn mock_create_address_and_transfer() {
let canned = json_rpc_ok( let canned = json_rpc_ok(
r#"{"address":"8BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB","tx_hash":"abc123"}"#, r#"{"address":"8BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB","tx_hash":"abc123"}"#,
); );
tokio::spawn(serve_once(listener, canned.clone())); tokio::spawn(serve_digest_then(listener, canned.clone()));
let w = Wallet::from_url(&format!("http://127.0.0.1:{}", addr.port())).unwrap(); let w = Wallet::from_url(&format!("http://ow:secret@127.0.0.1:{}", addr.port())).unwrap();
let created = w.create_address().await.expect("create_address"); let created = w.create_address().await.expect("create_address");
assert!(created.starts_with('8')); assert!(created.starts_with('8'));
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap(); let addr = listener.local_addr().unwrap();
tokio::spawn(serve_once(listener, canned)); tokio::spawn(serve_digest_then(listener, canned));
let w = Wallet::from_url(&format!("http://127.0.0.1:{}", addr.port())).unwrap(); let w = Wallet::from_url(&format!("http://ow:secret@127.0.0.1:{}", addr.port())).unwrap();
let txid = w let txid = w
.transfer("8BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", 1) .transfer("8BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", 1)
.await .await
@ -85,8 +134,8 @@ async fn mock_get_transfers_matches_txid() {
let canned = json_rpc_ok( let canned = json_rpc_ok(
r#"{"in":[{"txid":"deadbeef","amount":5,"address":"8BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"}],"pending":[]}"#, r#"{"in":[{"txid":"deadbeef","amount":5,"address":"8BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"}],"pending":[]}"#,
); );
tokio::spawn(serve_once(listener, canned)); tokio::spawn(serve_digest_then(listener, canned));
let w = Wallet::from_url(&format!("http://127.0.0.1:{}", addr.port())).unwrap(); let w = Wallet::from_url(&format!("http://ow:secret@127.0.0.1:{}", addr.port())).unwrap();
let rows = w.get_transfers().await.expect("get_transfers"); let rows = w.get_transfers().await.expect("get_transfers");
assert!(wallet::transfers_match( assert!(wallet::transfers_match(
&rows, &rows,
@ -96,3 +145,135 @@ async fn mock_get_transfers_matches_txid() {
)); ));
assert!(!wallet::transfers_match(&rows, "nope", "1", "nope")); 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}");
}

View file

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