From 73ff6a60bdd07582a458cc7f79d35674074172e0 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=D0=90=D0=BD=D1=82=D0=BE=D0=BD=20=D0=A2=D1=80=D0=BE=D1=88?=
=?UTF-8?q?=D0=B8=D0=BD?=
Date: Thu, 24 Sep 2026 08:05:07 +0300
Subject: [PATCH] fix: SCP80 RC/CPL parity, file-life-cycle labels, EF.ARR and
PS template decoders (v3.5.1)
Three fixes found while filling the ETSI/3GPP registry gaps against the
SIMalliance Stepping Stones R7:
- SCP80 builder/verification parity (TS 31.115 Table 1 NOTE / 4.2 / 4.3):
the CPL is now transmitted whenever the packet is ciphered or carries
RC/CC/DS - it is part of their input - and whenever the packet needs SMS
concatenation; a single unprotected SM keeps pySim's CHL-first form.
Before, the JS dropped the CPL for every unciphered packet while the
server reference re-added it, so "Verify vs pySim" reported a false
MISMATCH for every unciphered RC/CC packet (SPI1 01/02/0A/12/1A...).
All ten offered SPI1 values now match the server reference byte-for-byte.
- RC (SPI1 b2b1 = 01) was offered but not built: the JS now computes CRC-32
(TS 102 225 5.1.3.2, pySim zlib.crc32 parity) over the same CPL frame as
the CC; the packet no longer silently omits the 4-byte RC field.
- Server: _build_secured_packet/_ota_reference add the CPL to a concatenated
unprotected packet too (Table 1 NOTE / 4.3).
- fcpLifeCycle: unlisted values with b8 clear are RFU, b8 set is proprietary
(Table 11.7b); previously all unmatched values were labelled proprietary.
- EF.ARR decoder now decodes the expanded format (AM_DO/SC_DO per ISO 7816-4
5.4.3.2 + TS 102 221 9.2.7): operation bit masks, INCREASE/RESIZE AM_DO
0x84, OR/AND/NOT templates, PIN key references with usage qualifiers.
- FCP 'C6' PS template DO decoded (PS_DO bitmap + key references + usage
qualifiers, TS 102 221 11.1.1.4.10/9.5.2) with a shared key-reference map.
Tests: sp.test.js (CPL/RC vectors + crc32 known answer), ef_decode.test.js
(expanded-format ARR vectors), profiler.test.js (LCSI RFU/proprietary, C6),
test_ota_helpers.py (RC reference, unprotected single-SM vs concatenated).
Help EN/RU and READMEs: CPL size 2 (SMS), RC/CC/DS 4-8, RC bullet, CPL rule.
547 frontend / 397 Python green; version 3.5.1; sw cache simple-v253.
---
README.md | 4 +-
README_RUS.md | 4 +-
frontend/help-ru.html | 7 +-
frontend/help.html | 7 +-
frontend/index.html | 185 +++++++++++++++++++++++++++----
frontend/sw.js | 2 +-
frontend/tests/ef_decode.test.js | 17 ++-
frontend/tests/profiler.test.js | 18 ++-
frontend/tests/sp.test.js | 39 +++++--
pyproject.toml | 2 +-
pysim_simple_server/server.py | 15 ++-
tests/test_ota_helpers.py | 22 ++++
12 files changed, 271 insertions(+), 51 deletions(-)
diff --git a/README.md b/README.md
index 7d6f3c3..816c86d 100644
--- a/README.md
+++ b/README.md
@@ -415,7 +415,7 @@ Builds SCP80 secured packets per ETSI TS 102 225.
| Field | Size | Description |
|---|---|---|
| CPI | 1 | Command Packet Identifier (`02`) |
-| CPL | 1 | Command Packet Length |
+| CPL | 2 (SMS) | Command Packet Length (CHL..end incl. padding) |
| CHI | 1 | Command Header Identifier (`01`) |
| CHL | 1 | Command Header Length |
| SPI | 2 | Security Parameter Indicator |
@@ -424,7 +424,7 @@ Builds SCP80 secured packets per ETSI TS 102 225.
| TAR | 3 | Toolkit Application Reference |
| CNTR | 5 | Replay counter |
| PCNTR | 1 | Padding counter |
-| RC/CC/DS | 8 | Cryptographic Checksum / MAC |
+| RC/CC/DS | 4–8 | Redundancy Check (CRC-32) / Cryptographic Checksum (MAC) / Digital Signature |
| Secured Data | variable | Padded APDU (encrypted if required) |
#### SPI1 (Security Level)
diff --git a/README_RUS.md b/README_RUS.md
index 402704c..8a387ce 100644
--- a/README_RUS.md
+++ b/README_RUS.md
@@ -390,7 +390,7 @@ CLA = `80` (GlobalPlatform v2.3.1). Удалённое управление со
| Поле | Размер | Описание |
|---|---|---|
| CPI | 1 | Command Packet Identifier (`02`) |
-| CPL | 1 | Command Packet Length |
+| CPL | 2 (SMS) | Command Packet Length (CHL..конец, включая padding) |
| CHI | 1 | Command Header Identifier (`01`) |
| CHL | 1 | Command Header Length |
| SPI | 2 | Security Parameter Indicator |
@@ -399,7 +399,7 @@ CLA = `80` (GlobalPlatform v2.3.1). Удалённое управление со
| TAR | 3 | Toolkit Application Reference |
| CNTR | 5 | Счётчик повторов |
| PCNTR | 1 | Padding counter |
-| RC/CC/DS | 8 | Контрольная сумма / MAC |
+| RC/CC/DS | 4–8 | CRC (CRC-32) / контрольная сумма (MAC) / цифровая подпись |
| Secured Data | переменная | APDU (с шифрованием при необходимости) |
#### SPI1 (Уровень безопасности)
diff --git a/frontend/help-ru.html b/frontend/help-ru.html
index ead7bfa..b986146 100644
--- a/frontend/help-ru.html
+++ b/frontend/help-ru.html
@@ -285,7 +285,7 @@
| Поле | Размер | Описание |
| CPI | 1 | Command Packet Identifier (02) |
- | CPL | 1 | Command Packet Length |
+ | CPL | 2 (SMS) | Длина командного пакета (CHL..конец, включая padding) |
| CHI | 1 | Command Header Identifier (01) |
| CHL | 1 | Command Header Length |
| SPI | 2 | Security Parameter Indicator |
@@ -294,7 +294,7 @@
| TAR | 3 | Toolkit Application Reference |
| CNTR | 5 | Счётчик повторов |
| PCNTR | 1 | Padding counter |
- | RC/CC/DS | 8 | Контрольная сумма / MAC |
+ | RC/CC/DS | 4–8 | CRC (CRC-32) / контрольная сумма (MAC) / цифровая подпись |
| Secured Data | пер. | APDU (с шифрованием при необходимости) |
@@ -304,10 +304,11 @@
AES-CBC шифрование (нулевой ICV, дополнение нулями до 16), ключи 16/24/32 байта (TS 102 225 §5.1.2, KIc x2)
Retail MAC (ISO 9797-1, MAC algorithm 3) для контрольной суммы DES/3DES
AES-CMAC (NIST SP 800-38B, усечённый до 8 октетов) для контрольной суммы AES (TS 102 225 §5.1.3.1, KID x2)
+ Redundancy Check (RC) — CRC-32 по тому же кадру заголовка, что и CC (TS 102 225 §5.1.3.2; ключ не нужен, SPI1 b2b1 = 01)
AES требует счётчик с защитой от повтора: биты SPI1 b5 b4 должны быть 10 (счётчик больше) или 11 (счётчик +1) согласно TS 102 225 §5.1.2/§5.1.3.1
Байт паддинга настраивается (00 по умолчанию или FF)
-
Кнопка Проверить в pySim сверяет собранный пакет с эталонной реализацией OtaDialectSms.encode_cmd. Кнопка Отправить на карту доставляет пакет через ENVELOPE SMS-PP-DOWNLOAD (при подключении к серверу). Под полем пакета показывается его размер и число SMS; пакет, не помещающийся в одно SMS, отправляется конкатенированной загрузкой (TS 31.115 §4.3) — пакет делится на части SMS-пользовательских данных (первое SMS 132 октета, последующие по 134; первое дополнительно несёт информационные элементы конкатенации и CPI), и сегменты отправляются по порядку. Более 5 сегментов отклоняется: это практический предел буфера конкатенации карты. Полученный Proof of Receipt декодируется и показывается строкой статуса PoR (статус, TAR, счётчик, сырой PoR); статусное слово и данные ответа последней команды подставляются в подвкладку «Разбор R-APDU» (Remote APDU → «Разбор»), а успешный PoR увеличивает счётчик повторов и очищает пакет.
+ Кнопка Проверить в pySim сверяет собранный пакет с эталонной реализацией OtaDialectSms.encode_cmd. Кнопка Отправить на карту доставляет пакет через ENVELOPE SMS-PP-DOWNLOAD (при подключении к серверу). 2-байтовый префикс CPL передаётся, когда пакет зашифрован или несёт RC/CC/DS (входит в их входные данные), а также при конкатенации SMS; одиночное незащищённое SMS сохраняет форму pySim, начинающуюся с CHL (TS 31.115 Table 1 NOTE). Под полем пакета показывается его размер и число SMS; пакет, не помещающийся в одно SMS, отправляется конкатенированной загрузкой (TS 31.115 §4.3) — пакет делится на части SMS-пользовательских данных (первое SMS 132 октета, последующие по 134; первое дополнительно несёт информационные элементы конкатенации и CPI), и сегменты отправляются по порядку. Более 5 сегментов отклоняется: это практический предел буфера конкатенации карты. Полученный Proof of Receipt декодируется и показывается строкой статуса PoR (статус, TAR, счётчик, сырой PoR); статусное слово и данные ответа последней команды подставляются в подвкладку «Разбор R-APDU» (Remote APDU → «Разбор»), а успешный PoR увеличивает счётчик повторов и очищает пакет.
3.2 RAM
Выполняет операции удалённого управления приложениями (Remote Application Management) как защищённые пакеты SCP80 через SMS-PP-DOWNLOAD ENVELOPE. Карта должна поддерживать SCP03 (AES или 3DES). Предустановка карты со вкладки Карты обеспечивает SPI, ключи, TAR и счётчик (RAM использует ISD TAR предустановки, по умолчанию 000000).
diff --git a/frontend/help.html b/frontend/help.html
index 5ab229c..1760838 100644
--- a/frontend/help.html
+++ b/frontend/help.html
@@ -284,7 +284,7 @@
| Field | Size | Description |
| CPI | 1 | Command Packet Identifier (02) |
- | CPL | 1 | Command Packet Length |
+ | CPL | 2 (SMS) | Command Packet Length (CHL..end incl. padding) |
| CHI | 1 | Command Header Identifier (01) |
| CHL | 1 | Command Header Length |
| SPI | 2 | Security Parameter Indicator |
@@ -293,7 +293,7 @@
| TAR | 3 | Toolkit Application Reference |
| CNTR | 5 | Replay counter |
| PCNTR | 1 | Padding counter |
- | RC/CC/DS | 8 | Cryptographic Checksum / MAC |
+ | RC/CC/DS | 4–8 | Redundancy Check (CRC-32) / Cryptographic Checksum (MAC) / Digital Signature |
| Secured Data | var. | Padded APDU (encrypted if required) |
@@ -303,10 +303,11 @@
AES-CBC encryption (zero ICV, zero-padded to 16), 16/24/32-byte keys (TS 102 225 §5.1.2, KIc x2)
Retail MAC (ISO 9797-1 MAC algorithm 3) for the DES/3DES cryptographic checksum
AES-CMAC (NIST SP 800-38B, truncated to 8 octets) for the AES cryptographic checksum (TS 102 225 §5.1.3.1, KID x2)
+ Redundancy Check (RC) — CRC-32 over the same header frame as the CC (TS 102 225 §5.1.3.2; needs no key, SPI1 b2b1 = 01)
AES requires a replay-protected counter: SPI1 bits b5 b4 must be 10 (counter higher) or 11 (counter +1) per TS 102 225 §5.1.2/§5.1.3.1
Padding byte configurable (00 default, or FF)
- A “Verify vs pySim” button cross-checks the assembled packet against pySim’s reference OtaDialectSms.encode_cmd. A “Send to Card” button delivers it via SMS-PP-DOWNLOAD ENVELOPE (when connected to the server). Under the packet field the app reports its size and how many SMS it takes; a packet that does not fit one SMS is sent as a concatenated download (TS 31.115 §4.3) — the packet is split into SMS user-data parts (first SM 132 octets, following ones 134; the first one additionally carries the concatenation and CPI information elements) and the segments are sent in order. More than 5 segments is refused, that being the practical limit of the card’s concatenation buffer. The returned Proof of Receipt is decoded and shown as a PoR status line (status, TAR, counter, raw PoR); the last command’s status word and response data are filled into the R-APDU parser panel, and a successful PoR advances the replay counter and clears the packet.
+ A “Verify vs pySim” button cross-checks the assembled packet against pySim’s reference OtaDialectSms.encode_cmd. A “Send to Card” button delivers it via SMS-PP-DOWNLOAD ENVELOPE (when connected to the server). The 2-byte CPL prefix is transmitted whenever the packet is ciphered or carries RC/CC/DS (it is part of their input) and whenever the packet is concatenated; a single unprotected SMS keeps pySim’s CHL-first form (TS 31.115 Table 1 NOTE). Under the packet field the app reports its size and how many SMS it takes; a packet that does not fit one SMS is sent as a concatenated download (TS 31.115 §4.3) — the packet is split into SMS user-data parts (first SM 132 octets, following ones 134; the first one additionally carries the concatenation and CPI information elements) and the segments are sent in order. More than 5 segments is refused, that being the practical limit of the card’s concatenation buffer. The returned Proof of Receipt is decoded and shown as a PoR status line (status, TAR, counter, raw PoR); the last command’s status word and response data are filled into the R-APDU parser panel, and a successful PoR advances the replay counter and clears the packet.
3.2 RAM
Delivers Remote Application Management operations as SCP80 secured packets via SMS-PP-DOWNLOAD ENVELOPE. The card must support SCP03 (AES or 3DES). A saved card preset from the Cards tab provides the SPI, keys, TAR, and counter (RAM uses the preset's ISD TAR, 000000 by default).
diff --git a/frontend/index.html b/frontend/index.html
index 4fbf8e2..b1d4e1e 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -1559,7 +1559,7 @@
// ===== Version =====
// Single source of truth for the PWA version: shown in the header and used
// by the server version check in pysimConnect().
-const SIMPLE_VERSION = '3.5.0';
+const SIMPLE_VERSION = '3.5.1';
document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION;
// ===== Tab switching =====
@@ -4500,10 +4500,23 @@ function aesCmac(data, keyBytes) {
return mac.subarray(0, 8);
}
+// Redundancy Check (RC) = CRC-32 (TS 102 225 5.1.3.2): reflected polynomial
+// 0xEDB88320, init and final XOR 0xFFFFFFFF, result big-endian - byte-identical
+// to pySim's zlib.crc32() usage.
+function crc32Bytes(data) {
+ let crc = 0xFFFFFFFF;
+ for (let i = 0; i < data.length; i++) {
+ crc ^= data[i];
+ for (let k = 0; k < 8; k++) crc = (crc >>> 1) ^ (0xEDB88320 & -(crc & 1));
+ }
+ crc = (crc ^ 0xFFFFFFFF) >>> 0;
+ return new Uint8Array([(crc >>> 24) & 0xFF, (crc >>> 16) & 0xFF, (crc >>> 8) & 0xFF, crc & 0xFF]);
+}
+
// ===== Secure Packet assembly =====
// TS 102 225 Table 1/2 + TS 31.115 Table 1 (SMS-PP):
// CPL(2) CHL(1) SPI(2) KIc(1) KID(1) TAR(3) CNTR(5) PCNTR(1) [RC/CC/DS] secured-data(+padding)
-// CPL = octets from CHL to end incl. padding; CHL = 13 + len_sig.
+// CPL = octets from CHL to end incl. padding; CHL = 13 + len_sig (CC 8, RC 4).
// CPI (0x70) and CHI (null) are NOT part of the SMS packet data.
function genSp() {
_genSpBuild();
@@ -4575,7 +4588,9 @@ function _genSpBuild() {
const kidIsAes = kidAlg === 0x02;
const ciphering = (spi1 & 0x04) !== 0;
- const hasMac = (spi1 & 0x03) === 0x02;
+ const rcCcDs = spi1 & 0x03; // 00 none / 01 RC / 10 CC / 11 DS (not offered)
+ const hasRc = rcCcDs === 0x01;
+ const hasMac = rcCcDs === 0x02;
const resultEl = document.getElementById('sp-result');
@@ -4624,13 +4639,13 @@ function _genSpBuild() {
const apdu = hexToBytes(apduHex);
- const macLen = hasMac ? 8 : 0;
+ const sigLen = hasMac ? 8 : (hasRc ? 4 : 0);
// padding aligns CNTR+PCNTR+RC/CC/DS+data to the cipher block size (ciphering only)
const cipherBlock = kicIsAes ? 16 : 8;
- const padCnt = ciphering ? (cipherBlock - ((6 + macLen + apdu.length) % cipherBlock)) % cipherBlock : 0;
+ const padCnt = ciphering ? (cipherBlock - ((6 + sigLen + apdu.length) % cipherBlock)) % cipherBlock : 0;
- const chl = 13 + macLen;
- const cpl = 14 + macLen + apdu.length + padCnt;
+ const chl = 13 + sigLen;
+ const cpl = 14 + sigLen + apdu.length + padCnt;
const packetLen = 3 + chl + apdu.length + padCnt;
const packet = new Uint8Array(packetLen);
@@ -4652,18 +4667,18 @@ function _genSpBuild() {
packet[14] = parseInt(cntrHex.substr(8,2), 16);
packet[15] = padCnt;
const macOff = 16;
- const dataOff = macOff + macLen;
+ const dataOff = macOff + sigLen;
packet.set(apdu, dataOff);
for (let i = 0; i < padCnt; i++) packet[dataOff + apdu.length + i] = padByte;
- if (hasMac && kidKey) {
- // CC is computed over CPL+CHL+SPI+KIc+KID+TAR+CNTR+PCNTR+data(+padding),
+ if (sigLen) {
+ // RC/CC is computed over CPL+CHL+SPI+KIc+KID+TAR+CNTR+PCNTR+data(+padding),
// i.e. everything up to (but excluding) the RC/CC/DS field (TS 31.115 4.2).
- const macInput = new Uint8Array(packet.length - macLen);
- macInput.set(packet.subarray(0, macOff));
- macInput.set(packet.subarray(dataOff), macOff);
- const mac = kidIsAes ? aesCmac(macInput, kidKey) : cbcMac(macInput, kidKey);
- packet.set(mac, macOff);
+ const sigInput = new Uint8Array(packet.length - sigLen);
+ sigInput.set(packet.subarray(0, macOff));
+ sigInput.set(packet.subarray(dataOff), macOff);
+ if (hasRc) packet.set(crc32Bytes(sigInput), macOff);
+ else if (kidKey) packet.set(kidIsAes ? aesCmac(sigInput, kidKey) : cbcMac(sigInput, kidKey), macOff);
}
if (ciphering && kicKey) {
@@ -4675,9 +4690,12 @@ function _genSpBuild() {
packet.set(encrypted, 10);
}
- // pySim encode_cmd parity: the 2-byte CPL prefix is transmitted only when
- // ciphering is applied; unciphered packets start at CHL (v1.9.5).
- resultEl.value = bytesToHex(ciphering ? packet : packet.subarray(2));
+ // CPL transmission (TS 31.115 Table 1 NOTE + 4.3): it is transmitted when
+ // the packet is ciphered or carries RC/CC/DS - it is part of their input -
+ // and it is required once the packet needs SMS concatenation. A single
+ // unprotected SM keeps pySim's CHL-first form ("not absolutely necessary").
+ const withCpl = ciphering || rcCcDs !== 0 || (packet.length - 2) > SCP80_SINGLE_BYTES;
+ resultEl.value = bytesToHex(withCpl ? packet : packet.subarray(2));
}
// ===== BER-TLV =====
@@ -13017,13 +13035,17 @@ function fcpFileDescriptor(v) {
function fcpLifeCycle(v) {
if (v.length !== 2) return v;
const b = parseInt(v, 16);
+ // TS 102 221 Table 11.7b: the state rows all keep b8-b5 clear; b8 set is
+ // proprietary, any other unlisted value is RFU.
+ if (b & 0x80) return 'proprietary (' + v + ')';
+ if (b & 0x70) return 'RFU (' + v + ')';
if (b === 0x00) return 'no information';
if (b === 0x01) return 'creation';
if (b === 0x03) return 'initialization';
if ((b & 0x0C) === 0x0C) return 'termination';
if ((b & 0x05) === 0x05) return 'operational, activated';
if ((b & 0x05) === 0x04) return 'operational, deactivated';
- return 'proprietary (' + v + ')';
+ return 'RFU (' + v + ')';
}
// TS 102 221 11.1.1.4.8: SFI in b8-b4, b3-b1 zero; length 0 = no short id.
@@ -13033,6 +13055,35 @@ function fcpSfi(v) {
return v;
}
+// TS 102 221 11.1.1.4.10 / 9.5.2: PS template DO 'C6' = PS_DO ('90') + optional
+// usage qualifier ('95') + key reference ('83', repeatable). The PS_DO is a
+// bitmap over the key references in order, MSB first; a set bit = PIN enabled.
+function fcpPsTemplate(v) {
+ const parsed = fcpParseTlvs(v);
+ let psDo = null, uq = null;
+ const pins = [];
+ for (const t of parsed.tlvs) {
+ if (t.tag === '90') psDo = t.value;
+ else if (t.tag === '95') uq = t.value.length ? parseInt(t.value, 16) : 0;
+ else if (t.tag === '83' && t.value.length) {
+ const idx = pins.length;
+ let enabled = false;
+ if (psDo && psDo.length) {
+ const byteIdx = idx >> 3, bit = 7 - (idx & 7);
+ if (byteIdx * 2 + 2 <= psDo.length) enabled = ((parseInt(psDo.substr(byteIdx * 2, 2), 16) >> bit) & 1) === 1;
+ }
+ let s = pinKeyRefName(parseInt(t.value, 16)) + ': ' + (enabled ? 'enabled' : 'disabled');
+ if (uq === null) s += ', always verified';
+ else if (uq === 0) s += ', verification not used';
+ else if (uq & 0x08) s += ', verify';
+ else s += ', usage 0x' + uq.toString(16).toUpperCase().padStart(2, '0');
+ pins.push(s);
+ uq = null;
+ }
+ }
+ return pins.length ? pins.join('; ') : (v || '');
+}
+
function fcpDo(key, value) {
if (/^[0-9A-F]+$/.test(value) === false) value = value || '';
switch (key) {
@@ -13053,7 +13104,7 @@ function fcpDo(key, value) {
case 'A0': return { name: 'Security attributes (data objects)', decoded: value };
case 'A5': return { name: 'Proprietary information', decoded: null };
case 'AB': return { name: 'Security attributes (expanded)', decoded: value };
- case 'C6': return { name: 'PIN status template DO', decoded: null };
+ case 'C6': return { name: 'PIN status template DO', decoded: fcpPsTemplate(value) };
case 'A5/80': {
const b = fcpInt(value);
const parts = [(b & 0x01) ? 'clock stop allowed' : 'clock stop not allowed'];
@@ -13910,10 +13961,100 @@ function efDecDir(b) {
return apps.length ? { applications: apps } : { raw: efHex(b) };
}
+// TS 102 221 9.4.1 Table 9.3: PIN key reference names (ordinal + local PINs).
+function pinKeyRefName(v) {
+ const names = {
+ 0x01: 'PIN app1', 0x02: 'PIN app2', 0x03: 'PIN app3', 0x04: 'PIN app4',
+ 0x05: 'PIN app5', 0x06: 'PIN app6', 0x07: 'PIN app7', 0x08: 'PIN app8',
+ 0x0A: 'ADM1', 0x0B: 'ADM2', 0x0C: 'ADM3', 0x0D: 'ADM4', 0x0E: 'ADM5',
+ 0x11: 'universal PIN',
+ 0x81: 'PIN2 app1', 0x82: 'PIN2 app2', 0x83: 'PIN2 app3', 0x84: 'PIN2 app4',
+ 0x85: 'PIN2 app5', 0x86: 'PIN2 app6', 0x87: 'PIN2 app7', 0x88: 'PIN2 app8',
+ 0x8A: 'ADM6', 0x8B: 'ADM7', 0x8C: 'ADM8', 0x8D: 'ADM9', 0x8E: 'ADM10'
+ };
+ if (names[v]) return names[v];
+ return 'key ref 0x' + v.toString(16).toUpperCase().padStart(2, '0');
+}
+
+// ISO 7816-4 Tables 16/17: AM byte bit meanings. A record does not store
+// whether it protects an EF or a DF, so both mappings are shown.
+function efArrAmLabel(am) {
+ const bits = [
+ [0x40, 'DELETE (self)'],
+ [0x20, 'TERMINATE EF / TERMINATE DF'],
+ [0x10, 'ACTIVATE'],
+ [0x08, 'DEACTIVATE'],
+ [0x04, 'WRITE/APPEND (EF) / CREATE FILE DF (DF)'],
+ [0x02, 'UPDATE/ERASE (EF) / CREATE FILE EF (DF)'],
+ [0x01, 'READ/SEARCH (EF) / DELETE FILE child (DF)'],
+ ];
+ if (am & 0x80) {
+ const low = bits.filter(b => (b[0] & 0x07) && (am & b[0])).map(b => b[1]);
+ return 'bits 7-4 proprietary' + (low.length ? ' + ' + low.join(' + ') : '');
+ }
+ const ops = bits.filter(b => am & b[0]).map(b => b[1]);
+ return ops.length ? ops.join(' + ') : 'no operations';
+}
+
+// One SC_DO (ISO 7816-4 Table 23; TS 102 221 Annex E.3).
+function efArrSc(tlv) {
+ const tag = tlv.tag, v = tlv.value;
+ if (tag === 0x90) return 'always';
+ if (tag === 0x97) return 'never';
+ if (tag === 0x9E) return 'security condition 0x' + efHex(v);
+ if (tag === 0xA4) {
+ let ref = null, uq = null;
+ for (const t of efTlv(v)) {
+ if (t.tag === 0x83 && t.value.length) ref = t.value[0];
+ else if (t.tag === 0x95 && t.value.length) uq = t.value[0];
+ }
+ let s = ref === null ? 'PIN' : pinKeyRefName(ref);
+ if (uq === null) s += ' (verify)';
+ else if (uq === 0x00) s += ' (verification not used)';
+ else if (uq & 0x08) s += ' (verify)';
+ else s += ' (usage 0x' + uq.toString(16).toUpperCase().padStart(2, '0') + ')';
+ return s;
+ }
+ if (tag === 0xB4 || tag === 0xB6 || tag === 0xB8) return 'SM CRT 0x' + tag.toString(16).toUpperCase();
+ if (tag === 0xA0) return '(' + efTlv(v).map(x => efArrSc(x)).join(' OR ') + ')';
+ if (tag === 0xAF) return '(' + efTlv(v).map(x => efArrSc(x)).join(' AND ') + ')';
+ if (tag === 0xA7) {
+ const inner = efTlv(v)[0];
+ return 'NOT ' + (inner ? efArrSc(inner) : '?');
+ }
+ return '0x' + tag.toString(16).toUpperCase().padStart(2, '0') + ' ' + efHex(v);
+}
+
+// One AM_DO (ISO 7816-4 Table 21; INCREASE/RESIZE per TS 102 221 11.1.8.1 and
+// TS 102 222 6.10). Returns null for SC_DOs.
+function efArrAmDo(tlv) {
+ const tag = tlv.tag, v = tlv.value;
+ if (tag === 0x80 && v.length) return 'AM 0x' + efHex(v) + ' (' + efArrAmLabel(v[0]) + ')';
+ if (tag === 0x84) {
+ const ins = v.length ? v[v.length - 1] : 0;
+ if (ins === 0x32) return 'INCREASE (AM_DO 0x84, INS 0x32)';
+ if (ins === 0xD4) return 'RESIZE FILE (AM_DO 0x84, INS 0xD4)';
+ return 'AM_DO 0x84 (INS 0x' + ins.toString(16).toUpperCase().padStart(2, '0') + ')';
+ }
+ if (tag >= 0x81 && tag <= 0x8F) return 'command description 0x' + tag.toString(16).toUpperCase() + ' (' + efHex(v) + ')';
+ if (tag === 0x9C) return 'proprietary state machine (' + efHex(v) + ')';
+ return null;
+}
+
+// EF_ARR records: AM_DO + SC_DO sequences in the expanded format
+// (ISO 7816-4 5.4.3.2; TS 102 221 9.2.7/13.4). SC_DOs after one AM_DO
+// without an OR template all have to be fulfilled (AND).
function efDecArr(b) {
const rules = [];
- for (const t of efTlv(b)) rules.push({ tag: '0x' + t.tag.toString(16).toUpperCase().padStart(2, '0'), hex: efHex(t.value) });
- return rules.length ? { rules: rules } : { raw: efHex(b) };
+ for (const t of efTlv(b)) {
+ const am = efArrAmDo(t);
+ if (am !== null) { rules.push({ am: am, scs: [] }); continue; }
+ const sc = efArrSc(t);
+ if (!rules.length) rules.push({ am: '(no AM_DO)', scs: [] });
+ rules[rules.length - 1].scs.push(sc);
+ }
+ if (!rules.length) return { raw: efHex(b) };
+ return { rules: rules.map(r => r.am + (r.scs.length ? ': ' + r.scs.join(' AND ') : '')) };
}
function efDecPnn(b) {
diff --git a/frontend/sw.js b/frontend/sw.js
index c8be22e..7421825 100644
--- a/frontend/sw.js
+++ b/frontend/sw.js
@@ -1,4 +1,4 @@
-const CACHE = 'simple-v252';
+const CACHE = 'simple-v253';
const URLS = [
'index.html',
'help.html',
diff --git a/frontend/tests/ef_decode.test.js b/frontend/tests/ef_decode.test.js
index c527a43..bac7571 100644
--- a/frontend/tests/ef_decode.test.js
+++ b/frontend/tests/ef_decode.test.js
@@ -28,6 +28,7 @@ const FNS = [
'efDecSpn', 'efDecLoci', 'efDecPsLoci', 'efDecEpsLoci', 'efDecEpsNsc', 'efDecKc', 'efDecAcc',
'efDecPhase', 'efDecCbmi', 'efDecCbmir', 'efDecEcc', 'efDecOpl', 'efDecAd', 'efDecAcl',
'efDecSmsp', 'efDecSpdi', 'efDecNai', 'efDecDir', 'efDecArr', 'efDecPnn', 'efDecAdn',
+ 'pinKeyRefName', 'efArrAmLabel', 'efArrSc', 'efArrAmDo',
'efDecExt1', 'efDecSms', 'efDecSume', 'efFindDecoder', 'efFidFromPath', 'efDecodeBytes',
'efDecodeFile', 'efDecodeHex', 'efIsRawOnly', 'efFieldLabel', 'efPrimitive', 'efFlatten',
'efDiffData', 'efDataSummary', 'efContentDiff', 'gsm7Decode', 'efRenderFieldsHtml',
@@ -152,8 +153,22 @@ test('EF record decoders: SMS, OPL, DIR, ARR, IMPI/SMSP', () => {
assert.strictEqual(dir.applications.length, 1);
assert.strictEqual(dir.applications[0].aid, 'A0000000871002FFFFFFFF8907090000');
assert.strictEqual(dir.applications[0].label, 'USim1');
+ // EF.ARR: AM_DO 01 (read) guarded by ADM1 (TS 102 221 9.2.7 / ISO 7816-4 5.4.3.2)
const arr = efDecArr(efBytes('800101a40683010a950108'));
- assert.deepStrictEqual(arr.rules, [{ tag: '0x80', hex: '01' }, { tag: '0xA4', hex: '83010A950108' }]);
+ assert.deepStrictEqual(arr.rules, ['AM 0x01 (READ/SEARCH (EF) / DELETE FILE child (DF)): ADM1 (verify)']);
+ // Two rules: update needs PIN app1 or app2, read is always
+ assert.deepStrictEqual(
+ efDecArr(efBytes('800102a010a406830101950108a4068301029501088001019000')).rules,
+ ['AM 0x02 (UPDATE/ERASE (EF) / CREATE FILE EF (DF)): (PIN app1 (verify) OR PIN app2 (verify))',
+ 'AM 0x01 (READ/SEARCH (EF) / DELETE FILE child (DF)): always']);
+ // INCREASE / RESIZE ride AM_DO 0x84 with the command INS as value
+ assert.deepStrictEqual(efDecArr(efBytes('8401329000')).rules,
+ ['INCREASE (AM_DO 0x84, INS 0x32): always']);
+ assert.deepStrictEqual(efDecArr(efBytes('8401D49000')).rules,
+ ['RESIZE FILE (AM_DO 0x84, INS 0xD4): always']);
+ // b8=1: bits 7-4 are proprietary
+ assert.deepStrictEqual(efDecArr(efBytes('8001819000')).rules,
+ ['AM 0x81 (bits 7-4 proprietary + READ/SEARCH (EF) / DELETE FILE child (DF)): always']);
const impi = efDecNai(efBytes('803137333830303630303030303031303140696d732e6d6e633030302e6d63633733382e336770706e6574776f726b2e6f7267'));
assert.strictEqual(impi.text, '738006000000101@ims.mnc000.mcc738.3gppnetwork.org');
const smsp = efDecSmsp(efBytes('534d5343ffffffffffffffffffffffffe1ffffffffffffffffffffffff0891945197109099f9ffffff0000a9'));
diff --git a/frontend/tests/profiler.test.js b/frontend/tests/profiler.test.js
index f095044..c5acd16 100644
--- a/frontend/tests/profiler.test.js
+++ b/frontend/tests/profiler.test.js
@@ -21,7 +21,8 @@ function extractFunc(src, name, asyncFn) {
return (asyncFn ? 'async ' : '') + src.slice(m.index, i + 1);
}
-const FNS = ['profilerNormHex', 'profilerNormHexStrict', 'profilerMatch', 'profilerMatchMin', 'profilerMaskPrefix4', 'profilerFileFields', 'profilerContentKindForFileType', 'profilerEmptyRecordContent', 'profilerValidateProfile', 'profilerCustomNameForPath', 'pysimCustomNormPath', 'profilerUpdateRulePath', 'profilerResultAspects', 'profilerAspectSummary', 'profilerNumRanges', 'profilerMatchedRecordsText', 'profilerCloneName', 'profilerClone', 'profilerNewId', 'esc', 'escHtml', 'profilerRawDataCheck', 'profilerRenderReport', 'parseBerLen', 'parseTlvList', 'fcpInt', 'fcpParseTlvs', 'fcpFileDescriptor', 'fcpLifeCycle', 'fcpSfi', 'fcpDo', 'fcpDecode', 'fcpDiffHtml', 'profilerFciPreviewItems', 'profilerUpdateFciPreview', 'profilerUpdateRule', 'profilerFciInput', 'profilerScanToggleAll', 'profilerScanIgnoreAllState', 'swapNibbles', 'decIccid', 'profilerSnapshotIccid', 'profilerValidateSnapshot', 'profilerListSwitch', 'profilerScanRefreshOptions', 'profilerLiveSource', 'profilerSnapshotSource', 'profilerVisibleResults', 'profilerRulesFromSnapshot', 'profilerExtraFileResults', 'profilerScanNameKeydown', 'profilerTimingStats', 'profilerTimingAccumulator', 'profilerFormatMs', 'profilerRenderSnapshotSummary', 'profilerSnapshotCountLabel', 'pysimFsInfoHtml', 'profilerLabelText', 'profilerResultsHeaderText', 'profilerRenderResultsView', 'profilerBuildFileRuleFromSnapshot', 'profilerSnapshotPickListHtml', 'profilerScanSetTarget'];
+const FNS = ['profilerNormHex', 'profilerNormHexStrict', 'profilerMatch', 'profilerMatchMin', 'profilerMaskPrefix4', 'profilerFileFields', 'profilerContentKindForFileType', 'profilerEmptyRecordContent', 'profilerValidateProfile', 'profilerCustomNameForPath', 'pysimCustomNormPath', 'profilerUpdateRulePath', 'profilerResultAspects', 'profilerAspectSummary', 'profilerNumRanges', 'profilerMatchedRecordsText', 'profilerCloneName', 'profilerClone', 'profilerNewId', 'esc', 'escHtml', 'profilerRawDataCheck', 'profilerRenderReport', 'parseBerLen', 'parseTlvList', 'fcpInt', 'fcpParseTlvs', 'fcpFileDescriptor', 'fcpLifeCycle', 'fcpSfi', 'fcpDo', 'fcpDecode',
+ 'pinKeyRefName', 'fcpPsTemplate', 'fcpDiffHtml', 'profilerFciPreviewItems', 'profilerUpdateFciPreview', 'profilerUpdateRule', 'profilerFciInput', 'profilerScanToggleAll', 'profilerScanIgnoreAllState', 'swapNibbles', 'decIccid', 'profilerSnapshotIccid', 'profilerValidateSnapshot', 'profilerListSwitch', 'profilerScanRefreshOptions', 'profilerLiveSource', 'profilerSnapshotSource', 'profilerVisibleResults', 'profilerRulesFromSnapshot', 'profilerExtraFileResults', 'profilerScanNameKeydown', 'profilerTimingStats', 'profilerTimingAccumulator', 'profilerFormatMs', 'profilerRenderSnapshotSummary', 'profilerSnapshotCountLabel', 'pysimFsInfoHtml', 'profilerLabelText', 'profilerResultsHeaderText', 'profilerRenderResultsView', 'profilerBuildFileRuleFromSnapshot', 'profilerSnapshotPickListHtml', 'profilerScanSetTarget'];
let code = '';
for (const f of FNS) code += extractFunc(html, f) + '\n';
code += extractFunc(html, 'profilerBuildFileRule', true) + '\n';
@@ -694,6 +695,21 @@ test('fcpDecode maps life cycle status per table 11.7b', () => {
assert.strictEqual(lcs('0C'), 'termination');
assert.strictEqual(lcs('0D'), 'termination');
assert.strictEqual(lcs('80'), 'proprietary (80)');
+ assert.strictEqual(lcs('8C'), 'proprietary (8C)');
+ // Table 11.7b: unlisted values with b8 clear are RFU, not proprietary
+ assert.strictEqual(lcs('02'), 'RFU (02)');
+ assert.strictEqual(lcs('2C'), 'RFU (2C)');
+});
+
+test('fcpDecode decodes the PS template DO (C6) with PIN key references', () => {
+ // TS 102 221 11.1.1.4.10 / 9.5.2: PS_DO bitmap over the following key refs
+ const dec = hex => fcpDecode('620BC609' + hex).items.find(it => it.key === 'C6').decoded;
+ assert.strictEqual(dec('9001C0950108830101'), 'PIN app1: enabled, verify');
+ assert.strictEqual(fcpPsTemplate('9001C095010883010183010A'),
+ 'PIN app1: enabled, verify; ADM1: enabled, always verified');
+ assert.strictEqual(fcpPsTemplate('90014083010183010A'),
+ 'PIN app1: disabled, always verified; ADM1: enabled, always verified');
+ assert.strictEqual(fcpPsTemplate('900100830111'), 'universal PIN: disabled, always verified');
});
test('fcpDecode decodes A5 proprietary sub-TLVs', () => {
diff --git a/frontend/tests/sp.test.js b/frontend/tests/sp.test.js
index 2cb995c..4f4651f 100644
--- a/frontend/tests/sp.test.js
+++ b/frontend/tests/sp.test.js
@@ -27,7 +27,7 @@ function extractFunc(src, name) {
}
const FNS = ['hexToBytes', 'bytesToHex', 'des3Keys', 'des3EncryptBlock', 'des3CbcEncrypt',
- 'xorBytes', 'zeroPad', 'cbcMac', 'aesCbcEncrypt', 'aesShiftLeft1', 'aesCmacSubkeys',
+ 'xorBytes', 'zeroPad', 'crc32Bytes', 'cbcMac', 'aesCbcEncrypt', 'aesShiftLeft1', 'aesCmacSubkeys',
'aesCmac', '_genSpBuild', 'genSp', 'spNextCntr', 'scp80SegmentInfo', 'spSizeInfoText',
'spShowSizeInfo'];
let code = '';
@@ -95,16 +95,35 @@ test('ciphered + CC SPI 16/01 (counter_must_be_higher, plaintext PoR)', () => {
'00201516011515B00000E42573469E68A8462A57A505B0E2B1C09C1928C7A182311F');
});
-test('unciphered + CC SPI 02/09', () => {
- assert.strictEqual(
- makeRun({ 'sp-spi1': '02', 'sp-spi2-hex': '09' }),
- '1502091515B0000000000000010085A8CA1A9828B0BB00A40000023F00');
+test('unciphered + CC SPI 02/09 carries the CPL', () => {
+ const out = makeRun({ 'sp-spi1': '02', 'sp-spi2-hex': '09' });
+ assert.strictEqual(out,
+ '001D1502091515B0000000000000010085A8CA1A9828B0BB00A40000023F00');
+ assert.strictEqual(parseInt(out.slice(0, 4), 16), out.length / 2 - 2);
});
-test('unciphered packet starts at CHL, no CPL prefix (pySim parity)', () => {
- const out = makeRun({ 'sp-spi1': '02', 'sp-spi2-hex': '09' });
- assert.strictEqual(out.slice(0, 2), '15');
- assert.strictEqual(out.length, 58);
+test('unprotected packet (SPI 00) keeps the pySim CHL-first form', () => {
+ assert.strictEqual(
+ makeRun({ 'sp-spi1': '00', 'sp-spi2-hex': '09' }),
+ '0D00091515B0000000000000010000A40000023F00');
+});
+
+test('unprotected concatenated packet gains the CPL (TS 31.115 4.3)', () => {
+ const out = makeRun({ 'sp-spi1': '00', 'sp-spi2-hex': '09', 'sp-apdu': 'A0'.repeat(200) });
+ assert.strictEqual(out.length / 2, 216);
+ assert.strictEqual(parseInt(out.slice(0, 4), 16), 214);
+ assert.strictEqual(out.slice(4, 6), '0D');
+ assert.strictEqual(scp80SegmentInfo(out).segments, 2);
+});
+
+test('RC (SPI 01) computes CRC-32 over the CPL frame', () => {
+ assert.strictEqual(
+ makeRun({ 'sp-spi1': '01', 'sp-spi2-hex': '09' }),
+ '00191101091515B0000000000000010050C942DC00A40000023F00');
+});
+
+test('crc32Bytes known answer (TS 102 225 Annex B)', () => {
+ assert.strictEqual(bytesToHex(crc32Bytes(hexToBytes('0102030405'))), '470B99F4');
});
test('sysmocom public reference vector (spi1 04 / spi2 19, cntr=0)', () => {
@@ -174,7 +193,7 @@ test('AES unciphered + CC SPI 12/09 (counter higher)', () => {
'sp-kic-key': KIC_AES,
'sp-kid-key': KID_AES,
}),
- '1512092222B0001100000000110029826122C7A0B79500A40004023F00');
+ '001D1512092222B0001100000000110029826122C7A0B79500A40004023F00');
});
test('AES ciphered + CC SPI 1E/19 (counter +1)', () => {
diff --git a/pyproject.toml b/pyproject.toml
index 2fcddb5..5d941df 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "pysim-simple-server"
-version = "3.5.0"
+version = "3.5.1"
description = "HTTP REST server wrapping pysim for the SIMple PWA"
requires-python = ">=3.8"
# pysim is a git-only dependency installed explicitly by setup.bat/setup.sh.
diff --git a/pysim_simple_server/server.py b/pysim_simple_server/server.py
index 25d1eb5..8681e56 100644
--- a/pysim_simple_server/server.py
+++ b/pysim_simple_server/server.py
@@ -29,7 +29,7 @@ from osmocom.tlv import BER_TLV_IE
from osmocom.utils import rpad
-VERSION = '3.5.0'
+VERSION = '3.5.1'
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE
@@ -937,9 +937,12 @@ def _ota_reference(spi1, spi2, kic, kid, tar_hex, cntr_hex, apdu_hex, kic_key_he
otak = _ota_keyset(spi1, spi2, kic, kid, cntr_hex, kic_key_hex, kid_key_hex)
spi = _spi_from_bytes(int(spi1, 16), int(spi2, 16))
out = OtaDialectSms().encode_cmd(otak, h2b(tar_hex), spi, h2b(apdu_hex))
- if not spi['ciphering'] and spi['rc_cc_ds'] != 'no_rc_cc_ds':
+ if not spi['ciphering'] and (spi['rc_cc_ds'] != 'no_rc_cc_ds'
+ or len(out) > SCP80_SINGLE_BYTES):
# pySim drops the CPL octets from its unciphered output; re-add them
- # (they are included in the RC/CC/DS calculation) per TS 31.115 4.2.
+ # per TS 31.115 4.2 (they are part of the RC/CC/DS input) and per
+ # Table 1 NOTE / 4.3 (required for concatenation - the CHL-to-end
+ # range exceeds one SM).
# CPL counts octets from the CHL octet to the last octet of the
# Secured Data (incl. padding); pySim's unciphered output is exactly
# that range, so the CPL value equals its length.
@@ -1031,10 +1034,12 @@ def _build_secured_packet(spi1, spi2, kic, kid, tar_hex, cntr_hex, apdu_hex,
otak = _ota_keyset(spi1, spi2, kic, kid, cntr_hex, kic_key_hex, kid_key_hex)
spi = _spi_from_bytes(int(spi1, 16), int(spi2, 16))
out = _encode_cmd_unlimited(otak, spi, h2b(tar_hex), h2b(apdu_hex))
- if not spi['ciphering'] and spi['rc_cc_ds'] != 'no_rc_cc_ds':
+ if not spi['ciphering'] and (spi['rc_cc_ds'] != 'no_rc_cc_ds'
+ or len(out) > SCP80_SINGLE_BYTES):
# CPL counts octets from the CHL octet to the last octet of the
# Secured Data (incl. padding) - exactly the length of the
- # unciphered range.
+ # unciphered range. Added for the RC/CC/DS input and for
+ # concatenation (TS 31.115 Table 1 NOTE / 4.3).
cpl = len(out)
out = cpl.to_bytes(2, 'big') + out
return b2h(out), spi
diff --git a/tests/test_ota_helpers.py b/tests/test_ota_helpers.py
index 9eb6099..e97d1c5 100644
--- a/tests/test_ota_helpers.py
+++ b/tests/test_ota_helpers.py
@@ -59,6 +59,8 @@ REFERENCE_VECTORS = {
'00201516011515b00000e42573469e68a8462a57a505b0e2b1c09c1928c7a182311f',
('02', '09'):
'001d1502091515b0000000000000010085a8ca1a9828b0bb00a40000023f00',
+ ('01', '09'):
+ '00191101091515b0000000000000010050c942dc00a40000023f00',
}
# AES-128 reference vectors (public synthetic keys from pySim test_ota.py).
@@ -153,6 +155,10 @@ class TestOtaReference(unittest.TestCase):
out, _ = _ota_reference('02', '09', '15', '15', 'b00000', '0000000001', APDU, K, K)
self.assertEqual(out, REFERENCE_VECTORS[('02', '09')])
+ def test_unciphered_rc_reference(self):
+ out, _ = _ota_reference('01', '09', '15', '15', 'b00000', '0000000001', APDU, K, K)
+ self.assertEqual(out, REFERENCE_VECTORS[('01', '09')])
+
def test_unciphered_cpl_is_0x001d(self):
# Regression: CPL counts octets from the CHL octet to the last octet
# of the secured data (29 here), it must NOT be len(out)-2 (27/0x001b).
@@ -229,6 +235,22 @@ class TestSmsConcatenation(unittest.TestCase):
self.assertEqual(_split_secured_packet(b'A' * 140, include_cpi=False),
[b'A' * 140])
+ def test_unprotected_single_sm_keeps_the_chl_first_form(self):
+ # TS 31.115 Table 1 NOTE: the CPL is "not absolutely necessary" in a
+ # single SM - an unprotected packet keeps pySim's CHL-first form.
+ out, _ = _build_secured_packet('00', '09', '15', '15', 'b00000',
+ '0000000001', APDU, K, K)
+ self.assertEqual(out[:2], '0d')
+
+ def test_unprotected_concatenated_packet_gains_the_cpl(self):
+ # ... but it is required once the packet needs concatenation
+ # (TS 31.115 Table 1 NOTE / 4.3).
+ out, _ = _build_secured_packet('00', '09', '15', '15', 'b00000',
+ '0000000001', 'A0' * 200, K, K)
+ self.assertEqual(len(out) // 2, 216)
+ self.assertEqual(out[:4], '00d6') # CPL = 214 = CHL..end
+ self.assertEqual(int(out[:4], 16), len(out) // 2 - 2)
+
def test_240_byte_load_block_encodes_and_fits_the_card_buffer(self):
# The RAM path no longer clamps LOAD blocks to one SMS: a 240-byte
# block (the GP maximum) becomes a concatenated command.