Fountain-Coded Air-Gap Transport for Arbitrary Payloads
Getting data onto a machine deliberately kept off any network means moving it by eye through a camera. This sends it as a stream of QR codes that keeps going until the receiver has enough, so no missed frame needs re-showing.
Summary
- Why
- Air-gapped devices with only a screen and a camera need a reliable way to move payloads too large for one QR code without stalling on every missed frame.
- What
- BRC-141 defines a wire format for splitting any byte payload into a fountain-coded stream of QR-code-friendly parts that a receiver can reassemble from any subset in any order.
- How
- A sender chunks the payload into fixed-size blocks, mixes them into a systematic Luby-transform fountain sequence of parts each prefixed `air-gap:` and base64-encoded with a small binary header, and a receiver's decoder collects distinct parts until it can solve back to the original bytes and checks the CRC-32 before…
What this lets you do
- Encode any byte payload up to 65536 bytes as a stream of QR codes
- Recover the full payload from parts received in any order, duplicates included
- Tune block size per QR code to fit different screens or error-correction levels
- Lock onto one sender's session and ignore stray frames from other senders
- Verify payload integrity with a CRC-32 check before emitting any bytes
Written by claude-sonnet-5 from the specification text. Where the two differ, the original is correct.
Reference for an AI
Everything an assistant needs to answer questions about BRC-141 accurately, including what it depends on.
The specification
Abstract
This standard defines a payload-agnostic, one-directional optical transport that carries an arbitrary byte string across an air gap as a sequence of QR codes. Each QR decodes to a single US-ASCII string beginning with the fixed prefix air-gap:, followed by unpadded URL-safe base64 of a versioned binary header and one fixed-size block. Parts are produced by a systematic Luby-transform fountain: the first K sequence numbers are the source blocks themselves, and later sequence numbers are deterministic XOR mixtures of those blocks, selected by an exactly specified integer-arithmetic ideal-soliton sampler so every language reproduces identical parts bit for bit. A receiver assembles the payload from distinct parts in any order, with duplicates tolerated, so a missed camera frame does not force a full animation cycle of waiting; recovery from K + ε distinct parts is highly probable but not guaranteed, and a sender that loops its sequence makes eventual recovery certain. An 8-byte session identity in every header lets a receiver lock onto one sender and ignore stray frames from another. Integrity is checked with the IEEE CRC-32 of the complete payload, carried in every part header and re-verified before bytes are emitted. The scheme is deliberately independent of payment, signing, or wallet semantics: applications supply and interpret the payload. Symbol rendering, camera capture, and animation cadence are out of scope.
Motivation
Air-gapped and phone-to-phone workflows share a common constraint: there is no bidirectional socket, only a screen on one device and a camera on the other. Realistic payloads (unsigned extended transactions, AtomicBEEF, cosigning envelopes, BRC-100 call blobs) routinely exceed the capacity of a single QR symbol. Prior art on BSV includes:
- BRC-225 (TKQR1) — fixed-order indexed frames with a truncated SHA-256 set tag. Simple and fully deterministic, but every missed frame costs a full cycle until that exact index reappears.
- Application demos (for example colon-delimited
CHUNK:string splitters) — workable for small demos, but non-interoperable, non-byte-oriented, and without a strong integrity gate. - BC-UR on other chains — fountain-capable animated QR using bytewords and CBOR; no shared wire format with this BRC.
This BRC standardises the fountain approach for general air-gap use: miss-tolerant reassembly, a single wire prefix for every payload size (including the single-part case K = 1), tunable block size for different screens and error-correction budgets, and a small versioned binary header that is easy to implement in multiple languages. It is a peer alternative to BRC-225, not a revision of it. Implementations MAY support both; they MUST NOT treat the wire formats as interchangeable.
The reference TypeScript package is @bsv/air-gap (codec only: no camera, no QR renderer). Applications such as mobile wallets, air-gapped signers, and payment demos own presentation and scanning.
Specification
The key words MUST, MUST NOT, SHOULD, SHOULD NOT, and MAY are to be interpreted as in RFC 2119.
1. Terminology
- Payload: the arbitrary sequence of bytes to be transported,
1 .. 65536octets. This BRC imposes no structure on the payload. Authenticity and confidentiality are the responsibility of the enclosed payload. - Block: a fixed-length slice of the payload (zero-padded on the final source block).
- Part: one QR-decodable US-ASCII string encoding one fountain part (header plus one block-sized body).
- K (block count):
ceil(msgLen / blockBytes), the number of source blocks,1 .. 65535. - seq: the part sequence number, an unsigned 32-bit integer. Values
0 .. K-1are systematic; values≥ Kare coded. - blockBytes: the fixed body size of every part for a given encode,
1 .. 2048. Tunable by the application; not carried as an explicit header field (it is inferred from the decoded body length). - sessionId: 8 octets naming one encoder's stream. Chosen once at encoder construction — random unless the application supplies a value (deterministic test vectors do).
2. Wire grammar
A part is:
part = PREFIX base64url( header ‖ body )
PREFIX = "air-gap:" ; literal US-ASCII, case-sensitive
header = ver ‖ sessionId ‖ seq ‖ K ‖ msgLen ‖ crc32 ; 23 octets, big-endian (see §3)
body = blockBytes octets ; see §4–§5
Requirements:
- The part MUST be a single line with no surrounding whitespace in the canonical form.
base64urlis RFC 4648 §5 (URL- and filename-safe alphabet), unpadded. Encoders MUST omit=padding. Decoders MUST reject padding characters, embedded whitespace, characters outside the base64url alphabet, and any body whose length ≡ 1 (mod 4). (Lenient base64 handling differs across runtimes; rejecting uniformly is what keeps the soft-fail contract identical everywhere.)- The part uses characters outside the QR alphanumeric set (
:, lowercase letters,-,_), so symbols MUST be rendered in QR byte mode. - A decoder MUST soft-reject (no state change) any string that does not begin with the literal prefix
air-gap:, and — before any base64 decoding — any string longer than the longest legal part (2,770 characters, see §9), so hostile input costs no allocation.
3. Binary header
All multi-byte integers are big-endian.
| Offset | Type | Field | Description |
|---|---|---|---|
| 0 | uint8 | ver | Wire protocol version. MUST be 0x01; a decoder MUST reject any other value |
| 1 | 8 octets | sessionId | Stream identity chosen by the encoder (§1) |
| 9 | uint32 | seq | Part sequence number |
| 13 | uint16 | K | Source block count |
| 15 | uint32 | msgLen | Payload length in octets |
| 19 | uint32 | crc32 | IEEE CRC-32 of the full payload (§6) |
| 23 | … | body | Exactly blockBytes octets |
Header length is always 23 octets. blockBytes is not stored in the header: it equals len(decoded_bytes) - 23 and MUST be identical for every part of one session.
4. Chunking and source blocks
Input: payload (bytes), blockBytes (integer).
blockBytesMUST be an integer in1 .. 2048. The ceiling keeps every legal part inside the byte-mode capacity of a version-40 QR symbol at error-correction level L, and doubles as the decoder's resource bound (§8).- If
len(payload) = 0, the encoder MUST fail (empty payloads are not representable). Applications that need a typed empty record MUST wrap it in a non-empty envelope. - If
len(payload) > maxMessageBytes, the encoder MUST fail. Conforming implementations MUST enforcemaxMessageBytes = 65536unless a profile document specifies a lower bound; implementations MUST NOT raise the bound above 65536 without a new wire version. K = ceil(len(payload) / blockBytes).KMUST be ≤ 65535 (theuint16field); the encoder MUST fail rather than truncate. WhenblockBytes ≥ len(payload),K = 1and a single systematic part carries the entire payload (zero-padded toblockBytes).- Source block i for
iin0 .. K-1is ablockBytes-octet buffer: copypayload[i·blockBytes : min((i+1)·blockBytes, len)]into the start of the buffer; remaining octets are0x00.
Default blockBytes SHOULD be 1200 unless the application has measured reasons to differ (smaller screens, higher ECC, logo overlays). Smaller blocks yield more parts but lower per-symbol density; larger blocks reduce part count but push QR capacity.
5. Fountain part construction
Part body for sequence number seq:
- If
seq < K: body is source blockseq(as constructed in §4). - If
seq ≥ K: body is the XOR of source blocks whose indices areblocksForPart(seq, K)(§5.1).
The complete part bytes are header ‖ body with all §3 fields set, then base64url-encoded and prefixed with air-gap:. partAt(seq) MUST be a pure function of (payload, blockBytes, sessionId, seq).
seq is a finite uint32, not unbounded. Encoders used for animation SHOULD loop — for example cycling seq over a window a few multiples of K wide — until the receiver signals success out of band: re-emitting the systematic prefix is what makes eventual recovery deterministic rather than merely probable (§7a).
5.1. blocksForPart(seq, K) (normative)
Used only when seq ≥ K. Every operation is exact integer arithmetic; all intermediate products stay below 2⁴⁰, so 64-bit integer (or IEEE double) arithmetic reproduces it exactly. Pseudo-code:
makeRng(seed):
x ← seed as uint32
if x = 0: x ← 0x6d2b79f5
return function:
x ← x XOR (x << 13); x as uint32
x ← x XOR (x >> 17); x as uint32
x ← x XOR (x << 5); x as uint32
return x
draw23(rng):
return rng() >> 9 // top 23 bits: integer in [0, 2^23)
blocksForPart(seq, K):
rng ← makeRng( (seq × 0x9e3779b1) mod 2^32 ) // 32-bit modular product — see warning
r ← draw23(rng)
degree ← floor((2^23 + r) / (r + 1)) // = ceil(2^23 / (r+1))
if degree > K: degree ← 1
pool ← [0, 1, …, K-1]
for i in 0 .. degree-1:
j ← i + floor( draw23(rng) × (K - i) / 2^23 )
swap pool[i], pool[j]
return pool[0 .. degree-1]
The degree draw is an exact inverse-CDF sample of the ideal soliton distribution over 1 .. K — ρ(1) = 1/K, ρ(d) = 1/(d(d−1)) for d ≥ 2 — because the truncated tail degree > K carries total probability ≈ 1/K, exactly the mass ρ(1) requires. (For K = 1 every part is block 0.)
Seed-precision warning (JavaScript and other double-based languages). The seed is the 32-bit modular product
seq × 0x9e3779b1. In JavaScript this MUST be computed asMath.imul(seq, 0x9e3779b1) >>> 0. The expression(seq * 0x9e3779b1) >>> 0is wrong: IEEE-754 doubles lose low product bits onceseq ≥ 3,393,265, silently selecting different blocks than a native uint32 implementation (atseq = 0x7fffffffthe double path seeds 3,788,015,616 where the correct u32 product is 3,788,015,183). The shared conformance vectors pin parts on both sides of that boundary and at0xffffffff; a port that fails them is not conforming. The zero-seed substitution is unreachable on the wire (seq = 0is systematic) but is normative for any code path that exposes the mapping directly.
6. CRC-32
crc32 is the IEEE CRC-32 (ISO 3309 / ITU-T V.42 / Ethernet polynomial 0xEDB88320 reflected), as produced by the standard table algorithm with initial value 0xFFFFFFFF and final XOR 0xFFFFFFFF. The well-known check value is:
CRC32(ASCII "123456789") = 0xCBF43926
The field covers the entire payload (all msgLen bytes), not individual blocks. It is an integrity check against camera misreads, never an authenticator (§ Security Considerations).
7. Reassembly (decode)
A receiver maintains session state. The reference models this as a stateful decoder.
Per-part ingest (accept):
- If the string is not a well-formed version-1
air-gap:part (wrong prefix, over-length per §2, invalid base64url, decoded length ≤ 23 or > 23 + 2048,ver ≠ 1), return soft failure and leave state unchanged. A decoder used with a live camera MUST NOT throw on stray scans. - Parse header fields and body. Let
blockBytes = len(body). - Reject if
K = 0,msgLen = 0, ormsgLen > maxMessageBytes. - Reject if
ceil(msgLen / blockBytes) ≠ K(header and body disagree on message shape). - The session identity is the quadruple
(sessionId, K, msgLen, crc32). The decoder locks onto the first identity it accepts. A part carrying a different identity MUST NOT disturb the locked session; only 3 consecutive parts of the same foreign identity switch the decoder to that session, resetting state (a camera genuinely re-pointed at a new sender produces them back to back). A part of the locked session, or of a different foreign identity, restarts the count; unusable reads do not affect it. - On the first accepted part of a session, pin
blockBytes. Later parts whose body length differs MUST be rejected (soft-fail) without changing solved state. - Once the session is complete (§ below), further parts of its identity MUST be acknowledged without any state change.
- If
seqwas already seen in this session, ignore the duplicate. - Determine block indices: if
seq < K, indices ={seq}; else indices =blocksForPart(seq, K). - Ingest via peeling: XOR out already-solved blocks from the body; if one index remains, solve that block; cascade until fixpoint. Parts still mixing several unsolved blocks are buffered subject to the §8 budgets.
Completeness: the session is complete when all K source blocks are solved.
Finalize (message):
- If incomplete, return no payload.
- Concatenate solved blocks
0 .. K-1and trim tomsgLen. - Recompute CRC-32 over the trimmed bytes and require equality with the session
crc32. On mismatch, reset the session and return no payload (the sender is expected to still be looping). - Return the trimmed bytes.
A conforming decoder MUST NOT emit a truncated or blended payload.
7a. Recovery characteristics (informative, binding on documentation)
Distinct coded parts are not guaranteed to be linearly independent, so recovery from any K + ε distinct parts is probabilistic; documentation of this transport MUST NOT present it as absolute. Deterministic example: for K = 3, the six distinct parts seq = 4, 27, 38, 56, 63, 72 all reduce to source block 0, leaving progress at 1/3. Receivers simply keep scanning; senders keep looping. Measured with the reference implementation (400 deterministic trials per cell): a repair-only receiver that missed the entire systematic prefix completes at ≈1.4–1.5 K parts at the median and ≈3.8–4.6 K at the 99th percentile (K = 5..55); a receiver watching a sender that loops over an 8 K-wide window completes within ≈1.5 K reads at the median, bounded by the next systematic pass.
8. Mixed-stream, fail and resource rules (normative summary)
| Condition | Behaviour |
|---|---|
Not air-gap: / over-length / bad base64 / short | Soft-reject; no state change |
ver ≠ 1 | Soft-reject |
K, msgLen out of range | Soft-reject |
ceil(msgLen/blockBytes) ≠ K | Soft-reject |
| Body length > 2048 | Soft-reject |
Different (sessionId, K, msgLen, crc32) | Soft-reject; switch only after 3 consecutive parts of one new identity |
Body length ≠ pinned blockBytes | Soft-reject |
Duplicate seq | Acknowledge; no reprocessing |
| Part of a completed session | Acknowledge; no state change |
| Finalize with incomplete set | No emit |
| Final CRC mismatch | Reset; no emit |
Decoder state MUST be bounded against hostile or broken senders. The reference bounds (RECOMMENDED values; implementations MAY tune them but MUST bound): duplicate tracking ≤ 65,536 sequence numbers (past the cap, repeats are re-processed — idempotent, so correctness is unaffected); buffered unsolved mixes ≤ 1,024 parts and ≤ 4,096 total unresolved block references (a mix that would exceed either budget is soft-rejected). Systematic and degree-1 parts are never buffered, so the budgets cannot starve an honest looping sender.
9. Presentation guidance (non-normative)
- Animation cadence is not part of this BRC. Applications commonly use ~200 ms per part (~5/s) on phone cameras; slower is more reliable under motion blur.
- For K = 1, applications MAY show a single static QR (never advance
seq). - Every part for a given
blockBytesrenders at exactly8 + 4·floor((23 + blockBytes)/3) + tailcharacters (tail = 0, 2, or 3 for remainder 0, 1, 2). Because base64url forces byte mode, compare part length directly against byte-mode capacity tables: the defaultblockBytes = 1200yields 1,639 characters — inside a version-40 symbol at every ECC level up to Q (1,663 bytes) with 44 % headroom at L (2,953) — and theblockBytesceiling of 2,048 yields 2,770 characters, inside version 40-L. - Centre logos and colour styling consume error-correction budget; prefer ECC level M or higher and reduce
blockBytesif overlays are used.
10. Interoperability contract
Two implementations are interoperable if and only if, for the same (payload, blockBytes, sessionId), they produce identical part strings for every seq, and each can reassemble the other's stream to the exact original payload. Determinism is total: there is no timestamp or locale dependence, and the only randomness — the default sessionId — is an explicit input. Shared test vectors are the conformance oracle; the machine-readable corpus lives in the ts-stack repository at conformance/vectors/transport/air-gap-optical.json and includes encode vectors at the §5.1 seed-precision boundaries, decode and session-locking streams, the K = 3 linear-dependence stall, and hostile-input rejections. Vectors are append-only; a change that breaks one requires a new ver value.
Test Vectors
All vectors below use sessionId = 0102030405060708 (hex).
Vector A — CRC-32 check value
- Input: ASCII
123456789 - Output:
crc32 = 0xCBF43926
Vector B — Single-part (K = 1)
- Payload: ASCII
Hello, air-gap!(15 bytes) blockBytes = 64K = 1,msgLen = 15,crc32 = 0x8614FD1F- Systematic part
seq = 0(body is 15 payload bytes then 49 zero bytes):
air-gap:AQECAwQFBgcIAAAAAAABAAAAD4YU_R9IZWxsbywgYWlyLWdhcCEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
Reassembly: accept that single part → payload 15 bytes; CRC matches.
Vector C — Two systematic parts
- Payload: ASCII
Hello, air-gap!(15 bytes) blockBytes = 8K = 2,msgLen = 15,crc32 = 0x8614FD1F- Source blocks:
Hello, aandir-gap!\0
air-gap:AQECAwQFBgcIAAAAAAACAAAAD4YU_R9IZWxsbywgYQ
air-gap:AQECAwQFBgcIAAAAAQACAAAAD4YU_R9pci1nYXAhAA
Reassembly in either order, with optional duplicates, yields the original 15 bytes. Presenting only one part MUST NOT emit a payload.
Vector D — Behavioural (implementation tests)
Conforming decoders MUST:
- Soft-reject strings that are not version-1
air-gap:parts, including anyver ≠ 1and any string longer than 2,770 characters (the latter before base64 decoding). - Complete from the systematic set alone when no frames are missed.
- Complete when some systematic parts are missing but enough coded parts (
seq ≥ K) arrive to peel the remainder. - Keep the locked session when a single part with a different
(sessionId, K, msgLen, crc32)arrives, and switch only after 3 consecutive parts of one new identity. - Soft-reject a part whose body length differs from the first accepted part of the session.
- On final CRC mismatch (for example after a body bit-flip with header left intact), discard the assembly and continue accepting.
- Stall at 1/3 progress after the K = 3 parts
4, 27, 38, 56, 63, 72(linear dependence), then complete from subsequent parts.
The complete machine-readable set — including the seq = 3,393,264 / 3,393,265, 0x7fffffff and 0xffffffff seed-boundary encodings — is the ts-stack conformance corpus referenced in §10.
Implementations
- Reference (TypeScript):
@bsv/air-gap— pure codec (AirGapEncoder/AirGapDecoder), no camera or QR dependencies. Intended for browsers, React Native, and Node. Source: bsv-blockchain/ts-stack —packages/helpers/air-gap, with the repository-local wire spec atspecs/transport/air-gap-optical.mdand shared vectors atconformance/vectors/transport/air-gap-optical.json. - Operational precursor: the Luby-transform fountain previously embedded in BSV mobile wallet code for oversized nearby-payment frames (payment-specific prefixes; not part of this wire format, and its coding — a JavaScript float-precision seed and a mis-sampled degree distribution — is deliberately not reproduced by this revision).
Mathematical basis
Systematic fountain. The first K parts are an exact partition of the (padded) payload. Coded parts are linear combinations over GF(2) of whole blocks. The peel decoder solves degree-1 equations and substitutes, which recovers the source whenever the collected set spans the message — with high probability after roughly K distinct parts for the ideal-soliton draw, though never with certainty (§7a).
CRC-32 detects accidental corruption and distinguishes unrelated streams with low cost on constrained devices. It is not an authenticator: an adversary who can inject frames can forge a consistent CRC. Payload authenticity MUST be provided by the application layer (signatures, MACs, or verified transaction structure).
Security Considerations
- No authenticity. CRC-32 does not authenticate the sender, and the
sessionIdis an accident guard, not a security boundary — an active optical attacker can read it off the sender's screen. Sign or encrypt at the payload layer when required. - No confidentiality. Parts are plaintext on a screen. Sensitive material MUST be encrypted before framing.
- Fail closed. Decoders MUST NOT return partial payloads. Mixed streams soft-reject (with the 3-consecutive switch rule); CRC failure discards the assembly.
- Resource bounds. §8 bounds decoder memory and per-frame work against hostile headers and hostile senders; the pre-decode length gate caps allocation for non-part garbage at zero.
- No freshness. Duplicate-tolerant reassembly implies anti-replay lives in the payload (nonces, request IDs, expiry).
- Optical threat model. Shoulder-surfing and nearby cameras can capture the stream; treat the channel as public.
Relationship to other standards
| Standard | Relationship |
|---|---|
| BRC-225 TKQR1 | Peer alternative (indexed frames). No shared wire format. |
| BRC-100 | Online wallet interface; this BRC is the optical path when that channel is unavailable. |
| BRC-62 BEEF | Example payload this transport can carry. |
| BC-UR (Blockchain Commons) | Prior art on another chain; no shared format. |
References
- BRC-225, Animated-QR Air-Gap Transport for Arbitrary Payloads (TKQR1).
- BRC-100, Wallet-to-Application Interface.
- BRC-62, Background Evaluation Extended Format (BEEF).
- RFC 4648, Base16/32/64 encodings (§5 URL-safe base64).
- ISO/IEC 18004, QR Code symbology (byte mode; Reed-Solomon levels).
- RFC 2119, Key words for use in RFCs.
- Luby, M. "LT Codes." Proceedings of the 43rd Symposium on Foundations of Computer Science, 2002 (fountain degree distribution inspiration; this BRC specifies an exact ideal-soliton draw, not a full LT standard).
- Blockchain Commons, UR: Uniform Resources (BCR-2020-005), cited as prior art only.
- IEEE CRC-32 / ISO 3309.
- ts-stack conformance corpus:
conformance/vectors/transport/air-gap-optical.json.