Skip to main content

qrdrop: what 'no server' forces you to build

·1896 words·9 mins

“No server” sounds like a feature you’d put in a bullet list. It isn’t one. It’s a constraint, and constraints don’t hand you good decisions — they just remove the bad ones that would otherwise have been easiest. qrdrop is a file-transfer tool (npm i -g qrdrop, or just try it at share.stan-ely.com) built around exactly one rule: nothing in between the two devices ever holds a readable copy of the file. There’s no account, no upload, no backend that could see the bytes even if it wanted to. Everything below is what that rule cost, in the order the costs showed up.

One real transfer, filmed from both ends — not staged, not sped up.

No account, so the QR has to be the entire credential
#

Start from the constraint: there’s no server to register a device with, so there’s nothing to log into. Two devices in the same room have to recognize each other using only what passes between them, and the one channel a network attacker can’t stand in the middle of is a physical QR scan — a phone’s camera pointed at a laptop screen.

So the QR has to carry a credential, not a pointer to one. qrdrop’s QR is 32 bytes of CSPRNG output, full stop. Everything the protocol needs — the room the peers meet on, the password that encrypts their signalling, the session keys, even the words they’ll read aloud to each other — is derived from those 32 bytes with HKDF. There’s no identity behind the code, no account it belongs to. That’s also the honest limit of what it protects: anyone with the code can join. It’s the entire credential. Show the QR to a person, not to a room.

The room ID can’t be the secret — a public relay is watching
#

The obvious next move, once you have a 32-byte secret, is to use it directly as the room name two peers meet on. It’s the shortcut a first draft reaches for, and it works — the two peers show up in the same place, first try, every time you test it.

It’s also wrong, because “no server” means signalling has to happen somewhere public. qrdrop’s peers announce over Nostr relays and WebTorrent trackers — networks neither peer controls and neither trusts — and a room name on a public relay is not private. If the room ID were the secret, every relay operator watching announcements would have your decryption key the moment pairing started, for free, before a single file byte moved.

The fix is that the room ID is derived from the secret (HKDF(secret, "topic")), never the secret itself. A relay sees a room name it can’t invert back to the QR contents. This is the kind of bug that a demo doesn’t catch — the transfer completes, the file arrives, everything looks fine — because publishing the key to a room name doesn’t fail the transfer, it just fails the promise you made about it in the README.

No server to vouch for either side, so a human has to
#

A server, if there were one, could do identity checking for you — you’d log in, and the server would tell each side who the other one was. There isn’t one here, so nothing can attest that the peer on the other end of the WebRTC data channel is actually the phone that scanned the code, rather than something between them. WebRTC signalling gets a password derived from the secret too (HKDF(secret, "signal")), which stops an observer from substituting their own session description in transit — the textbook man-in-the-middle on WebRTC signalling, and the specific thing the QR exists to prevent. But that’s defense against a network attacker forging the connection; it isn’t proof the two humans looking at their screens agree on what got connected.

For that, qrdrop falls back to the oldest trust anchor there is: a person. Once the data channel is up, both screens derive and display four emoji — the same four, if the pairing is genuine — and a human reads them aloud and matches them by eye. The words under the emoji are what a screen reader speaks, because those are the words a person actually says out loud to their peer; the emoji tiles are decoration on top of that.

The sender confirms this SAS before the file manifest goes out, because the manifest alone discloses filename and size to whoever’s on the other end of that channel. This is the gesture the project treats as non-negotiable: --yes on the CLI skips the receiver’s accept prompt, and cannot skip the SAS confirmation. A flag that skipped it would be a vulnerability wearing convenience’s clothes — the SAS is the whole defense against a successful man-in-the-middle, and there’s no server anywhere in this picture that could do that job instead.

Bytes cross a relay you don’t control, so they’re sealed twice
#

Direct peer-to-peer doesn’t always work out. When NAT traversal fails, WebRTC falls back to a TURN relay — in qrdrop’s case, the Open Relay Project’s public, shared, metered infrastructure, because there’s no server of its own to run one. That relay sits between the two devices and forwards every packet. It should not be able to read any of them.

DTLS already encrypts the WebRTC data channel itself, terminating at each peer’s browser — but “no server” also means qrdrop can’t fully trust the signalling layer underneath that connection, so the file bytes get sealed a second time, independently, with an ephemeral ECDH key exchanged directly between the two peers and used to derive AES-GCM keys per direction. Two layers that fail independently: if Trystero’s signalling encryption were ever broken outright, an attacker still needs the QR secret to derive the session key, because the ECDH uses that secret as its HKDF salt. A TURN relay operator, in the worst case, sees ciphertext and byte counts. Never contents.

The load-bearing word in all of that is ephemeral — a keypair generated fresh, per session, and thrown away with the room, which is what gives a leaked code no power over a transfer that already finished. It rests on one line of code: the keypair is created inside joinVia, per call, never hoisted to module scope. Hoisting it looks like an obvious optimization — one keypair generation instead of two, since openRoom races two signalling strategies and only needs one to win — and it would silently turn every transfer a running process ever makes into one long session with forward secrecy quietly gone. test/room.test.mjs pairs twice over the same secret against a fake in-memory strategy and asserts the second session can’t decrypt the first’s traffic, specifically so that regression fails in two seconds locally rather than surviving to a release.

