diff --git a/README.md b/README.md
index cb3324f..2ed2cac 100644
--- a/README.md
+++ b/README.md
@@ -431,6 +431,10 @@ PoR confirms the card received and executed the secured packet. Two modes:
Delivery PoR (SPI2 `01`) is simpler — the card returns the PoR directly in the ENVELOPE response. Submit PoR (SPI2 `21`) is used when the card cannot respond inline (e.g. during ELF operations where the ENVELOPE response space is limited).
+#### SMS delivery
+
+A secured packet is delivered in an SMS-PP-DOWNLOAD ENVELOPE. Its size in bytes and the number of SMS it takes are shown under the packet field; a packet that does not fit one SMS is delivered as a **concatenated** SMS-PP 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. A packet that would need more than 5 segments is refused — that is the practical limit of the card's concatenation buffer.
+
#### References
- ETSI TS 102 225 V18.1.0: Secured packet structure for UICC based applications
@@ -449,7 +453,7 @@ The RAM subtab offers two operations selected from the **Operation** dropdown:
| Operation | Description |
|---|---|
| **Explore Card (all GP data)** | Queries GET STATUS for ISD, Applications, ELFs, and ELF Modules, plus GET DATA FF21 for memory info. Results appear in an explorer view with per-item **Delete** buttons. |
-| **Install Package (.cap file)** | Sends a `.cap` file to the card via the server: INSTALL\[for load\] → LOAD ×N → INSTALL\[for install (+make selectable)\]. |
+| **Install Package (.cap file)** | Sends a `.cap` file to the card via the server: INSTALL\[for load\] → LOAD ×N → INSTALL\[for install (+make selectable)\]. The load file is split into LOAD APDUs of 1–240 bytes of payload (240 by default, editable in **LOAD block size**); a secured packet larger than one SMS is delivered as a concatenated SMS-PP download of up to 5 segments. |
#### Explorer View
diff --git a/README_RUS.md b/README_RUS.md
index 45a0a78..38ce7e9 100644
--- a/README_RUS.md
+++ b/README_RUS.md
@@ -405,6 +405,10 @@ PoR подтверждает, что карта получила и выполн
Delivery PoR (SPI2 `01`) проще — карта возвращает PoR напрямую в ответе ENVELOPE. Submit PoR (SPI2 `21`) используется, когда карта не может ответить inline (ограничено пространство ответа ENVELOPE).
+#### Доставка SMS
+
+Защищённый пакет доставляется в ENVELOPE SMS-PP-DOWNLOAD. Его размер в байтах и число необходимых SMS показываются под полем пакета; пакет, не помещающийся в одно SMS, доставляется **конкатенированной** SMS-PP загрузкой (TS 31.115 §4.3): пакет делится на части SMS-пользовательских данных (первое SMS 132 октета, последующие по 134 — первое дополнительно несёт информационные элементы конкатенации и CPI), и сегменты отправляются по порядку. Пакет, которому нужно больше 5 сегментов, отклоняется — это практический предел буфера конкатенации карты.
+
#### Ссылки
- ETSI TS 102 225 V18.1.0
@@ -423,7 +427,7 @@ Delivery PoR (SPI2 `01`) проще — карта возвращает PoR на
| Операция | Описание |
|---|---|
| **Explore Card (all GP data)** | Запрос GET STATUS для ISD, приложений, ELF и модулей ELF, а также GET DATA FF21 для информации о памяти. Результаты отображаются в обзоре с кнопками **Delete** для каждого элемента. |
-| **Install Package (.cap file)** | Отправка `.cap` файла на карту через сервер: INSTALL\[for load\] → LOAD ×N → INSTALL\[for install (+make selectable)\]. |
+| **Install Package (.cap file)** | Отправка `.cap` файла на карту через сервер: INSTALL\[for load\] → LOAD ×N → INSTALL\[for install (+make selectable)\]. Load-файл делится на LOAD APDU по 1–240 байт полезной нагрузки (по умолчанию 240, изменяется в **размер блока LOAD**); защищённый пакет больше одного SMS доставляется конкатенированной SMS-PP загрузкой до 5 сегментов. |
#### Обзор карты (Explorer View)
diff --git a/docs/api.md b/docs/api.md
index 5ff5de9..a773a8e 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -275,8 +275,15 @@ Returns:
### `POST /api/send-ota`
-Send an OTA command (SCP80) to the card via SMS-PP-DOWNLOAD ENVELOPE.
-The secured packet is delivered in an SMS-DELIVER TPDU wrapped in an ENVELOPE command.
+Send an OTA command (SCP80) to the card via SMS-PP-DOWNLOAD ENVELOPE
+(one ENVELOPE per SMS). With `apdu` the secured packet is built
+server-side (no single-SMS limit); a packet that does not fit one SMS is
+sent as a **concatenated** download per 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 IEs) and the
+segments are sent in order. A packet that would need more than 5 segments
+is refused (the card's concatenation buffer is the limit). With `sp` a
+pre-built packet is delivered the same way.
**Request body:**
```json
@@ -296,11 +303,16 @@ The secured packet is delivered in an SMS-DELIVER TPDU wrapped in an ENVELOPE co
**Response (delivery PoR):**
```json
{"success": true, "sw": "9000", "response_data": "027100000e0a...",
+ "bytes": 36, "segments": 1,
"por": {"response_status": "por_ok", "tar": "B00000", "pcntr": 0,
"decoded": {"number_of_commands": 1, "last_status_word": "6e00",
"last_response_data": ""}}}
```
+`bytes` is the secured packet size and `segments` the number of SMS
+segments sent (1 = single SMS, >1 = concatenated download; the failure
+response carries them too, plus an `error`).
+
**Response (submit PoR):** PoR is extracted from the SMS-SUBMIT TPDU
fetched via a proactive command (FETCH). The response contains the
same `por` structure if decoding succeeds.
@@ -338,27 +350,28 @@ Install a Java Card `.cap` file on the card via GlobalPlatform commands (INSTALL
| `stk_params` | no | Hex CA TLV (TS 102 226 §8.2.1.3.2.1) for SIM toolkit app-specific params |
| `nv_quota` / `volatile_quota` | no | Integer memory quotas (bytes) for `gen_install_parameters()` |
| `make_selectable` | no | If true (default), final INSTALL uses P1=`0C` (install + make selectable) |
-| `load_block_size` | no | Bytes of load-file payload per LOAD APDU, 1–240. When empty/omitted the server auto-fits: the largest size whose SCP80 secured packet still encodes into one SMS (140 octets; e.g. 107 for the 3DES `spi1=16/spi2=01` configuration). An explicit value larger than the fitting size is clamped; over SCP80 the default 240 does **not** fit and used to fail with pySim's "Cannot encode command in a single SMS". |
+| `load_block_size` | no | Bytes of load-file payload per LOAD APDU, 1–240 (default 240 when omitted). SCP80 concatenation carries a secured packet larger than one SMS over up to 5 SMs, so the block size is no longer clamped to fit a single SMS. |
**Response (success):**
```json
{"success": true, "failed_step": null,
- "steps": [{"name": "install_for_load", "apdu": "80E60200...", "por_status": "por_ok", "sw": "9000"},
- {"name": "load_0", "apdu": "80E80000...", "por_status": "por_ok", "sw": "9000"},
- {"name": "install_for_install", "apdu": "80E60C00...", "por_status": "por_ok", "sw": "9000"}],
+ "steps": [{"name": "install_for_load", "apdu": "80E60200...", "por_status": "por_ok", "sw": "9000", "bytes": 58, "segments": 1},
+ {"name": "load_0", "apdu": "80E80000...", "por_status": "por_ok", "sw": "9000", "bytes": 274, "segments": 3},
+ {"name": "install_for_install", "apdu": "80E60C00...", "por_status": "por_ok", "sw": "9000", "bytes": 66, "segments": 1}],
"final_cntr": "0000000004",
"load_file_aid": "A000000003000000",
"module_aid": "A000000003000000",
"application_aid": "A000000003000000",
- "load_block_size": 107,
+ "load_block_size": 240,
"load_block_size_requested": null,
- "load_block_size_clamped": false}
+ "load_block_size_auto": true}
```
-`load_block_size` is the effective size used for the LOAD blocks,
-`load_block_size_requested` echoes an explicit `load_block_size` (null =
-auto-fit) and `load_block_size_clamped` is true when the requested size was
-reduced to fit one SMS.
+`load_block_size` is the effective size used for the LOAD blocks (240 by
+default), `load_block_size_requested` echoes an explicit `load_block_size`
+(null = the default was used) and `load_block_size_auto` marks that default.
+Each step reports the secured packet size `bytes` and the number of SMS
+`segments` it took.
**Response (failure):**
```json
diff --git a/frontend/help-ru.html b/frontend/help-ru.html
index 9b0dd90..106ca7c 100644
--- a/frontend/help-ru.html
+++ b/frontend/help-ru.html
@@ -273,7 +273,7 @@
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 (при подключении к серверу). Полученный Proof of Receipt декодируется и показывается строкой статуса PoR (статус, TAR, счётчик, сырой PoR); статусное слово и данные ответа последней команды подставляются в подвкладку «Парсер ответов» (Remote APDU), а успешный PoR увеличивает счётчик повторов и очищает пакет.
+
Кнопка Проверить в 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); статусное слово и данные ответа последней команды подставляются в подвкладку «Парсер ответов» (Remote APDU), а успешный PoR увеличивает счётчик повторов и очищает пакет.
3.2 RAM
Выполняет операции удалённого управления приложениями (Remote Application Management) как защищённые пакеты SCP80 через SMS-PP-DOWNLOAD ENVELOPE. Карта должна поддерживать SCP03 (AES или 3DES). Предустановка карты со вкладки Карты обеспечивает SPI, ключи, TAR и счётчик (RAM использует ISD TAR предустановки, по умолчанию 000000).
@@ -283,7 +283,7 @@
Операция
Описание
Обзор карты (все данные GP)
Запрос GET STATUS для ISD, приложений, ELF и модулей ELF, а также GET DATA FF21 для информации о памяти. Результаты отображаются в обзоре с кнопками Удалить для каждого элемента.
-
Установка пакета (.cap файл)
Отправка .cap файла на карту через сервер: INSTALL[for load] → LOAD ×N → INSTALL[for install (+make selectable)]. Load-файл делится на LOAD APDU, каждый из которых помещается в один SMS SCP80; поле размер блока LOAD переопределяет авто-подобранный размер (пусто = максимальный размер, чей secured-пакет укладывается в 140 октетов), так что большой .cap просто занимает несколько SMS.
+
Установка пакета (.cap файл)
Отправка .cap файла на карту через сервер: INSTALL[for load] → LOAD ×N → INSTALL[for install (+make selectable)]. Load-файл делится на LOAD APDU размера размер блока LOAD (1–240 байт полезной нагрузки, по умолчанию 240); каждый защищённый пакет доставляется одним SMS или, если он больше, конкатенированной SMS-PP загрузкой до 5 сегментов, так что большой .cap просто занимает несколько SMS.
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). 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 Response parser tab, 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). 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 Response parser tab, 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).
@@ -283,7 +283,7 @@
Operation
Description
Explore Card (all GP data)
Queries GET STATUS for ISD, Applications, ELFs, and ELF Modules, plus GET DATA FF21 for memory info. Results appear in an explorer view with per-item Delete buttons.
-
Install Package (.cap file)
Sends a .cap file to the card via the server: INSTALL[for load] → LOAD ×N → INSTALL[for install (+make selectable)]. The load file is split into LOAD APDUs that each fit one SCP80 SMS; the LOAD block size field overrides the auto-fitted size (empty = largest size whose secured packet still encodes into 140 octets), so a large .cap simply takes several SMS.
+
Install Package (.cap file)
Sends a .cap file to the card via the server: INSTALL[for load] → LOAD ×N → INSTALL[for install (+make selectable)]. The load file is split into LOAD APDUs of the LOAD block size (1–240 bytes of payload, 240 by default); each secured packet is delivered as a single SMS or, when larger, as a concatenated SMS-PP download of up to 5 segments, so a large .cap simply takes several SMS.
Splits the load file into LOAD APDUs that fit one SCP80 SMS; auto-fit picks the largest size that encodes into 140 octets.
+
+
+
Splits the load file into LOAD APDUs (1-240 bytes of payload). A secured packet larger than one SMS is sent as a concatenated SMS-PP download (up to 5 SMs).
@@ -1436,7 +1437,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.1.4';
+const SIMPLE_VERSION = '3.2.0';
document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION;
// ===== Tab switching =====
@@ -3268,6 +3269,7 @@ function spCntrAdjust(delta) {
}
function spInvalidate() {
document.getElementById('sp-result').value = '';
+ spShowSizeInfo();
}
function updateSp() {
@@ -3468,6 +3470,56 @@ function aesCmac(data, keyBytes) {
// CPL = octets from CHL to end incl. padding; CHL = 13 + len_sig.
// CPI (0x70) and CHI (null) are NOT part of the SMS packet data.
function genSp() {
+ _genSpBuild();
+ spShowSizeInfo();
+}
+
+// ===== SCP80 SMS segmentation (TS 31.115 4.2/4.3) =====
+// Capacities mirror the server's SCP80_*_BYTES constants (pysim_simple_server/
+// server.py): 140 octets of SMS user data minus the UDH, which carries the
+// concatenation IE (5 octets) in every SM and the CPI IE (2 octets) in the
+// first one of a concatenated command.
+const SCP80_MAX_SMS = 5;
+const SCP80_SINGLE_BYTES = 137;
+const SCP80_FIRST_BYTES = 132;
+const SCP80_NEXT_BYTES = 134;
+
+// Secured packet hex -> {bytes, segments} of the SMS-PP download.
+function scp80SegmentInfo(hex) {
+ const bytes = Math.floor((hex || '').replace(/[^0-9a-fA-F]/g, '').length / 2);
+ if (!bytes) return { bytes: 0, segments: 0 };
+ if (bytes <= SCP80_SINGLE_BYTES) return { bytes: bytes, segments: 1 };
+ let segments = 1;
+ let rest = bytes - SCP80_FIRST_BYTES;
+ while (rest > 0) { segments += 1; rest -= SCP80_NEXT_BYTES; }
+ return { bytes: bytes, segments: segments };
+}
+
+function spSizeInfoText(hex) {
+ const info = scp80SegmentInfo(hex);
+ if (!info.segments) return '';
+ let s = info.bytes + ' ' + t('bytes') + ' · ' + info.segments + ' SMS';
+ if (info.segments > 1) s += ' (' + t('concatenated') + ')';
+ if (info.segments > SCP80_MAX_SMS) {
+ s += ' — ' + t('too large for the card concatenation buffer') + ' (' + SCP80_MAX_SMS + ' SMS)';
+ }
+ return s;
+}
+
+// Refresh the packet size / SMS count line under the packet field.
+function spShowSizeInfo() {
+ const el = document.getElementById('sp-size-info');
+ if (!el) return;
+ const value = (document.getElementById('sp-result') || {}).value || '';
+ const isPacket = /^\s*[0-9a-fA-F\s]+$/.test(value);
+ const info = isPacket ? scp80SegmentInfo(value) : { bytes: 0, segments: 0 };
+ el.textContent = info.segments ? spSizeInfoText(value) : '';
+ el.className = 'text-xs mt-1 ' + (info.segments > SCP80_MAX_SMS
+ ? 'text-red-600 dark:text-red-400'
+ : 'text-gray-500 dark:text-slate-400');
+}
+
+function _genSpBuild() {
const apduHex = (document.getElementById('sp-apdu').value || '').replace(/[^0-9a-fA-F]/g, '');
if (!apduHex) { document.getElementById('sp-result').value = 'Error: specify APDU'; return; }
@@ -5804,6 +5856,9 @@ async function pysimSendOta() {
cApduSwitchSubtab('response');
}
let msg = 'OTA sent. SW: ' + data.sw;
+ if (data.bytes && data.segments) {
+ msg += ' | ' + data.bytes + ' ' + t('bytes') + ' · ' + data.segments + ' SMS';
+ }
const por = data.por;
if (por && por.response_status) {
msg += ' | PoR status: ' + por.response_status + ' (TAR ' + por.tar + ')';
@@ -5821,6 +5876,7 @@ async function pysimSendOta() {
cntrEl.value = spNextCntr(cntrEl.value);
msg += ' | CNTR -> ' + cntrEl.value;
document.getElementById('sp-result').value = '';
+ spShowSizeInfo();
// keep the selected card preset in sync with the new counter (v1.9.8)
spCntrSyncPreset();
sendResultEl.textContent = msg;
@@ -6429,7 +6485,9 @@ async function ramInstallCap(sp) {
let txt = '';
(data.steps || []).forEach((s, idx) => {
const mark = s.por_status === 'por_ok' ? '✅' : '❌';
- txt += mark + ' ' + t('Step') + ' ' + (idx + 1) + ': ' + s.name + ' — ' + s.por_status + ' (SW ' + s.sw + ')\n';
+ let line = mark + ' ' + t('Step') + ' ' + (idx + 1) + ': ' + s.name + ' — ' + s.por_status + ' (SW ' + s.sw + ')';
+ if (s.bytes && s.segments) line += ' · ' + s.bytes + ' ' + t('bytes') + ' / ' + s.segments + ' SMS';
+ txt += line + '\n';
});
stepsEl.textContent = txt;
@@ -6438,11 +6496,7 @@ async function ramInstallCap(sp) {
let sizeInfo = '';
if (data.load_block_size) {
sizeInfo = ' — ' + t('LOAD blocks') + ': ' + data.load_block_size + ' B';
- if (data.load_block_size_clamped) {
- sizeInfo += ' (' + t('clamped from') + ' ' + data.load_block_size_requested + ')';
- } else if (!data.load_block_size_requested) {
- sizeInfo += ' (' + t('auto-fit') + ')';
- }
+ if (data.load_block_size_auto) sizeInfo += ' (' + t('default') + ')';
}
resultEl.textContent = t('Install OK') + ' — load_file_aid=' + data.load_file_aid + ' module_aid=' + data.module_aid + sizeInfo;
resultEl.classList.remove('hidden', 'text-red-600'); resultEl.classList.add('text-green-600');
@@ -13993,12 +14047,13 @@ const LANG_RU = {
'Object and related objects': 'Объект и связанные объекты',
'SD AID (empty = ISD)': 'AID SD (пусто = ISD)',
'CAP file': 'CAP-файл',
- 'LOAD block size (bytes, empty = auto-fit)': 'Размер блока LOAD (байт, пусто = авто)',
- 'Splits the load file into LOAD APDUs that fit one SCP80 SMS; auto-fit picks the largest size that encodes into 140 octets.': 'Разбивает load-файл на LOAD APDU, помещающиеся в один SMS SCP80; авто-подбор выбирает максимальный размер, укладывающийся в 140 октетов.',
+ 'LOAD block size (bytes, empty = 240)': 'Размер блока LOAD (байт, пусто = 240)',
+ 'default': 'по умолчанию',
+ 'concatenated': 'конкатенация',
+ 'too large for the card concatenation buffer': 'слишком велик для буфера конкатенации карты',
+ 'Splits the load file into LOAD APDUs (1-240 bytes of payload). A secured packet larger than one SMS is sent as a concatenated SMS-PP download (up to 5 SMs).': 'Разбивает load-файл на LOAD APDU (полезная нагрузка 1–240 байт). Защищённый пакет больше одного SMS отправляется как конкатенированная SMS-PP загрузка (до 5 SMS).',
'LOAD block size must be 1..240': 'Размер блока LOAD должен быть в диапазоне 1..240',
'LOAD blocks': 'Блоки LOAD',
- 'clamped from': 'ограничено с',
- 'auto-fit': 'авто',
'Redirect to external server': 'Перенаправление на внешний сервер',
'Pass-through (card destination)': 'Проброс (адрес карты)',
'Redirect: every BIP channel the card opens is connected to this Host:Port (the external HTTP OTA platform); TLS is terminated there, and the address the card requests is only logged.': 'Перенаправление: каждый открываемый картой BIP-канал подключается к этому Host:Port (внешняя платформа HTTP OTA); TLS завершается там, а запрошенный картой адрес только журналируется.',
diff --git a/frontend/sw.js b/frontend/sw.js
index 3c6743a..2db1f5b 100644
--- a/frontend/sw.js
+++ b/frontend/sw.js
@@ -1,4 +1,4 @@
-const CACHE = 'simple-v239';
+const CACHE = 'simple-v240';
const URLS = [
'index.html',
'help.html',
diff --git a/frontend/tests/sp.test.js b/frontend/tests/sp.test.js
index f150090..2cb995c 100644
--- a/frontend/tests/sp.test.js
+++ b/frontend/tests/sp.test.js
@@ -28,9 +28,14 @@ function extractFunc(src, name) {
const FNS = ['hexToBytes', 'bytesToHex', 'des3Keys', 'des3EncryptBlock', 'des3CbcEncrypt',
'xorBytes', 'zeroPad', 'cbcMac', 'aesCbcEncrypt', 'aesShiftLeft1', 'aesCmacSubkeys',
- 'aesCmac', 'genSp', 'spNextCntr'];
+ 'aesCmac', '_genSpBuild', 'genSp', 'spNextCntr', 'scp80SegmentInfo', 'spSizeInfoText',
+ 'spShowSizeInfo'];
let code = '';
for (const f of FNS) code += extractFunc(html, f) + '\n';
+for (const c of ['SCP80_MAX_SMS', 'SCP80_SINGLE_BYTES', 'SCP80_FIRST_BYTES', 'SCP80_NEXT_BYTES']) {
+ code += html.match(new RegExp('const ' + c + ' = \\d+;'))[0].replace('const ', 'var ') + '\n';
+}
+code += 'function t(s){return s;}\n';
// All test vectors are computed with the synthetic dummy key material below
// (no live/sample card keys, no ICCIDs). They are cross-checked byte-for-byte
@@ -229,3 +234,39 @@ test('spNextCntr tolerates lower case and separators', () => {
assert.strictEqual(spNextCntr('00000000 0a'), '000000000B');
assert.strictEqual(spNextCntr(''), '0000000001');
});
+
+test('scp80SegmentInfo mirrors the server segmentation rules', () => {
+ assert.deepStrictEqual(scp80SegmentInfo(''), { bytes: 0, segments: 0 });
+ assert.deepStrictEqual(scp80SegmentInfo('AA'.repeat(137)), { bytes: 137, segments: 1 });
+ assert.deepStrictEqual(scp80SegmentInfo('AA'.repeat(138)), { bytes: 138, segments: 2 });
+ assert.deepStrictEqual(scp80SegmentInfo('AA'.repeat(132 + 134)), { bytes: 266, segments: 2 });
+ assert.deepStrictEqual(scp80SegmentInfo('AA'.repeat(132 + 134 * 2)), { bytes: 400, segments: 3 });
+});
+
+test('spSizeInfoText reports size, SMS count and the card buffer limit', () => {
+ assert.strictEqual(spSizeInfoText('AA'.repeat(18)), '18 bytes · 1 SMS');
+ assert.strictEqual(spSizeInfoText('AA'.repeat(266)), '266 bytes · 2 SMS (concatenated)');
+ assert.match(spSizeInfoText('AA'.repeat(132 + 134 * 5)),
+ /too large for the card concatenation buffer \(5 SMS\)/);
+ assert.strictEqual(spSizeInfoText(''), '');
+});
+
+test('the SCP80 UI constants match the server segmentation constants', () => {
+ const py = fs.readFileSync(
+ path.join(__dirname, '..', '..', 'pysim_simple_server', 'server.py'), 'utf8');
+ const read = (name) => {
+ const m = new RegExp('^' + name + '\\s*=\\s*(\\d+)', 'm').exec(py);
+ assert.ok(m, name + ' not found in server.py');
+ return parseInt(m[1], 10);
+ };
+ assert.strictEqual(SCP80_SINGLE_BYTES, read('SCP80_SINGLE_BYTES'));
+ assert.strictEqual(SCP80_FIRST_BYTES, read('SCP80_FIRST_BYTES'));
+ assert.strictEqual(SCP80_NEXT_BYTES, read('SCP80_NEXT_BYTES'));
+ assert.strictEqual(SCP80_MAX_SMS, read('MAX_ENVELOPE_SEGMENTS'));
+});
+
+test('a generated single-SMS packet shows its size and SMS count', () => {
+ const pkt = makeRun({});
+ const info = elements['sp-size-info'] || {};
+ assert.strictEqual(info.textContent, (pkt.length / 2) + ' bytes · 1 SMS');
+});
diff --git a/pyproject.toml b/pyproject.toml
index 09a04d7..5b683a0 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "pysim-simple-server"
-version = "3.1.4"
+version = "3.2.0"
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 357ccac..bff1d19 100644
--- a/pysim_simple_server/server.py
+++ b/pysim_simple_server/server.py
@@ -7,6 +7,7 @@ import threading
import traceback
import re
import codecs
+import zlib
from urllib.parse import unquote_plus
from datetime import datetime, timedelta
from http.server import HTTPServer, BaseHTTPRequestHandler
@@ -28,10 +29,29 @@ from osmocom.tlv import BER_TLV_IE
from osmocom.utils import rpad
-VERSION = '3.1.4'
+VERSION = '3.2.0'
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE
+# --- SCP80 SMS concatenation (TS 31.115 4.2/4.3) -------------------------
+#
+# SMS user data budget: 140 octets. A packet that fits one SM carries only
+# the CPI IE in the UDH; a concatenated command carries the concatenation IE
+# (5 octets) in every SM plus the CPI IE (2 octets) in the first one, so the
+# payload capacities differ. All figures include the UDHL octet.
+SCP80_SINGLE_BYTES = 137 # 140 - UDHL(1) - CPI IE(2)
+SCP80_FIRST_BYTES = 132 # 140 - UDHL(1) - concat IE(5) - CPI IE(2)
+SCP80_NEXT_BYTES = 134 # 140 - UDHL(1) - concat IE(5)
+# Fixed concatenation reference (TS 23.040 9.2.3.24.1): the server is the
+# only sender and only one concatenated message is ever in flight, so there
+# are no segments of another message to tell apart.
+SCP80_CONCAT_REF = 0x01
+# Segment cap: the card's concatenation buffer is limited (5-7 SMs is
+# typical for UICCs), so a longer packet could never be reassembled anyway.
+SCP80_MAX_SEGMENTS = MAX_ENVELOPE_SEGMENTS
+
+
+
# Static file serving (the PWA lives in /frontend, served by this server
# so the UI and the API share an origin and no CORS/PNA is involved).
@@ -798,18 +818,25 @@ def _cap_parse(cap_hex):
return loadfile_aid, module_aid, loadfile_data
-def _build_sms_tpdu(chunk_hex, chunk_total=1, chunk_num=1, oa_number='12345', include_cpi=True):
+def _build_sms_tpdu(chunk_hex, chunk_total=1, chunk_num=1, oa_number='12345', include_cpi=True,
+ chunk_ref=SCP80_CONCAT_REF):
chunk = bytes.fromhex(chunk_hex)
# TS 23.040 UDH: first octet is UDHL, then the information elements.
- # TS 31.115 4.2/4.3: the OTA CPI is UDH IEIa='70' with IEIDLa='00'.
+ # TS 31.115 4.2/4.3: a concatenated command carries the concatenation IE
+ # in every SM and the OTA CPI (IEIa='70', IEIDLa='00') in the first one.
udh = b''
if chunk_total > 1:
- udh = bytes([0x00, 0x03, 0x01, chunk_total, chunk_num])
+ udh = bytes([0x00, 0x03, chunk_ref, chunk_total, chunk_num])
if chunk_num == 1 and include_cpi:
udh += bytes([0x70, 0x00])
elif include_cpi:
udh = bytes([0x70, 0x00])
tp_ud = (bytes([len(udh)]) + udh + chunk) if udh else chunk
+ # TP-UDL counts the whole user data (UDHL octet + UDH + payload) and the
+ # SMS limit is 140 octets; the splitter sizes every part for its own UDH.
+ budget = 140 - len(udh) - (1 if udh else 0)
+ if len(chunk) > budget:
+ raise ValueError('SMS user data overflow: %d > %d octets' % (len(chunk), budget))
first_byte = 0x44 if udh and chunk_total > 1 else (0x40 if udh else 0x04)
tpdu = bytes([first_byte]) + _encode_sms_oa(oa_number) + bytes([0x7F, 0xF6]) + _encode_scts() + bytes([len(tp_ud)]) + tp_ud
return tpdu.hex()
@@ -921,26 +948,141 @@ def _ota_reference(spi1, spi2, kic, kid, tar_hex, cntr_hex, apdu_hex, kic_key_he
return b2h(out), spi
-def _max_load_block_size(spi1, spi2, kic, kid, tar_hex, cntr_hex,
- kic_key_hex, kid_key_hex, requested=240):
- """Largest LOAD block payload that still fits one SMS (TS 31.115).
+def _split_secured_packet(pkt, include_cpi=True):
+ """Split a command packet into SMS user-data parts (TS 31.115 4.3).
- pySim's SMS dialect refuses to encode a secured packet above 140 octets,
- so a LOAD APDU of the default 240-byte block cannot be sent over SCP80.
- Trial-encode a synthetic LOAD APDU for decreasing payload sizes (the
- cipher padding makes a closed-form bound unreliable) and return the
- largest one that encodes; 0 = not even a 1-byte block fits."""
- cap = max(1, min(int(requested or 240), 240))
- for n in range(cap, 0, -1):
- apdu = '80E80000%02X%s00' % (n, '00' * n)
+ Every part plus its UDH stays within the 140-octet SMS user data; the
+ first part of a concatenated command is smaller because its UDH also
+ carries the CPI IE."""
+ single_max = SCP80_SINGLE_BYTES if include_cpi else 140
+ if len(pkt) <= single_max:
+ return [pkt]
+ first = SCP80_FIRST_BYTES if include_cpi else SCP80_NEXT_BYTES
+ parts = [pkt[:first]]
+ rest = pkt[first:]
+ while rest:
+ parts.append(rest[:SCP80_NEXT_BYTES])
+ rest = rest[SCP80_NEXT_BYTES:]
+ return parts
+
+
+def _encode_cmd_unlimited(otak, spi, tar, apdu):
+ """SCP80 command packet (TS 102 225 5.1.1 / TS 31.115 4.2) of any size.
+
+ pySim's OtaDialectSms.encode_cmd refuses packets above 140 octets
+ ("Fragmentation not implemented") - exactly the packets that need SMS
+ concatenation. The coding is identical, so the packet is built here
+ with pySim's key material and header constructor and without the length
+ limit; the tests pin our output to pySim's byte-for-byte for packets
+ that fit one SMS (/api/sp-verify keeps using pySim as the reference)."""
+ from pySim.ota import OtaDialectSms
+ dialect = OtaDialectSms()
+ len_sig = dialect._compute_sig_len(spi)
+ pad_cnt = 0
+ apdu = bytes(apdu)
+ if spi['ciphering']:
+ # Append padding bytes to end up with blocksize.
+ len_cipher = 6 + len_sig + len(apdu)
+ padding = otak.crypt._get_padding(len_cipher, otak.crypt.blocksize)
+ pad_cnt = len(padding)
+ apdu += padding
+
+ kic = {'key': otak.kic_idx, 'algo': otak.algo_crypt}
+ kid = {'key': otak.kid_idx, 'algo': otak.algo_auth}
+ # CHL = octets from (and including) SPI to the end of RC/CC/DS:
+ # 13 == SPI(2) + KIc(1) + KID(1) + TAR(3) + CNTR(5) + PCNTR(1).
+ chl = 13 + len_sig
+ part_head = dialect.hdr_construct.build({'chl': chl, 'spi': spi, 'kic': kic,
+ 'kid': kid, 'tar': tar})
+ part_cnt = otak.cntr.to_bytes(5, 'big') + pad_cnt.to_bytes(1, 'big')
+ envelope_data = part_head + part_cnt + apdu
+ cpl = len(envelope_data) + len_sig
+ envelope_data = cpl.to_bytes(2, 'big') + envelope_data
+
+ if spi['rc_cc_ds'] == 'cc':
+ cc = otak.auth.sign(envelope_data)
+ envelope_data = part_cnt + cc + apdu
+ elif spi['rc_cc_ds'] == 'rc':
+ crc32 = zlib.crc32(envelope_data) & 0xffffffff
+ envelope_data = part_cnt + crc32.to_bytes(4, 'big') + apdu
+ elif spi['rc_cc_ds'] == 'no_rc_cc_ds':
+ envelope_data = part_cnt + apdu
+ else:
+ raise ValueError('Invalid rc_cc_ds: %s' % spi['rc_cc_ds'])
+
+ if spi['ciphering']:
+ ciph = otak.crypt.encrypt(envelope_data)
+ envelope_data = part_head + ciph
+ cpl = len(envelope_data)
+ envelope_data = cpl.to_bytes(2, 'big') + envelope_data
+ else:
+ envelope_data = part_head + envelope_data
+ return envelope_data
+
+
+def _build_secured_packet(spi1, spi2, kic, kid, tar_hex, cntr_hex, apdu_hex,
+ kic_key_hex, kid_key_hex):
+ """SCP80 command packet of any size; returns (hex, spi).
+
+ The CPL fix-up for the unciphered case mirrors _ota_reference: pySim
+ drops the CPL octets there, but they are part of the RC/CC/DS
+ calculation (TS 31.115 4.2)."""
+ from osmocom.utils import h2b, b2h
+ 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':
+ # CPL counts octets from the CHL octet to the last octet of the
+ # Secured Data (incl. padding) - exactly the length of the
+ # unciphered range.
+ cpl = len(out)
+ out = cpl.to_bytes(2, 'big') + out
+ return b2h(out), spi
+
+
+def _send_secured_packet(scc, sp_hex, oa_number, sm_sc=None, include_cpi=True,
+ submit_handler=None, max_segments=SCP80_MAX_SEGMENTS):
+ """Send a secured packet as SMS-PP download ENVELOPEs, one per segment.
+
+ TS 31.115 4.3: the whole command packet is split into SMS user-data
+ parts (the first SM carries the concatenation IE plus the CPI IE, the
+ following ones only the concatenation IE) and the card reassembles them.
+ Returns a dict with success/bytes/segments/sw/response_data or error."""
+ try:
+ pkt = bytes.fromhex(sp_hex or '')
+ except ValueError as e:
+ return {'success': False, 'bytes': 0, 'segments': 0,
+ 'error': 'Invalid secured packet: %s' % e}
+ if not pkt:
+ return {'success': False, 'bytes': 0, 'segments': 0,
+ 'error': 'Empty secured packet'}
+ parts = _split_secured_packet(pkt, include_cpi=include_cpi)
+ total = len(parts)
+ if total > max_segments:
+ return {'success': False, 'bytes': len(pkt), 'segments': total,
+ 'error': 'Secured packet too large: %d segments (max %d - the '
+ 'card concatenation buffer)' % (total, max_segments)}
+ data = None
+ sw = None
+ sys.stderr.write('OTA SEND: %d SMS segment(s), %d bytes\n' % (total, len(pkt)))
+ for i, part in enumerate(parts):
try:
- out_hex, _ = _ota_reference(spi1, spi2, kic, kid, tar_hex, cntr_hex,
- apdu, kic_key_hex, kid_key_hex)
- except ValueError:
- continue
- if len(out_hex) // 2 <= 140:
- return n
- return 0
+ tpdu = _build_sms_tpdu(part.hex(), total, i + 1, oa_number=oa_number,
+ include_cpi=include_cpi)
+ except ValueError as e:
+ return {'success': False, 'bytes': len(pkt), 'segments': total,
+ 'error': str(e)}
+ if total > 1:
+ sys.stderr.write('OTA SEND: ENVELOPE %d/%d (%d B)%s\n' % (
+ i + 1, total, len(part), ' + CPI' if i == 0 and include_cpi else ''))
+ data, sw = _send_envelope(tpdu, scc, sm_sc=sm_sc or '12345678912',
+ submit_handler=submit_handler)
+ if sw != '9000' and not sw.startswith('91'):
+ return {'success': False, 'sw': sw, 'bytes': len(pkt),
+ 'segments': total,
+ 'error': 'ENVELOPE failed at segment %d' % (i + 1)}
+ return {'success': True, 'bytes': len(pkt), 'segments': total, 'sw': sw,
+ 'response_data': data if data else None}
def _decode_por(spi1, spi2, kic, kid, cntr_hex, kic_key_hex, kid_key_hex, response_hex):
@@ -4563,7 +4705,9 @@ class PysimHandler(BaseHTTPRequestHandler):
include_cpi = body.get('includeCpi', True)
try:
if apdu:
- # RAM operation: SCP80-wrap the raw GP command
+ # RAM operation: SCP80-wrap the raw GP command. The packet
+ # may exceed one SMS (pySim refuses that), so use our own
+ # encoder and let _send_secured_packet segment it.
spi1 = body.get('spi1', '16')
spi2 = body.get('spi2', '01')
kic = body.get('kic', '25')
@@ -4572,12 +4716,10 @@ class PysimHandler(BaseHTTPRequestHandler):
cntr = body.get('cntr', '')
kic_key = body.get('kicKey', '')
kid_key = body.get('kidKey', '')
- sp_hex, _ = _ota_reference(spi1, spi2, kic, kid, tar, cntr, apdu, kic_key, kid_key)
- sp_bytes = bytes.fromhex(sp_hex)
+ sp_hex, _ = _build_secured_packet(spi1, spi2, kic, kid, tar, cntr, apdu, kic_key, kid_key)
else:
# Regular SCP80: use pre-built secured packet
sp_hex = sp
- sp_bytes = bytes.fromhex(sp_hex)
spi2_val = int(body.get('spi2', '00'), 16)
por_in_submit = bool(spi2_val & 0x20)
submit_handler = None
@@ -4587,65 +4729,55 @@ class PysimHandler(BaseHTTPRequestHandler):
old_proactive = scc._tp.proactive_handler
scc._tp.proactive_handler = submit_handler
try:
- max_chunk = 130
- chunks = [sp_bytes[i:i+max_chunk] for i in range(0, len(sp_bytes), max_chunk)]
- total = len(chunks)
- sys.stderr.write('OTA SEND: SPI %s %s KIc %s KID %s TAR %s CNTR %s LEN %dB CHUNKS %d\n' % (
+ sys.stderr.write('OTA SEND: SPI %s %s KIc %s KID %s TAR %s CNTR %s LEN %dB\n' % (
body.get('spi1', ''), body.get('spi2', ''), body.get('kic', ''),
body.get('kid', ''), body.get('tar', ''), body.get('cntr', ''),
- len(sp_bytes), total))
- if total > MAX_ENVELOPE_SEGMENTS:
- resp = {'success': False, 'error': 'Secured packet too large: %d segments (max %d)' % (total, MAX_ENVELOPE_SEGMENTS)}
- sys.stderr.write('OTA SEND FAILED: %d segments exceeds max %d\n' % (total, MAX_ENVELOPE_SEGMENTS))
+ len(sp_hex) // 2))
+ sys.stderr.write('RAM C-APDU: %s\n' % apdu if apdu else sp)
+ sys.stderr.write('RAM SECURED-PACKET: %s\n' % sp_hex)
+ result = _send_secured_packet(
+ scc, sp_hex, oa_number=self.server.sms_oa,
+ sm_sc=self.server.sms_sc, include_cpi=include_cpi,
+ submit_handler=submit_handler)
+ if not result['success']:
+ resp = result
+ sys.stderr.write('OTA SEND FAILED: %s\n' % result.get('error'))
else:
- sys.stderr.write('RAM C-APDU: %s\n' % apdu if apdu else sp)
- sys.stderr.write('RAM SECURED-PACKET: %s\n' % sp_hex)
- last_data = None
- last_sw = None
- for i, chunk in enumerate(chunks):
- tpdu = _build_sms_tpdu(chunk.hex(), total, i + 1, oa_number=self.server.sms_oa,
- include_cpi=include_cpi)
- data, sw = _send_envelope(tpdu, scc, sm_sc=self.server.sms_sc, submit_handler=submit_handler)
- last_data = data
- last_sw = sw
- if sw != '9000' and not sw.startswith('91'):
- resp = {'success': False, 'sw': sw, 'error': 'ENVELOPE failed at chunk %d' % (i + 1)}
- sys.stderr.write('OTA SEND FAILED: chunk %d SW %s\n' % (i + 1, sw))
- break
+ resp = {'success': True, 'sw': result['sw'],
+ 'response_data': result['response_data'],
+ 'bytes': result['bytes'], 'segments': result['segments']}
+ por_src = 'envelope'
+ por_hex = resp['response_data']
+ if submit_handler and submit_handler.submit_tpdu_hex:
+ tpdu_b = bytes.fromhex(submit_handler.submit_tpdu_hex)
+ idx = tpdu_b.find(b'\x02\x71\x00')
+ if idx >= 0:
+ por_hex = tpdu_b[idx:].hex()
+ por_src = 'sms-submit'
+ por = _decode_por(body.get('spi1', ''), body.get('spi2', ''), body.get('kic', ''),
+ body.get('kid', ''), body.get('cntr', ''), body.get('kicKey', ''),
+ body.get('kidKey', ''), por_hex)
+ # Check for SPI2=0x21 (PoR required) but got 9000 with no PoR → card refuses PoR
+ is_ram = bool(apdu)
+ por_required = bool(spi2_val & 0x01)
+ no_por_received = not por_hex and not (submit_handler and submit_handler.submit_tpdu_hex)
+ if is_ram and por_required and result['sw'] == '9000' and no_por_received:
+ sys.stderr.write('WARNING: Card refused to return PoR - ENVELOPE returned 9000 with no response data\n')
+ sys.stderr.write('RAM RESPONSE-PACKET: %s\n' % (por_hex if por_hex else 'empty'))
+ if por:
+ resp['por'] = por
+ extra = ''
+ if por.get('decoded'):
+ extra = ' (compact: %s cmd, last SW %s)' % (por['decoded'].get('number_of_commands', '?'),
+ por['decoded'].get('last_status_word', '?'))
+ sys.stderr.write('RAM R-APDU: %s\n' % por['decoded'].get('last_response_data', ''))
+ sys.stderr.write('OTA PoR[%s]: status=%s TAR=%s CNTR=%s PCNTR=%s RPL=%s RHL=%s%s\n' % (
+ por_src, por.get('response_status'), por.get('tar'), por.get('cntr'),
+ por.get('pcntr'), por.get('rpl'), por.get('rhl'), extra))
+ elif por_hex:
+ sys.stderr.write('OTA PoR[%s]: undecodable raw=%s\n' % (por_src, str(por_hex)))
else:
- resp = {'success': True, 'sw': last_sw, 'response_data': last_data if last_data else None}
- por_src = 'envelope'
- por_hex = resp['response_data']
- if submit_handler and submit_handler.submit_tpdu_hex:
- tpdu_b = bytes.fromhex(submit_handler.submit_tpdu_hex)
- idx = tpdu_b.find(b'\x02\x71\x00')
- if idx >= 0:
- por_hex = tpdu_b[idx:].hex()
- por_src = 'sms-submit'
- por = _decode_por(body.get('spi1', ''), body.get('spi2', ''), body.get('kic', ''),
- body.get('kid', ''), body.get('cntr', ''), body.get('kicKey', ''),
- body.get('kidKey', ''), por_hex)
- # Check for SPI2=0x21 (PoR required) but got 9000 with no PoR → card refuses PoR
- is_ram = bool(apdu)
- por_required = bool(spi2_val & 0x01)
- no_por_received = not por_hex and not (submit_handler and submit_handler.submit_tpdu_hex)
- if is_ram and por_required and last_sw == '9000' and no_por_received:
- sys.stderr.write('WARNING: Card refused to return PoR - ENVELOPE returned 9000 with no response data\n')
- sys.stderr.write('RAM RESPONSE-PACKET: %s\n' % (por_hex if por_hex else 'empty'))
- if por:
- resp['por'] = por
- extra = ''
- if por.get('decoded'):
- extra = ' (compact: %s cmd, last SW %s)' % (por['decoded'].get('number_of_commands', '?'),
- por['decoded'].get('last_status_word', '?'))
- sys.stderr.write('RAM R-APDU: %s\n' % por['decoded'].get('last_response_data', ''))
- sys.stderr.write('OTA PoR[%s]: status=%s TAR=%s CNTR=%s PCNTR=%s RPL=%s RHL=%s%s\n' % (
- por_src, por.get('response_status'), por.get('tar'), por.get('cntr'),
- por.get('pcntr'), por.get('rpl'), por.get('rhl'), extra))
- elif por_hex:
- sys.stderr.write('OTA PoR[%s]: undecodable raw=%s\n' % (por_src, str(por_hex)))
- else:
- sys.stderr.write('OTA PoR[%s]: none\n' % por_src)
+ sys.stderr.write('OTA PoR[%s]: none\n' % por_src)
finally:
if submit_handler and hasattr(scc, '_tp'):
scc._tp.proactive_handler = old_proactive
@@ -4732,22 +4864,11 @@ class PysimHandler(BaseHTTPRequestHandler):
self._send_json(err, 400)
self._log_resp(err)
return
- max_block = _max_load_block_size(spi1, spi2, kic, kid, tar, cntr,
- kic_key, kid_key,
- requested=block_size_req or 240)
- if max_block < 1:
- err = {'success': False,
- 'error': 'no LOAD block fits a single SMS with these '
- 'SCP80 parameters'}
- self._send_json(err, 500)
- self._log_resp(err)
- return
- block_size = min(block_size_req, max_block) if block_size_req else max_block
- block_clamped = block_size_req is not None and block_size != block_size_req
- sys.stderr.write('RAM-INSTALL: LOAD block size %d bytes%s\n' % (
- block_size,
- (' (requested %d, clamped to fit one SMS)' % block_size_req)
- if block_clamped else ''))
+ # The GP LOAD payload limit is 240 bytes per APDU; SCP80
+ # concatenates the secured packet over up to SCP80_MAX_SEGMENTS
+ # SMs, so a block no longer has to fit into a single SMS.
+ block_size = block_size_req or 240
+ sys.stderr.write('RAM-INSTALL: LOAD block size %d bytes\n' % block_size)
steps = []
encode_error = None
@@ -4758,16 +4879,13 @@ class PysimHandler(BaseHTTPRequestHandler):
def _send_gp_apdu(apdu_hex, step_name):
nonlocal cntr, encode_error
try:
- sp_hex, _ = _ota_reference(spi1, spi2, kic, kid, tar, cntr, apdu_hex, kic_key, kid_key)
+ sp_hex, _ = _build_secured_packet(spi1, spi2, kic, kid, tar, cntr, apdu_hex, kic_key, kid_key)
except ValueError as e:
encode_error = str(e)
steps.append({'name': step_name, 'por_status': 'encode_error',
'sw': encode_error})
sys.stderr.write('RAM-INSTALL: %s encode failed: %s\n' % (step_name, e))
return False
- sp_bytes = bytes.fromhex(sp_hex)
- max_chunk = 130
- chunks = [sp_bytes[i:i + max_chunk] for i in range(0, len(sp_bytes), max_chunk)]
submit_handler = None
old_proactive = None
if por_in_submit and hasattr(scc, '_tp'):
@@ -4775,19 +4893,20 @@ class PysimHandler(BaseHTTPRequestHandler):
old_proactive = scc._tp.proactive_handler
scc._tp.proactive_handler = submit_handler
try:
- last_data = None
- last_sw = None
- for i, chunk in enumerate(chunks):
- tpdu = _build_sms_tpdu(chunk.hex(), len(chunks), i + 1,
- oa_number=self.server.sms_oa, include_cpi=include_cpi)
- data, sw = _send_envelope(tpdu, scc, sm_sc=self.server.sms_sc,
- submit_handler=submit_handler)
- last_data = data
- last_sw = sw
- if sw != '9000' and not sw.startswith('91'):
- steps.append({'name': step_name, 'por_status': 'envelope_error', 'sw': sw})
- sys.stderr.write('RAM-INSTALL: %s ENVELOPE failed SW %s\n' % (step_name, sw))
- return False
+ result = _send_secured_packet(
+ scc, sp_hex, oa_number=self.server.sms_oa,
+ sm_sc=self.server.sms_sc, include_cpi=include_cpi,
+ submit_handler=submit_handler)
+ if not result['success']:
+ steps.append({'name': step_name, 'por_status': 'envelope_error',
+ 'sw': result.get('sw') or result.get('error'),
+ 'bytes': result.get('bytes'),
+ 'segments': result.get('segments')})
+ sys.stderr.write('RAM-INSTALL: %s send failed: %s\n' % (
+ step_name, result.get('error')))
+ return False
+ last_data = result['response_data']
+ last_sw = result['sw']
# Decode PoR
por_src = 'envelope'
por_hex = last_data
@@ -4802,10 +4921,12 @@ class PysimHandler(BaseHTTPRequestHandler):
if por and por.get('decoded'):
ps = por['decoded'].get('response_status', '')
por_status = 'por_ok' if ps == '9100' else 'por_error_%s' % ps
- sys.stderr.write('RAM-INSTALL: %s PoR[%s] status=%s\n' % (step_name, por_src, ps))
+ sys.stderr.write('RAM-INSTALL: %s PoR[%s] status=%s (%d B, %d SM)\n' % (
+ step_name, por_src, ps, result['bytes'], result['segments']))
elif last_sw == '9000' and not por_hex:
por_status = 'no_por'
- steps.append({'name': step_name, 'por_status': por_status, 'sw': last_sw})
+ steps.append({'name': step_name, 'por_status': por_status, 'sw': last_sw,
+ 'bytes': result['bytes'], 'segments': result['segments']})
# Increment counter
cntr = '%010X' % ((int(cntr, 16) + 1) % (2 ** 32))
return True
@@ -4834,7 +4955,7 @@ class PysimHandler(BaseHTTPRequestHandler):
'load_file_aid': loadfile_aid, 'module_aid': module_aid,
'load_block_size': block_size,
'load_block_size_requested': block_size_req,
- 'load_block_size_clamped': block_clamped}
+ 'load_block_size_auto': not block_size_req}
self._send_json(resp)
self._log_resp(resp)
return
@@ -4843,7 +4964,7 @@ class PysimHandler(BaseHTTPRequestHandler):
'module_aid': module_aid, 'final_cntr': cntr,
'load_block_size': block_size,
'load_block_size_requested': block_size_req,
- 'load_block_size_clamped': block_clamped}
+ 'load_block_size_auto': not block_size_req}
sys.stderr.write('RAM-INSTALL: Complete — loadfile_aid=%s module_aid=%s cntr=%s\n' % (
loadfile_aid, module_aid, cntr))
self._send_json(resp)
diff --git a/tests/test_ota_helpers.py b/tests/test_ota_helpers.py
index 0bb503c..9eb6099 100644
--- a/tests/test_ota_helpers.py
+++ b/tests/test_ota_helpers.py
@@ -20,17 +20,23 @@ if str(PY_SIM) not in sys.path:
sys.path.insert(0, str(PY_SIM))
from pysim_simple_server.server import (
+ _build_secured_packet,
_build_sms_tpdu,
_build_tr,
_decode_cmd,
_decode_por,
_decode_tr,
_log_proactive,
- _max_load_block_size,
_ota_reference,
_record_tr,
+ _send_secured_packet,
_spi_from_bytes,
+ _split_secured_packet,
_tr_data_only,
+ SCP80_FIRST_BYTES,
+ SCP80_MAX_SEGMENTS,
+ SCP80_NEXT_BYTES,
+ SCP80_SINGLE_BYTES,
)
# Synthetic dummy key material (no real card keys).
@@ -177,28 +183,136 @@ class TestOtaReference(unittest.TestCase):
self.assertEqual(out, AES_REFERENCE_VECTORS[('1e', '19')])
self.assertEqual(spi['counter'], 'counter_must_be_lower')
- def test_max_load_block_size_fits_one_sms(self):
- # LOAD blocks are too large for SCP80 at the 240-byte default (pySim
- # refuses a secured packet above 140 octets), so the helper finds the
- # largest payload that still encodes into a single SMS.
- mx = _max_load_block_size('16', '01', '15', '15', 'b00000',
- '0000000001', K, K)
- self.assertGreater(mx, 0)
- self.assertLessEqual(mx, 240)
- def load_apdu(n):
- return '80E80000%02X%s00' % (n, '00' * n)
- out, _ = _ota_reference('16', '01', '15', '15', 'b00000',
- '0000000001', load_apdu(mx), K, K)
- self.assertLessEqual(len(out) // 2, 140)
- with self.assertRaises(ValueError):
- _ota_reference('16', '01', '15', '15', 'b00000',
- '0000000001', load_apdu(mx + 1), K, K)
+ def test_ram_load_sequence_uses_full_240_byte_blocks(self):
+ # The one-SMS clamp was removed: the RAM path's default LOAD blocks
+ # are the GP maximum of 240 bytes, and each secured packet still fits
+ # the card's concatenation buffer.
+ from pysim_simple_server.server import _cap_apdu_sequence
+ seq = _cap_apdu_sequence('A000000003000000', 'A000000003000001',
+ 'AA' * 600, block_size=240)
+ loads = [a for a in seq if a.startswith('80E8')]
+ self.assertGreater(len(loads), 1)
+ for apdu in loads:
+ self.assertLessEqual(int(apdu[8:10], 16), 240)
+ sp, _ = _build_secured_packet('16', '01', '15', '15', 'b00000',
+ '0000000001', apdu, K, K)
+ self.assertLessEqual(len(_split_secured_packet(bytes.fromhex(sp))),
+ SCP80_MAX_SEGMENTS)
- def test_max_load_block_size_respects_the_requested_cap(self):
- mx = _max_load_block_size('16', '01', '15', '15', 'b00000',
- '0000000001', K, K, requested=50)
- self.assertLessEqual(mx, 50)
- self.assertGreater(mx, 0)
+
+class TestSmsConcatenation(unittest.TestCase):
+ """SCP80 SMS concatenation: packet building and segment sending
+ (TS 31.115 4.2/4.3)."""
+
+ def test_secured_packet_matches_the_pysim_reference_for_one_sms(self):
+ # Our encoder only lifts pySim's single-SMS refusal; for packets that
+ # fit one SMS it must stay byte-identical to the pySim reference.
+ for spi1, spi2 in (('06', '09'), ('16', '01'), ('02', '09'), ('04', '19')):
+ ref, _ = _ota_reference(spi1, spi2, '15', '15', 'b00000',
+ '0000000001', APDU, K, K)
+ out, _ = _build_secured_packet(spi1, spi2, '15', '15', 'b00000',
+ '0000000001', APDU, K, K)
+ self.assertEqual(out, ref, (spi1, spi2))
+
+ def test_split_keeps_the_sms_user_data_budget(self):
+ self.assertEqual(_split_secured_packet(b'A' * SCP80_SINGLE_BYTES),
+ [b'A' * SCP80_SINGLE_BYTES])
+ parts = _split_secured_packet(b'A' * (SCP80_SINGLE_BYTES + 1))
+ self.assertEqual([len(p) for p in parts],
+ [SCP80_FIRST_BYTES, SCP80_SINGLE_BYTES + 1 - SCP80_FIRST_BYTES])
+ pkt = bytes(range(256)) * 2
+ parts = _split_secured_packet(pkt)
+ self.assertEqual(len(parts[0]), SCP80_FIRST_BYTES)
+ self.assertTrue(all(len(p) <= SCP80_NEXT_BYTES for p in parts[1:]))
+ self.assertEqual(b''.join(parts), pkt)
+ # Without the CPI IE the single-SM budget is the full 140 octets.
+ self.assertEqual(_split_secured_packet(b'A' * 140, include_cpi=False),
+ [b'A' * 140])
+
+ 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.
+ apdu = '80E80000F0' + '00' * 240 + '00'
+ out, _ = _build_secured_packet('16', '01', '15', '15', 'b00000',
+ '0000000001', apdu, K, K)
+ self.assertGreater(len(out) // 2, 140)
+ parts = _split_secured_packet(bytes.fromhex(out))
+ self.assertTrue(2 <= len(parts) <= SCP80_MAX_SEGMENTS, len(parts))
+ # pySim still refuses the same command - the reason we build it here.
+ with self.assertRaises(ValueError):
+ _ota_reference('16', '01', '15', '15', 'b00000', '0000000001', apdu, K, K)
+
+ def test_send_secured_packet_sends_the_segments_in_order(self):
+ import pysim_simple_server.server as srv
+ apdu = '80E80000F0' + '00' * 240 + '00'
+ sp_hex, _ = _build_secured_packet('16', '01', '15', '15', 'b00000',
+ '0000000001', apdu, K, K)
+ sent = []
+
+ def fake_envelope(tpdu_hex, scc, sm_sc=None, submit_handler=None):
+ sent.append(tpdu_hex.upper())
+ return '', '9000'
+
+ with mock.patch.object(srv, '_send_envelope', side_effect=fake_envelope):
+ result = _send_secured_packet(object(), sp_hex, oa_number='12345')
+ self.assertTrue(result['success'], result)
+ self.assertEqual(result['bytes'], len(sp_hex) // 2)
+ self.assertEqual(result['segments'], len(sent))
+ self.assertTrue(2 <= result['segments'] <= SCP80_MAX_SEGMENTS)
+ total = result['segments']
+ for num, tpdu in enumerate(sent, start=1):
+ concat = '000301%02X%02X' % (total, num) # IEI 00, IEDL 3, ref 01
+ udhl = '07' if num == 1 else '05'
+ self.assertIn(udhl + concat, tpdu, num)
+ if num == 1:
+ self.assertIn(udhl + concat + '7000', tpdu) # CPI in the first SM
+ else:
+ self.assertNotIn(concat + '7000', tpdu)
+
+ def test_send_secured_packet_refuses_more_than_the_card_buffer(self):
+ length = SCP80_FIRST_BYTES + SCP80_NEXT_BYTES * (SCP80_MAX_SEGMENTS - 1) + 1
+ result = _send_secured_packet(object(), '00' * length, oa_number='12345')
+ self.assertFalse(result['success'])
+ self.assertIn('too large', result['error'])
+ self.assertEqual(result['segments'], SCP80_MAX_SEGMENTS + 1)
+
+ def test_send_secured_packet_rejects_bad_hex(self):
+ result = _send_secured_packet(object(), 'zz', oa_number='12345')
+ self.assertFalse(result['success'])
+ self.assertIn('Invalid secured packet', result['error'])
+
+ def test_send_secured_packet_reports_a_failed_envelope(self):
+ import pysim_simple_server.server as srv
+
+ def fake_envelope(*args, **kwargs):
+ return '', '6F00'
+
+ with mock.patch.object(srv, '_send_envelope', side_effect=fake_envelope):
+ result = _send_secured_packet(object(), '00' * 10, oa_number='12345')
+ self.assertFalse(result['success'])
+ self.assertEqual(result['sw'], '6F00')
+ self.assertEqual(result['bytes'], 10)
+ self.assertIn('segment 1', result['error'])
+
+ def test_sms_user_data_budget_is_enforced(self):
+ # The splitter sizes every part exactly; a caller passing more than
+ # the SM can carry gets a clear error instead of an invalid TPDU.
+ with self.assertRaises(ValueError):
+ _build_sms_tpdu('00' * (SCP80_SINGLE_BYTES + 1))
+ with self.assertRaises(ValueError):
+ _build_sms_tpdu('00' * 141, include_cpi=False)
+ with self.assertRaises(ValueError):
+ _build_sms_tpdu('00' * (SCP80_FIRST_BYTES + 1), chunk_total=2, chunk_num=1)
+ # The exact capacities are all accepted.
+ self.assertTrue(_build_sms_tpdu('00' * SCP80_SINGLE_BYTES))
+ self.assertTrue(_build_sms_tpdu('00' * SCP80_FIRST_BYTES, chunk_total=2, chunk_num=1))
+ self.assertTrue(_build_sms_tpdu('00' * SCP80_NEXT_BYTES, chunk_total=2, chunk_num=2))
+ self.assertTrue(_build_sms_tpdu('00' * 140, include_cpi=False))
+
+ def test_segment_cap_matches_the_envelope_segment_limit(self):
+ from pysim_simple_server.server import MAX_ENVELOPE_SEGMENTS
+ self.assertEqual(SCP80_MAX_SEGMENTS, MAX_ENVELOPE_SEGMENTS)
+ self.assertEqual(SCP80_MAX_SEGMENTS, 5)
class TestDecodePor(unittest.TestCase):