Direct answer
Move a file across an air gap by rendering it as a stream of QR codes on the isolated machine's screen and capturing them with a phone or laptop camera. The isolated host never joins a network and never mounts removable media, and the receiving device needs nothing but a browser. Expect tens to a few hundred kilobytes per second.
Key takeaways
- Direction decides the tooling:
qrsendrenders on the isolated host and a phone or laptop camera receives; the reverse needs a camera on the isolated machine. - Throughput is arithmetic:
fps × codes per frame × packets per code × packet size. QRTP carries 368-byte large packets (364 bytes of Wirehair payload after the 4-byteblock_id) or 40-byte small packets. - At 15 fps with two 368-byte packets per frame you move roughly 11 kB/s; 60 fps with four codes and two packets each is a ceiling near 176 kB/s, and only if every frame decodes.
- Keys, certificates, configs and patches are the right cargo: a 30 MB patch bundle is minutes of screen time, a multi-gigabyte disk image is not.
- A USB stick is bidirectional, needs a host driver and adds attack surface; a screen cannot be written to, so the optical link is output-only.
- Tune reliability with
--fps,--cols,--rows,--packets-per-qrand--ecc: fewer, larger, slower frames read further.
Step 1 — decide which way the bytes go
Outbound is the natural direction: rendering frames uses only the isolated machine's own display and a qrsend binary it already has. The isolated host writes to no device, mounts nothing and opens no socket.
Inbound reverses that. The isolated machine must see the codes, so it needs a camera and the receive page in its browser. Many air-gapped hosts are headless rack machines with no camera, and no software changes that. If yours has one, the outside machine renders and the isolated machine captures; if not, optical ingress is not available and the transfer belongs on your site's approved write-once path.
Step 2 — pick the renderer and the capture device
On the isolated machine, run the CLI: ./qrsend [options] <file|->, where - reads standard input, so tar cz ./logs | ./qrsend - works without writing a file to disk first. Static builds exist for macOS, Linux and Windows with no runtime dependencies, and where policy forbids new binaries the same code ships as a WASI module. The QR Send CLI guide walks the flag groups.
The receiver is a phone or laptop with a browser and a camera. Nothing is installed: the page scans, feeds frames into QRTP and writes the file out when the transfer completes. If the moving parts are still unclear, what QR Send is covers the sender, receiver and CLI in one screen.
Step 3 — set throughput against the time you have
Everything follows from one multiplication: frame rate × codes drawn per frame × packets packed into each code × packet size. QRTP uses 368-byte large packets and 40-byte small ones, and prefixes each Wirehair block with a 4-byte block_id, so a 368-byte packet contributes 364 bytes of file payload. Squeezed frames are the compact layout that implies stream_id = 0; full frames carry explicit stream headers.
A phone camera pointed at a monitor gets tens of kilobytes per second, rising to a few hundred when the codes are large, the screen bright and the camera steady. The table is arithmetic on that formula rather than a promise: every row assumes every frame decodes.
| Cargo | Size | Screen time at the ceiling | Note |
|---|---|---|---|
| Key, certificate, config | 4 KB | Under a second | Add --loops 3 for margin |
| Screenshot or diagram | 400 KB | 3–12 s | Comfortable at 20 fps, one packet per code |
| Document or PDF | 3 MB | 20–60 s | The sweet spot for this transport |
| Patch or firmware delta | 30 MB | 2–6 minutes | Schedule a window |
| Disk image or VM | 5 GB | Hours | Use an approved pipeline instead |
For anything big, --manifest-only rehearses the transfer: it reports what the payload contains without drawing a frame.
Step 4 — run it and tune when scans fail
A conservative start for a bright monitor and a phone at arm's length, with the file looping three times so the operator is not racing a single pass:
# On the isolated machine: render the file as a looping QR stream.
./qrsend --fps 20 --cols 4 --rows 2 --packets-per-qr 1 \
--ecc H --loops 3 \
--label "hsm-fw-4.2.patch" \
--log-audit ./airgap-4.2.audit \
./hsm-fw-4.2.patch
Change one thing at a time when the receiver does not finish. The flags below trade speed for scan reliability in the same direction: bigger modules, fewer per frame, lower cadence.
| Symptom | Adjustment | Why |
|---|---|---|
| Nothing decodes | --fps 5–10, smaller --cols/--rows, --packets-per-qr 1 | Larger modules and a slower cadence give the camera time to lock on |
| Transfer stalls | Raise --packets-per-qr, then --qrs-per-frame | More payload per frame, if the camera keeps up |
| Frames occasionally missed | Raise --ecc | More error correction inside each symbol |
| Glare, dim room, projector | --invert, --fg, --bg | The rendering is monochrome; match it to the room |
| Reader across the room | Lower --fps, reduce --cols/--rows | The angular size of each module decides what resolves |
| Bounded screen time | --loops N, or --once | Operators should know when the screen stops |
Step 5 — verify what arrived
Hash both ends before the file enters anything trusted: the source on the isolated machine, the recovered file on the receiving side, and treat a mismatch as a failed transfer. Because QRTP is fountain-coded, corruption is more likely to show up as a transfer that never completes than as a file that completes with wrong bytes — the receiver recovers the original only when its linear system is satisfied — and the hash is what proves it.
--log-audit writes a record of what was sent, the artifact a change record wants, and --dump-batches-json emits the drawn batches as JSON. Keep the file in a staging directory until the hash matches.
Where this is the wrong tool
A one-way link has no back-channel: no "resend packet 4000", no progress query from the isolated side. The fountain absorbs loss, but a systematic tuning problem costs the whole pass. Multi-gigabyte images, interactive sessions and anything you would run fifty times a day belong elsewhere — and the WebRTC upgrade that makes QR Send fast on a normal network cannot help a genuinely isolated host, which has no network to negotiate over.
For several machines in one session, work like an office QR kiosk: leave the file looping and let each machine capture a full pass in turn.
FAQ
Can I use this with a machine that has no camera?
Outbound, yes: rendering needs a display and the CLI. Inbound, no — capturing needs a camera, so a headless host needs a different approved ingress path.
How long does a 100 MB file take?
At a good 90 kB/s ceiling, about 19 minutes of continuous screen time; at an ordinary 20 kB/s, closer to 85 minutes. That second number is usually where you look for another mechanism.
Is the link really one-way?
Yes. The isolated machine renders frames and nothing listens; the receiver photographs them. There is no return path, so the isolated host cannot be probed or written to.
What if the receiver keeps failing to finish?
Lower --fps first, then shrink --cols and --rows so each module is larger, then raise --ecc. Keep --packets-per-qr 1 until single-packet codes decode reliably.
Does the receiving phone need an app or an account?
No. The receive page runs in the browser, uses the camera, and needs no install, no account and no network.
Related reading
- What is QR Send? — QR Send moves files and live media between devices by streaming fountain-coded QR codes, so the transfer works with no network at all.
- QR Send CLI guide for power users — The qrsend binary renders QR streams straight in your terminal, with flags for frame rate, grid size, error correction, colours and auditing.
- One screen, many receivers: QR Send in an office — Leave a QR stream running on a screen and let anyone in the room pull the file over WiFi or optically, whenever they arrive.