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.
9.4 KiB
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
- Each peer publishes a Tor v3 onion service (in-process Arti in this tree).
- Application port is 80 (
HS_PORT). - The sender opens a Tor circuit to
(friend.onion, 80), runs the handshake, sends one application frame, and drops the circuit. - 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]
lengthMUST be ≤ 65535. Check the length before allocating the body.- Other
versionvalues 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).
- IK msg 1 — initiator → responder (framed). Responder’s Noise static is
the invite
spk. - Responder looks up
get_remote_static()in the roster by prekey. Unknown prekey → close. This is how the answerer learns who is calling. - IK msg 2 — responder → initiator (framed).
- Both sides enter transport mode. Handshake hash
his 32 bytes (BLAKE2s). - Identity proof, initiator first — transport-encrypt a 96-byte proof, send as one frame.
- 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_pkequals the pinned identity (initiator: invite/rosterk; 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 base32a-z2-7chars +.onion)ts— unix seconds, decimal ASCIIsig_hex— 64-byte ed25519, 128 hex chars- signed bytes:
onion || LF || ascii(ts)(thelocprefix is not signed)
Apply only if all of:
- the Noise session’s peer identity is already in the roster
- signature verifies under that identity
tsis strictly greater than the storedonion_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 chunkidxis 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
version1 and inviteonionwire:v1are 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.