APDU Framing
How APDU commands are chunked into BLE frames and reassembled — the IRON Vault frame format.
Overview
BLE has a hard per-packet payload ceiling set by the ATT MTU. A raw APDU command can be longer than a single BLE packet, so Ledger's transport layer wraps every APDU in a simple framing protocol before putting it on the wire. Frames carry a tag byte, a chunk index, and (on the first chunk only) the total APDU length. The receiver accumulates chunks until it has collected totalLength bytes, then hands the reassembled APDU up to the application layer.
IRON Vault implements this framing on both the receive path (host → device, via the Write/WriteCmd characteristics) and the transmit path (device → host, via the Notify characteristic).
Protocol Tags
Two tag values are defined:
| Tag | Hex | Purpose |
|---|---|---|
TAG_MTU | 0x08 | MTU negotiation frame — 5-byte request/response |
TAG_APDU | 0x05 | APDU data frame — carries command or response payload |
All frames begin with one of these tag bytes. Any frame with an unrecognised tag is silently discarded.
First Chunk Layout
The first chunk of an APDU message carries the total payload length in bytes 3–4. This lets the receiver allocate a reassembly buffer of the exact right size before any subsequent chunks arrive.
| Byte(s) | Field | Value / Notes |
|---|---|---|
| 0 | TAG | 0x05 (TAG_APDU) |
| 1–2 | Chunk index (BE uint16) | 0x00 0x00 — always zero for the first chunk |
| 3–4 | Total APDU length (BE uint16) | e.g. 0x00 0x05 for a 5-byte APDU |
| 5… | APDU data | Up to MTU − 5 bytes of the APDU payload |
At the default MTU payload of 20 bytes, the first chunk can carry up to 15 bytes of APDU data (20 − 5 header bytes).
Subsequent Chunk Layout
Chunks after the first omit the total-length field (it was already communicated in chunk 0). The header shrinks by two bytes, so more APDU data fits per packet.
| Byte(s) | Field | Value / Notes |
|---|---|---|
| 0 | TAG | 0x05 (TAG_APDU) |
| 1–2 | Chunk index (BE uint16) | 0x00 0x01, 0x00 0x02, … increments by 1 |
| 3… | APDU data | Up to MTU − 3 bytes of the APDU payload |
At MTU payload 20, subsequent chunks carry up to 17 bytes of APDU data (20 − 3 header bytes).
Reassembly Algorithm
The receiver maintains a small state machine per connection:
state:
expectedChunkIdx = 0
totalLength = 0 (set from bytes 3–4 of chunk 0)
apduBuffer = []
apduReceived = 0
on frame received:
tag = frame[0]
chunkIdx = uint16_BE(frame[1], frame[2])
if tag != TAG_APDU:
handle TAG_MTU or discard
return
if chunkIdx != expectedChunkIdx:
// Out-of-order frame — drop entire message, reset state
reset()
return
if chunkIdx == 0:
totalLength = uint16_BE(frame[3], frame[4])
apduBuffer += frame[5:]
apduReceived = len(frame) - 5
else:
apduBuffer += frame[3:]
apduReceived += len(frame) - 3
expectedChunkIdx += 1
if apduReceived >= totalLength:
dispatch(apduBuffer[:totalLength])
reset()Key invariants:
totalLengthis only read from the first chunk (chunk index 0).- If any chunk arrives out of order (
chunkIdx != expectedChunkIdx), the entire APDU is discarded and state resets to zero. There is no selective-repeat or retransmit — BLE GATT provides in-order delivery at the link layer, so out-of-order arrival indicates a framing error. apduBufferis trimmed tototalLengthon dispatch to guard against oversized final chunks.
Response Framing
Device responses use the identical frame layout sent in the opposite direction — via notifyCharacteristicChanged() on the Notify characteristic. A response APDU is chunked and indexed exactly the same way as a request APDU. The host reassembles using the same algorithm described above.
Example: a 67-byte ETH signing response (v(1) + r(32) + s(32) + 9000(2)) at MTU 20 would be split across four notify frames:
| Frame | Bytes sent | Content |
|---|---|---|
| 0 | 05 00 00 00 43 <15 bytes> | Header + first 15 bytes of response |
| 1 | 05 00 01 <17 bytes> | Next 17 bytes |
| 2 | 05 00 02 <17 bytes> | Next 17 bytes |
| 3 | 05 00 03 <18 bytes> | Final 18 bytes |
0x0043 = 67 decimal (total APDU length in the first frame header).
Inter-Frame Delay
The Android implementation inserts a 20 ms sleep between consecutive notifyCharacteristicChanged() calls. Some host BLE stacks drop notifications if they arrive faster than the connection interval allows the stack to flush them. The 20 ms delay is conservative but safe — it keeps frame throughput at ~50 frames/second, which is more than sufficient for all wallet APDU workloads.
MTU Negotiation Frame
The TAG_MTU (0x08) frame is a special-case 5-byte message used only during the MTU handshake (connection step 5). It does not carry an APDU payload.
| Direction | Frame bytes | Meaning |
|---|---|---|
| Host → Device | 08 00 00 00 00 | "What is your MTU?" |
| Device → Host | 08 00 00 00 00 14 | "My MTU payload is 20 (0x14)" |
The 6th byte in the device response is the MTU payload size in decimal, expressed as a single hex byte. 0x14 = 20. Both sides then use this value to determine chunk sizes for all subsequent APDU frames.
Concrete Example
Sending E0 01 00 00 00 (GET_VERSION, 5 bytes) at MTU payload = 20:
The APDU fits entirely in the first chunk. Total length = 0x0005. APDU data occupies bytes 5–9.
Byte: 00 01 02 03 04 05 06 07 08 09
Value: 05 00 00 00 05 E0 01 00 00 00
^^ ^^^^^ ^^^^^ ^^^^^^^^^^^^^^^^^^^
TAG idx=0 len=5 APDU payloadSingle-frame transmission — no subsequent chunks needed.
Sending a 22-byte APDU at MTU payload = 20:
First chunk (bytes 0–19):
05 00 00 00 16 <15 bytes of APDU data>0x0016 = 22. Carries first 15 bytes of the APDU.
Second chunk (bytes 20–21, 7 remaining bytes of APDU, padded to fit):
05 00 01 <7 bytes of APDU data>Total transmitted bytes across both frames: 20 + 10 = 30.