docs: protocol spec and audit-notes cleanup #17

Open
sirius wants to merge 1 commit from docs/protocol into main
7 changed files with 333 additions and 188 deletions
Showing only changes of commit 22b3a0062a - Show all commits

View file

@ -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

View file

@ -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

View file

@ -27,7 +27,7 @@ onions directly. There is no `tor` binary, no torrc, no Orbot as the pipe.
onionwire:v1:k=<ed25519 pubkey hex64>:o=<v3 onion address>:spk=<x25519 prekey hex64>:sig=<ed25519 sig hex128>
```
* `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.

271
docs/PROTOCOL.md Normal file
View file

@ -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 responders identity pubkey (pinned) and x25519 prekey
from the invite (or roster).
1. **IK msg 1** — initiator → responder (framed). Responders 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}<space>…` 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 <onion>\n<ts>\n<sig_hex>
```
- `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 sessions 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 <display_name>\n<bio>\n<xmr_addr>\n<ts>\n<sig_hex>
```
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 <amount_atomic>\n<address>\n<memo>\n<ts>\n<sig_hex>
```
`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 <txid>\n<amount_atomic>\n<address>\n<ts>\n<sig_hex>
```
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 <xfer_id_hex>\n<filename>\n<sha256_hex>\n<idx>/<total>\n<raw bytes>
```
- `xfer_id` — 16 random bytes (32 hex)
- `filename` — 1128 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=<v3 onion>: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.

14
docs/README.md Normal file
View file

@ -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).

View file

@ -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 **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 |
| 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 F1F3 | 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).

View file

@ -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.