onionwire/docs/PROTOCOL.md
Sirius DevOps 22b3a0062a
Some checks are pending
ci / test (pull_request) Waiting to run
docs: add PROTOCOL.md and retire the stale audit writeup
Wire format lives in docs/PROTOCOL.md so the handshake, invite, and
frames can be read without the Rust. Threat model points at it. The
in-house audit notes are a finding-status table, not a third-party
audit. SDK README no longer claims the legacy concat invite signature
is what we encode.
2026-09-11 13:15:51 -04:00

271 lines
9.4 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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