How qrdrop pairs two devices, derives its keys, and moves a file, and the design decisions behind each step. The README has the short version; the threat model says what all of this does and does not protect.
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.
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.
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.
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.
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.
<script type="module">
import { defineQRDrop } from 'qrdrop/web'
defineQRDrop()
</script>
<qr-drop></qr-drop>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.
npx qrdrop web serves the same browser UI that 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 <n> |
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://<lan-ip> is not a secure context, so
WebCrypto and the camera would fail there.
To host the built bundle yourself, see hosting.md.
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-hellogoes out after its confirm, and the receiver speaks only in reply. The listener'slan-offernames 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. - 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'sonAcceptis 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 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.
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.