Metadata-Version: 2.4
Name: qrtransfert
Version: 2.0.2
Summary: Transfer a single file over a screen-to-camera link, using QR codes
Keywords: qrcode,qr,transfer,file,air-gap,fountain-code,pentesting
Author: Nyxko
Requires-Python: >=3.11
Description-Content-Type: text/markdown
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Intended Audience :: System Administrators
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Communications :: File Sharing
License-File: LICENSE
Requires-Dist: click~=8.4
Requires-Dist: numpy~=2.3
Requires-Dist: opencv-python-headless~=5.0
Requires-Dist: pillow~=12.3
Requires-Dist: segno~=1.6
Requires-Dist: tqdm~=4.70
Requires-Dist: zxing-cpp~=3.1
Requires-Dist: cryptography~=50.0 ; extra == "crypto"
Requires-Dist: cryptography~=50.0 ; extra == "dev"
Requires-Dist: flit_core~=3.12 ; extra == "dev"
Requires-Dist: mypy~=2.3 ; extra == "dev"
Requires-Dist: pytest~=9.1 ; extra == "dev"
Requires-Dist: pytest-cov~=7.1 ; extra == "dev"
Requires-Dist: ruff~=0.16 ; extra == "dev"
Project-URL: Homepage, https://github.com/Nyxko/qrtransfert
Provides-Extra: crypto
Provides-Extra: dev

# QRTransfert

Transfer a single file over a screen-to-camera link, using QR codes. No
network, no pairing, no shared storage — one machine displays an animated QR
stream, another decodes a video of it.

## Requirements

Python 3.11 or newer.

## Installing

```bash
pip3 install qrtransfert

# with passphrase encryption support
pip3 install 'qrtransfert[crypto]'
```

## Quick start

On the sending machine:

```bash
qrtransfert encode -f report.pdf
```

Film the window with a phone or a webcam, then on the receiving machine:

```bash
qrtransfert decode -f capture.mp4 -d ./received
```

The decoder stops as soon as it has enough frames, verifies the payload
against its SHA-256, and writes the file.

If one capture is not enough, film the same session again and pass both —
they combine rather than starting over:

```bash
qrtransfert decode -f first.mp4 -f second.mp4 -d ./received
qrtransfert info -f first.mp4          # what is in a capture, and why it failed
```

`python -m qrtransfert` works too, when the console script is not on PATH.

## How it works

A screen-to-camera link has no back-channel: the receiver cannot ask for a
retransmission, and it *will* miss frames to motion blur, autofocus and
refresh straddling.

The obvious design — chunk N lives in frame N, loop forever — turns that into
the coupon-collector problem. Capturing K distinct frames out of a loop of K
takes about `K·ln(K)` frame times, so a 1 MiB file that displays in 20 minutes
takes hours to actually capture.

QRTransfert uses a **systematic LT fountain code** instead. The first K frames
carry the source blocks verbatim; every frame after that is the XOR of a
pseudorandom subset of them, with the subset derived deterministically from
the frame's sequence number. The receiver rebuilds the file from *any* ~K
distinct frames, in any order:

Transferring 1 MiB at QR version 20 with `--loop-delay 0.6`:

| | frames | clean capture | with 30% frame loss |
|---|---|---|---|
| indexed loop (v1) | 2112 | 21 min | 134 min (6.4 passes) |
| systematic fountain (v2) | 1255 | 12.6 min | 18.8 min |

The frame count drops by 1.7× because the payload is raw bytes rather than
base64 (+33%) and the error correction is L rather than M (+29%). The rest of
the gain is structural: under loss, an indexed loop needs enough passes for
*every* frame to land at least once, while a fountain just needs enough
frames to land at all.

Every frame carries a 22-byte self-describing header, so the receiver can lock
onto a stream mid-flight, and restarting the sender resets it automatically.
Filename, media type, original length, SHA-256, optional gzip and optional
encryption live in a container that travels as a unit — there is no way to end
up with the bytes but not the filename.

The media type is recorded (inferred from the extension, or `--media-type`)
and also decides whether gzip is attempted at all: a `.zip` or a `.jpg` is
already entropy-coded, so the compression pass is skipped rather than spent
learning what the type already said.

The extension is resolved from a table shipped with the package, not from the
host's mime database. `mimetypes` answers differently depending on the machine
— a bare `python:3.13-slim` image does not know `.docx`, `.7z` or `.gz`, a
desktop with `media-types` installed does — and the sender and receiver are
different machines by construction, so the label a file arrives with must not
depend on who sent it. Anything outside the table still falls back to
`mimetypes`.

The original length is in the container so decompression can be bounded. A
gzip payload arrives over the same untrusted channel as everything else, so
the length it declares is a hint that lets us stop early, never a bound to
trust — the check is on bytes actually produced. Without it, 100 KB of crafted
stream inflates to gigabytes; with it, that payload is refused after 0.1 MB.

For the details, see the module docstrings in `src/qrtransfert/protocol/`.

## Throughput

