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:
@@ -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
|
||||
|
||||
|
||||
+5
-1
@@ -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)
|
||||
|
||||
|
||||
+25
-12
@@ -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
|
||||
|
||||
@@ -273,7 +273,7 @@
|
||||
<li>AES требует счётчик с защитой от повтора: биты SPI1 b5 b4 должны быть <code class="font-mono text-sm">10</code> (счётчик больше) или <code class="font-mono text-sm">11</code> (счётчик +1) согласно TS 102 225 §5.1.2/§5.1.3.1</li>
|
||||
<li>Байт паддинга настраивается (<code class="font-mono text-sm">00</code> по умолчанию или <code class="font-mono text-sm">FF</code>)</li>
|
||||
</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>«Парсер ответов»</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 §4.3) — пакет делится на части SMS-пользовательских данных (первое SMS 132 октета, последующие по 134; первое дополнительно несёт информационные элементы конкатенации и CPI), и сегменты отправляются по порядку. Более 5 сегментов отклоняется: это практический предел буфера конкатенации карты. Полученный Proof of Receipt декодируется и показывается строкой статуса PoR (статус, TAR, счётчик, сырой PoR); статусное слово и данные ответа последней команды подставляются в подвкладку <strong>«Парсер ответов»</strong> (Remote APDU), а успешный PoR увеличивает счётчик повторов и очищает пакет.</p>
|
||||
|
||||
<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>
|
||||
@@ -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>
|
||||
<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><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] → LOAD ×N → 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] → LOAD ×N → INSTALL[for install (+make selectable)]. Load-файл делится на LOAD APDU размера <strong>размер блока LOAD</strong> (1–240 байт полезной нагрузки, по умолчанию 240); каждый защищённый пакет доставляется одним SMS или, если он больше, конкатенированной SMS-PP загрузкой до 5 сегментов, так что большой <code>.cap</code> просто занимает несколько SMS.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
||||
+2
-2
@@ -273,7 +273,7 @@
|
||||
<li>AES requires a replay-protected counter: SPI1 bits b5 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 §5.1.2/§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>
|
||||
</ul>
|
||||
<p class="text-sm mb-3">A “Verify vs pySim” button cross-checks the assembled packet against pySim’s reference <code class="font-mono text-sm">OtaDialectSms.encode_cmd</code>. 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 <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 “Verify vs pySim” button cross-checks the assembled packet against pySim’s reference <code class="font-mono text-sm">OtaDialectSms.encode_cmd</code>. 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 <strong>concatenated</strong> 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 <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>
|
||||
<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>
|
||||
<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><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] → LOAD ×N → 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] → LOAD ×N → INSTALL[for install (+make selectable)]. The load file is split into LOAD APDUs of the <strong>LOAD block size</strong> (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 <code class="font-mono text-sm">.cap</code> simply takes several SMS.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
||||
+69
-14
@@ -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>
|
||||
<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>
|
||||
<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-verify-result" class="mt-2 text-xs font-mono hidden"></div>
|
||||
|
||||
@@ -693,9 +694,9 @@
|
||||
<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>
|
||||
</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>
|
||||
<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">
|
||||
<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>
|
||||
<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="240">
|
||||
<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 id="ram-progress" class="hidden mb-3">
|
||||
@@ -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 завершается там, а запрошенный картой адрес только журналируется.',
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
const CACHE = 'simple-v239';
|
||||
const CACHE = 'simple-v240';
|
||||
const URLS = [
|
||||
'index.html',
|
||||
'help.html',
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
+239
-118
@@ -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 <repo>/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)
|
||||
|
||||
+136
-22
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user