Writing / 6 min / on PhotonQR

Speaking Photon from a browser

PhotonQR's protocol is a documented format, so a web page can be a sender. Then the app's own code gets to judge it.

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  ciphertext
All integers big-endian. The tag is present only when ENCRYPTED is set.

The 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)
The key schedule, using HKDF-SHA-256 in transport-visible mode

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.

PhotonQR case study

Search only: every answer quotes this site and links its source. Nothing is generated.