Per-frame cost, one core:

| QR version | payload/frame | generate | decode | generate-bound ceiling |
|---|---|---|---|---|
| 10 | 249 B | 2.4 ms | 3.1 ms | 103 KB/s |
| 20 | 836 B | 6.9 ms | 3.5 ms | 119 KB/s |
| 40 | 2931 B | 23.2 ms | 5.6 ms | 123 KB/s |

Generation is the binding constraint, not decoding: a version 40 symbol takes
23 ms to build and 5.6 ms to read. The ceiling is what one core can draw, so
the actual rate is `payload/frame ÷ --loop-delay`, capped there.

`--loop-delay` is the frame *period*, with generation counted inside it, so a
requested rate is the achieved rate until the work alone exceeds the period.
When it does, `encode` says how many frames ran late rather than quietly
halving the frame rate.

Higher versions carry more per frame but need a better camera and a sharper
screen. Version 20 is the default because it decodes reliably from a phone at
arm's length; version 40 needs a good lens and a steady hand.

Maximum transfer size is about 183 MiB (65535 blocks × 2931 bytes at version
40). Oversized transfers are refused up front with the parameters to change.

## Privacy

**Nothing is transmitted anywhere except as light.** No account, no pairing,
no network path between the two machines.

**By default the channel is not confidential.** Whatever is on the sending
screen is readable by *any* camera pointed at it, and the decoder is this same
public tool. The property you get by default is *no network*, not secrecy;
`encode` prints a reminder saying so.

When that matters, encrypt the payload:

```bash
qrtransfert encode -f secrets.tar -p 'correct horse battery staple'
qrtransfert decode -f capture.mp4 -p 'correct horse battery staple'
```

This is AES-256-GCM under a scrypt-derived key (n=2¹⁵). A bystander's camera
then sees only ciphertext. GCM is authenticated, so a wrong passphrase and a
tampered stream are both rejected rather than yielding plausible garbage. What
it does *not* hide: that a transfer is happening, and roughly how large it is.

**Integrity is always checked.** Every received file is verified against the
SHA-256 recorded by the sender before anything is written; a corrupted capture
fails loudly instead of handing over damaged bytes.

**Received filenames are untrusted.** The name arrives inside the stream, so
it is reduced to a safe basename and written inside `--output-dir`. A stream
claiming to be `../../.ssh/authorized_keys` lands as `.ssh_authorized_keys` in
the directory you named, and the sanitisation is reported.

## Usage

### encode

```
Usage: qrtransfert encode [OPTIONS]

  Display a file as a stream of fountain-coded QR codes.

Options:
  -f PATH                      Path of the file to transfer  [required]
  -o, --out-to-file FILE       Write a video file instead of displaying
  -t, --terminal               Display in the terminal instead of a window
  -c, --qr-color               Pack 3 frames per image (or terminal cell)
  -qv, --qr-version INTEGER    QR code version  [1<=x<=40; default: 20]
  -qcor, --qr-correction [L|M|Q|H]
                               QR error correction level  [default: L]
  -r, --redundancy FLOAT       Frames to emit per source block  [default: 3.0]
  --no-compress                Skip gzip even when it would shrink the payload
  -p, --passphrase TEXT        Encrypt the payload (AES-256-GCM)
  -l, --loop-delay FLOAT       Seconds each frame stays on screen  [default: 0.15]
  -ww, --window-width INTEGER  [default: 600]
  -wh, --window-height INTEGER [default: 600]
  --qr-size INTEGER            Rendered symbol size in pixels  [default: 1050]
  --media-type TEXT            Media type to record (default: from the extension)
  --target-loss FLOAT          Expected frame loss in %, derives --redundancy
  -j, --jobs INTEGER           Processes building symbols; 0 uses every core
```

Symbol building is the only real throughput ceiling — 23 ms per version 40
frame against 5.6 ms to decode one. `--jobs` spreads it across cores: 3.6x on
four, taking version 40 from 45 to 161 frames per second. Frames are
independent by construction, so output is identical whatever `--jobs` is.

While displaying, both the window and the terminal show where you are in the
current pass:

```
frame 49/72  [██████████████░░░░░░]  68%  ~3s left in pass
Ctrl-C to stop. Pass 1 shows the whole file; later passes only add margin.
```

**One pass is the whole file, once.** The code is systematic, so a pass is K
frames — every byte on screen exactly once — and a clean capture is complete
the moment the bar reaches 100%. It is not sized on `--redundancy`, which
only bounds the length of a written video file.

The counter is a position *within* a pass, not a countdown to the end: the
sender never stops. Passes after the first are pure margin for a lossy
capture, and they are not replays — every frame emitted is new fountain
output. `pass 2` appearing means the first pass was not enough, which is
information about your capture, not about the file. In colour mode the count
is in displayed images, three fountain frames each.

`--redundancy` only bounds the length of a written video file; the interactive
sinks stream endlessly. `--target-loss` sets it from the table below instead
of guessing: `--target-loss 40` picks 3.0x. Measured completion rate at K=128:

