# 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}…` 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 \n\n ``` - `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 \n\n\n\n ``` 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 \n
\n\n\n ``` `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 \n\n
\n\n ``` 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 \n\n\n/\n ``` - `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=: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.