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

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. 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 ## Install
Pick one: a CI-built binary (fastest), or build from source. 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 ## 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 ## 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 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 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 ## 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> 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. * 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 * **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. 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 # OnionWire security notes
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. **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 Protocol: [PROTOCOL.md](PROTOCOL.md). Claims: [THREAT_MODEL.md](THREAT_MODEL.md).
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 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] ## Finding status (vs current tree)
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”. | Id | Original | Now |
```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` | | F1 receipt `verified` on OR-match | High — txid **or** addr+amount | **Fixed.** `transfers_match` requires txid ∧ amount ∧ address, all non-empty. |
| chacha20poly1305 | 0.10.1 | `0.10` | | F2 chat AEAD empty AAD | Medium — row swap | **Fixed.** AAD `owmsg1 \|\| friend_id_le64 \|\| dir \|\| 0x00 \|\| row_id_le64`. |
| argon2 | 0.5.3 | `0.5` | | 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. |
| ed25519-dalek | 2.2.0 | `2` | | F4 Monero address shape-only | Medium | **Fixed.** Base58 + Keccak checksum + network prefixes. |
| x25519-dalek | 2.0.1 | `2` | | F5 wallet RPC no auth | Medium | **Fixed.** HTTP Digest required; open RPC refused; 1 MiB read cap; `.onion` RPC URLs refused. |
| rusqlite | 0.36.0 | `0.36` (bundled) | | F6 `/wipe` skipped payments | Low | **Fixed.** `/wipe` also `DELETE FROM payments` + WAL checkpoint. Still not forensic. |
| arti-client | 0.46.0 | `0.46` + `onion-service-client` + `onion-service-service` | | F7 HS logs unredacted onion | Low | **Fixed.** stderr uses a redacted label. `dangerously_trust_everyone` remains on Arti storage only. |
| tor-hsservice | 0.46.0 | `0.46` | | 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? ## Verified correct (original pass, still the protocol)
- 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) Noise is `Noise_IK_25519_ChaChaPoly_BLAKE2s`, prologue `onionwire-v1`, 96-byte
identity proofs, loc/prf/inv/rcp signed under the session identity, unknown
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. `[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 threat model (v1)
OnionWire is a two-party chat over Tor onion services. There is no chat server. 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 ## 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. 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 ## 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.