PhotonQR moves files between two phones using only a screen and a camera. There is no back-channel, so the protocol has to carry everything inside QR frames. Because it is a byte-level format and not an app-to-app API, nothing about it requires the sender to be a phone. The demo on this site is a sender written in about 180 lines of TypeScript.
A frame
offset size field
0 4 magic "PHOT"
4 1 version
5 1 type (MANIFEST, DATA, ...)
6 2 flags
8 8 transferId
16 4 packetIndex
20 4 totalPackets
24 2 payloadLength
26 2 reserved
28 4 crc32
32 16 AES-GCM tag
48 n ciphertextThe header is 32 bytes and the tag is 16. The demo uses QR version 9 at error-correction level M, which holds 180 bytes in byte mode, so each frame carries 132 bytes of message. The version stays fixed for the whole transfer, because a symbol that changes size forces the scanner to find it again.
The two details that are easy to get wrong
The first is what gets authenticated. The AEAD's associated data is the first 28 bytes of the header, which is everything except the CRC, because the CRC is computed afterwards and covers the tag and ciphertext. Binding the header means a captured packet cannot be relabelled with a different index or transfer ID. It fails authentication and never reaches the output.
The second is the nonce. It is never transmitted: a 4-byte prefix from HKDF followed by the big-endian packet index. Both ends can derive it, which saves 12 bytes per frame on a link where every byte is a module on screen. It also matters that Web Crypto appends the GCM tag to the ciphertext while Photon places it before the ciphertext, so the sender has to split and reorder them.
secret = random(32) // used as both IKM and salt
key = HKDF(secret, secret, "photonqr/v1/payload-key", 32)
prefix = HKDF(secret, secret, "photonqr/v1/nonce-prefix", 4)
nonce = prefix || uint64_be(packetIndex)No back-channel, so repeat
The sender loops: manifest, then every data frame, then around again. A missed frame just comes back on the next pass, and the receiver ignores data until it has seen a manifest. The cost is time, and that is the trade the project openly accepts.
What the encryption does and does not do
In this default mode the secret travels in the manifest. So it protects against someone who captures some of the frames, such as a photo of the screen or a reflection, because data frames are useless without the manifest. It does not protect against someone who films the whole loop. The app's own comments and Send screen say the same thing, and so does the demo. The session can also only be as private as the room, so please do not transmit anything secret from a portfolio.
Testing it against the real thing
A sender checked only against itself proves nothing, since it can be wrong in a self-consistent way. So the test takes four transfers (short, non-Latin text, the maximum size, and an exact multiple of the payload size) and runs them through two independent checks:
- Every frame is rendered to a QR image and read back with jsQR, confirming the bytes survive the QR round trip exactly.
- Those bytes go through PhotonQR's own Dart code: PhotonFrameCodec.decode, TransferManifest.decode, CryptoSession.fromManifestSalt, AesGcmPayloadCipher.open, Reassembler, and the SHA-256 digest check.
- As a negative control, one header byte in a frame is flipped and decryption must fail. It does. If that check could not fail, the passes would mean nothing.
All four pass. What this does not cover is an actual camera pointed at an actual screen, with glare and motion blur. That still has to be done by hand with a real phone.