Conventions
- All multi-byte integers are little-endian.
- Every QR code carries exactly one frame, encoded as a single byte-mode segment.
- The first two bytes of a frame are its magic. The first byte alone tells the families apart.
| Magic | Meaning | Header | Source |
|---|---|---|---|
D2 0C | Mode 2 frame, RaptorQ | 24 bytes | src/mode2.rs |
AC 0C | Acknowledgement frame | 19 bytes, no payload | src/ack.rs |
4B 43 46 31 ("KCF1") | File container. A payload format, never a frame | 58 bytes | src/kcf.rs |
Mode 2 is the only forward frame. Any other leading pair sniffs as unknown and is dropped. Each parser rejects the other family at its length or magic check, so a screen showing mixed frames never corrupts either decoder.
The normative specifications are docs/MODE2-SPEC.md and docs/KCF1-SPEC.md in the Air Kalon repository.
Mode 2 frame
A 24-byte header followed by exactly symbolLen payload bytes.
| Offset | Size | Field | Type | Meaning |
|---|---|---|---|---|
| 0 | 2 | magic | u8 u8 | D2 0C |
| 2 | 1 | verFlags | u8 | Low nibble version, must be 1. High nibble flags, must be 0. The byte is always 01 |
| 3 | 1 | cipherSuite | u8 | 0 is plaintext. Other values are reserved and not supported |
| 4 | 2 | sessionId | u16 | Random per sender start |
| 6 | 4 | esi | u32 | RaptorQ encoding symbol identifier. Below k is a source symbol, from k up is a repair symbol |
| 10 | 2 | k | u16 | Source symbol count, 1 to 56,403 |
| 12 | 2 | symbolLen | u16 | Payload bytes per frame. A multiple of 8 when 64 or more |
| 14 | 4 | totalLen | u32 | Length of the transmitted object, the whole KCF1 container, in bytes |
| 18 | 4 | payloadFnv | u32 | FNV-1a of the whole container, checked once on completion |
| 22 | 2 | reserved | u16 | Must be 00 00 |
| 24 | symbolLen | symbol | bytes | The encoding symbol |
Validation order
The parser runs these checks in this order and returns nothing at the first failure:
- Length is more than 24 bytes.
- Magic is
D2 0C. verFlagsis01.k,symbolLenandtotalLenare all non-zero.- The reserved field is zero.
- Length is exactly
24 + symbolLen. - If
symbolLenis 64 or more, it is a multiple of 8. kis at most 56,403, the RFC 6330 limit for a single source block.kequalsceil(totalLen / symbolLen).
The cipher suite is deliberately not validated: a frame with a nonzero suite parses, and the decoder reports it as an unsupported suite rather than as garbage. The engine drops any symbol with esi of 2^24 or more, the RFC 6330 payload identifier limit, and refuses to lock on to a stream whose totalLen exceeds 256 MiB.
Derived parameters
No RaptorQ parameters travel on the wire. Both ends compute:
raptorq::ObjectTransmissionInformation::with_defaults(total_len as u64, symbol_len)Rules 7 to 9 guarantee that this yields a symbol size equal to symbolLen and one source block. The golden vectors pin the standard's 12-byte serialisation of the result for representative sizes, so a library upgrade cannot change it silently.
Stream identity
A stream is identified by (mode, sessionId, k, symbolLen, totalLen, payloadFnv, cipherSuite), where mode is always 2. Every frame of one stream carries identical values for all of them.
Reserved for encryption
Every nonzero cipher suite is reserved for a future authenticated encryption suite. Its associated data is already fixed as the exact 24 transmitted header bytes, so a future suite cannot authenticate less than the whole header, and flipping the suite byte fails the tag. No suite other than 0 is implemented.
Frame capacity
The largest frame is the byte capacity of a version 40 QR code at the chosen error correction level.
| Error correction | Largest frame |
|---|---|
| L | 2,953 bytes |
| M | 2,331 bytes |
| Q | 1,663 bytes |
| H | 1,273 bytes |
At error correction level L:
| QR version | Capacity | Largest symbol | Largest stream (56,403 symbols) |
|---|---|---|---|
| 27 | 1,465 bytes | 1,440 bytes | 81,220,320 bytes, about 77 MiB |
| 40 | 2,953 bytes | 2,928 bytes | 165,147,984 bytes, about 157 MiB |
The header is 1.6 percent of a version 27 frame and 0.8 percent of a version 40 frame.
Acknowledgement frame
Exactly 19 bytes, with no payload.
| Offset | Size | Field | Type | Meaning |
|---|---|---|---|---|
| 0 | 2 | magic | u8 u8 | AC 0C |
| 2 | 1 | verFlags | u8 | Must be 01 |
| 3 | 2 | sessionId | u16 | The session being acknowledged, non-zero |
| 5 | 1 | mode | u8 | Must be 2, the mode of the acknowledged stream |
| 6 | 4 | framesNew | u32 | Distinct frames received so far |
| 10 | 2 | k | u16 | The acknowledged stream's K, non-zero |
| 12 | 1 | done | u8 | 0 in progress, 1 complete |
| 13 | 4 | identityFnv | u32 | The acknowledged stream's payloadFnv |
| 17 | 2 | reserved | u16 | Must be 00 00 |
Checks, in order: exact length 19, magic, verFlags, mode 2, non-zero session and K, done of 0 or 1, reserved zero. framesNew and identityFnv accept any value; whether they match is the sender's decision.
KCF1 file container
Every transfer carries exactly one KCF1 container. The frame layer treats it as opaque bytes: totalLen is the container's length and payloadFnv is the FNV-1a of the container.
Layout
| Offset | Size | Field | Meaning |
|---|---|---|---|
| 0 | 4 | magic | KCF1 (4B 43 46 31) |
| 4 | 1 | version | 1 |
| 5 | 1 | flags | See below |
| 6 | 2 | nameLen | UTF-8 length of the file name, at most 255 |
| 8 | 2 | typeLen | UTF-8 length of the media type, at most 127 |
| 10 | 8 | originalLen | Length of the file before compression, at most 256 MiB |
| 18 | 8 | bodyLen | Length of the body as carried, at most 256 MiB |
| 26 | 32 | sha256 | SHA-256 of the original, uncompressed bytes |
| 58 | nameLen | name | File name |
| typeLen | type | Media type | |
| bodyLen | body | The file, raw DEFLATE when flag bit 0 is set |
A container is exactly 58 + nameLen + typeLen + bodyLen bytes long.
Flags
| Bit | Mask | Meaning |
|---|---|---|
| 0 | 0x01 | The body is a raw DEFLATE stream (RFC 1951, with no wrapper around it) |
| 1 | 0x02 | The file is a text snippet the sender typed or pasted |
| 2 | 0x04 | The file is a folder bundle: a zip archive of several files |
| 3 to 7 | Reserved, must be 0 |
Bits 1 and 2 are mutually exclusive. With neither set the container carries an ordinary file. The kind bits are presentation hints and never change how the body is decoded or verified.
Packing
- Name. Take the last path component, splitting on both
/and\. Remove every control character and every/,\and:. Trim leading dots and spaces. Clamp to 255 UTF-8 bytes on a character boundary. An empty result becomesfile. - Media type. Clamp to 127 UTF-8 bytes on a character boundary. An empty type becomes
application/octet-stream. - Hash. SHA-256 over the original bytes.
- Compression. With
othe original length andcthe raw DEFLATE length, compress only wheno - c >= 64and20 * (o - c) >= o, that is, when it saves at least 64 bytes and at least 5 percent. Otherwise store the original bytes. A packer may skip the attempt when it can tell it will not pay; the format is the same either way. - Flags. Set bit 0 if the body is compressed, and at most one of bits 1 and 2.
Packing refuses only a file over 256 MiB. An empty file is valid.
Unpacking
A receiver checks these rules in this order and stops at the first one broken. Each has its own error code, as reported through the C interface.
| # | Rule | Error | Code |
|---|---|---|---|
| 1 | Buffer at least 58 bytes | Truncated | 1 |
| 2 | Magic is KCF1 | BadMagic | 2 |
| 3 | Version is 1 | BadVersion | 3 |
| 4 | Flag bits 3 to 7 are 0 | UnknownFlags | 4 |
| 5 | Not both bit 1 and bit 2 | ConflictingFlags | 5 |
| 6 | Name length at most 255 | NameTooLong | 6 |
| 7 | Media type length at most 127 | MediaTypeTooLong | 7 |
| 8 | Original length at most 256 MiB | OriginalTooLarge | 8 |
| 9 | Body length at most 256 MiB | BodyTooLarge | 9 |
| 10 | The four lengths add up to the buffer length | LengthMismatch | 10 |
| 11 | Name is valid UTF-8 | NameNotUtf8 | 11 |
| 12 | Media type is valid UTF-8 | MediaTypeNotUtf8 | 12 |
| 13 | Uncompressed: body length equals original length | StoredLengthMismatch | 13 |
| 14 | Compressed: the body is one complete raw DEFLATE stream | DeflateInvalid | 14 |
| 15 | Compressed: no bytes follow the end of that stream | DeflateTrailingData | 15 |
| 16 | Compressed: it inflates to exactly the original length | DecompressedLengthMismatch | 16 |
| 17 | SHA-256 of the recovered bytes equals the header's | HashMismatch | 17 |
Packing's one refusal is FileTooLarge, code 20. A null pointer with a non-zero length, or an unknown kind, is BadArgument, code -1, at the C interface.
Requirements on the unpacker:
- Bounded inflation. Never produce more than the original length plus one byte while inflating. The reference unpacker grows its output in 1 MiB steps, so a small container that declares a large original length costs nothing until it actually inflates.
- Re-sanitise. Sanitise the name again with the packing rule after reading it, and read an empty media type as
application/octet-stream. - Verify before offering. Offer a file only after rule 17 passes.
The SHA-256 covers the file bytes only. The name, media type and flags are covered in transit by the frame header's FNV-1a over the whole container, a transport check rather than an integrity guarantee.
Hash
FNV-1a, 32-bit: offset basis 0x811C9DC5, prime 0x01000193, one byte at a time in wrapping arithmetic. It detects accidental corruption. It is not a cryptographic hash.
Golden vectors and tests
| File | Pins |
|---|---|
tests/golden_mode2.json | Mode 2 frames at edge values, a rejection vector for every validation rule, the RaptorQ engine's packets and parameter serialisation, acknowledgement frames and their rejections, and cross-family rejection in both directions |
tests/kcf.rs and the unit tests in src/kcf.rs | Round trips, the compression rule at its boundaries, every sanitising rule, one test per unpack rejection, a bounded-inflation test and seeded fuzz-style properties |
The golden vectors are generated from this implementation at specification freeze, with test payloads drawn from a fixed-seed SplitMix64 byte source. Every test run regenerates them in memory and compares them with the committed file. If a golden assertion fails, the code is wrong. A vector is never relaxed, and an intentional change to the frame layout is a new version number with new vectors.