onionwire/docs/PROTOCOL.md

272 lines
9.4 KiB
Markdown
Raw Normal View History

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