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

9.4 KiB
Raw Blame 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, audit notes.

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.