From 22b3a0062ab269716b80a8596170e414d5cc5917 Mon Sep 17 00:00:00 2001 From: Sirius DevOps Date: Fri, 11 Sep 2026 13:15:51 -0400 Subject: [PATCH] docs: add PROTOCOL.md and retire the stale audit writeup Wire format lives in docs/PROTOCOL.md so the handshake, invite, and frames can be read without the Rust. Threat model points at it. The in-house audit notes are a finding-status table, not a third-party audit. SDK README no longer claims the legacy concat invite signature is what we encode. --- README.md | 4 +- android/README.md | 2 +- crates/onionwire-sdk/README.md | 2 +- docs/PROTOCOL.md | 271 +++++++++++++++++++++++++++++++++ docs/README.md | 14 ++ docs/SECURITY_AUDIT.md | 213 ++++---------------------- docs/THREAT_MODEL.md | 15 +- 7 files changed, 333 insertions(+), 188 deletions(-) create mode 100644 docs/PROTOCOL.md create mode 100644 docs/README.md diff --git a/README.md b/README.md index b79b716..56e7896 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,8 @@ No hosted server. Identity is a public key; the onion is only a locator. You do **not** install a `tor` daemon, `torrc`, Prosody, or XMPP. OnionWire embeds Arti and publishes its own v3 onion. +**Docs** (protocol first, then claims): [PROTOCOL.md](docs/PROTOCOL.md) · [threat model](docs/THREAT_MODEL.md) · [audit notes](docs/SECURITY_AUDIT.md) (in-house, not a third-party audit). Index: [docs/README.md](docs/README.md). + ## Install Pick one: a CI-built binary (fastest), or build from source. @@ -308,7 +310,7 @@ Do not run `cargo publish`; `publish = false`. CI needs the repo secret ## Not in v1 -Prosody, XMPP, s2s, MAM, carbons, outbox, multi-device, DHT / name server, sqlcipher, QR codes. +Prosody, XMPP, s2s, MAM, carbons, outbox, multi-device, DHT / name server, sqlcipher, QR codes, A/V. Protocol lock: [docs/PROTOCOL.md](docs/PROTOCOL.md). ## License diff --git a/android/README.md b/android/README.md index 18f67bb..1667ae6 100644 --- a/android/README.md +++ b/android/README.md @@ -9,7 +9,7 @@ Two artifacts out of one Gradle build: The Linux TUI in the repository root is a **separate product** with a separate Cargo workspace. Nothing here turns it into a WebView, and nothing here imports -its TUI code. +its TUI code. Same wire protocol: [docs/PROTOCOL.md](../docs/PROTOCOL.md). ## Requirements diff --git a/crates/onionwire-sdk/README.md b/crates/onionwire-sdk/README.md index 5a1f13d..36e25a5 100644 --- a/crates/onionwire-sdk/README.md +++ b/crates/onionwire-sdk/README.md @@ -27,7 +27,7 @@ onions directly. There is no `tor` binary, no torrc, no Orbot as the pipe. onionwire:v1:k=:o=:spk=:sig= ``` -* `sig` is a signature over the concatenation `k ‖ o ‖ spk` as raw ASCII bytes. +* `sig` is ed25519 over `onionwire-invite-v1` + `0x00`-separated `k`, `o`, `spk` (hex/onion as they appear in the string). Decode still accepts the legacy raw concatenation so old invites verify. See [docs/PROTOCOL.md](../../docs/PROTOCOL.md). * A malformed, unsigned, or tampered invite is rejected before anything is stored. * **Same `k` never creates a second friend.** It updates the stored locator on the existing row. That is the whole point of keying the roster on the pubkey. diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md new file mode 100644 index 0000000..d7147f3 --- /dev/null +++ b/docs/PROTOCOL.md @@ -0,0 +1,271 @@ +# OnionWire protocol (v1) + +This is the wire protocol. A second implementation should be able to +interoperate from this document without reading the Rust. + +It is **not** an IETF RFC. It describes what this repository speaks today +(`frame::VERSION` 1, invite scheme `onionwire:v1`). There has been no public +third-party audit. Arti onion services are experimental upstream. + +Related: [threat model](THREAT_MODEL.md), [audit notes](SECURITY_AUDIT.md). + +## What this is not + +OnionWire is not Tox, not Tox-over-Tor, not XMPP, and not “a messenger plus a +SOCKS proxy.” + +| | Tox (including ToT) | OnionWire v1 | +|---|---|---| +| Discovery | DHT + bootstrap nodes | Out-of-band invite only | +| After lookup | Direct IP or TCP relay (SOCKS if you forced it) | v3 onion ↔ v3 onion | +| Address | Tox ID lives in the DHT | ed25519 pubkey is identity; onion is a locator | +| Offline send | Expected (relays / retry) | Fail closed — no outbox | +| Mixed clearnet | ToT can still talk to non-Tor Tox | No clearnet peers | + +There is no directory, no name server, no introduction point of our own, and no +UDP at the application layer. + +## Roles and keys + +Each install holds: + +- **Identity** — ed25519 keypair. The 32-byte public key **is** the person. + Roster primary key. Displayed as 64 lowercase hex chars (the fingerprint). +- **Prekey** — x25519 keypair. This is the Noise IK *static*. It is long-lived + in v1. Rotating the onion (`F4`) does **not** rotate it. +- **Onion** — Tor v3 address. Locator only. Disposable. + +A friend is `{identity_pk, current_onion, current_prekey_pk}`. Pasting the same +identity `k` updates the locator (and prekey) on that row. It never creates a +second person. + +Petnames, chat history, and the optional Monero wallet RPC are local. They are +not on the wire. + +## Transport + +1. Each peer publishes a Tor v3 onion service (in-process Arti in this tree). +2. Application port is **80** (`HS_PORT`). +3. The sender opens a Tor circuit to `(friend.onion, 80)`, runs the handshake, + sends **one** application frame, and drops the circuit. +4. File transfer is N independent sessions (one handshake per chunk). There is + no multiplexed long-lived stream in v1. + +If the onion is down, send fails. No retry queue except the locator-push path, +which retries for 180 seconds and then gives up. + +Incoming rendezvous accepts are token-bucket limited: 30 per 60s, burst 10. +Excess is dropped before handshake. That is availability, not anonymity. + +## Framing + +Every Noise handshake message and every ciphertext is wrapped: + +``` +uint8 version = 1 +uint32 length = big-endian body length +uint8 body[length] +``` + +- `length` MUST be ≤ 65535. Check the length **before** allocating the body. +- Other `version` values are a hard fail (close the stream). +- Length that does not match the bytes on the wire is a hard fail. + +## Handshake + +Noise pattern: `Noise_IK_25519_ChaChaPoly_BLAKE2s` + +Prologue (both sides, before the first handshake message): `onionwire-v1` +(13 ASCII bytes, no NUL). + +Handshake payloads are empty. A non-empty decrypted handshake payload is a +hard fail. + +### Sequence + +Initiator knows the responder’s identity pubkey (pinned) and x25519 prekey +from the invite (or roster). + +1. **IK msg 1** — initiator → responder (framed). Responder’s Noise static is + the invite `spk`. +2. Responder looks up `get_remote_static()` in the roster **by prekey**. + Unknown prekey → close. This is how the answerer learns who is calling. +3. **IK msg 2** — responder → initiator (framed). +4. Both sides enter transport mode. Handshake hash `h` is 32 bytes (BLAKE2s). +5. **Identity proof, initiator first** — transport-encrypt a 96-byte proof, + send as one frame. +6. **Identity proof, responder** — same, one frame. + +Proof encoding (96 bytes): + +``` +bytes[32] identity_pk // ed25519 +bytes[64] ed25519_sign(identity_sk, h) +``` + +Verify: + +- proof length is exactly 96 +- `identity_pk` equals the **pinned** identity (initiator: invite/roster `k`; + responder: roster row found via prekey) +- `ed25519_verify(identity_pk, h, sig)` succeeds + +Any mismatch is a fingerprint failure. Do not send application data. Do not +retry with a different key. + +After proofs, the initiator sends **one** transport frame of application +ciphertext and closes. The responder decrypts that one frame and closes. + +Compromise of the long-term x25519 prekey does not decrypt past transport +(IK + ephemeral). It does allow future impersonation at the Noise layer until +the peer notices the identity proof failing — so the proof is not optional. + +## Application plaintext + +After decrypt, classify the plaintext: + +| Prefix (4 bytes) | Kind | If unknown / invalid | +|---|---|---| +| `loc ` | locator update | drop | +| `prf ` | profile | drop | +| `inv ` | Monero invoice | drop | +| `rcp ` | Monero receipt claim | drop | +| `png ` | ping (reserved) | drop | +| `fil ` | file chunk | drop | +| `[a-z][a-z][a-z] ` | unknown typed frame | **drop** (not chat) | +| anything else | chat | store as a chat line | + +A peer that does not implement a typed prefix MUST still drop +`[a-z]{3}…` rather than render it as chat. Mixed-version 0.1.2 clients +that stored those as chat are obsolete; upgrade both sides. + +Chat MUST NOT begin with three lowercase ASCII letters and a space. + +Hex on the wire is lowercase on encode; decode accepts either case. No `0x` +prefix. + +### `loc ` — locator update + +Sent when the sender rotated their onion and the receiver is reachable. + +``` +loc \n\n +``` + +- `onion` — v3 onion (56 base32 `a-z2-7` chars + `.onion`) +- `ts` — unix seconds, decimal ASCII +- `sig_hex` — 64-byte ed25519, 128 hex chars +- signed bytes: `onion || LF || ascii(ts)` (the `loc ` prefix is **not** signed) + +Apply only if **all** of: + +- the Noise session’s peer identity is already in the roster +- signature verifies under that identity +- `ts` is strictly greater than the stored `onion_updated_at` + +Otherwise drop. Unknown pubkeys are not inserted from `loc`. There is no +directory: if you rotated while they were offline, they cannot find you until +they paste a new invite. + +### `prf ` — profile + +``` +prf \n\n\n\n +``` + +Limits: name ≤ 64 bytes, bio ≤ 512 bytes. No newlines in those fields. +`xmr_addr` may be empty; if non-empty it MUST be a checksummed Monero address +(see invoices). Signed bytes: the four fields joined by LF (no `prf ` prefix). + +Apply only for an existing friend, valid signature, newer `ts`. Not a public +profile. Not a directory. + +### `inv ` — invoice (we want to receive) + +``` +inv \n
\n\n\n +``` + +`amount_atomic` is decimal piconero (integer string, > 0). `address` is a +Monero mainnet/stagenet standard, subaddress, or integrated address with a +valid Keccak checksum. Signed bytes: the four fields joined by LF. + +The frame is a signed claim, not a payment. + +### `rcp ` — receipt claim (we paid) + +``` +rcp \n\n
\n\n +``` + +Signature proves which friend sent the claim. It does **not** prove money +moved. Local `verified=1` only if a user-hosted `monero-wallet-rpc` returns +one transfer row with the same non-empty `txid`, `amount`, and `address` +(conjunction, not OR). RPC down → stay unverified. Wallet RPC is loopback +HTTP Digest; it is not this protocol. + +### `fil ` — file chunk + +Cap: 1 MiB reconstructed file. No resume. One Noise session per chunk. + +``` +fil \n\n\n/\n +``` + +- `xfer_id` — 16 random bytes (32 hex) +- `filename` — 1–128 bytes, no `/`, NUL, newline, `.`, or `..` +- `sha256` — SHA-256 of the **entire** file (64 hex), repeated on every chunk +- `idx` is 0-based; `total` ≥ 1; `idx < total` +- last line is raw chunk bytes (not hex), length chosen so the framed Noise + ciphertext stays ≤ 65535 (account for a 16-byte Poly1305 tag plus ASCII header) + +Chunks MUST arrive in order for a given `xfer_id`. A bad chunk or hash +mismatch deletes the partial. Empty files are one chunk. Successful receive +is a chat line `[file] name (N bytes)`, never the raw frame. + +### Chat + +UTF-8 (this implementation does not enforce UTF-8). Displayed as a line. +No length limit beyond the frame cap. + +## Invite (out of band) + +Not a QR graphic. A single ASCII string, max 4096 characters: + +``` +onionwire:v1:k=<64 hex>:o=:spk=<64 hex>:sig=<128 hex> +``` + +Field order in the string is not significant; duplicate or unknown fields are +rejected. `k` is the identity pubkey, `spk` the x25519 prekey, `o` the current +locator. + +**Current signature (encode this):** + +``` +ed25519_sign(identity_sk, + "onionwire-invite-v1" || 0x00 || k_ascii || 0x00 || o_ascii || 0x00 || spk_ascii) +``` + +`k_ascii` / `spk_ascii` are the hex strings as they appear in the invite +(lowercase). + +**Legacy accept:** a signature over the raw concatenation `k || o || spk` +(no domain tag, no separators) is still accepted on decode so old invites +verify. New invites MUST use the domain-separated form. Do not emit legacy. + +Unknown `k`: prompt. Same `k`: update onion and prekey only. + +## What v1 will not grow into + +No DHT, no hosted server, no XMPP, no store-and-forward, no multi-device, no +A/V, no STUN/ICE/WebRTC, no SOCKS/C-tor fallback, no system DNS for friends. + +Those are how Tox-over-Tor leaks (UDP DHT, mixed clearnet, “just one direct +path”). They are out of scope, not postponed. + +## Versioning + +- Frame `version` 1 and invite `onionwire:v1` are this document. +- A new handshake prologue or a new invite scheme is a breaking change. +- New typed prefixes (`xyz `) are backward compatible if old peers drop them. diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..f18339a --- /dev/null +++ b/docs/README.md @@ -0,0 +1,14 @@ +# OnionWire docs + +Read these in order if you are evaluating the protocol without the Rust: + +1. **[PROTOCOL.md](PROTOCOL.md)** — wire format, handshake, invite, frames. + This is the document a second implementation would use. +2. **[THREAT_MODEL.md](THREAT_MODEL.md)** — what v1 claims, and what it + refuses to claim. +3. **[SECURITY_AUDIT.md](SECURITY_AUDIT.md)** — in-house review notes and + which findings are still open. **Not** a public third-party audit. + +Operator / product docs stay in the [root README](../README.md). Android +build notes: [android/README.md](../android/README.md). SDK: +[crates/onionwire-sdk/README.md](../crates/onionwire-sdk/README.md). diff --git a/docs/SECURITY_AUDIT.md b/docs/SECURITY_AUDIT.md index cec75ba..f45747b 100644 --- a/docs/SECURITY_AUDIT.md +++ b/docs/SECURITY_AUDIT.md @@ -1,194 +1,39 @@ -# 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`) +# OnionWire security notes -Ignored tests (`needs live Tor network`): `rotate_hs`, `tor_hs`, `two_node`. Not re-run. +**This is not a public third-party audit.** It is an in-house review of +commit `2b42864` plus a status check against the current tree. Do not cite it +as “audited.” -## 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 +Protocol: [PROTOCOL.md](PROTOCOL.md). Claims: [THREAT_MODEL.md](THREAT_MODEL.md). -## Findings +Original pass: rust-dev, no live hidden service / live wallet. Ignored tests +(`rotate_hs`, `tor_hs`, `two_node`) were not run. -### F1 — Incoming receipt `verified=1` on unrelated wallet history [High] -Location: `src/wallet.rs:142-148`, used at `src/node.rs:667-683` +## Finding status (vs current tree) -Evidence: confirmation is not “this txid paid this amount to this address”. - -```142:148:src/wallet.rs -pub fn transfers_match(rows: &[TransferRow], txid: &str, amount: &str, address: &str) -> bool { - rows.iter().any(|r| { - r.txid == txid - || (!address.is_empty() - && r.address == address - && (amount.is_empty() || r.amount == amount)) - }) -} -``` - -`ingest_receipt` verifies the **ed25519** on the `rcp` frame (so the *friend* signed it), inserts `verified: false`, then flips `verified` if `transfers_match` is true (`src/node.rs:671-682`). Replica of that predicate against two wallet rows `{txid:aaa111, amount:1000, addr:4AAA…}` and `{txid:bbb222, amount:5, addr:8BBB…}`: - -``` -txid-only match (wrong amount+addr): True -addr+amount match (wrong txid): True -honest miss: False -``` - -Impact: a friend who completed Noise IK can send a signed receipt for an arbitrary amount/address and get the TUI line `[receipt] N XMR` (verified) if *either* (a) `txid` appears anywhere in `get_transfers` `in`/`pending`, or (b) some inbound row already has that address and amount. That is payment forgery against the local “verified” bit. It is not a third-party wire injection: the frame still has to decrypt under the pinned session. `docs/THREAT_MODEL.md:33` says “Never trust a `rcp` frame without RPC confirmation (`verified` stays 0)” — the code *does* promote `verified`, and the RPC check does not bind amount+address+txid together. Tests encode the store default (`tests/pay.rs` `incoming_receipt_is_not_verified`) but never exercise `transfers_match` against mismatched amount. - -Fix: require `txid == row.txid && amount == row.amount && address == row.address` (and reject empty fields). Do not OR. Keep `verified=0` if RPC is down. - -### F2 — Chat AEAD has empty AAD; ciphertext rows are interchangeable [Medium] -Location: `src/backup.rs:98-108` (`aead_encrypt`), `src/store.rs:649` / `670` - -Evidence: bodies are `nonce || ChaCha20-Poly1305(key, nonce, pt)` with no associated data. The same 32-byte `msg_key` wraps every row. A DB writer who cannot open the passphrase can still swap `messages.plaintext` blobs. Throwaway against this tree (`/tmp/ow-audit-repro`, `CARGO_TARGET_DIR` = this worktree `target`): - -``` -SWAP: alice sees "secret-for-bob" -SWAP: bob sees "secret-for-alice" -``` - -Both `list_messages` calls returned `Ok`; Poly1305 verified. `dir` / `friend_id` / `id` / `created_at` are plaintext columns and are not in the MAC. - -Impact: anyone with write access to `onionwire.db` (same uid, stolen unlocked file, or a bug that writes sqlite) can reattribute ciphertext across friends and in/out without the passphrase. This is *not* remote plaintext disclosure. Identity secret keys are already plaintext in `self` (threat model says so); this is extra: the body encryption does not bind a row to its owner. Nonces are 96-bit random per `generate_nonce` — reuse across restarts/`wipe-all` (new key) / first-unlock rewrap is not the failure mode here. - -Fix: encrypt as `Aead::encrypt` with AAD = `friend_id || dir || row_id` (or a committed header), or include those fields in the plaintext that is MACed. Reject decrypt if AAD does not match the row. - -### F3 — Invite `sig` is over concatenated strings, not length-prefixed fields [Medium] -Location: `src/qr.rs:67-72` (`sign_msg`), `src/qr.rs:36-64` (`decode`) - -Evidence: `sig` covers `k.as_bytes() || onion.as_bytes() || spk.as_bytes()` with no delimiters or lengths. `k` is 64 hex chars after the 32-byte check, so it cannot shift. `o` and `spk` can. Encode a real v3 onion + 32-byte `spk`, then move the first 8 hex chars of `spk` onto `o`, keep `k` and `sig`. `qr::decode` **accepts** the mutant: - -``` -CONCAT: mutated invite accepted -CONCAT: onion_changed=true -CONCAT: onion=abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopqrstuvwxyz234567.onionabababab -CONCAT: spk_len=28 (want 32) -CONCAT: pubkey_unchanged=true -``` - -`decode` does not require `signed_prekey.len()==32` or a v3 onion. `Node::add_friend_payload` then `upsert_friend` (same `k` **replaces** locator) and `set_friend_prekey`. Duplicate/unknown fields are rejected (`src/qr.rs:85-106`); all four fields are required. Empty `sig` fails `from_hex`. This is not a classic steal-the-identity concat: `k` stays the signer. - -Impact: a mutated invite still verifies under the real identity key. F3-paste (or a same-`k` rescan) can poison `friends.onion` / `prekey` for that pubkey. The shifted onion is not an arbitrary attacker HS (you can only append a hex prefix of the original `spk`), so this is roster integrity / availability, not a silent MITM. Handshake then fails (`prekey` length ≠ 32 at `node.rs:388`). No panic on this path. `from_hex` has **no size cap**: a 4,000,000-char hex string decoded to 2,000,000 bytes in 0.34s in CPython; `decode` allocates that before `pubkey.len()!=32` rejects. - -Fix: sign a domain-separated encoding (`k` || `0x00` || `o` || `0x00` || `spk`, or length prefixes). Reject `spk` ≠ 32 bytes and onion ≠ v3. Cap invite length before `from_hex` (a few KiB). - -### F4 — Monero address check is prefix+length, not checksum [Medium] -Location: `src/pay.rs:38-48`; tests *require* the junk form to pass (`tests/pay.rs:15-50`) - -Evidence: - -```38:48:src/pay.rs -pub fn check_address(addr: &str) -> Result<()> { - let ok = match addr.as_bytes().first() { - Some(b'4') if addr.len() == 95 || addr.len() == 106 => true, - Some(b'8') if addr.len() == 95 => true, - _ => false, - }; - if ok && !addr.contains('\n') { - Ok(()) - } else { - Err(Error("invalid Monero address".into())) - } -} -``` - -`4` + `'A' * 94` is accepted (len 95). No network byte, no Keccak checksum, no alphabet check. `profile::check_fields` (`src/profile.rs:118-128`) does not call `check_address` at all — `xmr_addr` may be `"4abc"` (`tests/profile.rs`). `/tip` does call `check_address` on the stored profile address (`src/node.rs:331`). `/pay` invoices are signed over that address (`src/node.rs:290`). - -Impact: OnionWire will persist and sign invoices/receipts for strings no Monero wallet should pay. A live `monero-wallet-rpc` will usually reject checksum failures on `transfer`, so this is not by itself silent theft. It *is* a local fail-open: garbage becomes a signed `inv`/`rcp` payload and a payments row. Combined with F1, a verified receipt can cite such an address. - -Fix: decode base58, check network prefix + checksum. Empty profile `xmr_addr` stays allowed; non-empty must pass the same check. - -### F5 — Wallet RPC is unauthenticated HTTP and can mark receipts from its full transfer list [Medium] -Location: `src/wallet.rs:151-171`, `223-237`, `117-138`; README documents `ONIONWIRE_WALLET_RPC=http://127.0.0.1:18083` - -Evidence: URL parser requires `http://` (no TLS), host must be loopback or `.onion` (`allowed_host`, tested in `tests/wallet.rs` `refuse_non_loopback_non_onion_host`). There is no `user:pass` / Digest / header. `http_post` is raw `TcpStream` + `read_to_end` with a 5s timeout, no body size cap. `.onion` hosts are allowed but `TcpStream::connect((onion, port))` does **not** go through Arti — so an onion RPC URL fail-closes at connect (not examined live). `eprintln!("ONIONWIRE_WALLET_RPC: {e}")` prints the parse error, not the URL, on bad env. - -Impact: the documented operator setup is “HTTP to loopback, no login”. Any local process that can reach that port can `transfer` (spend) and `get_transfers` (the same list F1 trusts). OnionWire never holds spend keys (threat model — true); it also never authenticates to the process that does. This is local, not remote, if the operator actually bound loopback. Code cannot express `--rpc-login`. - -Fix: require digest (or a unix socket). Refuse URLs without credentials. Cap RPC read size. If onion RPC is a goal, dial it through the Arti client, not `TcpStream`. - -### F6 — `/wipe` does not touch payments; sqlite is not `secure_delete` [Low] -Location: `src/store.rs:677-685`, `122-125` - -Evidence: `wipe_messages` overwrites `messages.plaintext` with `zeroblob(length)`, `DELETE FROM messages`, `VACUUM`. No `PRAGMA secure_delete`. No `DELETE FROM payments`. WAL is required (`journal_mode = WAL` fail-closed). Threat model (`docs/THREAT_MODEL.md:21`) says “`/wipe` overwrites message bodies and vacuums” — that part matches. It does not say payments go away; they do not. Identity / friend pubkeys / onions stay plaintext (disclosed, not a finding). `onionwire.db` itself is never `chmod 0600`; the home and `arti/` dirs are `0700` after `create_dir_all` (`src/store.rs:753-758`, `tests/store.rs:61-62`). Backup files *are* `0o600` at create (`src/node.rs:145-150`). - -Impact: `/wipe` is not a forensic erase (SSD wear-leveling, WAL snapshots, payments table, roster). A seized disk after `/wipe` still has who you pay and who you talk to. `/wipe-all` is `remove_dir_all` — same disk caveat. - -Fix: if `/wipe` should mean “chat history gone”, also drop `payments` (and checkpoint WAL). Document that `/wipe` is not crypto-shred. Optional `secure_delete` is still not a guarantee on flash. - -### F7 — HS publish logs the onion; `dangerously_trust_everyone` is Arti-only [Low] -Location: `src/hs.rs:31-36`, `src/hs.rs:95`, `src/node.rs:95` - -Evidence: `wait_until_published(..., &onion, &onion)` uses the unredacted onion as `label`. `eprintln!("{label} hs status: {state:?}")` and probe lines go to stderr. `onion_string` uses `display_unredacted` (`src/hs.rs:70`) — required to persist the locator; the leak is the log. `client_config` calls `builder.storage().permissions().dangerously_trust_everyone()` after `create_dir_all` on the Arti state/cache paths. That API is fs-mistrust for **Arti’s** directories, not sqlite. `Store::open_at_with_passphrase` `mkdir_700`s `home` and `home/arti` first; `home/cache` is created by Arti’s `create_dir_all` without `0700`. `cbtmintimeout` / `cbtinitialtimeout` = 20s is a circuit-build floor (perf / publish reliability), not an auth bypass. - -Impact: journald/script logs contain the current v3 locator. Arti state/cache may be created `0755` until something else tightens them; sqlite lives under the `0700` home. A world-readable Arti cache is descriptor/consensus metadata, not chat bodies. - -Fix: log a redacted onion (safelog). `mkdir_700` the cache dir before `client_config`. Keep `dangerously_trust_everyone` scoped to Arti storage; do not reuse it for `onionwire.db`. - -### F8 — Threat model overstates receipt verification and omits F1–F3 [Low] -Location: `docs/THREAT_MODEL.md:17-21`, `:33`, `:39-41` - -Evidence: TM correctly describes live-only send, loc rules, identity-vs-locator, passphrase-wrapped message key, plaintext identity/roster, experimental Arti, global 30/60s burst-10 token bucket, backup = identity. It claims RPC confirmation keeps `verified` at 0 unless the chain view agrees — F1 shows the matcher is not that. It does not mention empty AEAD AAD, invite concat, shape-only XMR addresses, or env passphrase (`ONIONWIRE_STORE_PASSPHRASE` in `src/store.rs:745-750`, visible in `/proc//environ`). - -Impact: an operator who treats TM as the capability list will believe “verified receipt ⇒ wallet saw that payment”. - -Fix: either implement F1’s conjunctive match or change the sentence to “incoming `rcp` is displayed; `verified` is best-effort and must not be trusted in v0.2”. - -## Verified correct - -- Noise pattern is actually `Noise_IK_25519_ChaChaPoly_BLAKE2s` with prologue `onionwire-v1` (`src/session.rs:8-9, 111-122`). Initiator sets `remote_public_key` to the QR/roster x25519 prekey (`src/session.rs:189`). Responder takes remote static from snow (`get_remote_static`) and looks up the friend (`src/session.rs:255-260`, `src/node.rs:550-555`). -- Mutual identity proofs are `ed25519_sign(handshake_hash)` with the 32-byte pubkey prefix; `verify_proof` calls `VerifyingKey::verify` (`src/session.rs:289-310`). Initiator compares proof prefix to the **pinned** identity (`src/session.rs:219-222`); responder to the roster id (`src/session.rs:273-276`). Mismatch is `Error::mismatch` → send hard-fails (`src/node.rs:490`). -- Transport nonces are snow `TransportState` counters (Noise spec: increment, reject reuse). Application code does not set ChaCha nonces on the wire. Session keys include ephemeral DH → compromise of long-term static does not decrypt **past** transport; it does allow impersonation **forward**. That is IK, not a bug. -- Loc frames: `apply_loc` verifies ed25519 against the **session** `peer_identity`, requires the pubkey already in `friends`, and requires `ts > onion_updated_at` (`src/store.rs:452-480`, `src/loc.rs:38-55`, `src/node.rs:561-568`). A peer cannot silently move you to an onion they control unless they hold that identity key (Noise proof + loc sig). An old loc with smaller `ts` cannot rewind after a later rotate. (F3 invite paste can still overwrite locator; that is out-of-band.) -- Unknown typed prefixes (`xyz `) are `Kind::Drop`, not fatal (`src/dispatch.rs:30-38`). Unparseable loc/inv/rcp are ignored (`src/node.rs:562`, `610-611`, `643-644`). Handshake/frame errors print `incoming: {e}` and the rend task ends; the accept loop continues (`src/node.rs:723-729`). -- Frames are length-prefixed, `MAX_FRAME = 65535`, checked **before** allocating the body (`src/frame.rs:5, 63-67`). -- Token bucket is **one global** `TokenBucket::default()` = 30 tokens / 60s, burst 10 (`src/ratelimit.rs:41-44`, `src/node.rs:86, 712-720`). Matches TM; one flood can starve every friend (availability, not auth). -- Backup: Argon2id v0x13, `m=19456` KiB, `t=2`, `p=1` (`src/backup.rs:52-53`) = OWASP 2023 minimum. Per-export 16-byte salt + 12-byte random nonce in the file. `open` AEAD-fails with a single error before `replace_identity_keys` (`src/backup.rs:91-95`, `src/node.rs:155-168`). Onion is not in the blob (`tests/backup.rs`). Wrong passphrase does not write keys. -- First-run message key: 32 random bytes, wrapped with the same KDF/AEAD, stored in `store_meta` (`src/store.rs:281-293`). Empty passphrase refused. Integrity check fail-closed (`src/store.rs:191-194`). -- Amounts on the pay path are decimal **integer piconero** (`src/pay.rs:51-59`); `xmr_to_atomic` pads a ≤12-digit fraction without float (`src/pay.rs:62-85`). `/tip` then `parse`s to `u64` for RPC (`src/node.rs:332-334`). -- Incoming `inv`/`rcp` are verified against **session peer identity**, not a field inside the frame (`src/node.rs:613`, `646`). -- Locked product decisions (no server/XMPP/MAM/DHT, live-only send, identity=pubkey, onion=locator, no dual-host grace) match the code. Not findings. - -## Not examined / out of scope - -- Live hidden-service reachability, IPT/HsDir, and two-node Tor tests (ignored; no HS from this auditor). -- Live `monero-wallet-rpc` (auth defaults, `get_transfers` JSON shape vs `json_amount`, unlock/spend confirm). -- snow 0.10 internals beyond the `Builder`/`TransportState` API used here (constant-time, rekey at 2^64). -- Arti keystore encryption at rest, fs-mistrust semantics of `dangerously_trust_everyone` beyond “it is called on Arti storage”. -- Timing of Argon2 / ed25519 verify (failed backup passphrase is one error string; KDF still runs). -- TUI rendering of hostile chat (ratatui text; no HTML). -- Traffic analysis / HS existence (TM already declines that). -- `cargo audit`: **not installed** (`which cargo-audit` empty). Lockfile inspected by hand; no RustSec lookup was executed against this `Cargo.lock`. - -## Dependencies (Cargo.lock) - -| Crate | Lock version | Cargo.toml | +| Id | Original | Now | |---|---|---| -| 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` | +| F1 receipt `verified` on OR-match | High — txid **or** addr+amount | **Fixed.** `transfers_match` requires txid ∧ amount ∧ address, all non-empty. | +| F2 chat AEAD empty AAD | Medium — row swap | **Fixed.** AAD `owmsg1 \|\| friend_id_le64 \|\| dir \|\| 0x00 \|\| row_id_le64`. | +| F3 invite concat signature | Medium — field shift | **Fixed on encode.** Domain tag `onionwire-invite-v1` + `0x00` separators. Decode still **accepts** legacy concat so old invites work. | +| F4 Monero address shape-only | Medium | **Fixed.** Base58 + Keccak checksum + network prefixes. | +| F5 wallet RPC no auth | Medium | **Fixed.** HTTP Digest required; open RPC refused; 1 MiB read cap; `.onion` RPC URLs refused. | +| F6 `/wipe` skipped payments | Low | **Fixed.** `/wipe` also `DELETE FROM payments` + WAL checkpoint. Still not forensic. | +| F7 HS logs unredacted onion | Low | **Fixed.** stderr uses a redacted label. `dangerously_trust_everyone` remains on Arti storage only. | +| F8 threat model vs F1–F3 | Low | **Fixed** in [THREAT_MODEL.md](THREAT_MODEL.md) when the code landed. | -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`). +## Still true / not a “finding we forgot” -## Open questions for Lance +- Identity secret key and roster (pubkeys + locators) are **plaintext** in sqlite. Chat bodies are not. Seized laptop without FDE yields who you are and who you talk to. +- Arti onion services are experimental. Fail closed; no C-tor fallback. +- No public third-party audit, no `cargo-audit` gate in CI as of the original pass. +- Invite decode still accepts the legacy concat signature (compatibility). +- Rate limit is availability, not traffic-analysis defense. +- One-shot sessions: no channel binding across file chunks beyond “same Noise peer.” -- 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? +## Verified correct (original pass, still the protocol) -## 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. +Noise is `Noise_IK_25519_ChaChaPoly_BLAKE2s`, prologue `onionwire-v1`, 96-byte +identity proofs, loc/prf/inv/rcp signed under the session identity, unknown +`[a-z]{3} ` prefixes dropped, frames length-checked before allocate. See +[PROTOCOL.md](PROTOCOL.md). diff --git a/docs/THREAT_MODEL.md b/docs/THREAT_MODEL.md index e8372a5..a2f3aa1 100644 --- a/docs/THREAT_MODEL.md +++ b/docs/THREAT_MODEL.md @@ -1,6 +1,8 @@ # OnionWire threat model (v1) OnionWire is a two-party chat over Tor onion services. There is no chat server. +Wire format: [PROTOCOL.md](PROTOCOL.md). In-house review notes: +[SECURITY_AUDIT.md](SECURITY_AUDIT.md) — **not** a public third-party audit. ## Tor relays are not a chat server @@ -40,6 +42,17 @@ OnionWire never holds spend keys. Optional `ONIONWIRE_WALLET_RPC` talks HTTP Dig Incoming rendezvous accepts are token-bucket limited (30/60s, burst 10). Excess is dropped without handshake. That is a DoS/availability control. It does not hide that you run an onion, and it is not a traffic-analysis defense. +## Network (UDP / clearnet) + +The application does not open UDP sockets and does not resolve friends via +system DNS. Peer dials are Arti `connect((onion, 80))` only. The one +intentional clearnet socket is optional `monero-wallet-rpc` on **loopback TCP** +(numeric loopback preferred; `localhost` still does `getaddrinfo`). + +Do not add STUN/ICE/WebRTC, a DHT, SOCKS/C-tor fallback, or “direct TCP when +the onion is slow.” Those are the Tox-over-Tor leak class. + ## Out of v1 -Prosody, XMPP, s2s, MAM, carbons, outbox, multi-device, DHT / name server, sqlcipher. +Prosody, XMPP, s2s, MAM, carbons, outbox, multi-device, DHT / name server, +sqlcipher, A/V. -- 2.45.3