272 lines
9.4 KiB
Markdown
272 lines
9.4 KiB
Markdown
|
|
# 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.
|