# Networking, end to end
Everything that crosses a network when two devices move a file, in the order it
happens: who the devices talk to, what each of those parties can see, and which
route the file itself ends up on. It covers the app, the command line and the
website, because the app runs the website's own code and the same wire. The
cryptography is in [protocol.md](protocol.md), and what all of this does and does
not protect is in [threat-model.md](threat-model.md). This page links to both
rather than repeating them.
## Who is involved
There is **no qrdrop server**. Nothing run by this project sees a transfer, so
there is nothing that could log one. The parties are:
| Party | When | What it carries | What it can see |
| --- | --- | --- | --- |
| **The QR code** | first | 32 random bytes, by light and a camera | nothing: it never touches a network |
| **Signalling**: 7 Nostr relays, 2 WebTorrent trackers | pairing only | each device's announcement, and its WebRTC session description | a room ID derived from the code, both devices' IP addresses, and session descriptions sealed with a password derived from the code |
| **STUN servers** | pairing only | "what is my public address?" | that a device at some IP address asked |
| **TURN relay** | only when no direct route works | the WebRTC traffic, forwarded | both IP addresses, and sealed bytes it cannot open |
| **The other device** | throughout | everything | the file, after the four symbols match and the receiver accepts |
The relays and servers are listed in `src/transport/room.js`: `RELAYS`,
`TORRENT_TRACKERS` and `ICE_SERVERS`. The Rust copy is
`crates/qrdrop-session/src/networks.rs`, and a test there reads `room.js` so the
two lists cannot drift apart.
## The whole transfer
### 1. Pairing
The sender draws 32 random bytes and shows them as a QR code, or as a link that
carries them in the URL fragment, which a browser never sends to a server. Both
devices derive everything else from those bytes (the
[table in protocol.md](protocol.md#pairing-and-keys)). The derived **room ID** is
what they meet on, so a relay learns that two devices joined the same room and
never learns the code itself.
Each device then joins that room on **every signalling network at once**: the
Nostr relays and the WebTorrent trackers, through Trystero. Whichever network
introduces the two devices first wins, and the others are dropped. Racing them
means the two devices never have to agree on a network, and a relay that is
down or blocks writes costs nothing.
Through that introduction the two devices swap **WebRTC session descriptions**:
the offer and answer, with their network candidates and DTLS fingerprints.
Trystero seals these with a password derived from the code. So a relay sees
ciphertext, and someone who does not hold the code cannot swap in their own
fingerprint. This is the step the [threat model](threat-model.md) cares about
most.
**ICE** then finds a route between the two. Each device lists its candidate
addresses: its local ones, the public one a STUN server reports, and an address
on the TURN relay. Both sides then try the candidate pairs until one works. The
one it settles on is the **route**:
- **local**: both on one network, talking by private address.
- **direct**: across the internet, through each side's NAT, with no relay.
- **relay**: through the free TURN relay, because nothing direct worked.
Once the WebRTC data channel is open (DTLS over SCTP), the two exchange fresh
ECDH public keys over it. That gives one key per direction for this session
alone, salted with the code.
### 2. Verifying
Both screens show four emoji derived from that ECDH. The two people compare them.
Until the sender confirms they match, **nothing about the file and no network
address beyond what ICE needed** goes to the other device. After that point the
negotiation below can hand out a listening port, so the order matters.
[protocol.md](protocol.md#two-gestures-and-why-neither-is-decorative) explains
why neither gesture can be skipped.
### 3. Negotiating
Every message from here on is a **sealed control frame** on the data channel,
encrypted and counted like a file chunk. Four kinds matter to the network:
- **The path verdict.** Each side reads the route ICE chose from WebRTC's own
statistics and tells the other, and the screen shows the more expensive of the
two readings. If the route is the TURN relay and the file is over 100 MiB, the
sender stops before the manifest goes out, because free TURN is metered and
would throttle or cut a large file.
- **The hellos**, `lan-hello` and `quic-hello`. The sender sends them after its
confirm, and the receiver sends them only in reply. A peer that doesn't know a
message type drops it, so an older build or a browser tab simply never answers,
and the session stays on WebRTC.
- **The offers.** The side chosen to listen sends `lan-offer`, which holds its
private addresses, a port, a one-time token and an answer. A Rust peer also
sends `quic-offer`, with its addresses and its certificate's fingerprint. These
addresses go only to the verified peer, and never into the signalling, where
anyone holding the code could read them.
- **The manifest**: the file's name and size.
The direct links are dialled while the person reads the offer. They are ready by
the time Accept arrives, but nothing moves over them yet.
### 4. Moving the file
The receiver's Accept is the **one moment the sender changes route**. At that
instant, every frame the sender has put on WebRTC is one the receiver has
provably handled, so nothing on a new link can overtake anything on the old one.
An authenticated frame arriving out of order is fatal by design. The receiver
never changes route: its replies, including `pause` and `resume`, stay on WebRTC.
The file goes as sealed chunks of 16,348 bytes on WebRTC: a size chosen so each
chunk crosses as exactly one message. The receiver also controls the pace. It
sends `pause` when 4 MiB is waiting to be written and `resume` below 1 MiB, so a
slow disk holds the sender back instead of filling memory. At the end, the
sender sends the file's SHA-256 digest in `complete`, and the receiver answers
`done` once its copy matches.
### 5. Leaving
Each side sends a leave message to the room and closes every socket it opened. A
device that disappears without leaving is noticed when WebRTC's connection checks
stop being answered, about 30 seconds later. That is also what happens to a phone
whose operating system freezes the app in the background
([app.md, ColorOS](app.md#android-on-coloros)).
## Which route carries the file
| Route | Who can use it | Why it exists | Gives way to |
| --- | --- | --- | --- |
| **WebRTC data channel** | everyone, including a browser tab | the only route a browser can make, and it works almost anywhere thanks to TURN | anything faster that is up |
| **LAN WebSocket** | two native ends on one network, one of which can listen quietly | the OS's TCP, about 4 to 6 times a data channel on one network | nothing: it wins whenever it is up |
| **QUIC** | two Rust sessions built with QUIC (the Rust CLI today) | direct across most NATs, and far faster than a data channel over distance or loss | LAN, when both are up |
Three rules make this safe to choose automatically:
- **Every route carries the same sealed frames into the same handler.** A faster
route adds speed, never trust. A frame that fails its check is dropped and
counted, on any route.
- **"Listen quietly" is a real constraint, not a preference.** Accepting a
connection on Windows, or in an unsigned macOS app, puts an OS firewall prompt
in front of the person. So those ends only ever dial out, and two Windows
machines stay on WebRTC.
- **Each direct link proves who is on the other end.**
- On LAN, the dialler presents the token, and the listener must reply with the
answer from its sealed offer.
- On QUIC, each side's TLS accepts only the certificate fingerprint the other
named.
- So someone on the network who takes the connection can neither join it nor
pose as the peer
([protocol.md, the local-network fast path](protocol.md#design-notes)).
## Inside the app
The app is a webview around the website's own code, plus a small Rust process.
Its page is bundled into the app, so opening the app fetches nothing. The
network traffic is exactly what the sections above describe. Which part of the
app makes it depends on the session.
**The JS session** is the default on Windows, macOS and Android. The page does
everything a browser tab does: Trystero, WebRTC, sealing. The app adds the LAN
route, which a browser tab cannot have, because a page can open a connection but
cannot accept one:
- **Dialling out** is the page's own WebSocket to the peer's private address.
- **Listening** (Android only, among these three) is
`app/src-tauri/src/lan.rs`, a relay in the Rust process. It binds one socket
on the LAN for the peer and one on `127.0.0.1` for the page. Both sides must
present the session's token. The relay then forwards whole messages between
the two, never reading a frame or holding a key.
- **Why a relay and not IPC:** on Android, IPC carries bytes as base64 inside
JSON, which would cost the LAN route its speed.
**The native session** is the default on Linux, whose webview has no WebRTC, and
can be switched on elsewhere. There, the whole transfer runs in Rust:
`qrdrop-session` over `tryst`, a Rust port of Trystero, using `str0m` for WebRTC.
It speaks the same wire, so it meets a browser tab, the Node CLI or another app on
the same relays. The page only draws the screens. It sends the person's decisions
(confirm, accept, cancel) as words over IPC, and gets events back in order. Both
safety gestures still belong to the page:
- Nothing is sent to the peer until the page says the four emoji matched.
- Nothing is written until the page's Accept arrives.
The app is built **without QUIC** for now, so an app transfer takes the LAN route
or WebRTC. QUIC waits on measurements of the phone between two networks
(`docs/notes/rust-session.md`).
**What the app is allowed to reach.** The app's Content-Security-Policy is
generated from the same signalling list the website uses. It adds `ws:` for the
LAN route, and the website's policy does not. A pairing link opened from the
camera or a QR scanner reaches the app as a `qrdrop:` link or an App Link. Either
way the code goes into the page's URL fragment, and never into a request.
## The website and the command line
- **The website** is the JS session in a browser tab, with WebRTC as its only
route. GitHub Pages serves the page itself at `share.stan-ely.com`, and its
service worker caches nothing. So after the page loads, the only traffic is the
signalling, STUN, TURN and the peer.
- **The Node CLI** is the JS session under Node, using node-datachannel for
WebRTC. It dials LAN routes everywhere, and listens on Linux.
- **The Rust CLI** runs `qrdrop-session`, the same Rust session as the app's
native one. It is the one peer that offers QUIC today. `--no-lan` keeps either CLI's file on WebRTC, which turns off both LAN
and QUIC.
**Beam** is the exception to this whole page. It moves a file as an animated QR
code from one screen to a camera, and uses no network at all
([beam.md](beam.md)).
## Where it lives in the code
| Part | JS | Rust |
| --- | --- | --- |
| Signalling, ICE, the data channel, pairing | `src/transport/room.js` | `crates/tryst`, `crates/qrdrop-session/src/pair.rs` |
| Route classification and the TURN cap | `room.js` (`classifyPath`, `RELAYED_MAX_BYTES`) | `qrdrop-session/src/path.rs` |
| Switching a sender's frames between routes | `src/transport/channel.js` | `qrdrop-session/src/session.rs` (`at_accept`) |
| The LAN route | `src/transport/lan.js`, `src/node/lan-server.js` | `qrdrop-session/src/lan.rs`, `app/src-tauri/src/lan.rs` |
| The QUIC route | none | `qrdrop-session/src/quic.rs` |
| Sealed frames and the control stream | `src/core/` | `crates/qrdrop-protocol` |
| The app's choice of session | `app/src/main.js`, `src/web/platform.js` | `app/src-tauri/src/native.rs` |