Animated-QR Air-Gap Transport for Arbitrary Payloads (TKQR1)
Carrying data to an offline machine as QR codes fails if a single frame is missed and nobody notices. This frames it as plain text lines with checks that confirm the far side really reassembled the whole thing.
Summary
- Why
- Air-gapped signers can only exchange data optically, and a real transaction request is too big for one QR code, so there needs to be an agreed way to split it into a sequence of QR codes and reassemble it correctly on the other side.
- What
- TKQR1 is a text format for splitting an arbitrary byte payload into a numbered sequence of QR-code frames and reassembling it with integrity and set-consistency checks.
- How
- An encoder tags the whole payload with a truncated SHA-256 hash, slices it into fixed-size chunks, base64url-encodes each chunk, and writes each as a pipe-delimited ASCII line with the tag, sequence number, and frame total; a decoder collects frames in any order, rejects any whose tag or total does not match the rest,
What this lets you do
- Split any byte payload into a deterministic sequence of QR-encodable text frames
- Reassemble frames captured in any order, including duplicates, into the exact original payload
- Detect and reject frames that belong to a different or stale payload before they corrupt reassembly
- Fail loudly instead of returning a truncated or partial payload when frames are missing
- Carry any payload type, including a BRC-100 call or a BRC-62 BEEF structure, without interpreting it
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-225 accurately, including what it depends on.
The specification
Abstract
TKQR1 is a text framing that carries an arbitrary byte string across an optical air gap as a sequence of QR codes ("animated QR"), then reassembles it on the far side with integrity and set-consistency checks. Each frame is a single line of US-ASCII: a fixed prefix, a 64-bit truncated SHA-256 tag of the whole payload, a zero-based sequence number, a frame total, and a URL-safe base64 slice of the raw payload. Every frame of one payload carries the same payload tag, so a collector deterministically rejects frames that belong to a different or stale stream (mixed-set detection), refuses to emit until all frames are present, and re-verifies the full payload hash before returning bytes. The scheme is deliberately minimal (fixed-order indexed frames, no fountain code, no CBOR) so it can be implemented identically in two languages and validated byte-for-byte against a shared vector set. It is payload-agnostic: authenticity and confidentiality are the responsibility of the enclosed payload, not the transport.
Motivation
An air-gapped signer that never exposes key material can only communicate with an online wallet through data a camera can read. BRC-100 (the wallet-to-application interface) assumes a live, bidirectional substrate connection: a call is made, a response returns over the same channel. That model does not survive an air gap. When the signing substrate is offline by construction, there is no channel: only a screen on one device and a camera on the other, in each direction. A single QR code caps out well below the size of a realistic cosigning request (an unsigned extended-format transaction plus per-input metadata routinely exceeds one QR's byte budget), so the payload must be split across a sequence of codes and reassembled.
BSV has no standard for this. There is no BRC that defines how to chunk an arbitrary payload into an ordered set of scannable frames, how a receiver distinguishes one payload's frames from another's when a camera sweeps across two screens, or how reassembly fails loudly rather than silently returning a truncated result. Every air-gapped wallet therefore invents an incompatible framing, and two independent implementations cannot interoperate or even share test vectors.
Prior art exists in another chain: Blockchain Commons' BC-UR ("Uniform Resources") encodes binary data as a series of ur:… URIs using bytewords over CBOR, optionally with a rateless fountain code, and animates them as QR. BC-UR is used by several Bitcoin (BTC) hardware wallets. TKQR1 solves the same shape of problem but shares no wire format with BC-UR: it uses pipe-delimited ASCII rather than bytewords/CBOR, fixed-order indexed frames rather than fountain-coded blocks, and a 64-bit payload tag for set-consistency rather than a per-message CRC/digest inside a CBOR envelope. TKQR1 is intended to complement, not replace, the online BRC-100 path: it is the transport used only when the wallet substrate is air-gapped, and it carries whatever bytes the payload layer produces: a BRC-100 call, a signed enrollment export, a BRC-62 BEEF structure, or any other record.
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. Length MAY be zero. TKQR1 imposes no structure on the payload.
- Frame: one line of US-ASCII text encoding one contiguous slice of the payload, suitable for rendering as a single QR code.
- Frame set: the ordered collection of all frames produced from one payload.
- Payload tag: a 64-bit set identifier derived from the whole payload (§3).
max_chunk_bytes, the maximum number of raw payload bytes carried in one frame (before base64 expansion). A positive integer.
2. Frame grammar
A frame is the concatenation of five fields separated by a single ASCII vertical bar | (U+007C, 0x7C):
frame = PREFIX "|" tag "|" seq "|" total "|" chunk64
PREFIX = "TKQR1" ; literal, US-ASCII, case-sensitive
tag = 16 * lowerhex ; §3
seq = 1*DIGIT ; base-10, zero-based, no leading zeros, 0 <= seq < total
total = 1*DIGIT ; base-10, >= 1, no leading zeros
chunk64 = *b64urlchar ; §4 — MAY be empty only for the empty-payload frame
lowerhex = "0"-"9" / "a"-"f"
DIGIT = "0"-"9"
b64urlchar= "A"-"Z" / "a"-"z" / "0"-"9" / "-" / "_" / "="
Requirements:
- The frame MUST be a single line. A canonical frame MUST NOT contain a trailing newline or any surrounding whitespace. A decoder SHOULD tolerate and strip leading/trailing whitespace on input (the reference trims before parsing).
- The
|separator character does not occur in any field value:tagis lowercase hex,seq/totalare decimal digits, and the base64url alphabet (§4) contains none of|. A parser therefore MUST split the frame into exactly five fields on|, treating everything after the fourth|aschunk64(i.e. split with a field limit of 5). This preserves any=padding, which is the only character that could otherwise be mistaken for structure. - A parser MUST reject a frame whose field count is not exactly 5, or whose first field is not the literal
TKQR1. seq,total, andchunk64occupy fixed positions; there are no optional or extension fields in v1. Unknown trailing data is not permitted (there is no field beyondchunk64).
3. Payload tag (set binding)
tag = lowerhex( SHA-256(payload)[0:8])
- Compute SHA-256 over the entire payload (all bytes, in order), take the first 8 bytes of the 32-byte digest, and lowercase-hex-encode them to a 16-character string.
- The identical
tagMUST appear in every frame of the set. It binds all frames to one payload and enables mixed-set detection (§7). - The tag is a 64-bit integrity/identity aid, not an authenticator. It provides ~2⁻⁶⁴ accidental collision probability and offers no defence against an adversary who controls the payload (§ Security Considerations).
4. Chunk encoding
chunk64is the RFC 4648 §5 URL- and filename-safe base64 alphabet (A-Z a-z 0-9 - _) applied to the raw chunk bytes, including=padding. Encoders MUST emit padding so output is byte-identical tobase64.urlsafe_b64encode(Python) /java.util.Base64.getUrlEncoder()(Java). Decoders MUST accept padded input and SHOULD also accept unpadded input.- Implementer note: some prose in the reference source describes this field as "without padding." That comment is superseded by the normative rule here; the canonical test vectors (§ Test Vectors) are padded and are authoritative.
- The base64url alphabet substitutes
-for+and_for/relative to standard base64; a decoder MUST use the URL-safe alphabet. - Each chunk is base64-encoded independently (the payload is split into raw-byte chunks first, then each chunk is encoded), so padding may appear at the end of any frame, not only the last.
5. Chunking algorithm (encode)
Input: payload (bytes), max_chunk_bytes (integer). Output: an ordered list of frame strings.
max_chunk_bytesMUST be > 0; otherwise fail loudly (do not produce frames).- Compute
tagper §3. - If
payloadis empty (length 0), return a single framePREFIX "|" tag "|0|1|", i.e.total = 1,seq = 0,chunk64empty. Stop. - Otherwise, slice
payloadinto consecutive chunks of at mostmax_chunk_bytesraw bytes: chunk i ispayload[i·m : min((i+1)·m, len)]. All chunks except possibly the last have exactlymax_chunk_bytesbytes; every chunk has ≥ 1 byte. total = ceil(len(payload) / max_chunk_bytes)= the number of chunks.- For each chunk index
seqin0.. total-1, emitPREFIX "|" tag "|" seq "|" total "|" base64url(chunk[seq]).
The chunk boundary is on the raw payload, not on the base64 text. seq and total are rendered in base-10 with no leading zeros (the natural decimal rendering of a non-negative integer).
6. Reassembly (decode)
A receiver collects frames (in any order, possibly with duplicates) and reconstructs the payload. The reference models this as a stateful Collector:
Per-frame ingest (add):
- Parse the frame per §2 (fail loudly on a malformed or non-
TKQR1frame). - On the first accepted frame, record
tagandtotalfrom that frame as the set's expected values. - For every frame (including the first), the frame's
tagMUST equal the recordedtag, otherwise fail loudly (frame from a different payload). - The frame's
totalMUST equal the recordedtotal, otherwise fail loudly (inconsistent frame total). - Store the frame's decoded chunk bytes keyed by its
seq. A repeatedseqoverwrites the prior chunk (ingest is idempotent for a well-formed set). Encoders MUST only emitseqin[0, total); a decoder MAY ignore an out-of-rangeseq(the reference stores it but never reads it back, since reassembly indexes only0.. total-1).
Completeness:
missing()= the list of indices in0.. total-1for which no chunk has been stored.- The set is complete iff
total >= 1andmissing()is empty. A decoder MUST NOT emit a payload until the set is complete.
Finalize (payload):
- Assert completeness (fail loudly if
missing()is non-empty; the error MUST name the missing indices). - Concatenate the stored chunks in strict index order
0, 1, …, total-1. - Recompute the payload tag over the concatenation (§3) and require it to equal the recorded
tag; otherwise fail loudly (payload hash mismatch). - Return the concatenated bytes.
Step 3 is a mandatory final integrity gate: it catches within-chunk corruption or substitution that still base64-decoded cleanly. It is NOT optional and NOT replaceable by the per-frame tag equality of step 3 in ingest (that checks only that each frame claims the same tag; this checks that the bytes actually reconstruct to that tag).
7. Mixed-set detection and fail-loud rules (normative summary)
A conforming decoder MUST fail loudly (raise/throw/return an error, never a partial or best-effort result) in each of these cases:
| Condition | Rule |
|---|---|
| Frame is not exactly 5 ` | -fields, or field 0 ≠ TKQR1` |
seq or total is not a base-10 integer | reject the frame |
chunk64 is not valid base64url | reject the frame |
Frame tag ≠ first-seen tag | mixed set, reject the frame |
Frame total ≠ first-seen total | inconsistent set, reject the frame |
Finalize requested while missing() non-empty | incomplete, refuse, report missing indices |
Reassembled bytes' tag ≠ recorded tag | integrity failure, refuse |
Mixed-set detection is the combination of the shared 64-bit tag and the first-seen-wins rule: when a camera captures frames from two different animated-QR streams (a stale prior transfer, or a neighbouring screen), the second stream's frames carry a different tag and are rejected on ingest. Silent acceptance of a truncated or blended payload MUST NOT occur.
8. Chunk-size and rendering guidance
- The reference default
max_chunk_bytesis 800 raw payload bytes. Implementations SHOULD default to 800 unless they have measured reasons to differ. - Base64 expands raw bytes by 4/3; a frame of
mraw bytes carriesceil(m/3)·4base64 characters plus a fixed header oflen("TKQR1") + 1 + 16 + 1 + len(seq) + 1 + len(total) + 1ASCII characters. Atm = 800the chunk field is 1068 characters and the whole frame is ≈ 1096 bytes. - Because a frame contains lowercase letters,
_,-,=, and|, it does not fit the QR alphanumeric character set and MUST be rendered in QR byte mode. A version-40 QR at error-correction level L holds ≈ 2953 bytes and at level M ≈ 2331 bytes, so an 800-byte-chunk frame fits comfortably with error-correction headroom. - Smaller chunks yield more frames but each scans faster and tolerates more optical noise; larger chunks reduce frame count but push QR density up. Implementations MAY expose
max_chunk_bytesas a tunable and SHOULD keep a single frame within the capacity of the target QR version and ECC level with margin. - Symbol presentation is out of scope for conformance. TKQR1 constrains the text a symbol decodes to, never the pixels that render it. Styling such as color, module shape, or an image overlaid on the symbol center (a logo) is permitted, provided the scanned symbol still decodes to the exact frame string of §2. The receiving collector cannot tell a styled symbol from a plain one, and no field of the frame grammar describes presentation.
- Center overlays spend error-correction budget. A QR symbol recovers approximately 7% of codewords at level L, 15% at M, 25% at Q, and 30% at H (ISO/IEC 18004); an overlay consumes recovery capacity exactly as physical damage would. Implementations that overlay imagery SHOULD render at level Q or H, SHOULD keep the occluded area well below the level's recovery bound (roughly 10 to 15% of the symbol), and SHOULD reduce
max_chunk_bytesto offset the capacity cost of the higher level. In animated use the same budget also absorbs screen glare and motion blur during continuous scanning, so an overlay sized near the recovery limit lowers the scan rate before it produces a clean failure. Leave margin. - A single-frame payload (
total = 1) is valid and common for small records; such a payload MAY additionally be conveyed as raw text/JSON without framing where the far side accepts it, but the framed single-frame form remains canonical.
9. Interoperability contract
Two implementations are interoperable iff, for the same (payload, max_chunk_bytes), they produce byte-identical frame strings, and each can reassemble the other's frame set to the exact original payload. The reference treats a shared vector file as the sole conformance oracle: reproducing every listed frame string exactly, and round-tripping it, is necessary and sufficient for correctness. Determinism is total, there is no randomness, timestamp, or locale dependence anywhere in the scheme.
Test Vectors
All vectors below are reproducible from the algorithm in §3-§6. tag values are SHA-256(payload)[0:8] in lowercase hex; chunk64 is padded base64url.
Vector A, empty payload (single frame, empty chunk)
- Input:
payload = ""(0 bytes), anymax_chunk_bytes. SHA-256("")beginse3b0c44298fc1c14…;tag = e3b0c44298fc1c14.- Output (one frame):
TKQR1|e3b0c44298fc1c14|0|1|
Reassembly: single frame, total = 1, chunk empty → payload is 0 bytes; final tag check passes.
Vector B, small ASCII payload, two frames (shows padding and boundary)
- Input:
payload = "Hello, air-gap!"(15 US-ASCII bytes),max_chunk_bytes = 8. SHA-256(payload) = 46d95be0f89a7ce996e64d1dde5b9bb15d03ce03940e5b0adcbcc43bd1023a36;tag = 46d95be0f89a7ce9.- Chunks: chunk 0 =
Hello, a(bytes 0-7), chunk 1 =ir-gap!(bytes 8-14);total = 2. - Output (order as emitted):
TKQR1|46d95be0f89a7ce9|0|2|SGVsbG8sIGE=
TKQR1|46d95be0f89a7ce9|1|2|aXItZ2FwIQ==
Reassembly (frames MAY be presented in either order): base64url-decode both chunks → Hello, a + ir-gap! = Hello, air-gap!; recomputed tag 46d95be0f89a7ce9 matches → return 15 bytes. Feeding a third frame with a different tag MUST be rejected (frame from a different payload); presenting only frame 1 and finalizing MUST fail with missing [0].
Vector C, single-frame JSON payload
- Input:
payload = {"ok":true}(11 bytes),max_chunk_bytes = 800. tag = 4062edaf750fb807;total = 1.- Output:
TKQR1|4062edaf750fb807|0|1|eyJvayI6dHJ1ZX0=
Vector D, canonical multi-frame vector (from the reference vector set)
Drawn from the reference conformance vectors. The payload is a 1000-byte UTF-8 JSON cosigning request (a cosign-request envelope built entirely from fabricated demonstration keys and a dummy transaction, no live key material), framed with max_chunk_bytes = 200, yielding total = 5 chunks of exactly 200 raw bytes each.
tag = 608823f264b82113(identical on all five frames).- Frame headers:
TKQR1|608823f264b82113|0|5|<b64url of payload[0:200]>
TKQR1|608823f264b82113|1|5|<b64url of payload[200:400]>
TKQR1|608823f264b82113|2|5|<b64url of payload[400:600]>
TKQR1|608823f264b82113|3|5|<b64url of payload[600:800]>
TKQR1|608823f264b82113|4|5|<b64url of payload[800:1000]>
Each 200-byte chunk base64url-encodes to a 268-character field carrying a single = pad (200 mod 3 = 2). Frame 0 begins TKQR1|608823f264b82113|0|5|eyJ2IjoxLCJ0eXBlIjoidGFrYXJhLWNvc2lnbi1yZXF1ZXN0Ii…. Concatenating the five decoded chunks in seq order reproduces the 1000-byte payload, whose recomputed tag equals 608823f264b82113. Any implementation that reproduces these five frame strings exactly, and round-trips them, is TKQR1-conformant.
Implementations
A reference implementation exists as two byte-compatible ports, developed and owned by the author (RexStarBSV): a Python module and a Kotlin/Android object exposing chunk, parseFrame, a Collector reassembler, and a one-shot reassemble. Both are validated against a shared JSON vector file that each port must reproduce exactly (the same vectors given in this document). The frame grammar and encodings (lowercase hex; RFC 4648 §5 base64url with padding) are exactly as specified above. The implementation is not published at this time and is referenced here descriptively rather than by path.
Mathematical and cryptographic basis
Reassembly is an exact inverse. Encoding maps a payload P of length L to frames 0..total-1 with total = ceil(L / m) for chunk size m, chunk i being P[i*m : min((i+1)*m, L)]. Base64url is a bijection on byte strings, so each chunk survives the optical hop without loss, and concatenating the decoded chunks in index order reproduces P exactly. There is no rateless coding and no chunk overlap, so decode is deterministic with a single fixed point.
Set binding is a truncated-hash discriminator, not an authenticator. The tag is the first 8 bytes of SHA-256(P), ranging over 2^64 values. Two unrelated payloads share a tag with probability about 2^-64 under the uniform-image model for SHA-256, and across s simultaneous streams the chance any two collide is at most s(s-1)/2 * 2^-64, negligible for realistic s. The final gate recomputes the tag over the reassembled bytes, catching accidental in-chunk corruption with miss probability about 2^-64. Because the tag is truncated and unkeyed, it detects stream-mixing and corruption but makes no authenticity claim: a party that controls the payload can retag a stream or craft a colliding input. Authenticity and confidentiality are properties the enclosed payload carries (for example a signed transaction or an encrypted record).
Security Considerations
- The tag is not an authenticator. The 64-bit
SHA-256[0:8]payload tag defends against accidental mixing and corruption (collision probability ≈ 2⁻⁶⁴ for unrelated payloads). It does not defend against an adversary who controls or can inject frames: crafting a different payload that reassembles to the same 8-byte tag is a second-preimage search of ≈ 2⁶⁴ work (non-trivial but far below cryptographic strength, and TKQR1 offers no defence beyond it. Payload authenticity and integrity MUST be provided by the payload layer) e.g. an ECDSA signature over a canonical-JSON envelope (as the signer's enrollment and cosign records do) or a BRC-62 structure that the receiver independently verifies. TKQR1 transports bytes; it does not vouch for them. - No confidentiality. Frames are plaintext base64 rendered on a screen. The threat model is optical: shoulder-surfing and stray-camera capture. TKQR1 payloads in the reference use are public by design (XPUB exports, unsigned transactions, already-committed data); no private key ever crosses the gap. If a payload is sensitive, it MUST be encrypted at the payload layer (e.g. a BRC-2-style scheme) before framing, TKQR1 will faithfully transport ciphertext.
- Fail-loud, never partial. A decoder MUST NOT return a truncated or blended payload. Missing frames leave the set incomplete and no bytes are emitted; a differing tag or total rejects the offending frame; a final hash mismatch refuses the whole result. This prevents a signer from ever acting on a payload assembled from two streams or from a partially-scanned set.
- Resource bounds. A
Collectorfed a frame with a very largetotal, or many distinct high-seqframes, can be driven to allocate. Implementations SHOULD boundtotaland per-frame chunk size to values consistent with their QR-capacity budget, and SHOULD reject frames whoseseq >= totalrather than storing them. - Ordering and replay. Reassembly is order-independent and duplicate-tolerant by design, which is necessary for camera capture but means a decoder derives no freshness from frame arrival. Any anti-replay or freshness guarantee (nonces, request IDs, expiry) MUST live in the payload envelope, not the transport.
- Canonical output. Encoders MUST emit exactly the byte sequence in §2 (no extra whitespace, no trailing newline, padded base64url). Divergence breaks the vector-based interoperability contract even when a lenient decoder would still parse it.
References
- BRC-100, Wallet-to-Application interface (the online substrate model TKQR1 complements when the wallet is air-gapped).
- BRC-62, Background Evaluation Extended Format (BEEF): an example payload TKQR1 can transport.
- RFC 4648, The Base16, Base32, and Base64 Data Encodings (§4 hex reference; §5 URL- and filename-safe base64 alphabet, used with padding).
- RFC 6234 / FIPS 180-4, SHA-256 (payload tag).
- ISO/IEC 18004, QR Code bar code symbology specification (byte mode; Reed-Solomon error-correction levels L/M/Q/H cited in §8).
- RFC 2119, Key words for use in RFCs to Indicate Requirement Levels.
- Blockchain Commons, "UR: Uniform Resources" (BCR-2020-005), prior art for animated-QR binary transport on another chain (Bitcoin/BTC); cited for context only. TKQR1 shares no wire format with BC-UR (pipe-delimited ASCII vs. bytewords/CBOR; fixed indexed frames vs. fountain-coded blocks; 64-bit payload tag vs. CBOR-embedded digest).
- RexStarBSV, air-gapped signer wire-protocol document, §4 "QR transport": the author's design document from which the reference framing originates (not publicly available). The reference implementation is developed and owned by RexStarBSV.