| redundancy | 0% loss | 10% | 20% | 30% | 40% | 50% |
|---|---|---|---|---|---|---|
| 1.6× | 100% | 74% | 26% | 1% | 0% | 0% |
| 2.0× | 100% | 95% | 85% | 61% | 13% | 0% |
| 2.5× | 100% | 99% | 98% | 97% | 89% | 35% |
| 3.0× | 100% | 100% | 100% | 100% | 98% | 91% |

`--qr-color` packs three symbols into the R, G and B channels. It triples the
payload per displayed frame, and it works through a phone camera: the decoder
undoes the channel bleed rather than hoping there is none (see below). Filming
a version 20 colour stream at 1080p, a 7.8 s capture yields 197 symbols and
rebuilds a 34 KB file from 50 of them.

`--terminal` and `--qr-color` combine. In the terminal each cell carries all
three channels at once, so the symbol occupies exactly the same space and
carries three times the payload. Only the eight corners of the RGB cube are
ever emitted, and always as 24-bit escapes rather than the basic palette,
which terminal themes remap.

A symbol taller than the window makes the terminal scroll, which pushes the
top of the symbol — finder patterns included — off screen, and nothing
decodes. `encode --terminal` therefore measures the window and picks a
version that fits, saying so:

```
Terminal is 80x24: using QR version 5 instead of 20 so the symbol fits.
```

An explicit `--qr-version` is your decision and is never overridden; if it
cannot fit, the command stops and names one that would. The space each
version needs, including the three lines of status below it:

| version | terminal needed | mono | colour |
|---|---|---|---|
| 3 | 33 x 20 | 31 B | 93 B |
| 5 | 41 x 24 | 84 B | 252 B |
| 8 | 53 x 30 | 170 B | 510 B |
| 10 | 61 x 34 | 249 B | 747 B |
| 20 | 101 x 54 | 836 B | 2508 B |
| 40 | 181 x 94 | 2931 B | 8793 B |

A wider window buys throughput directly: each step up carries more per frame.

### decode

```
Usage: qrtransfert decode [OPTIONS]

  Rebuild a file from a video of the QR stream.

Options:
  -f PATH                      Video file to decode; repeat to combine  [required]
  -fps, --fps-to-decode INTEGER  Frames per second to sample  [1<=x<=120; default: 10]
  -d, --output-dir DIRECTORY   Directory to write the received file into  [default: .]
  -p, --passphrase TEXT        Passphrase for an encrypted transfer
  --no-color                   Skip RGB channel splitting
  --overwrite                  Overwrite an existing file instead of refusing
```

Colour packing is detected from the pixels, so `--no-color` is only a
performance hint. There is no threshold to tune: zxing-cpp's adaptive
binarizer handles uneven lighting far better than a global cut-off.

A colour-packed frame is not read from its channel planes as stored. Every
codec a camera writes subsamples chroma — 4:2:0 keeps Cb/Cr at half
resolution while luminance stays sharp — so each plane comes back as its own
channel plus a full-resolution ghost of the other two, landing right on the
binarizer's decision. Pushing every pixel away from its own channel mean
undoes that, and because the mean is kept rather than subtracted, a module
that is on or off in all three channels — finder patterns and quiet zones
included — stays where it was. On the same 1080p phone capture, of the 156
frames it contained:

| | frames read |
|---|---|
| channel planes as stored | 17 |
| pushed apart | 149 |

The transform is the identity on a monochrome frame, so it costs nothing when
there is no colour to undo. The planes as stored are still tried, but only on
a frame the first pass could not fully read.

Captures only combine within one continuous `encode` session — a second
`encode` is a different stream, and `decode` says so rather than silently
recovering nothing.

A capture of a stream that was **already running** is decoded differently.
The fountain is normally unwound by peeling, which needs frames carrying a
single unknown block to get started; the systematic prefix supplies them, and
filming late means missing it. Whether peeling still finishes then comes down
to which sequence numbers happened to survive — most sets decode, some stall
holding every bit of the file. An 18-second phone capture of a 167-block
stream, starting at frame 159, peeled 48 of 167 blocks; the same frames as a
linear system had full rank and returned the file in 0.3 s. So `decode` and
`info` solve the system once, when the video ends and peeling has run out.
Elimination is quadratic where peeling is near-linear, so it is a last resort
and refuses outright on a transfer large enough that refilming is cheaper.

### info

```
Usage: qrtransfert info [OPTIONS]

  Report what is in a capture, without writing anything.
```

Separates the two failures that look identical from the outside: a high
rejected-symbol count means blur or focus, while finding no stream at all
means the wrong thing was filmed.

## Compatibility

Wire format v2 is **not** compatible with v1. A video produced by qrtransfert
1.x cannot be decoded by 2.x: v1 was indexed rather than fountain-coded, and
base64-encoded its payload.

Container version 2 added the media type and the original length. A capture
made against container version 1 is rejected by name rather than misread.

## License

MIT.

