feat: SCP80 SMS concatenation + packet size / SMS count display (v3.2.0)

Packets longer than one SMS now go out as concatenated SMS-PP downloads
per TS 31.115 4.3 and the UI shows how many SMS a packet needs.

Server:
- `_split_secured_packet` cuts the command packet at the exact SMS
  user-data capacities (first SM 132 octets: concat IE 5 + CPI IE 2;
  following ones 134; a single-SM packet may be 137 with the CPI IE) and
  `_build_sms_tpdu` tags every segment with the fixed concatenation
  reference 01; `_build_sms_tpdu` also enforces the 140-octet budget.
- `_send_secured_packet` (shared by /api/send-ota and /api/ram-install)
  sends one ENVELOPE per segment in order, refuses more than
  MAX_ENVELOPE_SEGMENTS (5, the card's concatenation buffer) and reports
  `bytes`/`segments` in the response (RAM install per step as well).
- `_build_secured_packet`/`_encode_cmd_unlimited`: our own TS 102 225
  5.1.1 encoder on pySim's keyset/header constructors, byte-identical to
  pySim for packets <= 140 octets (tests) and not limited to one SMS
  (pySim refuses the longer ones, which is why they never went out).
- RAM LOAD blocks are no longer clamped to one SMS: 1-240 bytes of
  payload with the default 240 (the GP maximum); `load_block_size_auto`
  replaces `load_block_size_clamped`.

PWA:
- `scp80SegmentInfo` / `spSizeInfoText` show "N bytes . M SMS
  (concatenated)" under the packet field, turn red past 5 SMS and report
  size/SMS in the send result and the RAM step log; LOAD block hints and
  placeholders updated; EN/RU.

Tests/docs: Python +2 cases incl. segment order/capacities and byte
identity with pySim; Node drift guard against the server constants and
UI text tests; README/README_RUS, help EN/RU, docs/api.md, AGENTS.
This commit is contained in:
2026-09-22 23:02:13 +03:00
parent f72054f61f
commit 43f50d3b72
11 changed files with 527 additions and 175 deletions
+5 -1
View File
@@ -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). 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 #### References
- ETSI TS 102 225 V18.1.0: Secured packet structure for UICC based applications - 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 | | 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. | | **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 1240 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 #### Explorer View
+5 -1
View File
@@ -405,6 +405,10 @@ PoR подтверждает, что карта получила и выполн
Delivery PoR (SPI2 `01`) проще — карта возвращает PoR напрямую в ответе ENVELOPE. Submit PoR (SPI2 `21`) используется, когда карта не может ответить inline (ограничено пространство ответа ENVELOPE). 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 - 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** для каждого элемента. | | **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) #### Обзор карты (Explorer View)
+25 -12
View File
@@ -275,8 +275,15 @@ Returns:
### `POST /api/send-ota` ### `POST /api/send-ota`
Send an OTA command (SCP80) to the card via SMS-PP-DOWNLOAD ENVELOPE. 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. (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:** **Request body:**
```json ```json
@@ -296,11 +303,16 @@ The secured packet is delivered in an SMS-DELIVER TPDU wrapped in an ENVELOPE co
**Response (delivery PoR):** **Response (delivery PoR):**
```json ```json
{"success": true, "sw": "9000", "response_data": "027100000e0a...", {"success": true, "sw": "9000", "response_data": "027100000e0a...",
"bytes": 36, "segments": 1,
"por": {"response_status": "por_ok", "tar": "B00000", "pcntr": 0, "por": {"response_status": "por_ok", "tar": "B00000", "pcntr": 0,
"decoded": {"number_of_commands": 1, "last_status_word": "6e00", "decoded": {"number_of_commands": 1, "last_status_word": "6e00",
"last_response_data": ""}}} "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 **Response (submit PoR):** PoR is extracted from the SMS-SUBMIT TPDU
fetched via a proactive command (FETCH). The response contains the fetched via a proactive command (FETCH). The response contains the
same `por` structure if decoding succeeds. 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 | | `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()` | | `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) | | `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, 1240. 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, 1240 (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):** **Response (success):**
```json ```json
{"success": true, "failed_step": null, {"success": true, "failed_step": null,
"steps": [{"name": "install_for_load", "apdu": "80E60200...", "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"}, {"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"}], {"name": "install_for_install", "apdu": "80E60C00...", "por_status": "por_ok", "sw": "9000", "bytes": 66, "segments": 1}],
"final_cntr": "0000000004", "final_cntr": "0000000004",
"load_file_aid": "A000000003000000", "load_file_aid": "A000000003000000",
"module_aid": "A000000003000000", "module_aid": "A000000003000000",
"application_aid": "A000000003000000", "application_aid": "A000000003000000",
"load_block_size": 107, "load_block_size": 240,
"load_block_size_requested": null, "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` is the effective size used for the LOAD blocks (240 by
`load_block_size_requested` echoes an explicit `load_block_size` (null = default), `load_block_size_requested` echoes an explicit `load_block_size`
auto-fit) and `load_block_size_clamped` is true when the requested size was (null = the default was used) and `load_block_size_auto` marks that default.
reduced to fit one SMS. Each step reports the secured packet size `bytes` and the number of SMS
`segments` it took.
**Response (failure):** **Response (failure):**
```json ```json
+2 -2
View File
@@ -273,7 +273,7 @@
<li>AES требует счётчик с защитой от повтора: биты SPI1 b5&nbsp;b4 должны быть <code class="font-mono text-sm">10</code> (счётчик больше) или <code class="font-mono text-sm">11</code> (счётчик +1) согласно TS 102 225 &sect;5.1.2/&sect;5.1.3.1</li> <li>AES требует счётчик с защитой от повтора: биты SPI1 b5&nbsp;b4 должны быть <code class="font-mono text-sm">10</code> (счётчик больше) или <code class="font-mono text-sm">11</code> (счётчик +1) согласно TS 102 225 &sect;5.1.2/&sect;5.1.3.1</li>
<li>Байт паддинга настраивается (<code class="font-mono text-sm">00</code> по умолчанию или <code class="font-mono text-sm">FF</code>)</li> <li>Байт паддинга настраивается (<code class="font-mono text-sm">00</code> по умолчанию или <code class="font-mono text-sm">FF</code>)</li>
</ul> </ul>
<p class="text-sm mb-3">Кнопка <strong>Проверить в pySim</strong> сверяет собранный пакет с эталонной реализацией <code class="font-mono text-sm">OtaDialectSms.encode_cmd</code>. Кнопка <strong>Отправить на карту</strong> доставляет пакет через ENVELOPE SMS-PP-DOWNLOAD (при подключении к серверу). Полученный Proof of Receipt декодируется и показывается строкой статуса PoR (статус, TAR, счётчик, сырой PoR); статусное слово и данные ответа последней команды подставляются в подвкладку <strong>&laquo;Парсер ответов&raquo;</strong> (Remote APDU), а успешный PoR увеличивает счётчик повторов и очищает пакет.</p> <p class="text-sm mb-3">Кнопка <strong>Проверить в pySim</strong> сверяет собранный пакет с эталонной реализацией <code class="font-mono text-sm">OtaDialectSms.encode_cmd</code>. Кнопка <strong>Отправить на карту</strong> доставляет пакет через ENVELOPE SMS-PP-DOWNLOAD (при подключении к серверу). Под полем пакета показывается его размер и число SMS; пакет, не помещающийся в одно SMS, отправляется <strong>конкатенированной</strong> загрузкой (TS 31.115 &sect;4.3) &mdash; пакет делится на части SMS-пользовательских данных (первое SMS 132 октета, последующие по 134; первое дополнительно несёт информационные элементы конкатенации и CPI), и сегменты отправляются по порядку. Более 5 сегментов отклоняется: это практический предел буфера конкатенации карты. Полученный Proof of Receipt декодируется и показывается строкой статуса PoR (статус, TAR, счётчик, сырой PoR); статусное слово и данные ответа последней команды подставляются в подвкладку <strong>&laquo;Парсер ответов&raquo;</strong> (Remote APDU), а успешный PoR увеличивает счётчик повторов и очищает пакет.</p>
<h3 id="ram" class="text-lg font-medium mb-2">3.2 RAM</h3> <h3 id="ram" class="text-lg font-medium mb-2">3.2 RAM</h3>
<p class="mb-2">Выполняет операции удалённого управления приложениями (Remote Application Management) как защищённые пакеты SCP80 через SMS-PP-DOWNLOAD ENVELOPE. Карта должна поддерживать SCP03 (AES или 3DES). Предустановка карты со вкладки <strong>Карты</strong> обеспечивает SPI, ключи, TAR и счётчик (RAM использует <strong>ISD TAR</strong> предустановки, по умолчанию <code class="font-mono text-sm">000000</code>).</p> <p class="mb-2">Выполняет операции удалённого управления приложениями (Remote Application Management) как защищённые пакеты SCP80 через SMS-PP-DOWNLOAD ENVELOPE. Карта должна поддерживать SCP03 (AES или 3DES). Предустановка карты со вкладки <strong>Карты</strong> обеспечивает SPI, ключи, TAR и счётчик (RAM использует <strong>ISD TAR</strong> предустановки, по умолчанию <code class="font-mono text-sm">000000</code>).</p>
@@ -283,7 +283,7 @@
<thead><tr class="border-b border-gray-300 dark:border-slate-700"><th class="text-left py-1 px-2">Операция</th><th class="text-left py-1 px-2">Описание</th></tr></thead> <thead><tr class="border-b border-gray-300 dark:border-slate-700"><th class="text-left py-1 px-2">Операция</th><th class="text-left py-1 px-2">Описание</th></tr></thead>
<tbody> <tbody>
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">Обзор карты (все данные GP)</td><td class="py-1 px-2">Запрос GET STATUS для ISD, приложений, ELF и модулей ELF, а также GET DATA FF21 для информации о памяти. Результаты отображаются в обзоре с кнопками <strong>Удалить</strong> для каждого элемента.</td></tr> <tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">Обзор карты (все данные GP)</td><td class="py-1 px-2">Запрос GET STATUS для ISD, приложений, ELF и модулей ELF, а также GET DATA FF21 для информации о памяти. Результаты отображаются в обзоре с кнопками <strong>Удалить</strong> для каждого элемента.</td></tr>
<tr><td class="py-1 px-2">Установка пакета (.cap файл)</td><td class="py-1 px-2">Отправка <code class="font-mono text-sm">.cap</code> файла на карту через сервер: INSTALL[for load] &rarr; LOAD &times;N &rarr; INSTALL[for install (+make selectable)]. Load-файл делится на LOAD APDU, каждый из которых помещается в один SMS SCP80; поле <strong>размер блока LOAD</strong> переопределяет авто-подобранный размер (пусто = максимальный размер, чей secured-пакет укладывается в 140 октетов), так что большой <code>.cap</code> просто занимает несколько SMS.</td></tr> <tr><td class="py-1 px-2">Установка пакета (.cap файл)</td><td class="py-1 px-2">Отправка <code class="font-mono text-sm">.cap</code> файла на карту через сервер: INSTALL[for load] &rarr; LOAD &times;N &rarr; INSTALL[for install (+make selectable)]. Load-файл делится на LOAD APDU размера <strong>размер блока LOAD</strong> (1&ndash;240 байт полезной нагрузки, по умолчанию 240); каждый защищённый пакет доставляется одним SMS или, если он больше, конкатенированной SMS-PP загрузкой до 5 сегментов, так что большой <code>.cap</code> просто занимает несколько SMS.</td></tr>
</tbody> </tbody>
</table> </table>
+2 -2
View File
@@ -273,7 +273,7 @@
<li>AES requires a replay-protected counter: SPI1 bits b5&nbsp;b4 must be <code class="font-mono text-sm">10</code> (counter higher) or <code class="font-mono text-sm">11</code> (counter +1) per TS 102 225 &sect;5.1.2/&sect;5.1.3.1</li> <li>AES requires a replay-protected counter: SPI1 bits b5&nbsp;b4 must be <code class="font-mono text-sm">10</code> (counter higher) or <code class="font-mono text-sm">11</code> (counter +1) per TS 102 225 &sect;5.1.2/&sect;5.1.3.1</li>
<li>Padding byte configurable (<code class="font-mono text-sm">00</code> default, or <code class="font-mono text-sm">FF</code>)</li> <li>Padding byte configurable (<code class="font-mono text-sm">00</code> default, or <code class="font-mono text-sm">FF</code>)</li>
</ul> </ul>
<p class="text-sm mb-3">A &ldquo;Verify vs pySim&rdquo; button cross-checks the assembled packet against pySim&rsquo;s reference <code class="font-mono text-sm">OtaDialectSms.encode_cmd</code>. A &ldquo;Send to Card&rdquo; 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&rsquo;s status word and response data are filled into the <strong>Response parser</strong> tab, and a successful PoR advances the replay counter and clears the packet.</p> <p class="text-sm mb-3">A &ldquo;Verify vs pySim&rdquo; button cross-checks the assembled packet against pySim&rsquo;s reference <code class="font-mono text-sm">OtaDialectSms.encode_cmd</code>. A &ldquo;Send to Card&rdquo; 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 <strong>concatenated</strong> download (TS 31.115 &sect;4.3) &mdash; 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&rsquo;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&rsquo;s status word and response data are filled into the <strong>Response parser</strong> tab, and a successful PoR advances the replay counter and clears the packet.</p>
<h3 id="ram" class="text-lg font-medium mb-2">3.2 RAM</h3> <h3 id="ram" class="text-lg font-medium mb-2">3.2 RAM</h3>
<p class="mb-2">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 <strong>Cards</strong> tab provides the SPI, keys, TAR, and counter (RAM uses the preset's <strong>ISD TAR</strong>, <code class="font-mono text-sm">000000</code> by default).</p> <p class="mb-2">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 <strong>Cards</strong> tab provides the SPI, keys, TAR, and counter (RAM uses the preset's <strong>ISD TAR</strong>, <code class="font-mono text-sm">000000</code> by default).</p>
@@ -283,7 +283,7 @@
<thead><tr class="border-b border-gray-300 dark:border-slate-700"><th class="text-left py-1 px-2">Operation</th><th class="text-left py-1 px-2">Description</th></tr></thead> <thead><tr class="border-b border-gray-300 dark:border-slate-700"><th class="text-left py-1 px-2">Operation</th><th class="text-left py-1 px-2">Description</th></tr></thead>
<tbody> <tbody>
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">Explore Card (all GP data)</td><td class="py-1 px-2">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 <strong>Delete</strong> buttons.</td></tr> <tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">Explore Card (all GP data)</td><td class="py-1 px-2">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 <strong>Delete</strong> buttons.</td></tr>
<tr><td class="py-1 px-2">Install Package (.cap file)</td><td class="py-1 px-2">Sends a <code class="font-mono text-sm">.cap</code> file to the card via the server: INSTALL[for load] &rarr; LOAD &times;N &rarr; INSTALL[for install (+make selectable)]. The load file is split into LOAD APDUs that each fit one SCP80 SMS; the <strong>LOAD block size</strong> field overrides the auto-fitted size (empty = largest size whose secured packet still encodes into 140 octets), so a large <code>.cap</code> simply takes several SMS.</td></tr> <tr><td class="py-1 px-2">Install Package (.cap file)</td><td class="py-1 px-2">Sends a <code class="font-mono text-sm">.cap</code> file to the card via the server: INSTALL[for load] &rarr; LOAD &times;N &rarr; INSTALL[for install (+make selectable)]. The load file is split into LOAD APDUs of the <strong>LOAD block size</strong> (1&ndash;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 <code class="font-mono text-sm">.cap</code> simply takes several SMS.</td></tr>
</tbody> </tbody>
</table> </table>
+69 -14
View File
@@ -550,6 +550,7 @@
<button data-needs="card" onclick="pysimSendOta()" id="sp-send-btn" class="hidden mb-3 px-5 py-2.5 bg-emerald-600 text-white text-sm font-medium rounded hover:bg-emerald-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Send to Card">Send to Card</button> <button data-needs="card" onclick="pysimSendOta()" id="sp-send-btn" class="hidden mb-3 px-5 py-2.5 bg-emerald-600 text-white text-sm font-medium rounded hover:bg-emerald-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Send to Card">Send to Card</button>
<div id="sp-por-status" class="mt-2 text-base font-semibold font-mono hidden"></div> <div id="sp-por-status" class="mt-2 text-base font-semibold font-mono hidden"></div>
<textarea id="sp-result" rows="3" readonly class="w-full font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 bg-gray-100 dark:bg-slate-800"></textarea> <textarea id="sp-result" rows="3" readonly class="w-full font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 bg-gray-100 dark:bg-slate-800"></textarea>
<div id="sp-size-info" class="text-xs mt-1 text-gray-500 dark:text-slate-400"></div>
<div id="sp-send-result" class="mt-2 text-xs font-mono hidden"></div> <div id="sp-send-result" class="mt-2 text-xs font-mono hidden"></div>
<div id="sp-verify-result" class="mt-2 text-xs font-mono hidden"></div> <div id="sp-verify-result" class="mt-2 text-xs font-mono hidden"></div>
@@ -693,9 +694,9 @@
<label class="flex items-center gap-2 mt-2"> <label class="flex items-center gap-2 mt-2">
<input type="checkbox" id="ram-make-sel" checked> <span class="text-sm" data-l10n="Make selectable">Make selectable</span> <input type="checkbox" id="ram-make-sel" checked> <span class="text-sm" data-l10n="Make selectable">Make selectable</span>
</label> </label>
<label class="block mb-1 text-xs font-medium text-gray-500 dark:text-slate-400 mt-2" data-l10n="LOAD block size (bytes, empty = auto-fit)">LOAD block size (bytes, empty = auto-fit)</label> <label class="block mb-1 text-xs font-medium text-gray-500 dark:text-slate-400 mt-2" data-l10n="LOAD block size (bytes, empty = 240)">LOAD block size (bytes, empty = 240)</label>
<input id="ram-load-size" type="number" min="1" max="240" class="w-32 font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800" placeholder="auto"> <input id="ram-load-size" type="number" min="1" max="240" class="w-32 font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800" placeholder="240">
<div class="text-xs text-gray-500 dark:text-slate-400 mt-1" data-l10n="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 that fit one SCP80 SMS; auto-fit picks the largest size that encodes into 140 octets.</div> <div class="text-xs text-gray-500 dark:text-slate-400 mt-1" data-l10n="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).">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).</div>
</div> </div>
<div id="ram-progress" class="hidden mb-3"> <div id="ram-progress" class="hidden mb-3">
@@ -1436,7 +1437,7 @@
// ===== Version ===== // ===== Version =====
// Single source of truth for the PWA version: shown in the header and used // Single source of truth for the PWA version: shown in the header and used
// by the server version check in pysimConnect(). // 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; document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION;
// ===== Tab switching ===== // ===== Tab switching =====
@@ -3268,6 +3269,7 @@ function spCntrAdjust(delta) {
} }
function spInvalidate() { function spInvalidate() {
document.getElementById('sp-result').value = ''; document.getElementById('sp-result').value = '';
spShowSizeInfo();
} }
function updateSp() { function updateSp() {
@@ -3468,6 +3470,56 @@ function aesCmac(data, keyBytes) {
// CPL = octets from CHL to end incl. padding; CHL = 13 + len_sig. // 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. // CPI (0x70) and CHI (null) are NOT part of the SMS packet data.
function genSp() { 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, ''); const apduHex = (document.getElementById('sp-apdu').value || '').replace(/[^0-9a-fA-F]/g, '');
if (!apduHex) { document.getElementById('sp-result').value = 'Error: specify APDU'; return; } if (!apduHex) { document.getElementById('sp-result').value = 'Error: specify APDU'; return; }
@@ -5804,6 +5856,9 @@ async function pysimSendOta() {
cApduSwitchSubtab('response'); cApduSwitchSubtab('response');
} }
let msg = 'OTA sent. SW: ' + data.sw; let msg = 'OTA sent. SW: ' + data.sw;
if (data.bytes && data.segments) {
msg += ' | ' + data.bytes + ' ' + t('bytes') + ' · ' + data.segments + ' SMS';
}
const por = data.por; const por = data.por;
if (por && por.response_status) { if (por && por.response_status) {
msg += ' | PoR status: ' + por.response_status + ' (TAR ' + por.tar + ')'; msg += ' | PoR status: ' + por.response_status + ' (TAR ' + por.tar + ')';
@@ -5821,6 +5876,7 @@ async function pysimSendOta() {
cntrEl.value = spNextCntr(cntrEl.value); cntrEl.value = spNextCntr(cntrEl.value);
msg += ' | CNTR -> ' + cntrEl.value; msg += ' | CNTR -> ' + cntrEl.value;
document.getElementById('sp-result').value = ''; document.getElementById('sp-result').value = '';
spShowSizeInfo();
// keep the selected card preset in sync with the new counter (v1.9.8) // keep the selected card preset in sync with the new counter (v1.9.8)
spCntrSyncPreset(); spCntrSyncPreset();
sendResultEl.textContent = msg; sendResultEl.textContent = msg;
@@ -6429,7 +6485,9 @@ async function ramInstallCap(sp) {
let txt = ''; let txt = '';
(data.steps || []).forEach((s, idx) => { (data.steps || []).forEach((s, idx) => {
const mark = s.por_status === 'por_ok' ? '✅' : '❌'; 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; stepsEl.textContent = txt;
@@ -6438,11 +6496,7 @@ async function ramInstallCap(sp) {
let sizeInfo = ''; let sizeInfo = '';
if (data.load_block_size) { if (data.load_block_size) {
sizeInfo = ' — ' + t('LOAD blocks') + ': ' + data.load_block_size + ' B'; sizeInfo = ' — ' + t('LOAD blocks') + ': ' + data.load_block_size + ' B';
if (data.load_block_size_clamped) { if (data.load_block_size_auto) sizeInfo += ' (' + t('default') + ')';
sizeInfo += ' (' + t('clamped from') + ' ' + data.load_block_size_requested + ')';
} else if (!data.load_block_size_requested) {
sizeInfo += ' (' + t('auto-fit') + ')';
}
} }
resultEl.textContent = t('Install OK') + ' — load_file_aid=' + data.load_file_aid + ' module_aid=' + data.module_aid + sizeInfo; 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'); resultEl.classList.remove('hidden', 'text-red-600'); resultEl.classList.add('text-green-600');
@@ -13993,12 +14047,13 @@ const LANG_RU = {
'Object and related objects': 'Объект и связанные объекты', 'Object and related objects': 'Объект и связанные объекты',
'SD AID (empty = ISD)': 'AID SD (пусто = ISD)', 'SD AID (empty = ISD)': 'AID SD (пусто = ISD)',
'CAP file': 'CAP-файл', 'CAP file': 'CAP-файл',
'LOAD block size (bytes, empty = auto-fit)': 'Размер блока LOAD (байт, пусто = авто)', 'LOAD block size (bytes, empty = 240)': 'Размер блока LOAD (байт, пусто = 240)',
'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 октетов.', '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 block size must be 1..240': 'Размер блока LOAD должен быть в диапазоне 1..240',
'LOAD blocks': 'Блоки LOAD', 'LOAD blocks': 'Блоки LOAD',
'clamped from': 'ограничено с',
'auto-fit': 'авто',
'Redirect to external server': 'Перенаправление на внешний сервер', 'Redirect to external server': 'Перенаправление на внешний сервер',
'Pass-through (card destination)': 'Проброс (адрес карты)', '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 завершается там, а запрошенный картой адрес только журналируется.', '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 завершается там, а запрошенный картой адрес только журналируется.',
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'simple-v239'; const CACHE = 'simple-v240';
const URLS = [ const URLS = [
'index.html', 'index.html',
'help.html', 'help.html',
+42 -1
View File
@@ -28,9 +28,14 @@ function extractFunc(src, name) {
const FNS = ['hexToBytes', 'bytesToHex', 'des3Keys', 'des3EncryptBlock', 'des3CbcEncrypt', const FNS = ['hexToBytes', 'bytesToHex', 'des3Keys', 'des3EncryptBlock', 'des3CbcEncrypt',
'xorBytes', 'zeroPad', 'cbcMac', 'aesCbcEncrypt', 'aesShiftLeft1', 'aesCmacSubkeys', 'xorBytes', 'zeroPad', 'cbcMac', 'aesCbcEncrypt', 'aesShiftLeft1', 'aesCmacSubkeys',
'aesCmac', 'genSp', 'spNextCntr']; 'aesCmac', '_genSpBuild', 'genSp', 'spNextCntr', 'scp80SegmentInfo', 'spSizeInfoText',
'spShowSizeInfo'];
let code = ''; let code = '';
for (const f of FNS) code += extractFunc(html, f) + '\n'; 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 // 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 // (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('00000000 0a'), '000000000B');
assert.strictEqual(spNextCntr(''), '0000000001'); 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');
});
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "pysim-simple-server" name = "pysim-simple-server"
version = "3.1.4" version = "3.2.0"
description = "HTTP REST server wrapping pysim for the SIMple PWA" description = "HTTP REST server wrapping pysim for the SIMple PWA"
requires-python = ">=3.8" requires-python = ">=3.8"
# pysim is a git-only dependency installed explicitly by setup.bat/setup.sh. # pysim is a git-only dependency installed explicitly by setup.bat/setup.sh.
+239 -118
View File
@@ -7,6 +7,7 @@ import threading
import traceback import traceback
import re import re
import codecs import codecs
import zlib
from urllib.parse import unquote_plus from urllib.parse import unquote_plus
from datetime import datetime, timedelta from datetime import datetime, timedelta
from http.server import HTTPServer, BaseHTTPRequestHandler from http.server import HTTPServer, BaseHTTPRequestHandler
@@ -28,10 +29,29 @@ from osmocom.tlv import BER_TLV_IE
from osmocom.utils import rpad 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 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 <repo>/frontend, served by this server # Static file serving (the PWA lives in <repo>/frontend, served by this server
# so the UI and the API share an origin and no CORS/PNA is involved). # 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 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) chunk = bytes.fromhex(chunk_hex)
# TS 23.040 UDH: first octet is UDHL, then the information elements. # 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'' udh = b''
if chunk_total > 1: 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: if chunk_num == 1 and include_cpi:
udh += bytes([0x70, 0x00]) udh += bytes([0x70, 0x00])
elif include_cpi: elif include_cpi:
udh = bytes([0x70, 0x00]) udh = bytes([0x70, 0x00])
tp_ud = (bytes([len(udh)]) + udh + chunk) if udh else chunk 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) 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 tpdu = bytes([first_byte]) + _encode_sms_oa(oa_number) + bytes([0x7F, 0xF6]) + _encode_scts() + bytes([len(tp_ud)]) + tp_ud
return tpdu.hex() 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 return b2h(out), spi
def _max_load_block_size(spi1, spi2, kic, kid, tar_hex, cntr_hex, def _split_secured_packet(pkt, include_cpi=True):
kic_key_hex, kid_key_hex, requested=240): """Split a command packet into SMS user-data parts (TS 31.115 4.3).
"""Largest LOAD block payload that still fits one SMS (TS 31.115).
pySim's SMS dialect refuses to encode a secured packet above 140 octets, Every part plus its UDH stays within the 140-octet SMS user data; the
so a LOAD APDU of the default 240-byte block cannot be sent over SCP80. first part of a concatenated command is smaller because its UDH also
Trial-encode a synthetic LOAD APDU for decreasing payload sizes (the carries the CPI IE."""
cipher padding makes a closed-form bound unreliable) and return the single_max = SCP80_SINGLE_BYTES if include_cpi else 140
largest one that encodes; 0 = not even a 1-byte block fits.""" if len(pkt) <= single_max:
cap = max(1, min(int(requested or 240), 240)) return [pkt]
for n in range(cap, 0, -1): first = SCP80_FIRST_BYTES if include_cpi else SCP80_NEXT_BYTES
apdu = '80E80000%02X%s00' % (n, '00' * n) 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: try:
out_hex, _ = _ota_reference(spi1, spi2, kic, kid, tar_hex, cntr_hex, tpdu = _build_sms_tpdu(part.hex(), total, i + 1, oa_number=oa_number,
apdu, kic_key_hex, kid_key_hex) include_cpi=include_cpi)
except ValueError: except ValueError as e:
continue return {'success': False, 'bytes': len(pkt), 'segments': total,
if len(out_hex) // 2 <= 140: 'error': str(e)}
return n if total > 1:
return 0 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): 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) include_cpi = body.get('includeCpi', True)
try: try:
if apdu: 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') spi1 = body.get('spi1', '16')
spi2 = body.get('spi2', '01') spi2 = body.get('spi2', '01')
kic = body.get('kic', '25') kic = body.get('kic', '25')
@@ -4572,12 +4716,10 @@ class PysimHandler(BaseHTTPRequestHandler):
cntr = body.get('cntr', '') cntr = body.get('cntr', '')
kic_key = body.get('kicKey', '') kic_key = body.get('kicKey', '')
kid_key = body.get('kidKey', '') kid_key = body.get('kidKey', '')
sp_hex, _ = _ota_reference(spi1, spi2, kic, kid, tar, cntr, apdu, kic_key, kid_key) sp_hex, _ = _build_secured_packet(spi1, spi2, kic, kid, tar, cntr, apdu, kic_key, kid_key)
sp_bytes = bytes.fromhex(sp_hex)
else: else:
# Regular SCP80: use pre-built secured packet # Regular SCP80: use pre-built secured packet
sp_hex = sp sp_hex = sp
sp_bytes = bytes.fromhex(sp_hex)
spi2_val = int(body.get('spi2', '00'), 16) spi2_val = int(body.get('spi2', '00'), 16)
por_in_submit = bool(spi2_val & 0x20) por_in_submit = bool(spi2_val & 0x20)
submit_handler = None submit_handler = None
@@ -4587,65 +4729,55 @@ class PysimHandler(BaseHTTPRequestHandler):
old_proactive = scc._tp.proactive_handler old_proactive = scc._tp.proactive_handler
scc._tp.proactive_handler = submit_handler scc._tp.proactive_handler = submit_handler
try: try:
max_chunk = 130 sys.stderr.write('OTA SEND: SPI %s %s KIc %s KID %s TAR %s CNTR %s LEN %dB\n' % (
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' % (
body.get('spi1', ''), body.get('spi2', ''), body.get('kic', ''), body.get('spi1', ''), body.get('spi2', ''), body.get('kic', ''),
body.get('kid', ''), body.get('tar', ''), body.get('cntr', ''), body.get('kid', ''), body.get('tar', ''), body.get('cntr', ''),
len(sp_bytes), total)) len(sp_hex) // 2))
if total > MAX_ENVELOPE_SEGMENTS: sys.stderr.write('RAM C-APDU: %s\n' % apdu if apdu else sp)
resp = {'success': False, 'error': 'Secured packet too large: %d segments (max %d)' % (total, MAX_ENVELOPE_SEGMENTS)} sys.stderr.write('RAM SECURED-PACKET: %s\n' % sp_hex)
sys.stderr.write('OTA SEND FAILED: %d segments exceeds max %d\n' % (total, MAX_ENVELOPE_SEGMENTS)) 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: else:
sys.stderr.write('RAM C-APDU: %s\n' % apdu if apdu else sp) resp = {'success': True, 'sw': result['sw'],
sys.stderr.write('RAM SECURED-PACKET: %s\n' % sp_hex) 'response_data': result['response_data'],
last_data = None 'bytes': result['bytes'], 'segments': result['segments']}
last_sw = None por_src = 'envelope'
for i, chunk in enumerate(chunks): por_hex = resp['response_data']
tpdu = _build_sms_tpdu(chunk.hex(), total, i + 1, oa_number=self.server.sms_oa, if submit_handler and submit_handler.submit_tpdu_hex:
include_cpi=include_cpi) tpdu_b = bytes.fromhex(submit_handler.submit_tpdu_hex)
data, sw = _send_envelope(tpdu, scc, sm_sc=self.server.sms_sc, submit_handler=submit_handler) idx = tpdu_b.find(b'\x02\x71\x00')
last_data = data if idx >= 0:
last_sw = sw por_hex = tpdu_b[idx:].hex()
if sw != '9000' and not sw.startswith('91'): por_src = 'sms-submit'
resp = {'success': False, 'sw': sw, 'error': 'ENVELOPE failed at chunk %d' % (i + 1)} por = _decode_por(body.get('spi1', ''), body.get('spi2', ''), body.get('kic', ''),
sys.stderr.write('OTA SEND FAILED: chunk %d SW %s\n' % (i + 1, sw)) body.get('kid', ''), body.get('cntr', ''), body.get('kicKey', ''),
break 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: else:
resp = {'success': True, 'sw': last_sw, 'response_data': last_data if last_data else None} sys.stderr.write('OTA PoR[%s]: none\n' % por_src)
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)
finally: finally:
if submit_handler and hasattr(scc, '_tp'): if submit_handler and hasattr(scc, '_tp'):
scc._tp.proactive_handler = old_proactive scc._tp.proactive_handler = old_proactive
@@ -4732,22 +4864,11 @@ class PysimHandler(BaseHTTPRequestHandler):
self._send_json(err, 400) self._send_json(err, 400)
self._log_resp(err) self._log_resp(err)
return return
max_block = _max_load_block_size(spi1, spi2, kic, kid, tar, cntr, # The GP LOAD payload limit is 240 bytes per APDU; SCP80
kic_key, kid_key, # concatenates the secured packet over up to SCP80_MAX_SEGMENTS
requested=block_size_req or 240) # SMs, so a block no longer has to fit into a single SMS.
if max_block < 1: block_size = block_size_req or 240
err = {'success': False, sys.stderr.write('RAM-INSTALL: LOAD block size %d bytes\n' % block_size)
'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 ''))
steps = [] steps = []
encode_error = None encode_error = None
@@ -4758,16 +4879,13 @@ class PysimHandler(BaseHTTPRequestHandler):
def _send_gp_apdu(apdu_hex, step_name): def _send_gp_apdu(apdu_hex, step_name):
nonlocal cntr, encode_error nonlocal cntr, encode_error
try: 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: except ValueError as e:
encode_error = str(e) encode_error = str(e)
steps.append({'name': step_name, 'por_status': 'encode_error', steps.append({'name': step_name, 'por_status': 'encode_error',
'sw': encode_error}) 'sw': encode_error})
sys.stderr.write('RAM-INSTALL: %s encode failed: %s\n' % (step_name, e)) sys.stderr.write('RAM-INSTALL: %s encode failed: %s\n' % (step_name, e))
return False 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 submit_handler = None
old_proactive = None old_proactive = None
if por_in_submit and hasattr(scc, '_tp'): if por_in_submit and hasattr(scc, '_tp'):
@@ -4775,19 +4893,20 @@ class PysimHandler(BaseHTTPRequestHandler):
old_proactive = scc._tp.proactive_handler old_proactive = scc._tp.proactive_handler
scc._tp.proactive_handler = submit_handler scc._tp.proactive_handler = submit_handler
try: try:
last_data = None result = _send_secured_packet(
last_sw = None scc, sp_hex, oa_number=self.server.sms_oa,
for i, chunk in enumerate(chunks): sm_sc=self.server.sms_sc, include_cpi=include_cpi,
tpdu = _build_sms_tpdu(chunk.hex(), len(chunks), i + 1, submit_handler=submit_handler)
oa_number=self.server.sms_oa, include_cpi=include_cpi) if not result['success']:
data, sw = _send_envelope(tpdu, scc, sm_sc=self.server.sms_sc, steps.append({'name': step_name, 'por_status': 'envelope_error',
submit_handler=submit_handler) 'sw': result.get('sw') or result.get('error'),
last_data = data 'bytes': result.get('bytes'),
last_sw = sw 'segments': result.get('segments')})
if sw != '9000' and not sw.startswith('91'): sys.stderr.write('RAM-INSTALL: %s send failed: %s\n' % (
steps.append({'name': step_name, 'por_status': 'envelope_error', 'sw': sw}) step_name, result.get('error')))
sys.stderr.write('RAM-INSTALL: %s ENVELOPE failed SW %s\n' % (step_name, sw)) return False
return False last_data = result['response_data']
last_sw = result['sw']
# Decode PoR # Decode PoR
por_src = 'envelope' por_src = 'envelope'
por_hex = last_data por_hex = last_data
@@ -4802,10 +4921,12 @@ class PysimHandler(BaseHTTPRequestHandler):
if por and por.get('decoded'): if por and por.get('decoded'):
ps = por['decoded'].get('response_status', '') ps = por['decoded'].get('response_status', '')
por_status = 'por_ok' if ps == '9100' else 'por_error_%s' % ps 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: elif last_sw == '9000' and not por_hex:
por_status = 'no_por' 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 # Increment counter
cntr = '%010X' % ((int(cntr, 16) + 1) % (2 ** 32)) cntr = '%010X' % ((int(cntr, 16) + 1) % (2 ** 32))
return True return True
@@ -4834,7 +4955,7 @@ class PysimHandler(BaseHTTPRequestHandler):
'load_file_aid': loadfile_aid, 'module_aid': module_aid, 'load_file_aid': loadfile_aid, 'module_aid': module_aid,
'load_block_size': block_size, 'load_block_size': block_size,
'load_block_size_requested': block_size_req, '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._send_json(resp)
self._log_resp(resp) self._log_resp(resp)
return return
@@ -4843,7 +4964,7 @@ class PysimHandler(BaseHTTPRequestHandler):
'module_aid': module_aid, 'final_cntr': cntr, 'module_aid': module_aid, 'final_cntr': cntr,
'load_block_size': block_size, 'load_block_size': block_size,
'load_block_size_requested': block_size_req, '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' % ( sys.stderr.write('RAM-INSTALL: Complete — loadfile_aid=%s module_aid=%s cntr=%s\n' % (
loadfile_aid, module_aid, cntr)) loadfile_aid, module_aid, cntr))
self._send_json(resp) self._send_json(resp)
+136 -22
View File
@@ -20,17 +20,23 @@ if str(PY_SIM) not in sys.path:
sys.path.insert(0, str(PY_SIM)) sys.path.insert(0, str(PY_SIM))
from pysim_simple_server.server import ( from pysim_simple_server.server import (
_build_secured_packet,
_build_sms_tpdu, _build_sms_tpdu,
_build_tr, _build_tr,
_decode_cmd, _decode_cmd,
_decode_por, _decode_por,
_decode_tr, _decode_tr,
_log_proactive, _log_proactive,
_max_load_block_size,
_ota_reference, _ota_reference,
_record_tr, _record_tr,
_send_secured_packet,
_spi_from_bytes, _spi_from_bytes,
_split_secured_packet,
_tr_data_only, _tr_data_only,
SCP80_FIRST_BYTES,
SCP80_MAX_SEGMENTS,
SCP80_NEXT_BYTES,
SCP80_SINGLE_BYTES,
) )
# Synthetic dummy key material (no real card keys). # 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(out, AES_REFERENCE_VECTORS[('1e', '19')])
self.assertEqual(spi['counter'], 'counter_must_be_lower') self.assertEqual(spi['counter'], 'counter_must_be_lower')
def test_max_load_block_size_fits_one_sms(self): def test_ram_load_sequence_uses_full_240_byte_blocks(self):
# LOAD blocks are too large for SCP80 at the 240-byte default (pySim # The one-SMS clamp was removed: the RAM path's default LOAD blocks
# refuses a secured packet above 140 octets), so the helper finds the # are the GP maximum of 240 bytes, and each secured packet still fits
# largest payload that still encodes into a single SMS. # the card's concatenation buffer.
mx = _max_load_block_size('16', '01', '15', '15', 'b00000', from pysim_simple_server.server import _cap_apdu_sequence
'0000000001', K, K) seq = _cap_apdu_sequence('A000000003000000', 'A000000003000001',
self.assertGreater(mx, 0) 'AA' * 600, block_size=240)
self.assertLessEqual(mx, 240) loads = [a for a in seq if a.startswith('80E8')]
def load_apdu(n): self.assertGreater(len(loads), 1)
return '80E80000%02X%s00' % (n, '00' * n) for apdu in loads:
out, _ = _ota_reference('16', '01', '15', '15', 'b00000', self.assertLessEqual(int(apdu[8:10], 16), 240)
'0000000001', load_apdu(mx), K, K) sp, _ = _build_secured_packet('16', '01', '15', '15', 'b00000',
self.assertLessEqual(len(out) // 2, 140) '0000000001', apdu, K, K)
with self.assertRaises(ValueError): self.assertLessEqual(len(_split_secured_packet(bytes.fromhex(sp))),
_ota_reference('16', '01', '15', '15', 'b00000', SCP80_MAX_SEGMENTS)
'0000000001', load_apdu(mx + 1), K, K)
def test_max_load_block_size_respects_the_requested_cap(self):
mx = _max_load_block_size('16', '01', '15', '15', 'b00000', class TestSmsConcatenation(unittest.TestCase):
'0000000001', K, K, requested=50) """SCP80 SMS concatenation: packet building and segment sending
self.assertLessEqual(mx, 50) (TS 31.115 4.2/4.3)."""
self.assertGreater(mx, 0)
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): class TestDecodePor(unittest.TestCase):