Projects

Handing a file to the device next to you, with no server in between

Rustamp's phone-to-phone sharing as a page for any file up to 10 MB. A handshake that travels as a link, a phone that cannot show a code and read one at once, a reply the receiver must not answer, and what is untested.

Fileshare hands a file of up to 10 MB to a phone or computer standing next to you. Two QR scans pair the devices, and the file then goes straight from one to the other over a WebRTC data channel: no upload, no account, and no server in the path of the file. It is live at fileshare.tamasvigh.com.

The transfer itself is not new. It is the phone-to-phone track sharing from Rustamp, taken out of the player and given a page of its own: the binary handshake that fits in about a hundred bytes, the ICE candidate filter, the chunked transfer with its SHA-256 check, the camera reader, and the QR encoder and fountain code, which are Rust compiled to WebAssembly (30 KB gzipped). What changed is what it carries: any file, not a track in a playlist, and everything around it. This post is about the parts the player never needed.

In the player, both people already have the player open, so the first code can be any bytes at all. A page cannot assume that: the receiving phone may never have heard of it.

So the first code is a URL, https://fileshare.tamasvigh.com/# followed by the handshake in base64url. A phone’s own camera app recognises it and offers to open it, and the page that opens reads the handshake from the fragment, builds its answer and shows the reply code at once. On this path the receiving phone never asks for camera permission, because it never needs to read anything.

The handshake sits after the #, which browsers do not send to the server, so it appears in no request log. The page also removes it from the address bar as soon as it has read it, so that a reload or a bookmark cannot replay an offer nobody is waiting on. The cost is size. Base64 adds a third and the prefix 33 characters, so the 71 to 133 bytes the player measured become a link of 128 to 211 characters, and each public address described below adds about ten more, or 25 for IPv6. That is usually a version 9 to 11 code at error correction level M, 53 to 61 modules across, and version 9 is where the player had already pinned its binary handshake so that Firefox’s longer credentials would fit.

A phone cannot show a code and read one

The receiver answers with a code of its own, and the sender has to read it. On a phone that is awkward: the rear camera faces away from the screen, so while the screen is held up to the other device, the camera is looking the other way.

The sending phone therefore alternates, as the player’s did: five seconds showing its code, five looking for the reply, with a bar that shows when it will switch and a button to switch now. The camera runs throughout and only the screen changes, so a reply is read the moment it comes into view. A computer is the opposite case. Its webcam faces the same way as its screen, so the code stays up and the camera image sits in a strip below it, where you can see whether the reply is in frame.

Below it, not over it. The first version put the camera preview in a corner of the code, covering part of exactly what the other device’s camera was trying to read. The end-to-end test did not notice, because it copies the pixels of the code’s canvas rather than the screen. A screenshot did.

iOS Safari adds one more rule: it stops delivering frames to a <video> that is display: none. During the showing half, the camera preview is therefore not hidden but shrunk to two pixels and made transparent.

The reply the receiver must not answer

Take the laptop case from the other side: a laptop receiving, its webcam pointed at a phone that is looking for the reply. The phone’s screen shows its camera preview, and the preview shows the laptop’s own reply code. The laptop can read that. Taken for a fresh offer, it would be answered, and the pairing in progress torn down.

The handshake already says which kind it is. The flag byte after the magic carries the DTLS setup role, and an offer is always actpass, an answer active or passive. So the receiver accepts only offers, the sender accepts only answers, and the receiver does not answer the same offer twice.

A related behaviour came over from the player and went the other way. When setRemoteDescription rejects a reply, the connection stays where it was, still waiting for an answer, and the right code can be applied a second later. The player reported that rejection as a failed connection, which ends the attempt as if the two devices could not reach each other. Here a code that cannot be applied counts as a misread, and only after eight of them does the page say that the codes do not fit together.

Twenty seconds is not enough for a person

