Compare commits
1 commit
main
...
docs/proto
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
22b3a0062a |
7 changed files with 333 additions and 188 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
271
docs/PROTOCOL.md
Normal 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 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}<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 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 <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` — 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=<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
14
docs/README.md
Normal 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).
|
||||
|
|
@ -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/<pid>/environ`).
|
||||
|
||||
Impact: an operator who treats TM as the capability list will believe “verified receipt ⇒ wallet saw that payment”.
|
||||
|
||||
Fix: either implement F1’s conjunctive match or change the sentence to “incoming `rcp` is displayed; `verified` is best-effort and must not be trusted in v0.2”.
|
||||
|
||||
## Verified correct
|
||||
|
||||
- Noise pattern is actually `Noise_IK_25519_ChaChaPoly_BLAKE2s` with prologue `onionwire-v1` (`src/session.rs:8-9, 111-122`). Initiator sets `remote_public_key` to the QR/roster x25519 prekey (`src/session.rs:189`). Responder takes remote static from snow (`get_remote_static`) and looks up the friend (`src/session.rs:255-260`, `src/node.rs:550-555`).
|
||||
- Mutual identity proofs are `ed25519_sign(handshake_hash)` with the 32-byte pubkey prefix; `verify_proof` calls `VerifyingKey::verify` (`src/session.rs:289-310`). Initiator compares proof prefix to the **pinned** identity (`src/session.rs:219-222`); responder to the roster id (`src/session.rs:273-276`). Mismatch is `Error::mismatch` → send hard-fails (`src/node.rs:490`).
|
||||
- Transport nonces are snow `TransportState` counters (Noise spec: increment, reject reuse). Application code does not set ChaCha nonces on the wire. Session keys include ephemeral DH → compromise of long-term static does not decrypt **past** transport; it does allow impersonation **forward**. That is IK, not a bug.
|
||||
- Loc frames: `apply_loc` verifies ed25519 against the **session** `peer_identity`, requires the pubkey already in `friends`, and requires `ts > onion_updated_at` (`src/store.rs:452-480`, `src/loc.rs:38-55`, `src/node.rs:561-568`). A peer cannot silently move you to an onion they control unless they hold that identity key (Noise proof + loc sig). An old loc with smaller `ts` cannot rewind after a later rotate. (F3 invite paste can still overwrite locator; that is out-of-band.)
|
||||
- Unknown typed prefixes (`xyz `) are `Kind::Drop`, not fatal (`src/dispatch.rs:30-38`). Unparseable loc/inv/rcp are ignored (`src/node.rs:562`, `610-611`, `643-644`). Handshake/frame errors print `incoming: {e}` and the rend task ends; the accept loop continues (`src/node.rs:723-729`).
|
||||
- Frames are length-prefixed, `MAX_FRAME = 65535`, checked **before** allocating the body (`src/frame.rs:5, 63-67`).
|
||||
- Token bucket is **one global** `TokenBucket::default()` = 30 tokens / 60s, burst 10 (`src/ratelimit.rs:41-44`, `src/node.rs:86, 712-720`). Matches TM; one flood can starve every friend (availability, not auth).
|
||||
- Backup: Argon2id v0x13, `m=19456` KiB, `t=2`, `p=1` (`src/backup.rs:52-53`) = OWASP 2023 minimum. Per-export 16-byte salt + 12-byte random nonce in the file. `open` AEAD-fails with a single error before `replace_identity_keys` (`src/backup.rs:91-95`, `src/node.rs:155-168`). Onion is not in the blob (`tests/backup.rs`). Wrong passphrase does not write keys.
|
||||
- First-run message key: 32 random bytes, wrapped with the same KDF/AEAD, stored in `store_meta` (`src/store.rs:281-293`). Empty passphrase refused. Integrity check fail-closed (`src/store.rs:191-194`).
|
||||
- Amounts on the pay path are decimal **integer piconero** (`src/pay.rs:51-59`); `xmr_to_atomic` pads a ≤12-digit fraction without float (`src/pay.rs:62-85`). `/tip` then `parse`s to `u64` for RPC (`src/node.rs:332-334`).
|
||||
- Incoming `inv`/`rcp` are verified against **session peer identity**, not a field inside the frame (`src/node.rs:613`, `646`).
|
||||
- Locked product decisions (no server/XMPP/MAM/DHT, live-only send, identity=pubkey, onion=locator, no dual-host grace) match the code. Not findings.
|
||||
|
||||
## Not examined / out of scope
|
||||
|
||||
- Live hidden-service reachability, IPT/HsDir, and two-node Tor tests (ignored; no HS from this auditor).
|
||||
- Live `monero-wallet-rpc` (auth defaults, `get_transfers` JSON shape vs `json_amount`, unlock/spend confirm).
|
||||
- snow 0.10 internals beyond the `Builder`/`TransportState` API used here (constant-time, rekey at 2^64).
|
||||
- Arti keystore encryption at rest, fs-mistrust semantics of `dangerously_trust_everyone` beyond “it is called on Arti storage”.
|
||||
- Timing of Argon2 / ed25519 verify (failed backup passphrase is one error string; KDF still runs).
|
||||
- TUI rendering of hostile chat (ratatui text; no HTML).
|
||||
- Traffic analysis / HS existence (TM already declines that).
|
||||
- `cargo audit`: **not installed** (`which cargo-audit` empty). Lockfile inspected by hand; no RustSec lookup was executed against this `Cargo.lock`.
|
||||
|
||||
## Dependencies (Cargo.lock)
|
||||
|
||||
| Crate | Lock version | Cargo.toml |
|
||||
| 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).
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue