# Protocol
How qrdrop pairs two devices, derives its keys, and moves a file, and the
design decisions behind each step. The [README](../README.md#how-it-works) has
the short version; the [threat model](threat-model.md) says what all of this
does and does not protect. [Networking, end to end](networking.md) follows a
transfer across the network, and says which route carries the file.
## Pairing and keys
Two devices in the same room. One shows a QR code, the other points a camera at
it, and that scan is the one channel an attacker cannot stand in the middle of.
The QR carries 32 bytes of CSPRNG output. Everything else derives from it:
| Derivation | Purpose |
| --- | --- |
| `HKDF(secret, "topic")` | Trystero room ID — what peers meet on |
| `HKDF(secret, "signal")` | Trystero `password`, encrypting session descriptions |
| `HKDF(ECDH, salt=secret, "host->guest")` | file bytes, sender to receiver |
| `HKDF(ECDH, salt=secret, "guest->host")` | file bytes, the other way |
| `HKDF(ECDH, salt=secret, "sas")` | the four emoji shown on both screens |
The room ID is derived rather than being the secret itself. Using the secret as
the room name is the obvious shortcut, works perfectly in testing, and silently
publishes your key to every relay on the network.
### Why `password` is not optional
Trystero owns the session descriptions, so we cannot seal the SDP ourselves.
`password` is what replaces that. Without it Trystero derives its key from the
app ID and room name — both of which any relay observer already has — which
would leave the DTLS fingerprint substitutable in transit. That substitution is
the textbook man-in-the-middle on WebRTC signalling, and it is the thing the QR
code exists to prevent.
### Why file chunks are encrypted again on top
Two layers that fail independently. Ephemeral ECDH per session gives forward
secrecy — a code photographed later cannot decrypt a transfer that already
happened — and it uses the QR secret as its HKDF salt, so even if Trystero's
signalling encryption were broken outright an attacker would still need the code
to derive the session key.
"Per session" is the load-bearing word, and it rests on one line: the keypair is
generated inside `joinVia`, per call, and thrown away with the room. Since
`openRoom` races two signalling networks at once, one pairing generates two
keypairs and discards one — which makes lifting that line to module scope look
like an easy saving and would quietly turn every transfer a process ever made
into one long session. `test/room.test.mjs` pairs twice over the same secret
against an in-memory signalling strategy and asserts the second session cannot
decrypt the first's traffic, so that edit fails a test in two seconds rather than
surviving to a release.
DTLS also terminates at the peer's browser, and when NAT traversal fails packets
pass through a third-party TURN relay. Sealing each chunk ourselves means a
relay operator sees ciphertext and byte counts, never contents.
Nonces are unique by counting rather than by chance — `fileSeq || chunkIndex`,
with a separate key per direction — because AES-GCM does not degrade gracefully
under nonce reuse. The end-of-file flag is authenticated, so an attacker who
stops forwarding frames cannot pass a truncated file off as complete.
### Two gestures, and why neither is decorative
The sender confirms the four-emoji SAS before a manifest goes out — the manifest
alone would disclose the filename and size. The receiver accepts, which is also
the click that permits `showSaveFilePicker` to open, and that is what lets large
files stream to disk instead of accumulating in memory.
> [!IMPORTANT]
> Both gestures survive into the CLI as stdin prompts. `--yes` skips the accept
> prompt and **cannot** skip the SAS confirmation: the SAS is the entire
> man-in-the-middle defence, so a flag that skipped it would be a vulnerability
> wearing a convenience's clothes.
## Three faces, one protocol
```js
import { openRoom, sendFile, createReceiver } from 'qrdrop' // isomorphic
import { defineQRDrop, fromFile } from 'qrdrop/web' // browser
import { fromPath, createFileSink } from 'qrdrop/node' // Node
```
The `qrdrop` entry is the protocol and the transport, and it touches neither a
DOM nor an `fs`. That is enforced rather than asserted: `src/core/` and
`src/transport/` are typechecked twice, once with `types: []` and no Node lib
and once with Node's globals, so a stray `Buffer` in code destined for a browser
fails the build instead of throwing at runtime.
What is deliberately *not* in that entry is anything that knows where bytes come
from or go to. `sendFile` takes a `FileSource`, `createReceiver` takes a
`createSink`, and each runtime supplies its own. That is the whole trick behind
having a CLI at all.
### Drop the UI into a page
```html
```
A custom element with its own shadow root, so it brings its styles with it and
collides with nothing. No framework, and no framework adapter to keep up to
date — every framework already renders a custom element.
### Run the browser UI locally
`npx qrdrop web` serves the same browser UI that [share.stan-ely.com](https://share.stan-ely.com)
deploys, from the package you just installed, on `http://127.0.0.1:4173` — nothing
is uploaded and no other device can reach it. It is the way to use the browser
flow while running code you can read first.
| Flag | |
| --- | --- |
| `--port ` | changes the port; `0` picks a free one |
| `--no-open` | prints the URL instead of opening a browser |
| `Ctrl-C` | stops it |
Loopback only, by design: `http://` is not a secure context, so
WebCrypto and the camera would fail there.
To host the built bundle yourself, see [hosting.md](hosting.md).
## Design notes
### The transport seam
Everything in `src/core/` is written against one interface and nothing else:
`Channel` in `types/qrdrop.d.ts`. Five members — `send`, `bufferedAmount`,
`bufferedAmountLowThreshold`, and the `addEventListener` / `removeEventListener`
pair.
That seam is why replacing the entire signalling layer — hand-rolled Nostr plus
WebRTC negotiation, for Trystero — cost 11 lines across all of the transfer code
and nothing at all in the framing, session, control, digest, or sink modules.
The security core was untouched by a total rewrite beneath it.
> [!IMPORTANT]
> The one subtlety worth knowing before writing another transport:
> **backpressure may be signalled either way, but it must be signalled.** A
> transport can defer the promise returned by `send`, or it can report
> `bufferedAmount` and fire `bufferedamountlow` — Trystero does the former, a
> raw `RTCDataChannel` the latter. A transport that does neither will let a
> large file queue entirely into memory and take the tab down.
> `test/channel.test.mjs` runs a full sealed transfer over a channel with
> exactly those five members and nothing else, so a new transport finds out
> what it is missing there rather than against a live relay.
More than one signalling network, raced
Trystero ships a package per strategy behind an identical `joinRoom` interface,
so `src/transport/room.js` lists them in `STRATEGIES` — Nostr relays and
WebTorrent trackers today — and `openRoom` joins all of them at once, pairs on
whichever completes the handshake first, and tears the rest down. Both peers are
present on every network simultaneously, so no agreement on *which* network is
needed; a sequential fallback could not promise that. The tracker strategy
shares Trystero's core and costs ~2 kB gzipped; `/mqtt` was measured at ~112 kB
and left out, `/ws-relay` would mean running a server. Adding a strategy is one
entry in `STRATEGIES` and its URL list — the CSP follows automatically, because
`scripts/build-site.mjs` generates `connect-src` from `SIGNALING_URLS` (every
strategy's URLs, reduced to origins) rather than a second list kept in step by
hand.
Relay choice was measured, not assumed
The best-known Nostr relays — `relay.damus.io`, `relay.nostr.band`,
`relay.snort.social` — were all unreachable when the list was built. The seven
in `room.js` were picked by connecting to every relay in Trystero's pool and
keeping the ones that answered. The tracker list is seeded from
`@trystero-p2p/torrent`'s defaults and has not had the same publish-test
scrutiny yet.
**Measure by publishing, not by connecting.** `relay.nostr.place` was dropped
after it began demanding proof-of-work (NIP-13) on writes. It still accepts
connections and still answers reads, so a connectivity probe calls it healthy —
it just cannot be used to announce a peer, which is the only thing a relay is
needed for here. A socket that opens is not a relay that works.
The local-network fast path, and the one moment it can switch
A WebRTC data channel levels off at 14–17 MB/s even on one network. That limit
comes from SCTP's congestion control, and nothing in JavaScript can change it.
A WebSocket between the same two devices runs over the OS's TCP. It measured
about 4× the data channel in a network namespace and about 6× on a phone over
Wi-Fi; the figures are in `app/CAPABILITIES.md`. So when both ends are native
(the CLI or the app), `src/transport/lan.js` moves the sender's frames onto a
direct WebSocket. The website never does this: a page cannot accept a
connection, and only the app registers the platform hook.
- **The frames are the same frames.** They go into the same handler, face the
same tag check and ordering checks, and a frame that fails its tag is
counted and dropped just as before. The socket makes the transfer faster; it
is not trusted any more than WebRTC is.
- **Nothing is said before the SAS.** The sender's `lan-hello` goes out after
its confirm, and the receiver speaks only in reply. The listener's
`lan-offer` names its addresses and a 16-byte token, sealed like every other
control message. The side that dials in presents the token first, and the
listener takes exactly one connection that does, then stops listening.
- **The listener proves itself back.** The offer also carries a 16-byte
`answer`, random and independent of the token. A dialler that has one sends
the token followed by the byte `0x01`, and takes the link only if the reply
is the answer. Without it, anyone on the path who took the connection could
reply with the one-byte ACK the protocol used to end on, become the link,
and stall the transfer, since LAN wins over every other path. An answer
derived from the token would not stop them, because the dialler has already
sent them the token. An offer without an `answer` comes from an older
listener, and there the dialler accepts ACK. An older dialler sends the bare
token and is answered ACK. Since the offer is sealed, nobody else can strip
the answer to force the older exchange.
- **A link carries the file only once a frame over it passes its tag.** A
receiver that loses a link it believes the file is on fails the transfer,
so counting from the first message would let one junk frame and a close end
a transfer that was arriving whole by another way.
- **Only a quiet side listens.** Listening on Windows, and for an unsigned
macOS app, raises an OS firewall prompt. Each hello says whether it would,
and when neither side can listen quietly, the transfer stays on WebRTC.
- **The switch happens at Accept, and only there.** A frame on a second
channel can overtake one still in flight on the first, and an authenticated
frame that arrives out of order is fatal. When the sender reads the peer's
accept, every frame it has sent over WebRTC is one the receiver has provably
handled. From that moment every frame this side sends goes over the socket,
and the receiver's replies never move. `sendFile`'s `onAccept` is the hook.
- **Older builds are unaffected.** They drop both messages as unknown types.
The receiver's hello is queued ahead of its Accept, so a sender that sees
Accept without a hello knows at once that the peer is an older build, and
never waits.
The app listens through a relay in its own Rust process (`src-tauri/src/lan.rs`),
which its page reaches over `127.0.0.1`. It uses a relay rather than IPC
because, on Android, IPC carries bytes as base64 inside JSON. `--no-lan` turns
the fast path off for the CLI.
The QUIC path, when both ends run the Rust session
When both ends run the Rust session (the Rust CLI today), the sender's frames
can move onto a direct QUIC connection instead. Over a clean round trip it
measured four to five times the WebRTC data channel, whose SCTP receive
window caps it at about 1 MiB per round trip, and five to thirty-five times
under packet loss. The numbers are in `app/CAPABILITIES.md`.
It is the local-network path's design on another transport, and every rule
above holds for it:
- **Nothing is said before the SAS.** The addresses and the certificate
fingerprint travel in sealed control messages (`quic-hello`, then
`quic-offer`) after the confirm, never in the signalling. The signalling is
sealed only with the code's password, so anyone holding the code could read
it and race the real peer to the address.
- **The connection is pinned.** Each end accepts exactly the certificate the
other one named, and checks the TLS signature against it. The frames on it
are the same sealed frames, as untrusted as on WebRTC.
- **It crosses NATs without a relay**, through the common kind that maps a
socket to one public port whoever it sends to (measured behind two).
Both sides send first, which opens each NAT towards the other. A pair that
cannot connect stays on WebRTC, TURN included, after waiting at most three
seconds at Accept.
- **The switch happens at Accept, and only there.** A browser or the Node CLI
ignores the sender's `quic-hello` as an unknown type and never answers, so
the sender knows at Accept that the path is not there and does not wait.
- **The local-network path wins when both are up.** On a phone sending over
Wi-Fi, the LAN path's kernel TCP measured about half as fast again as QUIC.
QUIC carries the file where LAN cannot: between networks, or when neither
end can listen quietly.
`--no-lan` keeps the Rust CLI's file on WebRTC, which turns this path off too.
**The confidentiality path uses WebCrypto only** — P-256, HKDF, AES-GCM, no
third-party code. Trystero sits below that boundary: it protects signalling, but
a compromise there could not read a file byte.
### Sources and sinks
The mirror-image pair that make one protocol serve three runtimes.
`FileSource` is three fields and a range read; `Sink` is a name, a `write`, a
`close`, and an `abort`. `sendFile` and `createReceiver` know nothing else about
where bytes live. The browser supplies File System Access with a Blob fallback;
Node supplies `fs`; the tests supply arrays.
`createSink` is a *required* argument to `createReceiver` rather than a
defaulted one. Defaulting it to the browser implementation is what quietly made
the protocol layer depend on the DOM in the first place, and making every call
site answer the question out loud is what stopped it.
A sink's `createSink` has two ways not to hand back a sink, and they mean
different things. It **resolves `null`** when the person chose not to save — a
dismissed dialog — which declines the file. It **rejects** only when saving
failed, and that error is shown on the receiving screen and sent to the sender as
itself. They used to be one path, every rejection read as a dismissal, and that is
how the Android app spent a release unable to receive while telling people they had
closed the save dialog. `createReceiver` also returns `cancel()`, which aborts the
open sink so an abandoned receive does not keep a partial file, and `settled()`,
which resolves once every frame already handed to the receiver has been processed —
wait on it before treating the other side's departure as a failure, or a `done`
still being decrypted loses the race to the leave behind it. A paired room's
`close()` returns a promise that settles once the peer has been told and never
rejects; a process about to exit should await it.
### Text is a file
Sending text adds nothing to the wire. The web UI wraps what was typed as a `File`
called `message.txt`, of type `text/plain;charset=utf-8`, and sends it like any other
file. The SAS is confirmed before its manifest goes out, and the receiver answers it
with Accept. A receiving web UI treats any offer that is `text/plain` and 64 KiB or
smaller as text (`src/web/text.js`). It hands `createReceiver` a sink that keeps the
bytes in memory instead of opening a save dialog. Once the digest has checked out, it
shows them as a text node with Copy and Save as file. A peer that has never heard of
this (either CLI, the app's native session, an older page) sees a small text file and
saves it, so neither side has to know what the other runs.
The type and the size are the sender's claims, like everything in a manifest. Showing
the text is safe whatever it says, because the view has no `innerHTML` path. The size
bound is also enforced in the sink, not trusted from the manifest.