Each side gives up on a connection that never opens. The sender’s clock starts when it has read the reply, and 20 seconds is generous for two devices on one network. In the player, the receiver’s 20 seconds started when it showed its reply, before anyone had turned the other phone round and aimed it. The receiver here waits three minutes.

The bytes decide what a file is

“Any file” covers a photo, a PDF and a program. The receiver is told a name and a type by the sender and trusts neither: the kind of file is read from its first bytes. A few dozen signatures cover images, audio and video (an ftyp box is either, depending on its brand), PDF, archives, fonts and programs, a zip becomes a Word document, a spreadsheet or an EPUB by its extension, and text is text if its first 8 KB is valid UTF-8 with no NUL bytes.

That decision matters because of where the file ends up. A received file becomes a blob: URL, and a blob URL inherits the origin of the page that made it: an HTML or SVG file opened that way would run its scripts as fileshare.tamasvigh.com. So previews use only elements that execute nothing: <img>, <video>, <audio>, and textContent for text. This is also how an SVG is shown: as an image, where its scripts do not run. A download that is not a verified image, sound, video or PDF is saved as application/octet-stream, and programs and scripts carry a warning. Nothing is written to disk until Save is tapped.

The channel stays open

In the player, a transfer ended with the file. Here the data channel stays open and carries files in both directions, so once two devices have paired, either can send another without scanning again. Each transfer shows the first eight hex digits of its SHA-256 on both screens, computed by the sender over the bytes it sent and by the receiver over the bytes it got, so two people can compare them at a glance. That is a check against damage, not authentication: anyone who can show you a code can send you a file, which is why the receiver decides what to keep.

Across networks, and with none

Two devices on different networks cannot find each other alone. The page asks one public STUN server, Cloudflare’s, for each device’s address on the internet, and puts that address in the code after the local ones. With it, two phones on different networks can often get through their routers and connect directly. The price is that the code carries the device’s public address, which the player deliberately left out, and that each device now sends one request to a third party. The file still never passes through a server, because there is no TURN relay to carry it. Pairing also stays two scans: skipping the second one would need a server to carry the reply back, and the page does without one.

When a network does not allow the direct connection, and guest Wi-Fi and mobile carriers often do not, the page suggests one phone’s hotspot, and for files up to 2 MB it offers the player’s other transport: the file itself as an animated, fountain-coded QR stream, 501 bytes a frame at up to ten frames a second. A short note takes seconds, a megabyte four to eight minutes, and the page says which before anyone starts. The 2 MB cap follows from the same principle as the estimate: nobody should begin a quarter-hour scan by accident.

Two devices in one browser

The end-to-end tests open two browser contexts and give each a camera that is really a canvas. The test copies whatever code one page shows onto the other page’s camera, so everything behind the glass runs for real: the QR decoder reads the bytes, the handshake is applied, and a WebRTC connection opens between the two contexts. The tests cover a 3 MB transfer with a reply in the other direction, the camera-app link, the phone’s alternation, the QR stream down to its completion code, and the 10 MB limit.

The same test did not get as far against the live site. The hosted browser I ran it in replaces the camera stream a page receives with a grey canvas of its own, even when the page’s getUserMedia has been swapped out, so the live check stops at the sender drawing its code under the production content security policy, with nothing in the console.

Before any camera is involved the page loads about 59 KB compressed, the Rust core included. The QR decoder, 429 KB, arrives only when a camera opens.

What is untested

Real devices. No phone has run this yet: not the camera-app link on an iPhone, not the alternation in someone’s hands, not iOS Safari or Firefox as the receiver. Everything above ran in Chromium.

Real networks. The STUN path has not yet connected two devices on different networks, and client isolation on guest Wi-Fi has not been seen to end, as it should, in the message that the devices could not reach each other, with the QR stream offered.

QR stream density. Version 15 at level L is the player’s figure, derived from how many camera pixels a module needs rather than measured on phones.

The sender needs a camera. A computer without one can send only as a QR stream, and so only files up to 2 MB.