How to transfer files to and from air-gapped machines

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

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.

CargoSizeScreen time at the ceilingNote
Key, certificate, config4 KBUnder a secondAdd --loops 3 for margin
Screenshot or diagram400 KB3–12 sComfortable at 20 fps, one packet per code
Document or PDF3 MB20–60 sThe sweet spot for this transport
Patch or firmware delta30 MB2–6 minutesSchedule a window
Disk image or VM5 GBHoursUse 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.

SymptomAdjustmentWhy
Nothing decodes--fps 5–10, smaller --cols/--rows, --packets-per-qr 1Larger modules and a slower cadence give the camera time to lock on
Transfer stallsRaise --packets-per-qr, then --qrs-per-frameMore payload per frame, if the camera keeps up
Frames occasionally missedRaise --eccMore error correction inside each symbol
Glare, dim room, projector--invert, --fg, --bgThe rendering is monochrome; match it to the room
Reader across the roomLower --fps, reduce --cols/--rowsThe angular size of each module decides what resolves
Bounded screen time--loops N, or --onceOperators 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

References