Sequence diagram of the WebRTC path. The sender generates 32 random bytes and shows them as a QR code, which the receiver scans; the room ID, signalling password, session keys and the SAS all derive from those bytes. Both peers announce on every signalling network at once, exchange session descriptions encrypted under the derived password, and open a WebRTC data channel. An ephemeral ECDH gives one key per direction; four emoji are read aloud and matched by hand; only then does the manifest go out, the receiver accepts, and the file chunks follow, each one sealed. Sequence diagram of the WebRTC path. The sender generates 32 random bytes and shows them as a QR code, which the receiver scans; the room ID, signalling password, session keys and the SAS all derive from those bytes. Both peers announce on every signalling network at once, exchange session descriptions encrypted under the derived password, and open a WebRTC data channel. An ephemeral ECDH gives one key per direction; four emoji are read aloud and matched by hand; only then does the manifest go out, the receiver accepts, and the file chunks follow, each one sealed.

No network at all means no transfer — unless the screen is the network
#

Every decision above assumes a network exists, even a hostile, public, unreliable one. Take that away — an air-gapped machine — and there’s genuinely nothing left: no relay to announce on, no TURN to fall back to, no data channel to open. The constraint doesn’t relax here; it just changes shape. If there’s no network, the only channel left is the screen itself.

That’s what Beam is: the sender animates QR codes on screen at roughly ten frames a second, and the receiver’s camera reads them, with no wire, no radio, and no signalling of any kind between the two devices. The payload is fountain-coded rather than a numbered loop, because a numbered loop turns a missed frame into a coupon-collector problem — waiting for the specific frame you missed, out of a loop you have to sit through again. A fountain frame doesn’t care which frames were missed, only how many arrived, which is why the difference between the two grows sharply as frame loss climbs (measured over 1,748 blocks of a 1 MiB payload): 1.05× N frames needed at 0% loss versus 1.00× N for a numbered loop, but 2.08× N versus 10.7× N at 30% loss.

Sequence diagram of beam mode. The sender screen emits about ten QR frames a second, roughly 600 bytes each, every one an LT-coded block, to the receiver camera. The manifest is woven in every 20 frames so a receiver can join mid-stream. There is no back channel at all: the sender cannot tell how the receiver is doing except by looking at it. Sequence diagram of beam mode. The sender screen emits about ten QR frames a second, roughly 600 bytes each, every one an LT-coded block, to the receiver camera. The manifest is woven in every 20 frames so a receiver can join mid-stream. There is no back channel at all: the sender cannot tell how the receiver is doing except by looking at it.

And here is where the constraint stops being convenient and just states its own limit plainly: Beam is not encrypted, and it cannot be. No network means no handshake, which means no key agreement, no forward secrecy, and no SAS — there’s no peer to authenticate over a channel of light, only photons, and a key shown on the same screen as the payload protects nothing. Both beam screens say so directly. It’s offered as the alternative to a USB stick on an air-gapped machine, not as a private channel, and the project is explicit that everything it claims about confidentiality elsewhere in the document stops at Beam’s edge.

The constraint is what made it worth building
#

None of this reads like a feature list, and that’s the point. A server would have made every one of these problems disappear — an account instead of a 32-byte credential, a private room name instead of a derived one, a trusted third party instead of four emoji read aloud, TLS to a backend instead of a second encryption layer over a public relay. Removing the server didn’t remove the problems; it just moved every one of them onto the two devices and the person standing between them, which is exactly where a network attacker can’t reach the contents.

The honest caveats stay attached rather than getting rounded off: TURN is free, shared, and capped at 100 MB for exactly that reason; Firefox and Safari buffer whole files in memory before saving, so practical transfers there cap out well short of what Chromium can stream to disk; the page serving the code could itself be compromised, and no in-browser design stops that — it’s mitigated by a strict CSP and shipped source maps so the claims can be checked against what’s actually running, not eliminated. None of that is dressed up, because the constraint that produced the interesting decisions is the same one that produces the limits.

That distinction is not academic. A report came in from the field — Out-of-order frame: expected 0, got 13877, on a transfer under a megabyte — and behind it was a stranger in the rendezvous room ending a live transfer with fourteen bytes of well-formed cleartext. Frame headers were being read for type and sequence before the AES-GCM tag was checked, and inbound frames weren’t filtered to the paired peer, so one packet from someone who held no key and never paired was enough to make the receiver abort and throw away the partial file. Nothing was readable, forgeable or alterable; what was lost was availability, which is still a real thing to lose. Frames are authenticated before their headers are trusted now, and one that fails its tag is dropped and counted rather than treated as a fault in the transfer.

The part worth keeping isn’t the fix. It’s that the report was legible as a bug at all. A stray packet only reads as an attack if the document already says who was supposed to be able to send one.

If you want to see whether it holds up: open share.stan-ely.com on a laptop, drop a file onto it, and point a phone’s camera at the code that appears. Watch the four words. Then check that they matched.