Low-counter reporting, counter sync probe, Bad-format decode, expanded forms
Live findings that drove this: the "card counter 0000000000" the low-counter notice showed is not the card's counter at all (the PoR's CNTR is a copy of the command's counter, TS 102 225 5.2 table 3, and a rejection leaves it zeroed); and an earlier "no card accepts the expanded format" observation was a hand-made template whose declared length did not match its body - the card validates the lengths and answers with a Bad format TLV. - _cntr_low_fields(status) reports the verdict as a flag; card_cntr/ suggested_cntr and the UI's "(card counter ...)" text are gone - the value is not observable. The notice offers a bounded Sync counter probe instead. - POST /api/counter-probe: a doubling counter ladder (start+1, +2, +4, ...) with a read-only command until the card accepts one, then accepted+1 is persisted (monotonic, source counter-probe). Rejected attempts change nothing on the card; the search stops at a ceiling (default FFFFFFFF, far below the 40-bit maximum where the counter blocks) and after max_attempts (default 40, clamped 1-64); a non-cntr_low failure stops it at once. - _parse_response_scripting decodes the Bad format TLV (90, TS 101 220 table 7.20; error types per TS 102 226 table 5.12) and returns a dict; the PoR carries bad_format/bad_format_name and _ram_step_result fails the step with "bad format 02 (wrong length)" instead of falling through to the compact decoder (which read the AB tag as 171 commands). - _wrap_expanded_apdu(apdu, form) emits the definite (AA) or the indefinite (AE 80 ... 00 00) scripting template; the RAM command formats are compact/ expanded/expanded-ae and _ram_detect_format probes compact, then both expanded codings (the expanded probes use Le='00' and no chained GET RESPONSE, TS 102 226 5.2.1.1). The RAM form gains a Format selector. - Live-verified: the verdict carries no value; the probe walked 0x1000001/0x1000003 (low) then 0x1000007 (accepted, stored 0x1000008); the ISD executes both expanded codings (R-APDU 08A0000000030000000F80, SW 9000) and a length-mismatched template answers bad_format 02 - decoded now. - Tests: the ladder, the probe walk + persistence, params validation, both template forms, the Bad-format TLV (definite and plain tag styles), the live ISD response through _decode_por, the step-result failure, and the PWA guards; help EN/RU, docs/api.md, the repo AGENTS.md; sw cache simple-v352.
This commit is contained in:
+35
@@ -37,6 +37,7 @@ a 3.x PWA).
|
|||||||
| `/api/presets/delete` | POST | Delete a preset (`{id}`) |
|
| `/api/presets/delete` | POST | Delete a preset (`{id}`) |
|
||||||
| `/api/presets/import` | POST | Import presets (`{presets: [...], mode: merge\|replace}`) |
|
| `/api/presets/import` | POST | Import presets (`{presets: [...], mode: merge\|replace}`) |
|
||||||
| `/api/tar-probe` | POST | Probe the card's registered OTA applications (one secured packet per TAR, TS 101 220 Annex D) |
|
| `/api/tar-probe` | POST | Probe the card's registered OTA applications (one secured packet per TAR, TS 101 220 Annex D) |
|
||||||
|
| `/api/counter-probe` | POST | Bounded counter sync: probe increasing counter values (start+1, +2, +4, …) with a read-only command until the card accepts one (ceiling/attempt-capped), then persist accepted+1 |
|
||||||
| `/api/esim/chip` | GET | eUICC chip details (EID, EUICCInfo1/2, configured addresses) |
|
| `/api/esim/chip` | GET | eUICC chip details (EID, EUICCInfo1/2, configured addresses) |
|
||||||
| `/api/esim/profiles` | GET | Installed eSIM profiles with their metadata |
|
| `/api/esim/profiles` | GET | Installed eSIM profiles with their metadata |
|
||||||
| `/api/esim/notifications` | GET | Pending eSIM notifications (read-only) |
|
| `/api/esim/notifications` | GET | Pending eSIM notifications (read-only) |
|
||||||
@@ -246,6 +247,40 @@ import also accepts presets exported by the older localStorage-based builds.
|
|||||||
`"replace"`) → `{"ok": true, "added": N, "skipped": M, "errors": […]}`;
|
`"replace"`) → `{"ok": true, "added": N, "skipped": M, "errors": […]}`;
|
||||||
invalid entries and duplicates are skipped and reported, never fatal.
|
invalid entries and duplicates are skipped and reported, never fatal.
|
||||||
|
|
||||||
|
### `POST /api/counter-probe`
|
||||||
|
|
||||||
|
Sync a preset's SCP80 counter with the card. The card accepts only a counter
|
||||||
|
*above* its own (TS 102 225 §5.1.1, SPI1 b5b4 = `10`) and updates it only when
|
||||||
|
the packet is accepted, so rejected attempts change nothing; the probe walks a
|
||||||
|
doubling ladder (`start+1`, `+2`, `+4`, …) with a read-only command until one
|
||||||
|
is accepted, then persists `accepted+1` (monotonic) into the keyset. The
|
||||||
|
search stops at a ceiling and after a bounded number of packets, so it can
|
||||||
|
never run towards the 40-bit maximum, where the card blocks the counter. The
|
||||||
|
card's own counter is never reported by the card (the PoR's CNTR is a copy of
|
||||||
|
the packet's, TS 102 225 §5.2 Table 3), which is why this probe exists.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"preset_id": "…", "kvn": 2, "tar": "000000", "spi1": "16", "spi2": "21",
|
||||||
|
"cntr": "0001000000", "ceiling": "FFFFFFFF", "max_attempts": 40,
|
||||||
|
"apdu": "00A40000023F00"}
|
||||||
|
```
|
||||||
|
|
||||||
|
All fields except `preset_id` are optional: `kvn` picks a keyset (the first
|
||||||
|
otherwise), `tar`/`spi1`/`spi2` default to the preset's values, `cntr` to the
|
||||||
|
keyset's counter, `ceiling` to `FFFFFFFF` (must stay below `FFFFFFFFFF`),
|
||||||
|
`max_attempts` to 40 (clamped to 1–64), and `apdu` to a read-only `SELECT MF`
|
||||||
|
(the probe command should never modify anything). A SPI1 without a counter
|
||||||
|
check is refused (400).
|
||||||
|
|
||||||
|
**Response:** `{"success": true, "accepted_cntr": "0001000000",
|
||||||
|
"stored_cntr": "0001000001", "packets": 3, "stopped": "accepted",
|
||||||
|
"attempts": [{"cntr": "…", "por_status": "cntr_low"}, …],
|
||||||
|
"start": "…", "ceiling": "FFFFFFFF", "kvn": 2}`
|
||||||
|
|
||||||
|
`stopped` is `accepted`, `ceiling`, `attempts` or `error`; on a non-`cntr_low`
|
||||||
|
failure (refused ENVELOPE, wrong TAR, security error) the probe stops at the
|
||||||
|
first attempt instead of hammering the card.
|
||||||
|
|
||||||
### `POST /api/tar-probe`
|
### `POST /api/tar-probe`
|
||||||
|
|
||||||
Probe which OTA applications the card has registered: one **secured packet per
|
Probe which OTA applications the card has registered: one **secured packet per
|
||||||
|
|||||||
@@ -279,7 +279,7 @@
|
|||||||
</section>
|
</section>
|
||||||
<section class="mb-10">
|
<section class="mb-10">
|
||||||
<h2 id="scp80" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">3. Вкладка SCP80</h2>
|
<h2 id="scp80" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">3. Вкладка SCP80</h2>
|
||||||
<p class="mb-3">Верхнеуровневая вкладка <strong>SCP80</strong> объединяет разделы, связанные с SCP80. Переключение — тремя переключателями: <strong>Secured Packet</strong>, <strong>RAM</strong> и <strong>TAR-сканер</strong>. Собирает защищённые пакеты SCP80 по ETSI TS 102 225. При подключении карты сервер читает её <strong>EF.ICCID</strong> (2FE2); если в пресете указан тот же номер (цифрами или сырым hex из EF), он автоматически выбирается во всех трёх представлениях SCP80. Вердикт <strong>низкого счётчика</strong> (<code class="font-mono text-sm">cntr_low</code> или предупреждающий ответ ENVELOPE, в котором всё ещё есть такой PoR) <em>не</em> исправляется повторной отправкой: любая операция SCP80/RAM немедленно останавливается, результат показывает счётчик карты и кнопку <strong>К пресету</strong>, открывающую соответствующий пресет на вкладке Cards, где счётчик нужно поднять выше — автоматически ничего не записывается.</p>
|
<p class="mb-3">Верхнеуровневая вкладка <strong>SCP80</strong> объединяет разделы, связанные с SCP80. Переключение — тремя переключателями: <strong>Secured Packet</strong>, <strong>RAM</strong> и <strong>TAR-сканер</strong>. Собирает защищённые пакеты SCP80 по ETSI TS 102 225. При подключении карты сервер читает её <strong>EF.ICCID</strong> (2FE2); если в пресете указан тот же номер (цифрами или сырым hex из EF), он автоматически выбирается во всех трёх представлениях SCP80. Вердикт <strong>низкого счётчика</strong> (<code class="font-mono text-sm">cntr_low</code> или предупреждающий ответ ENVELOPE, в котором всё ещё есть такой PoR) <em>не</em> исправляется повторной отправкой: любая операция SCP80/RAM немедленно останавливается. Карта не сообщает свой счётчик (поле CNTR в PoR — копия счётчика пакета), поэтому в уведомлении есть ограниченный пробник <strong>«Синхронизировать счётчик»</strong> — read-only команда с возрастающими значениями счётчика (шаг удваивается, потолок 32 бита, не более 40 пакетов), пока карта не примет одно; затем сохраняется принятое+1 — и кнопка <strong>К пресету</strong> для ручного повышения; больше ничего автоматически не записывается.</p>
|
||||||
|
|
||||||
<h3 id="secured-packet" class="text-lg font-medium mb-2">3.1 Secured Packet</h3>
|
<h3 id="secured-packet" class="text-lg font-medium mb-2">3.1 Secured Packet</h3>
|
||||||
<p class="mb-2">Собирает защищённые пакеты SCP80 по ETSI TS 102 225. Селектор <strong>Keyset</strong> рядом с выбором пресета указывает, из какого keyset берутся ключи и счётчик; отправка отклоняется, если в пресете нет такого номера keyset или KIc/KID несут разные номера (TS 102 225 A.2). Селектор недоступен, пока не выбран пресет карты. Кнопка запуска недоступна, пока не выбран пресет карты и один из его keysets.</p>
|
<p class="mb-2">Собирает защищённые пакеты SCP80 по ETSI TS 102 225. Селектор <strong>Keyset</strong> рядом с выбором пресета указывает, из какого keyset берутся ключи и счётчик; отправка отклоняется, если в пресете нет такого номера keyset или KIc/KID несут разные номера (TS 102 225 A.2). Селектор недоступен, пока не выбран пресет карты. Кнопка запуска недоступна, пока не выбран пресет карты и один из его keysets.</p>
|
||||||
@@ -314,7 +314,7 @@
|
|||||||
<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 (при подключении к серверу). 2-байтовый префикс CPL передаётся, когда пакет зашифрован или несёт RC/CC/DS (входит в их входные данные), а также при конкатенации SMS; одиночное незащищённое SMS сохраняет форму pySim, начинающуюся с CHL (TS 31.115 Table 1 NOTE). Под полем пакета показывается его размер и число SMS; пакет, не помещающийся в одно SMS, отправляется <strong>конкатенированной</strong> загрузкой (TS 31.115 §4.3) — пакет делится на части SMS-пользовательских данных (первое SMS 132 октета, последующие по 134; первое дополнительно несёт информационные элементы конкатенации и CPI), и сегменты отправляются по порядку. Более 5 сегментов отклоняется: это практический предел буфера конкатенации карты. Полученный Proof of Receipt декодируется и показывается строкой статуса PoR (статус, TAR, счётчик, сырой PoR); статусное слово и данные ответа последней команды подставляются в подвкладку <strong>«Разбор R-APDU»</strong> (Remote APDU → «Разбор»), а успешный PoR увеличивает счётчик повторов (когда в SPI1 есть проверка счётчика — b5 b4 ≠ 00; без неё счётчик не увеличивается и не сохраняется) и очищает пакет.</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 (при подключении к серверу). 2-байтовый префикс CPL передаётся, когда пакет зашифрован или несёт RC/CC/DS (входит в их входные данные), а также при конкатенации SMS; одиночное незащищённое SMS сохраняет форму pySim, начинающуюся с CHL (TS 31.115 Table 1 NOTE). Под полем пакета показывается его размер и число SMS; пакет, не помещающийся в одно SMS, отправляется <strong>конкатенированной</strong> загрузкой (TS 31.115 §4.3) — пакет делится на части SMS-пользовательских данных (первое SMS 132 октета, последующие по 134; первое дополнительно несёт информационные элементы конкатенации и CPI), и сегменты отправляются по порядку. Более 5 сегментов отклоняется: это практический предел буфера конкатенации карты. Полученный Proof of Receipt декодируется и показывается строкой статуса PoR (статус, TAR, счётчик, сырой PoR); статусное слово и данные ответа последней команды подставляются в подвкладку <strong>«Разбор R-APDU»</strong> (Remote APDU → «Разбор»), а успешный PoR увеличивает счётчик повторов (когда в SPI1 есть проверка счётчика — b5 b4 ≠ 00; без неё счётчик не увеличивается и не сохраняется) и очищает пакет.</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>). Селектор <strong>Keyset</strong> рядом с выбором пресета указывает, из какого keyset берутся ключи и счётчик операции. Селектор недоступен, пока не выбран пресет карты. Кнопка запуска недоступна, пока не выбран пресет карты и один из его keysets.</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>). Селектор <strong>Keyset</strong> рядом с выбором пресета указывает, из какого keyset берутся ключи и счётчик операции. Селектор недоступен, пока не выбран пресет карты. Кнопка запуска недоступна, пока не выбран пресет карты и один из его keysets. Селектор <strong>Формат</strong> фиксирует формат удалённой команды: <strong>Авто (определение)</strong> опрашивает read-only команду GET STATUS в компактном виде и в обоих expanded-кодированиях и оставляет первое, которое карта выполнила; <strong>Компактный</strong> отправляет голую C-APDU; <strong>Expanded (AA, определённая длина)</strong> / <strong>Expanded (AE 80, неопределённая длина)</strong> заворачивают её в шаблон Command Scripting из TS 102 226 §5.2.1 (на живой карте выполняются обе; шаблон, у которого заявленная длина не совпадает с телом, получает Bad format TLV — операция показывает это как ошибку шага).</p>
|
||||||
|
|
||||||
<h4 id="ram-operations" class="font-medium mb-1">Операции</h4>
|
<h4 id="ram-operations" class="font-medium mb-1">Операции</h4>
|
||||||
<table class="w-full text-sm mb-3 border-collapse">
|
<table class="w-full text-sm mb-3 border-collapse">
|
||||||
|
|||||||
+2
-2
@@ -310,10 +310,10 @@
|
|||||||
<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>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>
|
<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 “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 2-byte CPL prefix is transmitted whenever the packet is ciphered or carries RC/CC/DS (it is part of their input) and whenever the packet is concatenated; a single unprotected SMS keeps pySim’s CHL-first form (TS 31.115 Table 1 NOTE). Under the packet field the app reports its size and how many SMS it takes; a packet that does not fit one SMS is sent as a <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>R-APDU parser</strong> panel, and a successful PoR advances the replay counter (when SPI1 carries a counter check — b5 b4 ≠ 00; without one no counter is advanced or saved) and clears the packet. A <strong>low-counter</strong> verdict (<code class="font-mono text-sm">cntr_low</code>, or a warning ENVELOPE answer that still carries such a PoR) is <em>not</em> recoverable by retrying: every SCP80/RAM operation stops immediately and the result states the card's counter with a <strong>Go to preset</strong> button that opens the matching preset in the Cards tab, where the counter must be raised above it — nothing is written automatically.</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). The 2-byte CPL prefix is transmitted whenever the packet is ciphered or carries RC/CC/DS (it is part of their input) and whenever the packet is concatenated; a single unprotected SMS keeps pySim’s CHL-first form (TS 31.115 Table 1 NOTE). Under the packet field the app reports its size and how many SMS it takes; a packet that does not fit one SMS is sent as a <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>R-APDU parser</strong> panel, and a successful PoR advances the replay counter (when SPI1 carries a counter check — b5 b4 ≠ 00; without one no counter is advanced or saved) and clears the packet. A <strong>low-counter</strong> verdict (<code class="font-mono text-sm">cntr_low</code>, or a warning ENVELOPE answer that still carries such a PoR) is <em>not</em> recoverable by retrying: every SCP80/RAM operation stops immediately. The card never reports its counter (the PoR’s CNTR field is a copy of the packet’s), so the notice offers a bounded <strong>Sync counter</strong> probe — a read-only command with increasing counter values (doubling steps, a 32-bit ceiling, at most 40 packets) until the card accepts one, then accepted+1 is stored — and a <strong>Go to preset</strong> button for raising the counter by hand; nothing else is written automatically.</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). The <strong>Keyset</strong> selector next to the preset selector picks the keyset whose keys and counter the operation uses. It is disabled until a card preset is selected. The start button stays disabled until a card preset and one of its keysets are selected.</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). The <strong>Keyset</strong> selector next to the preset selector picks the keyset whose keys and counter the operation uses. It is disabled until a card preset is selected. The start button stays disabled until a card preset and one of its keysets are selected. The <strong>Format</strong> selector pins the remote command format: <strong>Auto (detect)</strong> probes the read-only GET STATUS command compact and in both expanded codings and keeps the first that the card executes, <strong>Compact</strong> sends the bare C-APDU, and <strong>Expanded (AA, definite length)</strong> / <strong>Expanded (AE 80, indefinite length)</strong> wrap it in the TS 102 226 §5.2.1 scripting template (both execute on the live ISD; a template whose declared lengths do not match its body draws a Bad format TLV, which the operation shows as the step failure).</p>
|
||||||
|
|
||||||
<h4 id="ram-operations" class="font-medium mb-1">Operations</h4>
|
<h4 id="ram-operations" class="font-medium mb-1">Operations</h4>
|
||||||
<table class="w-full text-sm mb-3 border-collapse">
|
<table class="w-full text-sm mb-3 border-collapse">
|
||||||
|
|||||||
+103
-51
@@ -708,6 +708,15 @@
|
|||||||
<option value="make-selectable" data-l10n="INSTALL [for make selectable]">INSTALL [for make selectable]</option>
|
<option value="make-selectable" data-l10n="INSTALL [for make selectable]">INSTALL [for make selectable]</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300" data-l10n="Format">Format</label>
|
||||||
|
<select id="ram-format" class="w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800">
|
||||||
|
<option value="auto" data-l10n="Auto (detect)">Auto (detect)</option>
|
||||||
|
<option value="compact" data-l10n="Compact">Compact</option>
|
||||||
|
<option value="expanded" data-l10n="Expanded (AA, definite length)">Expanded (AA, definite length)</option>
|
||||||
|
<option value="expanded-ae" data-l10n="Expanded (AE 80, indefinite length)">Expanded (AE 80, indefinite length)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<button data-needs="card" data-needs-keyset="ram" data-cap-gate="ram" onclick="ramExecute()" class="px-5 py-1.5 bg-blue-600 text-white text-sm font-medium rounded hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Execute">Execute</button>
|
<button data-needs="card" data-needs-keyset="ram" data-cap-gate="ram" onclick="ramExecute()" class="px-5 py-1.5 bg-blue-600 text-white text-sm font-medium rounded hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Execute">Execute</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="ram-preset-warning" class="hidden mb-3 px-3 py-2 rounded border border-amber-400 dark:border-amber-600 bg-amber-50 dark:bg-amber-900/20 text-sm text-amber-700 dark:text-amber-300"></div>
|
<div id="ram-preset-warning" class="hidden mb-3 px-3 py-2 rounded border border-amber-400 dark:border-amber-600 bg-amber-50 dark:bg-amber-900/20 text-sm text-amber-700 dark:text-amber-300"></div>
|
||||||
@@ -8304,13 +8313,11 @@ async function pysimSendOta() {
|
|||||||
try {
|
try {
|
||||||
const data = await pysimFetch('/api/send-ota', { sp, ...spParams() });
|
const data = await pysimFetch('/api/send-ota', { sp, ...spParams() });
|
||||||
// low counter: not recoverable by retrying (v3.6.58)
|
// low counter: not recoverable by retrying (v3.6.58)
|
||||||
const low = spCntrLow(data);
|
if (spCntrLow(data)) {
|
||||||
if (low) {
|
sendResultEl.textContent = t('Low counter') + ' — ' + t('the card rejected the packet');
|
||||||
sendResultEl.textContent = t('Low counter') + ' — ' + t('the card rejected the packet') +
|
|
||||||
(low.card_cntr ? ' (' + t('card counter') + ' ' + low.card_cntr + ')' : '');
|
|
||||||
sendResultEl.classList.remove('text-gray-500', 'text-green-600');
|
sendResultEl.classList.remove('text-gray-500', 'text-green-600');
|
||||||
sendResultEl.classList.add('text-red-600');
|
sendResultEl.classList.add('text-red-600');
|
||||||
ramShowCntrLow(low);
|
ramShowCntrLow();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
@@ -8851,6 +8858,7 @@ function getRamSpParams(selId) {
|
|||||||
cntr: (document.getElementById('sp-cntr').value || '0000000001').replace(/[^0-9a-fA-F]/g, ''),
|
cntr: (document.getElementById('sp-cntr').value || '0000000001').replace(/[^0-9a-fA-F]/g, ''),
|
||||||
kicKey: (document.getElementById('sp-kic-key').value || '').replace(/[^0-9a-fA-F]/g, ''),
|
kicKey: (document.getElementById('sp-kic-key').value || '').replace(/[^0-9a-fA-F]/g, ''),
|
||||||
kidKey: (document.getElementById('sp-kid-key').value || '').replace(/[^0-9a-fA-F]/g, ''),
|
kidKey: (document.getElementById('sp-kid-key').value || '').replace(/[^0-9a-fA-F]/g, ''),
|
||||||
|
ram_format: (document.getElementById('ram-format') || {}).value || 'auto',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -9599,13 +9607,10 @@ async function ramExplore(sp) {
|
|||||||
// own - a retry cannot help, so the run stops immediately and the user is
|
// own - a retry cannot help, so the run stops immediately and the user is
|
||||||
// told to raise the preset counter (v3.6.58). Every send of the run goes
|
// told to raise the preset counter (v3.6.58). Every send of the run goes
|
||||||
// through sendOta() so the verdict is caught wherever it appears.
|
// through sendOta() so the verdict is caught wherever it appears.
|
||||||
let cntrLow = null;
|
let cntrLow = false;
|
||||||
async function sendOta(apdu, params) {
|
async function sendOta(apdu, params) {
|
||||||
const res = await ramSendOta(apdu, params);
|
const res = await ramSendOta(apdu, params);
|
||||||
if (!cntrLow) {
|
if (!cntrLow && spCntrLow(res)) cntrLow = true;
|
||||||
const low = spCntrLow(res);
|
|
||||||
if (low) cntrLow = low;
|
|
||||||
}
|
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -9900,7 +9905,7 @@ async function ramExplore(sp) {
|
|||||||
// the run was stopped: state the verdict, do not retry
|
// the run was stopped: state the verdict, do not retry
|
||||||
resultEl.textContent = t('Low counter') + ' — ' + t('the operation was stopped');
|
resultEl.textContent = t('Low counter') + ' — ' + t('the operation was stopped');
|
||||||
resultEl.classList.remove('hidden', 'text-green-600'); resultEl.classList.add('text-red-600');
|
resultEl.classList.remove('hidden', 'text-green-600'); resultEl.classList.add('text-red-600');
|
||||||
ramShowCntrLow(cntrLow);
|
ramShowCntrLow();
|
||||||
} else if (errors.length) {
|
} else if (errors.length) {
|
||||||
resultEl.textContent = t('Partial') + ' — ' + errors.join('; ');
|
resultEl.textContent = t('Partial') + ' — ' + errors.join('; ');
|
||||||
resultEl.classList.remove('hidden', 'text-green-600'); resultEl.classList.add('text-red-600');
|
resultEl.classList.remove('hidden', 'text-green-600'); resultEl.classList.add('text-red-600');
|
||||||
@@ -10005,12 +10010,11 @@ async function ramDeleteFromExplorer(aid, withCascade) {
|
|||||||
const res = await ramSendOta(apdu, sp);
|
const res = await ramSendOta(apdu, sp);
|
||||||
ramHideProgress();
|
ramHideProgress();
|
||||||
// low counter: stop - retrying cannot help (v3.6.58)
|
// low counter: stop - retrying cannot help (v3.6.58)
|
||||||
const low = spCntrLow(res);
|
if (spCntrLow(res)) {
|
||||||
if (low) {
|
|
||||||
const el = document.getElementById('ram-result');
|
const el = document.getElementById('ram-result');
|
||||||
el.textContent = t('Low counter') + ' — ' + t('the operation was stopped');
|
el.textContent = t('Low counter') + ' — ' + t('the operation was stopped');
|
||||||
el.classList.remove('hidden', 'text-green-600'); el.classList.add('text-red-600');
|
el.classList.remove('hidden', 'text-green-600'); el.classList.add('text-red-600');
|
||||||
ramShowCntrLow(low);
|
ramShowCntrLow();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// The card consumes the counter as soon as it accepts the packet, even
|
// The card consumes the counter as soon as it accepts the packet, even
|
||||||
@@ -10435,10 +10439,9 @@ async function ramCompatTest(sp) {
|
|||||||
let modal = esc(txt);
|
let modal = esc(txt);
|
||||||
if (probeNote) modal += '<div class="mt-2 text-gray-500 dark:text-slate-400">' + esc(probeNote) + '</div>';
|
if (probeNote) modal += '<div class="mt-2 text-gray-500 dark:text-slate-400">' + esc(probeNote) + '</div>';
|
||||||
modal += ramCompatDetailHtml(data, mem);
|
modal += ramCompatDetailHtml(data, mem);
|
||||||
const low = spCntrLow(data);
|
if (spCntrLow(data)) {
|
||||||
if (low) {
|
modal += ramCntrLowHtml(ramCntrLowPresetIdx());
|
||||||
modal += ramCntrLowHtml(low, ramCntrLowPresetIdx());
|
ramShowCntrLow();
|
||||||
ramShowCntrLow(low);
|
|
||||||
}
|
}
|
||||||
ramOpModalFinish(verdict, !!data.imports_ok, modal, true);
|
ramOpModalFinish(verdict, !!data.imports_ok, modal, true);
|
||||||
}
|
}
|
||||||
@@ -10808,27 +10811,27 @@ function ramCntrLowPresetIdx() {
|
|||||||
return ram >= 0 ? ram : pick('sp-card-sel');
|
return ram >= 0 ? ram : pick('sp-card-sel');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Low-counter notice (pure HTML): the facts plus a button that opens the
|
// Low-counter notice (pure HTML): the facts plus two actions - a bounded
|
||||||
// failed operation's preset in the Cards tab, where the user raises the
|
// counter sync probe and a button that opens the failed operation's preset in
|
||||||
// counter - nothing is written automatically.
|
// the Cards tab, where the counter can be raised by hand.
|
||||||
function ramCntrLowHtml(info, presetIdx) {
|
function ramCntrLowHtml(presetIdx) {
|
||||||
if (!info) return '';
|
|
||||||
const idx = Number.isInteger(presetIdx) && presetIdx >= 0 ? presetIdx : -1;
|
const idx = Number.isInteger(presetIdx) && presetIdx >= 0 ? presetIdx : -1;
|
||||||
let text = t('Low counter') + ' \u2014 ' + t('the card rejected the packet');
|
const text = t('Low counter') + ' \u2014 ' + t('the card rejected the packet');
|
||||||
if (info.card_cntr) text += ' (' + t('card counter') + ' ' + esc(info.card_cntr) + ')';
|
const btnCls = 'px-2 py-1 text-xs rounded border border-red-300 dark:border-red-700 ' +
|
||||||
if (info.suggested) text += ' \u2014 ' + t('the counter must be above it');
|
'hover:bg-red-100 dark:hover:bg-red-900/40 whitespace-nowrap';
|
||||||
return '<div class="flex items-center gap-3 text-sm text-red-600 dark:text-red-400">' +
|
return '<div class="flex items-center gap-3 text-sm text-red-600 dark:text-red-400">' +
|
||||||
'<span>' + text + '</span>' +
|
'<span>' + text + '</span>' +
|
||||||
'<button type="button" onclick="ramGoToPreset(' + idx + ')" class="px-2 py-1 text-xs rounded border border-red-300 dark:border-red-700 hover:bg-red-100 dark:hover:bg-red-900/40 whitespace-nowrap">' +
|
'<button type="button" onclick="ramSyncCounter(' + idx + ')" class="' + btnCls + '">' +
|
||||||
|
esc(t('Sync counter')) + '</button>' +
|
||||||
|
'<button type="button" onclick="ramGoToPreset(' + idx + ')" class="' + btnCls + '">' +
|
||||||
esc(t('Go to preset')) + '</button></div>';
|
esc(t('Go to preset')) + '</button></div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show the low-counter notice in the RAM result area (and, when the operation
|
// Show the low-counter notice in the RAM result area (and, when the operation
|
||||||
// modal is still open, in its detail block).
|
// modal is still open, in its detail block).
|
||||||
function ramShowCntrLow(info, presetIdx) {
|
function ramShowCntrLow(presetIdx) {
|
||||||
if (!info) return;
|
|
||||||
const idx = presetIdx === undefined ? ramCntrLowPresetIdx() : presetIdx;
|
const idx = presetIdx === undefined ? ramCntrLowPresetIdx() : presetIdx;
|
||||||
const html = ramCntrLowHtml(info, idx);
|
const html = ramCntrLowHtml(idx);
|
||||||
const el = document.getElementById('ram-cntr-low');
|
const el = document.getElementById('ram-cntr-low');
|
||||||
if (el && el.classList) {
|
if (el && el.classList) {
|
||||||
el.innerHTML = html;
|
el.innerHTML = html;
|
||||||
@@ -10840,6 +10843,54 @@ function ramShowCntrLow(info, presetIdx) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sync the counter of the failed operation's preset with the card: the server
|
||||||
|
// probes with a read-only command and increasing counter values (start+1, +2,
|
||||||
|
// +4, ...) until the card accepts one, stops at a 32-bit ceiling and persists
|
||||||
|
// accepted+1. A rejected attempt changes nothing on the card, so only the
|
||||||
|
// accepted packet moves its counter (the card never reports its value).
|
||||||
|
async function ramSyncCounter(presetIdx) {
|
||||||
|
const i = Number(presetIdx);
|
||||||
|
const preset = (Number.isInteger(i) && i >= 0 && cards[i]) ? cards[i] : null;
|
||||||
|
const box = document.getElementById('ram-cntr-low');
|
||||||
|
if (!preset) { alert(t('No card preset selected')); return; }
|
||||||
|
// The keyset the failed operation used: the RAM form's selector, else the
|
||||||
|
// SCP80 form's (both hold the keyset number as their option value).
|
||||||
|
const kvnOf = id => {
|
||||||
|
const sel = document.getElementById(id);
|
||||||
|
const v = sel && sel.value !== '' ? parseInt(sel.value, 10) : NaN;
|
||||||
|
return Number.isInteger(v) && v > 0 ? v : null;
|
||||||
|
};
|
||||||
|
const val = id => (document.getElementById(id) || {}).value || '';
|
||||||
|
const body = { preset_id: preset.id };
|
||||||
|
const kvn = kvnOf('ram-keyset-sel') || kvnOf('sp-keyset-sel');
|
||||||
|
if (kvn) body.kvn = kvn;
|
||||||
|
const tar = val('sp-tar').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
|
||||||
|
if (tar) body.tar = tar;
|
||||||
|
if (val('sp-spi1')) body.spi1 = val('sp-spi1');
|
||||||
|
if (val('sp-spi2-hex')) body.spi2 = val('sp-spi2-hex');
|
||||||
|
const cntr = val('sp-cntr').replace(/[^0-9a-fA-F]/g, '');
|
||||||
|
if (cntr) body.cntr = cntr;
|
||||||
|
if (box) box.innerHTML = '<div class="text-sm text-gray-500 dark:text-slate-400">' +
|
||||||
|
esc(t('Syncing counter...')) + '</div>';
|
||||||
|
try {
|
||||||
|
const data = await pysimFetch('/api/counter-probe', body);
|
||||||
|
if (data.success) {
|
||||||
|
await cardsFetch();
|
||||||
|
const cntrEl = document.getElementById('sp-cntr');
|
||||||
|
if (cntrEl && data.stored_cntr) cntrEl.value = data.stored_cntr;
|
||||||
|
const msg = t('Counter synced') + ': ' + esc(data.accepted_cntr) + ' \u2192 ' +
|
||||||
|
esc(data.stored_cntr) + '. ' + t('Retry the operation.');
|
||||||
|
if (box) box.innerHTML = '<div class="text-sm text-emerald-600 dark:text-emerald-400">' + msg + '</div>';
|
||||||
|
} else {
|
||||||
|
const msg = t('Counter sync failed') + ': ' + esc(data.error || '');
|
||||||
|
if (box) box.innerHTML = '<div class="text-sm text-red-600 dark:text-red-400">' + msg + '</div>';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (box) box.innerHTML = '<div class="text-sm text-red-600 dark:text-red-400">' +
|
||||||
|
esc(String(e.message || e)) + '</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Open a preset in the Cards tab for editing (the user fixes the counter):
|
// Open a preset in the Cards tab for editing (the user fixes the counter):
|
||||||
// select it, scroll to the counter field and focus it.
|
// select it, scroll to the counter field and focus it.
|
||||||
function ramGoToPreset(idx) {
|
function ramGoToPreset(idx) {
|
||||||
@@ -10904,12 +10955,11 @@ function ramShowInstallResult(data, okText, capMem) {
|
|||||||
// successful run and for failures that are not LOAD/INSTALL [for load]).
|
// successful run and for failures that are not LOAD/INSTALL [for load]).
|
||||||
let modal = esc(txt);
|
let modal = esc(txt);
|
||||||
modal += ramCompatDetailHtml(data, capMem || null);
|
modal += ramCompatDetailHtml(data, capMem || null);
|
||||||
// low counter: not recoverable by retrying - state it and point at the
|
// low counter: not recoverable by retrying - state it and offer the
|
||||||
// preset, where the user raises the counter (v3.6.58)
|
// bounded sync probe or the preset jump (v3.6.58)
|
||||||
const low = spCntrLow(data);
|
if (spCntrLow(data)) {
|
||||||
if (low) {
|
modal += ramCntrLowHtml(ramCntrLowPresetIdx());
|
||||||
modal += ramCntrLowHtml(low, ramCntrLowPresetIdx());
|
ramShowCntrLow();
|
||||||
ramShowCntrLow(low);
|
|
||||||
}
|
}
|
||||||
ramOpModalFinish(resultEl.textContent, !!data.success, modal, true);
|
ramOpModalFinish(resultEl.textContent, !!data.success, modal, true);
|
||||||
}
|
}
|
||||||
@@ -12003,22 +12053,15 @@ function spPorAccepted(por) {
|
|||||||
// The card's low-counter verdict of a send response (v3.6.58). Every further
|
// The card's low-counter verdict of a send response (v3.6.58). Every further
|
||||||
// packet with a counter <= the card's is rejected again, so the operation must
|
// packet with a counter <= the card's is rejected again, so the operation must
|
||||||
// stop and the preset counter must be raised - a retry cannot recover it.
|
// stop and the preset counter must be raised - a retry cannot recover it.
|
||||||
// Returns {card_cntr, suggested} or null.
|
// Returns true for a cntr_low verdict, false otherwise. No value is reported:
|
||||||
|
// the PoR's CNTR is a copy of the command's counter (TS 102 225 5.2, table 3)
|
||||||
|
// and a rejection leaves it zeroed, so the card's counter is not observable -
|
||||||
|
// the server's counter probe or a manual raise are the ways forward.
|
||||||
function spCntrLow(res) {
|
function spCntrLow(res) {
|
||||||
if (!res) return null;
|
if (!res) return false;
|
||||||
const por = res.por || null;
|
const por = res.por || null;
|
||||||
const status = (por && por.response_status) || '';
|
const status = (por && por.response_status) || '';
|
||||||
if (!res.cntr_low && status !== 'cntr_low') return null;
|
return !!(res.cntr_low || status === 'cntr_low');
|
||||||
const card = String(res.card_cntr || (por && por.cntr) || '').toUpperCase();
|
|
||||||
let suggested = String(res.suggested_cntr || '').toUpperCase();
|
|
||||||
if (!suggested && /^[0-9A-F]+$/.test(card)) {
|
|
||||||
try {
|
|
||||||
// the SCP80 counter field is fixed-width: wrap like the server does
|
|
||||||
const mod = 16n ** BigInt(card.length);
|
|
||||||
suggested = ((BigInt('0x' + card) + 1n) % mod).toString(16).toUpperCase().padStart(card.length, '0');
|
|
||||||
} catch (e) { suggested = ''; }
|
|
||||||
}
|
|
||||||
return { card_cntr: card, suggested: suggested };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remote-command status words that count as success (mirrors the server's
|
// Remote-command status words that count as success (mirrors the server's
|
||||||
@@ -20264,8 +20307,17 @@ const LANG_RU = {
|
|||||||
'request error': 'ошибка запроса',
|
'request error': 'ошибка запроса',
|
||||||
'Low counter': 'Низкий счётчик',
|
'Low counter': 'Низкий счётчик',
|
||||||
'the card rejected the packet': 'карта отклонила пакет',
|
'the card rejected the packet': 'карта отклонила пакет',
|
||||||
'card counter': 'счётчик карты',
|
'Sync counter': 'Синхронизировать счётчик',
|
||||||
'the counter must be above it': 'счётчик должен быть больше',
|
'Syncing counter...': 'Синхронизация счётчика...',
|
||||||
|
'Counter synced': 'Счётчик синхронизирован',
|
||||||
|
'Counter sync failed': 'Не удалось синхронизировать счётчик',
|
||||||
|
'Retry the operation.': 'Повторите операцию.',
|
||||||
|
'No card preset selected': 'Пресет карты не выбран',
|
||||||
|
'Format': 'Формат',
|
||||||
|
'Auto (detect)': 'Авто (определение)',
|
||||||
|
'Compact': 'Компактный',
|
||||||
|
'Expanded (AA, definite length)': 'Expanded (AA, определённая длина)',
|
||||||
|
'Expanded (AE 80, indefinite length)': 'Expanded (AE 80, неопределённая длина)',
|
||||||
'Go to preset': 'К пресету',
|
'Go to preset': 'К пресету',
|
||||||
'first page only': 'только первая страница',
|
'first page only': 'только первая страница',
|
||||||
'the operation was stopped': 'операция остановлена',
|
'the operation was stopped': 'операция остановлена',
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
const CACHE = 'simple-v351';
|
const CACHE = 'simple-v352';
|
||||||
const URLS = [
|
const URLS = [
|
||||||
'index.html',
|
'index.html',
|
||||||
'help.html',
|
'help.html',
|
||||||
|
|||||||
+37
-19
@@ -746,33 +746,33 @@ test('the explorer hints why the package versions are missing', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('spCntrLow reads the low-counter verdict of a send response', () => {
|
test('spCntrLow reads the low-counter verdict of a send response', () => {
|
||||||
// the server's explicit fields
|
// the server's explicit flag
|
||||||
const a = spCntrLow({ cntr_low: true, card_cntr: '00000000BB', suggested_cntr: '00000000BC' });
|
assert.strictEqual(spCntrLow({ cntr_low: true }), true);
|
||||||
assert.deepStrictEqual(a, { card_cntr: '00000000BB', suggested: '00000000BC' });
|
|
||||||
// derived from the PoR alone (a 9000/91xx ENVELOPE can carry a cntr_low PoR)
|
// derived from the PoR alone (a 9000/91xx ENVELOPE can carry a cntr_low PoR)
|
||||||
const b = spCntrLow({ por: { response_status: 'cntr_low', cntr: '00000000BB' } });
|
assert.strictEqual(spCntrLow({ por: { response_status: 'cntr_low', cntr: '00000000BB' } }), true);
|
||||||
assert.deepStrictEqual(b, { card_cntr: '00000000BB', suggested: '00000000BC' });
|
|
||||||
// the counter is 40-bit: the wrap keeps the width
|
|
||||||
const c = spCntrLow({ por: { response_status: 'cntr_low', cntr: 'FFFFFFFFFF' } });
|
|
||||||
assert.strictEqual(c.suggested, '0000000000');
|
|
||||||
// every other verdict (and a missing response) is not a low counter
|
// every other verdict (and a missing response) is not a low counter
|
||||||
assert.strictEqual(spCntrLow({ por: { response_status: 'por_ok', cntr: '01' } }), null);
|
assert.strictEqual(spCntrLow({ por: { response_status: 'por_ok', cntr: '01' } }), false);
|
||||||
assert.strictEqual(spCntrLow({ por: { response_status: 'cntr_high' } }), null);
|
assert.strictEqual(spCntrLow({ por: { response_status: 'cntr_high' } }), false);
|
||||||
assert.strictEqual(spCntrLow({}), null);
|
assert.strictEqual(spCntrLow({}), false);
|
||||||
assert.strictEqual(spCntrLow(null), null);
|
assert.strictEqual(spCntrLow(null), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('ramCntrLowHtml states the facts and offers the preset jump', () => {
|
test('ramCntrLowHtml states the facts without a fabricated counter', () => {
|
||||||
globalThis.t = s => s;
|
globalThis.t = s => s;
|
||||||
const out = ramCntrLowHtml({ card_cntr: '00000000BB', suggested: '00000000BC' }, 2);
|
const out = ramCntrLowHtml(2);
|
||||||
const noPreset = ramCntrLowHtml({ card_cntr: '00000000BB' }, -1);
|
const noPreset = ramCntrLowHtml(-1);
|
||||||
delete globalThis.t;
|
delete globalThis.t;
|
||||||
assert.ok(out.includes('Low counter \u2014 the card rejected the packet (card counter 00000000BB) \u2014 the counter must be above it'), out);
|
assert.ok(out.includes('Low counter \u2014 the card rejected the packet'), out);
|
||||||
|
// the PoR's CNTR is a copy of the command's counter, never the card's: no
|
||||||
|
// value may be shown (v3.9.x), only the verdict and the two actions
|
||||||
|
assert.ok(!out.includes('card counter'), out);
|
||||||
|
assert.ok(out.includes('onclick="ramSyncCounter(2)"'), out);
|
||||||
|
assert.ok(out.includes('Sync counter'), out);
|
||||||
assert.ok(out.includes('onclick="ramGoToPreset(2)"'), out);
|
assert.ok(out.includes('onclick="ramGoToPreset(2)"'), out);
|
||||||
assert.ok(out.includes('Go to preset'), out);
|
assert.ok(out.includes('Go to preset'), out);
|
||||||
// without a known preset the button still opens the tab (-1)
|
// without a known preset the buttons still work (-1)
|
||||||
assert.ok(noPreset.includes('onclick="ramGoToPreset(-1)"'), noPreset);
|
assert.ok(noPreset.includes('onclick="ramGoToPreset(-1)"'), noPreset);
|
||||||
assert.strictEqual(ramCntrLowHtml(null, 0), '');
|
assert.ok(noPreset.includes('onclick="ramSyncCounter(-1)"'), noPreset);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the explorer shows versions on app and ISD rows too', () => {
|
test('the explorer shows versions on app and ISD rows too', () => {
|
||||||
@@ -804,7 +804,7 @@ test('every SCP80/RAM flow checks the low counter and stops', () => {
|
|||||||
const explore = extractFunc(html, 'ramExplore');
|
const explore = extractFunc(html, 'ramExplore');
|
||||||
assert.ok(explore.includes('spCntrLow(res)'), 'the Explore must check every send');
|
assert.ok(explore.includes('spCntrLow(res)'), 'the Explore must check every send');
|
||||||
assert.ok(explore.includes('if (cntrLow) break;'), 'the loops must stop');
|
assert.ok(explore.includes('if (cntrLow) break;'), 'the loops must stop');
|
||||||
assert.ok(explore.includes('ramShowCntrLow(cntrLow)'), 'the verdict must be shown');
|
assert.ok(explore.includes('ramShowCntrLow()'), 'the verdict must be shown');
|
||||||
// the delete flow and the plain send check it too
|
// the delete flow and the plain send check it too
|
||||||
assert.ok(extractFunc(html, 'ramDeleteFromExplorer').includes('spCntrLow(res)'),
|
assert.ok(extractFunc(html, 'ramDeleteFromExplorer').includes('spCntrLow(res)'),
|
||||||
'the delete flow must stop');
|
'the delete flow must stop');
|
||||||
@@ -813,6 +813,24 @@ test('every SCP80/RAM flow checks the low counter and stops', () => {
|
|||||||
assert.ok(go.includes("switchTab('cards')"), go);
|
assert.ok(go.includes("switchTab('cards')"), go);
|
||||||
assert.ok(go.includes('cardsEdit('), go);
|
assert.ok(go.includes('cardsEdit('), go);
|
||||||
assert.ok(go.includes("getElementById('cards-ks-' + row + '-cntr')"), go);
|
assert.ok(go.includes("getElementById('cards-ks-' + row + '-cntr')"), go);
|
||||||
|
// the notice's sync probe posts to the bounded server endpoint with the
|
||||||
|
// preset, the keyset the failed operation used and its SPI/TAR
|
||||||
|
const sync = extractFunc(html, 'ramSyncCounter');
|
||||||
|
assert.ok(sync.includes("'/api/counter-probe'"), sync);
|
||||||
|
assert.ok(sync.includes("kvnOf('ram-keyset-sel')"), sync);
|
||||||
|
assert.ok(sync.includes("kvnOf('sp-keyset-sel')"), sync);
|
||||||
|
assert.ok(sync.includes("getElementById('sp-cntr')"), sync);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the RAM form offers the command format choice', () => {
|
||||||
|
// auto (detect) is the default; the two expanded codings can be pinned
|
||||||
|
assert.match(html, /<select id="ram-format"[\s\S]*?<option value="auto"/,
|
||||||
|
'the RAM format selector must exist and default to auto');
|
||||||
|
for (const v of ['compact', 'expanded', 'expanded-ae']) {
|
||||||
|
assert.ok(html.includes('<option value="' + v + '"'), 'missing format ' + v);
|
||||||
|
}
|
||||||
|
assert.ok(extractFunc(html, 'getRamSpParams').includes("getElementById('ram-format')"),
|
||||||
|
'the RAM params must carry the selected format');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the Explore detects the expanded query form and reports it', () => {
|
test('the Explore detects the expanded query form and reports it', () => {
|
||||||
|
|||||||
+320
-61
@@ -1407,25 +1407,20 @@ def _por_remote_sw(por):
|
|||||||
return ''
|
return ''
|
||||||
|
|
||||||
|
|
||||||
def _cntr_low_fields(status, cntr_hex):
|
def _cntr_low_fields(status):
|
||||||
"""The card's `cntr_low` verdict as plain data: the card's own counter and
|
"""The card's `cntr_low` verdict as a flag - the packet's counter was not
|
||||||
the next valid value (the packet counter must be above it). {} when the
|
above the card's for that key version - or {} for any other verdict.
|
||||||
verdict is not a low counter.
|
|
||||||
|
|
||||||
A low counter is NOT recoverable by retrying: every further packet with a
|
The card's own counter is deliberately not reported: the response
|
||||||
counter <= the card's is rejected again, so operations must stop and the
|
packet's CNTR is "a copy of the contents of the CNTR in the Command
|
||||||
user must raise the preset counter (v3.6.58)."""
|
Packet" (TS 102 225 §5.2, Table 3) and a cntr_low rejection leaves it
|
||||||
|
zeroed, so no usable value can be derived from it (v3.6.58). A low
|
||||||
|
counter is not recoverable by retrying: the operation stops and the user
|
||||||
|
raises the preset counter above the card's (the `counter-probe` endpoint
|
||||||
|
can do that in bounded steps)."""
|
||||||
if str(status or '') != 'cntr_low':
|
if str(status or '') != 'cntr_low':
|
||||||
return {}
|
return {}
|
||||||
card = str(cntr_hex or '').upper()
|
return {'cntr_low': True}
|
||||||
suggested = None
|
|
||||||
if card:
|
|
||||||
try:
|
|
||||||
width = 4 * len(card)
|
|
||||||
suggested = '%0*X' % (len(card), (int(card, 16) + 1) % (1 << width))
|
|
||||||
except ValueError:
|
|
||||||
suggested = None
|
|
||||||
return {'cntr_low': True, 'card_cntr': card, 'suggested_cntr': suggested}
|
|
||||||
|
|
||||||
|
|
||||||
def _ram_next_cntr(cntr, advance):
|
def _ram_next_cntr(cntr, advance):
|
||||||
@@ -1514,36 +1509,59 @@ def _preset_counter_persist(server, preset_id, cntr, source, kvn=None):
|
|||||||
# and never stored: cards differ batch to batch and may behave differently
|
# and never stored: cards differ batch to batch and may behave differently
|
||||||
# later, so every operation re-checks (v3.6.24).
|
# later, so every operation re-checks (v3.6.24).
|
||||||
_RAM_PROBE_APDU = '80F28000024F0000C0000000' # GET STATUS [ISD], read-only
|
_RAM_PROBE_APDU = '80F28000024F0000C0000000' # GET STATUS [ISD], read-only
|
||||||
|
# The expanded format does not use GET RESPONSE (TS 102 226 5.2.1.1): with
|
||||||
|
# Le='00' the whole response comes back in the R-APDU, so the expanded probes
|
||||||
|
# drop the chained GET RESPONSE of the compact form.
|
||||||
|
_RAM_PROBE_APDU_LE = '80F28000024F0000'
|
||||||
|
|
||||||
|
|
||||||
def _wrap_expanded_apdu(apdu_hex):
|
def _wrap_expanded_apdu(apdu_hex, form='definite'):
|
||||||
"""Wrap a C-APDU in the expanded remote-management format: the Command
|
"""Wrap a C-APDU in the expanded remote-management format (TS 102 226
|
||||||
TLV '22' inside the 'AA' template (TS 102 226 5.2.1), the exact form of
|
5.2.1): the Command TLV '22' inside the Command Scripting template.
|
||||||
the reference trace `AA0A220880F24000024F0000`."""
|
|
||||||
|
form='definite' -> `AA <len> 22 <len> <apdu>` (TS 101 220 table 7.19
|
||||||
|
definite length coding; the reference trace's form
|
||||||
|
`AA0A220880F24000024F0000`)
|
||||||
|
form='indefinite' -> `AE 80 22 <len> <apdu> 00 00` (indefinite length
|
||||||
|
coding, the variant the spec recommends for RAM/RFM
|
||||||
|
over HTTPS; some cards accept only this one)."""
|
||||||
cmd = '22' + _ber_len(len(apdu_hex) // 2) + apdu_hex
|
cmd = '22' + _ber_len(len(apdu_hex) // 2) + apdu_hex
|
||||||
|
if form == 'indefinite':
|
||||||
|
return ('AE80' + cmd + '0000').upper()
|
||||||
return ('AA' + _ber_len(len(cmd) // 2) + cmd).upper()
|
return ('AA' + _ber_len(len(cmd) // 2) + cmd).upper()
|
||||||
|
|
||||||
|
|
||||||
def _ram_format_apdu(apdu_hex, ram_format):
|
def _ram_format_apdu(apdu_hex, ram_format):
|
||||||
"""Apply the RAM command format ('compact' or 'expanded')."""
|
"""Apply the RAM command format (TS 102 226 5.2.1): the bare C-APDU
|
||||||
return _wrap_expanded_apdu(apdu_hex) if ram_format == 'expanded' else apdu_hex
|
(compact), the definite-length scripting template (expanded) or the
|
||||||
|
indefinite-length one (expanded-ae)."""
|
||||||
|
if ram_format == 'expanded':
|
||||||
|
return _wrap_expanded_apdu(apdu_hex, 'definite')
|
||||||
|
if ram_format == 'expanded-ae':
|
||||||
|
return _wrap_expanded_apdu(apdu_hex, 'indefinite')
|
||||||
|
return apdu_hex
|
||||||
|
|
||||||
|
|
||||||
def _ram_normalize_format(value):
|
def _ram_normalize_format(value):
|
||||||
"""'auto' | 'compact' | 'expanded'; anything else (or missing) is
|
"""'auto' | 'compact' | 'expanded' | 'expanded-ae'; anything else (or
|
||||||
'auto' for callers that opted in to the format handling."""
|
missing) is 'auto' for callers that opted in to the format handling."""
|
||||||
v = str(value or 'auto').strip().lower()
|
v = str(value or 'auto').strip().lower()
|
||||||
return v if v in ('auto', 'compact', 'expanded') else 'auto'
|
return v if v in ('auto', 'compact', 'expanded', 'expanded-ae') else 'auto'
|
||||||
|
|
||||||
|
|
||||||
def _ram_detect_format(server, scc, sp, state):
|
def _ram_detect_format(server, scc, sp, state):
|
||||||
"""Detect the card's RAM command format for one operation: send the
|
"""Detect the card's RAM command format for one operation: send the
|
||||||
read-only GET STATUS [ISD] probe compact, then wrapped; the first format
|
read-only GET STATUS [ISD] probe compact, then in the two expanded
|
||||||
whose remote SW succeeds wins (compact preferred). Probe packets are
|
codings; the first format whose remote SW succeeds wins (compact
|
||||||
recorded as steps and consume counters only when accepted; the result is
|
preferred). Cards implement different subsets - the live ISD answers the
|
||||||
used for this operation only - the next operation re-checks (v3.6.24)."""
|
definite form with a Bad format TLV (wrong length) and executes the
|
||||||
|
indefinite one, while an RFM applet took the definite form. Probe
|
||||||
|
packets are recorded as steps and consume counters only when accepted;
|
||||||
|
the result is used for this operation only - the next operation re-checks
|
||||||
|
(v3.6.24)."""
|
||||||
for fmt, apdu in (('compact', _RAM_PROBE_APDU),
|
for fmt, apdu in (('compact', _RAM_PROBE_APDU),
|
||||||
('expanded', _wrap_expanded_apdu(_RAM_PROBE_APDU))):
|
('expanded', _wrap_expanded_apdu(_RAM_PROBE_APDU_LE, 'definite')),
|
||||||
|
('expanded-ae', _wrap_expanded_apdu(_RAM_PROBE_APDU_LE, 'indefinite'))):
|
||||||
scratch = {'steps': [], 'encode_error': None, 'failure': {},
|
scratch = {'steps': [], 'encode_error': None, 'failure': {},
|
||||||
'cntr': state['cntr'], 'preset_id': state.get('preset_id')}
|
'cntr': state['cntr'], 'preset_id': state.get('preset_id')}
|
||||||
ok = _ram_send_gp_apdu(server, scc, sp, scratch,
|
ok = _ram_send_gp_apdu(server, scc, sp, scratch,
|
||||||
@@ -1555,6 +1573,187 @@ def _ram_detect_format(server, scc, sp, state):
|
|||||||
return 'compact'
|
return 'compact'
|
||||||
|
|
||||||
|
|
||||||
|
# Counter synchronisation probe (v3.9.x): a bounded upward search for a
|
||||||
|
# counter value the card accepts. The card only accepts a counter *above* its
|
||||||
|
# own (TS 102 225 5.1.1 b5b4 = 10) and updates it only when the packet is
|
||||||
|
# accepted, so rejected attempts change nothing; the search walks a doubling
|
||||||
|
# ladder (start+1, +2, +4, ...) and stops at a ceiling far below the 40-bit
|
||||||
|
# maximum, where the counter would get blocked.
|
||||||
|
COUNTER_PROBE_DEFAULT_APDU = '00A40000023F00' # SELECT MF: read-only, applet-visible
|
||||||
|
COUNTER_PROBE_CEILING = 'FFFFFFFF' # 32-bit default
|
||||||
|
COUNTER_PROBE_MAX_ATTEMPTS = 40 # doubling covers 40 bits
|
||||||
|
|
||||||
|
|
||||||
|
def _counter_probe_candidates(start_hex, ceiling_hex, max_attempts):
|
||||||
|
"""The cautious counter ladder: start + 1, +2, +4, ... (doubling).
|
||||||
|
|
||||||
|
Returns ``(candidates, reason)``: reason is 'ceiling' when the next value
|
||||||
|
would exceed the ceiling, else 'attempts' when the attempt budget ran
|
||||||
|
out. Doubling keeps the packet count low (<= ~40 across the whole 40-bit
|
||||||
|
range) and the accepted value below ~2x the card's counter - only the
|
||||||
|
accepted packet advances anything, every rejected one is a no-op."""
|
||||||
|
try:
|
||||||
|
value = int(str(start_hex or '0'), 16)
|
||||||
|
ceiling = int(str(ceiling_hex or COUNTER_PROBE_CEILING), 16)
|
||||||
|
max_attempts = int(max_attempts)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return [], 'attempts'
|
||||||
|
candidates = []
|
||||||
|
step = 1
|
||||||
|
while len(candidates) < max_attempts:
|
||||||
|
value += step
|
||||||
|
if value > ceiling:
|
||||||
|
return candidates, 'ceiling'
|
||||||
|
candidates.append('%010X' % value)
|
||||||
|
step *= 2
|
||||||
|
return candidates, 'attempts'
|
||||||
|
|
||||||
|
|
||||||
|
def _counter_probe_params(preset, body):
|
||||||
|
"""Resolve a counter probe's parameters from the named preset: the keyset
|
||||||
|
(by `kvn`, else the first), its keys and counter, the preset's SPI/TAR
|
||||||
|
(overridable), the ceiling and the attempt budget. Raises ValueError
|
||||||
|
with a user-facing message."""
|
||||||
|
keysets = _preset_keysets(preset)
|
||||||
|
if not keysets:
|
||||||
|
raise ValueError('the preset defines no keyset')
|
||||||
|
want = body.get('kvn')
|
||||||
|
if want not in (None, ''):
|
||||||
|
try:
|
||||||
|
want = int(want)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise ValueError('invalid keyset number')
|
||||||
|
keyset = next((k for k in keysets if presets.keyset_kvn(k) == want), None)
|
||||||
|
if keyset is None:
|
||||||
|
raise ValueError('keyset %d is not defined in the preset' % want)
|
||||||
|
else:
|
||||||
|
keyset = keysets[0]
|
||||||
|
kic = str(keyset.get('kic') or '').strip().upper()
|
||||||
|
kid = str(keyset.get('kid') or '').strip().upper()
|
||||||
|
kic_key = str(keyset.get('kicKey') or '').strip()
|
||||||
|
kid_key = str(keyset.get('kidKey') or '').strip()
|
||||||
|
if not (kic and kid and kic_key and kid_key):
|
||||||
|
raise ValueError('the keyset needs KIc, KID and both keys')
|
||||||
|
kvn, err = _kvn_of(kic, kid)
|
||||||
|
if err:
|
||||||
|
raise ValueError(err)
|
||||||
|
start = str(body.get('cntr') or keyset.get('cntr') or '').strip().upper()
|
||||||
|
if not _counter_valid(start):
|
||||||
|
raise ValueError('the keyset needs a counter to start from')
|
||||||
|
ceiling = str(body.get('ceiling') or COUNTER_PROBE_CEILING).strip().upper()
|
||||||
|
try:
|
||||||
|
ceiling_value = int(ceiling, 16)
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError('invalid ceiling')
|
||||||
|
if ceiling_value < int(start, 16):
|
||||||
|
raise ValueError('the ceiling is below the current counter')
|
||||||
|
if ceiling_value > 0xFFFFFFFFFE:
|
||||||
|
raise ValueError('the ceiling must stay below the 40-bit maximum')
|
||||||
|
try:
|
||||||
|
max_attempts = int(body.get('max_attempts') or COUNTER_PROBE_MAX_ATTEMPTS)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise ValueError('invalid attempt budget')
|
||||||
|
max_attempts = max(1, min(64, max_attempts))
|
||||||
|
spi1 = str(body.get('spi1') or preset.get('spi1') or '16').strip().upper()
|
||||||
|
if not _counter_tracked(spi1):
|
||||||
|
raise ValueError('the probe needs a counter check - SPI1 %s has none '
|
||||||
|
'(b5b4 = 00)' % spi1)
|
||||||
|
return {
|
||||||
|
'kvn': kvn, 'kic': kic, 'kid': kid,
|
||||||
|
'kic_key': kic_key, 'kid_key': kid_key,
|
||||||
|
'start': start, 'ceiling': ceiling,
|
||||||
|
'max_attempts': max_attempts,
|
||||||
|
'spi1': spi1,
|
||||||
|
'spi2': str(body.get('spi2') or preset.get('spi2') or '21').strip().upper(),
|
||||||
|
'tar': str(body.get('tar') or preset.get('tar') or '000000').strip().upper(),
|
||||||
|
'apdu': str(body.get('apdu') or COUNTER_PROBE_DEFAULT_APDU).replace(' ', '').upper(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _counter_probe(server, scc, preset, body, send_fn=None):
|
||||||
|
"""Sync a preset's counter with the card: send a read-only probe command
|
||||||
|
with increasing counter values until the card accepts one (PoR `por_ok`),
|
||||||
|
then persist accepted+1 (monotonic).
|
||||||
|
|
||||||
|
Every rejected attempt is a no-op on the card; the ceiling and the attempt
|
||||||
|
budget bound the search away from the 40-bit maximum, where the counter
|
||||||
|
would be blocked. Returns a result dict with the attempt list - the
|
||||||
|
caller (UI) retries the failed operation afterwards. `send_fn` is a test
|
||||||
|
seam (defaults to the `_send_secured_packet` path)."""
|
||||||
|
p = _counter_probe_params(preset, body)
|
||||||
|
candidates, stop = _counter_probe_candidates(p['start'], p['ceiling'],
|
||||||
|
p['max_attempts'])
|
||||||
|
out = {'success': False, 'preset_id': preset.get('id'),
|
||||||
|
'preset_name': preset.get('name'), 'kvn': p['kvn'],
|
||||||
|
'start': p['start'], 'ceiling': p['ceiling'],
|
||||||
|
'max_attempts': p['max_attempts'], 'tar': p['tar'],
|
||||||
|
'spi1': p['spi1'], 'spi2': p['spi2'], 'apdu': p['apdu'],
|
||||||
|
'packets': 0, 'attempts': [], 'stopped': stop}
|
||||||
|
if not candidates:
|
||||||
|
out['error'] = 'the counter is already at the ceiling %s' % p['ceiling']
|
||||||
|
return out
|
||||||
|
for cntr in candidates:
|
||||||
|
if send_fn is not None:
|
||||||
|
send, por = send_fn(p, cntr)
|
||||||
|
else:
|
||||||
|
sp_hex, _ = _build_secured_packet(p['spi1'], p['spi2'], p['kic'], p['kid'],
|
||||||
|
p['tar'], cntr, p['apdu'],
|
||||||
|
p['kic_key'], p['kid_key'])
|
||||||
|
submit_handler = PoRSubmitHandler()
|
||||||
|
old_proactive = None
|
||||||
|
if hasattr(scc, '_tp'):
|
||||||
|
old_proactive = scc._tp.proactive_handler
|
||||||
|
scc._tp.proactive_handler = submit_handler
|
||||||
|
try:
|
||||||
|
send = _send_secured_packet(scc, sp_hex, oa_number=server.sms_oa,
|
||||||
|
sm_sc=server.sms_sc, include_cpi=True,
|
||||||
|
submit_handler=submit_handler)
|
||||||
|
finally:
|
||||||
|
if hasattr(scc, '_tp'):
|
||||||
|
scc._tp.proactive_handler = old_proactive
|
||||||
|
por_hex = send.get('response_data') or ''
|
||||||
|
submit_hex = _sms_submit_por(submit_handler)
|
||||||
|
if submit_hex:
|
||||||
|
por_hex = submit_hex
|
||||||
|
por = _decode_por(p['spi1'], p['spi2'], p['kic'], p['kid'], cntr,
|
||||||
|
p['kic_key'], p['kid_key'], por_hex) if por_hex else None
|
||||||
|
pstatus = str((por or {}).get('response_status') or '')
|
||||||
|
out['packets'] += 1
|
||||||
|
attempt = {'cntr': cntr, 'por_status': pstatus or None,
|
||||||
|
'sw': send.get('sw'), 'bytes': send.get('bytes'),
|
||||||
|
'segments': send.get('segments')}
|
||||||
|
remote_sw = _por_remote_sw(por)
|
||||||
|
if remote_sw:
|
||||||
|
attempt['remote_sw'] = remote_sw
|
||||||
|
if (por or {}).get('bad_format'):
|
||||||
|
attempt['bad_format'] = por['bad_format']
|
||||||
|
out['attempts'].append(attempt)
|
||||||
|
sys.stderr.write('COUNTER-PROBE: %s -> %s\n'
|
||||||
|
% (cntr, pstatus or ('ENVELOPE %s' % (send.get('sw') or '?'))))
|
||||||
|
if pstatus == 'por_ok':
|
||||||
|
stored = _ram_next_cntr(cntr, True)
|
||||||
|
_preset_counter_persist(server, preset.get('id'), stored,
|
||||||
|
'counter-probe', p['kvn'])
|
||||||
|
out.update({'success': True, 'stopped': 'accepted',
|
||||||
|
'accepted_cntr': cntr, 'stored_cntr': stored})
|
||||||
|
sys.stderr.write('COUNTER-PROBE: accepted %s, preset stores %s\n'
|
||||||
|
% (cntr, stored))
|
||||||
|
return out
|
||||||
|
if pstatus == 'cntr_low':
|
||||||
|
continue
|
||||||
|
out['stopped'] = 'error'
|
||||||
|
out['error'] = send.get('error') or ('card verdict: %s' % (pstatus or 'no PoR'))
|
||||||
|
return out
|
||||||
|
if stop == 'ceiling':
|
||||||
|
out['error'] = ('no counter up to the ceiling %s was accepted - the card\'s '
|
||||||
|
'counter is above it, raise the preset counter manually'
|
||||||
|
% p['ceiling'])
|
||||||
|
else:
|
||||||
|
out['error'] = ('no counter above %s was accepted in %d attempts'
|
||||||
|
% (p['start'], p['max_attempts']))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
# Live progress of the RAM operation currently running. The long install
|
# Live progress of the RAM operation currently running. The long install
|
||||||
# chains hold _CARD_LOCK for the whole request, so the PWA cannot learn the
|
# chains hold _CARD_LOCK for the whole request, so the PWA cannot learn the
|
||||||
# step from the response - /api/status (lock-free cached state, polled every
|
# step from the response - /api/status (lock-free cached state, polled every
|
||||||
@@ -1607,7 +1806,13 @@ def _ram_step_result(step_name, last_sw, por, por_hex, bytes_, segments):
|
|||||||
step['por_raw'] = por.get('raw')
|
step['por_raw'] = por.get('raw')
|
||||||
if remote_sw:
|
if remote_sw:
|
||||||
step['por_sw'] = remote_sw
|
step['por_sw'] = remote_sw
|
||||||
if pstatus != 'por_ok':
|
if por.get('bad_format'):
|
||||||
|
# the expanded script was rejected as malformed before executing:
|
||||||
|
# there is no remote status word
|
||||||
|
step['por_bad_format'] = por['bad_format']
|
||||||
|
error = 'bad format %s (%s)' % (por['bad_format'],
|
||||||
|
por.get('bad_format_name') or '')
|
||||||
|
elif pstatus != 'por_ok':
|
||||||
error = 'PoR %s' % (pstatus or 'unknown')
|
error = 'PoR %s' % (pstatus or 'unknown')
|
||||||
elif remote_sw and not _ram_remote_sw_ok(remote_sw):
|
elif remote_sw and not _ram_remote_sw_ok(remote_sw):
|
||||||
error = 'remote SW %s' % remote_sw
|
error = 'remote SW %s' % remote_sw
|
||||||
@@ -1755,14 +1960,25 @@ def _ram_nv_fields(nv_before, nv_after):
|
|||||||
return {'nv_before': nv_before, 'nv_after': nv_after, 'nv_delta': delta}
|
return {'nv_before': nv_before, 'nv_after': nv_after, 'nv_delta': delta}
|
||||||
|
|
||||||
|
|
||||||
|
_BAD_FORMAT_NAMES = {
|
||||||
|
'01': 'unknown tag',
|
||||||
|
'02': 'wrong length',
|
||||||
|
'03': 'length not found',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _parse_response_scripting(data):
|
def _parse_response_scripting(data):
|
||||||
"""Parse a Response Scripting template (TS 102 226 5.2.2, tables
|
"""Parse a Response Scripting template (TS 102 226 5.2.2, tables
|
||||||
5.10/5.10a): `AB <len>` (definite) or `AF 80 ... 00 00` (indefinite),
|
5.10/5.10a): `AB <len>` (definite) or `AF 80 ... 00 00` (indefinite),
|
||||||
containing the executed-command-count TLV `80` and one or more R-APDU
|
containing the executed-command-count TLV `80`, one or more R-APDU TLVs
|
||||||
TLVs `23` (COMPREHENSION-TLV; the last two bytes are SW1 SW2).
|
`23` (COMPREHENSION-TLV; the last two bytes are SW1 SW2) and/or the Bad
|
||||||
|
format TLV `90` (TS 101 220 table 7.20; error type 01 unknown tag,
|
||||||
|
02 wrong length, 03 length not found - TS 102 226 table 5.12).
|
||||||
|
|
||||||
Returns (count, sw, rapdu_data_hex) from the last R-APDU, or None when the
|
Returns {'count', 'sw', 'data', 'bad_format'} or None when the data is
|
||||||
data is not a scripting template."""
|
not a scripting template. `bad_format` is the error type hex when the
|
||||||
|
card aborted the script on a malformed command (then `sw` is None and
|
||||||
|
`data` ''); an R-APDU is not required for a bad-format answer."""
|
||||||
if not data:
|
if not data:
|
||||||
return None
|
return None
|
||||||
if data[0] == 0xAF:
|
if data[0] == 0xAF:
|
||||||
@@ -1778,6 +1994,7 @@ def _parse_response_scripting(data):
|
|||||||
return None
|
return None
|
||||||
count = None
|
count = None
|
||||||
last = None
|
last = None
|
||||||
|
bad = None
|
||||||
off = 0
|
off = 0
|
||||||
while off < len(body) - 1:
|
while off < len(body) - 1:
|
||||||
tag = body[off]
|
tag = body[off]
|
||||||
@@ -1789,10 +2006,16 @@ def _parse_response_scripting(data):
|
|||||||
count = int.from_bytes(val, 'big')
|
count = int.from_bytes(val, 'big')
|
||||||
elif tag in (0x23, 0xA3) and len(val) >= 2:
|
elif tag in (0x23, 0xA3) and len(val) >= 2:
|
||||||
last = (val[-2:].hex().upper(), val[:-2].hex().upper())
|
last = (val[-2:].hex().upper(), val[:-2].hex().upper())
|
||||||
|
elif tag in (0x90, 0x10) and val:
|
||||||
|
# both tag styles: the spec pins the CR flag to 0, cards mix them
|
||||||
|
bad = val[:1].hex().upper()
|
||||||
off = voff + ln
|
off = voff + ln
|
||||||
if last is None:
|
if last is None and bad is None:
|
||||||
return None
|
return None
|
||||||
return (count, last[0], last[1])
|
return {'count': count,
|
||||||
|
'sw': last[0] if last else None,
|
||||||
|
'data': last[1] if last else '',
|
||||||
|
'bad_format': bad}
|
||||||
|
|
||||||
|
|
||||||
def _sms_submit_por(submit_handler):
|
def _sms_submit_por(submit_handler):
|
||||||
@@ -1852,13 +2075,18 @@ def _decode_por(spi1, spi2, kic, kid, cntr_hex, kic_key_hex, kid_key_hex, respon
|
|||||||
if res.response_status == 'por_ok' and len(res['secured_data']):
|
if res.response_status == 'por_ok' and len(res['secured_data']):
|
||||||
scripted = _parse_response_scripting(bytes(res['secured_data']))
|
scripted = _parse_response_scripting(bytes(res['secured_data']))
|
||||||
if scripted is not None:
|
if scripted is not None:
|
||||||
count, sw, data_hex = scripted
|
|
||||||
out['response_type'] = 'scripting'
|
out['response_type'] = 'scripting'
|
||||||
out['decoded'] = {
|
out['decoded'] = {
|
||||||
'number_of_commands': count,
|
'number_of_commands': scripted['count'],
|
||||||
'last_status_word': sw,
|
'last_status_word': scripted['sw'],
|
||||||
'last_response_data': data_hex,
|
'last_response_data': scripted['data'],
|
||||||
}
|
}
|
||||||
|
if scripted['bad_format']:
|
||||||
|
# the card parsed the scripting template but aborted on a
|
||||||
|
# malformed command (TS 102 226 5.2.2 Bad format TLV)
|
||||||
|
out['bad_format'] = scripted['bad_format']
|
||||||
|
out['bad_format_name'] = _BAD_FORMAT_NAMES.get(scripted['bad_format'],
|
||||||
|
'unknown error type')
|
||||||
elif dec is not None:
|
elif dec is not None:
|
||||||
out['response_type'] = 'compact'
|
out['response_type'] = 'compact'
|
||||||
out['decoded'] = {
|
out['decoded'] = {
|
||||||
@@ -5239,7 +5467,7 @@ _TEST_BLOCKED_PATHS = frozenset([
|
|||||||
'/api/command', '/api/cardinfo', '/api/tree', '/api/select', '/api/read',
|
'/api/command', '/api/cardinfo', '/api/tree', '/api/select', '/api/read',
|
||||||
'/api/write', '/api/apdu', '/api/verify-adm', '/api/send-ota',
|
'/api/write', '/api/apdu', '/api/verify-adm', '/api/send-ota',
|
||||||
'/api/ram-install', '/api/ram-install-app', '/api/cap-compat',
|
'/api/ram-install', '/api/ram-install-app', '/api/cap-compat',
|
||||||
'/api/tar-probe',
|
'/api/tar-probe', '/api/counter-probe',
|
||||||
'/api/sp-verify', '/api/menu-select',
|
'/api/sp-verify', '/api/menu-select',
|
||||||
'/api/menu-respond', '/api/event-send', '/api/net-sim',
|
'/api/menu-respond', '/api/event-send', '/api/net-sim',
|
||||||
'/api/net-state-refresh', '/api/status-poll', '/api/rescue',
|
'/api/net-state-refresh', '/api/status-poll', '/api/rescue',
|
||||||
@@ -7061,13 +7289,11 @@ class PysimHandler(BaseHTTPRequestHandler):
|
|||||||
body.get('kidKey', ''), failed_por_hex)
|
body.get('kidKey', ''), failed_por_hex)
|
||||||
if failed_por:
|
if failed_por:
|
||||||
resp['por'] = failed_por
|
resp['por'] = failed_por
|
||||||
low = _cntr_low_fields(failed_por.get('response_status'),
|
low = _cntr_low_fields(failed_por.get('response_status'))
|
||||||
failed_por.get('cntr'))
|
|
||||||
if low:
|
if low:
|
||||||
resp.update(low)
|
resp.update(low)
|
||||||
sys.stderr.write('OTA CNTR-LOW: card counter %s '
|
sys.stderr.write('OTA CNTR-LOW: the packet counter is not '
|
||||||
'(the packet counter must be above it)\n'
|
'above the card\'s\n')
|
||||||
% low['card_cntr'])
|
|
||||||
sys.stderr.write('OTA SEND FAILED: %s\n' % result.get('error'))
|
sys.stderr.write('OTA SEND FAILED: %s\n' % result.get('error'))
|
||||||
else:
|
else:
|
||||||
resp = {'success': True, 'sw': result['sw'],
|
resp = {'success': True, 'sw': result['sw'],
|
||||||
@@ -7100,16 +7326,19 @@ class PysimHandler(BaseHTTPRequestHandler):
|
|||||||
sys.stderr.write('RAM RESPONSE-PACKET: %s\n' % (por_hex if por_hex else 'empty'))
|
sys.stderr.write('RAM RESPONSE-PACKET: %s\n' % (por_hex if por_hex else 'empty'))
|
||||||
if por:
|
if por:
|
||||||
resp['por'] = por
|
resp['por'] = por
|
||||||
low = _cntr_low_fields(por.get('response_status'), por.get('cntr'))
|
low = _cntr_low_fields(por.get('response_status'))
|
||||||
if low:
|
if low:
|
||||||
# a 9000/91xx ENVELOPE can still carry a
|
# a 9000/91xx ENVELOPE can still carry a
|
||||||
# cntr_low PoR (the live card does)
|
# cntr_low PoR (the live card does)
|
||||||
resp.update(low)
|
resp.update(low)
|
||||||
sys.stderr.write('OTA CNTR-LOW: card counter %s '
|
sys.stderr.write('OTA CNTR-LOW: the packet counter is not '
|
||||||
'(the packet counter must be above it)\n'
|
'above the card\'s\n')
|
||||||
% low['card_cntr'])
|
|
||||||
extra = ''
|
extra = ''
|
||||||
if por.get('decoded'):
|
if por.get('bad_format'):
|
||||||
|
extra = ' (expanded: bad format %s - %s)' % (
|
||||||
|
por['bad_format'], por.get('bad_format_name') or '')
|
||||||
|
sys.stderr.write('RAM R-APDU: (bad format %s)\n' % por['bad_format'])
|
||||||
|
elif por.get('decoded'):
|
||||||
extra = ' (compact: %s cmd, last SW %s)' % (por['decoded'].get('number_of_commands', '?'),
|
extra = ' (compact: %s cmd, last SW %s)' % (por['decoded'].get('number_of_commands', '?'),
|
||||||
por['decoded'].get('last_status_word', '?'))
|
por['decoded'].get('last_status_word', '?'))
|
||||||
sys.stderr.write('RAM R-APDU: %s\n' % por['decoded'].get('last_response_data', ''))
|
sys.stderr.write('RAM R-APDU: %s\n' % por['decoded'].get('last_response_data', ''))
|
||||||
@@ -7297,12 +7526,10 @@ class PysimHandler(BaseHTTPRequestHandler):
|
|||||||
'load_block_size_auto': not block_size_req}
|
'load_block_size_auto': not block_size_req}
|
||||||
resp.update(_ram_nv_fields(nv_before, nv_after))
|
resp.update(_ram_nv_fields(nv_before, nv_after))
|
||||||
failed_rec = steps[-1] if steps else {}
|
failed_rec = steps[-1] if steps else {}
|
||||||
resp.update(_cntr_low_fields(failed_rec.get('por_status'),
|
resp.update(_cntr_low_fields(failed_rec.get('por_status')))
|
||||||
failed_rec.get('por_cntr')))
|
|
||||||
if resp.get('cntr_low'):
|
if resp.get('cntr_low'):
|
||||||
sys.stderr.write('RAM-INSTALL: low counter - card counter %s, '
|
sys.stderr.write('RAM-INSTALL: low counter - the packet '
|
||||||
'the preset counter must be above it\n'
|
'counter is not above the card\'s\n')
|
||||||
% resp.get('card_cntr'))
|
|
||||||
self._send_json(resp)
|
self._send_json(resp)
|
||||||
self._log_resp(resp)
|
self._log_resp(resp)
|
||||||
return
|
return
|
||||||
@@ -7408,8 +7635,7 @@ class PysimHandler(BaseHTTPRequestHandler):
|
|||||||
or ('%s failed' % step_name))
|
or ('%s failed' % step_name))
|
||||||
resp['failed_step'] = len(state['steps'])
|
resp['failed_step'] = len(state['steps'])
|
||||||
failed_rec = state['steps'][-1] if state['steps'] else {}
|
failed_rec = state['steps'][-1] if state['steps'] else {}
|
||||||
resp.update(_cntr_low_fields(failed_rec.get('por_status'),
|
resp.update(_cntr_low_fields(failed_rec.get('por_status')))
|
||||||
failed_rec.get('por_cntr')))
|
|
||||||
self._send_json(resp)
|
self._send_json(resp)
|
||||||
self._log_resp(resp)
|
self._log_resp(resp)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -7535,8 +7761,7 @@ class PysimHandler(BaseHTTPRequestHandler):
|
|||||||
or 'the card rejected the import list')
|
or 'the card rejected the import list')
|
||||||
resp['failed_step'] = len(steps)
|
resp['failed_step'] = len(steps)
|
||||||
failed_rec = steps[-1] if steps else {}
|
failed_rec = steps[-1] if steps else {}
|
||||||
resp.update(_cntr_low_fields(failed_rec.get('por_status'),
|
resp.update(_cntr_low_fields(failed_rec.get('por_status')))
|
||||||
failed_rec.get('por_cntr')))
|
|
||||||
sys.stderr.write('CAP-COMPAT: %s — imports %s (LOADS to %d/%d)\n' % (
|
sys.stderr.write('CAP-COMPAT: %s — imports %s (LOADS to %d/%d)\n' % (
|
||||||
loadfile_aid, 'OK' if ok else 'REJECTED', boundary, total_blocks))
|
loadfile_aid, 'OK' if ok else 'REJECTED', boundary, total_blocks))
|
||||||
self._send_json(resp)
|
self._send_json(resp)
|
||||||
@@ -7748,6 +7973,40 @@ class PysimHandler(BaseHTTPRequestHandler):
|
|||||||
'spi1': spi1, 'spi2': spi2, 'apdu': apdu}
|
'spi1': spi1, 'spi2': spi2, 'apdu': apdu}
|
||||||
self._send_json(resp)
|
self._send_json(resp)
|
||||||
self._log_resp(resp)
|
self._log_resp(resp)
|
||||||
|
elif self.path == '/api/counter-probe':
|
||||||
|
scc = self.server.scc
|
||||||
|
if not scc:
|
||||||
|
self._send_json({'error': _err('reader_not_init', lang)}, 503)
|
||||||
|
self._log_resp({'error': _err('reader_not_init', lang)})
|
||||||
|
return
|
||||||
|
body = self._read_body()
|
||||||
|
self._log_req(body)
|
||||||
|
if not body.get('preset_id'):
|
||||||
|
resp = {'success': False, 'error': 'preset_id is required'}
|
||||||
|
self._send_json(resp, 400)
|
||||||
|
self._log_resp(resp)
|
||||||
|
return
|
||||||
|
store = self._preset_store_or_503()
|
||||||
|
if store is None:
|
||||||
|
return
|
||||||
|
preset = store.get(body.get('preset_id'))
|
||||||
|
if not preset:
|
||||||
|
resp = {'success': False, 'error': 'unknown preset'}
|
||||||
|
self._send_json(resp, 400)
|
||||||
|
self._log_resp(resp)
|
||||||
|
return
|
||||||
|
sys.stderr.write('COUNTER-PROBE: preset %s, start %s, ceiling %s\n'
|
||||||
|
% (preset.get('name'), body.get('cntr', ''),
|
||||||
|
body.get('ceiling', COUNTER_PROBE_CEILING)))
|
||||||
|
try:
|
||||||
|
resp = _counter_probe(self.server, scc, preset, body)
|
||||||
|
except ValueError as e:
|
||||||
|
resp = {'success': False, 'error': str(e)}
|
||||||
|
self._send_json(resp, 400)
|
||||||
|
self._log_resp(resp)
|
||||||
|
return
|
||||||
|
self._send_json(resp)
|
||||||
|
self._log_resp(resp)
|
||||||
elif self.path == '/api/presets':
|
elif self.path == '/api/presets':
|
||||||
body = self._read_body()
|
body = self._read_body()
|
||||||
self._log_req(body)
|
self._log_req(body)
|
||||||
|
|||||||
@@ -389,8 +389,10 @@ class CntrLowGuardHttpTests(unittest.TestCase):
|
|||||||
p.stop()
|
p.stop()
|
||||||
self.assertEqual(status, 200, resp)
|
self.assertEqual(status, 200, resp)
|
||||||
self.assertTrue(resp['cntr_low'], resp)
|
self.assertTrue(resp['cntr_low'], resp)
|
||||||
self.assertEqual(resp['card_cntr'], '00000000BB')
|
# the PoR's CNTR is a copy of the command's counter, not the card's:
|
||||||
self.assertEqual(resp['suggested_cntr'], '00000000BC')
|
# the verdict is reported without a value (v3.9.x)
|
||||||
|
self.assertNotIn('card_cntr', resp)
|
||||||
|
self.assertNotIn('suggested_cntr', resp)
|
||||||
|
|
||||||
def test_the_ram_chain_stops_at_a_low_counter(self):
|
def test_the_ram_chain_stops_at_a_low_counter(self):
|
||||||
calls = []
|
calls = []
|
||||||
@@ -425,8 +427,10 @@ class CntrLowGuardHttpTests(unittest.TestCase):
|
|||||||
self.assertFalse(resp['success'], resp)
|
self.assertFalse(resp['success'], resp)
|
||||||
self.assertEqual(len(calls), 2, calls) # nothing after the verdict
|
self.assertEqual(len(calls), 2, calls) # nothing after the verdict
|
||||||
self.assertTrue(resp['cntr_low'], resp)
|
self.assertTrue(resp['cntr_low'], resp)
|
||||||
self.assertEqual(resp['card_cntr'], '00000000BB')
|
# the PoR's CNTR is a copy of the command's counter, not the card's:
|
||||||
self.assertEqual(resp['suggested_cntr'], '00000000BC')
|
# the verdict is reported without a value (v3.9.x)
|
||||||
|
self.assertNotIn('card_cntr', resp)
|
||||||
|
self.assertNotIn('suggested_cntr', resp)
|
||||||
|
|
||||||
|
|
||||||
class TarProbeListTests(unittest.TestCase):
|
class TarProbeListTests(unittest.TestCase):
|
||||||
|
|||||||
+169
-14
@@ -33,6 +33,10 @@ from pysim_simple_server.server import (
|
|||||||
_calc_ud_offset,
|
_calc_ud_offset,
|
||||||
_find_sms_tpdu,
|
_find_sms_tpdu,
|
||||||
_cntr_low_fields,
|
_cntr_low_fields,
|
||||||
|
_counter_probe,
|
||||||
|
_counter_probe_candidates,
|
||||||
|
_counter_probe_params,
|
||||||
|
_ram_normalize_format,
|
||||||
_parse_proactive_header,
|
_parse_proactive_header,
|
||||||
_parse_display_text,
|
_parse_display_text,
|
||||||
_tlv_map,
|
_tlv_map,
|
||||||
@@ -1653,20 +1657,32 @@ class ResponseScriptingTest(unittest.TestCase):
|
|||||||
data = bytes([0xAB, len(body)]) + body
|
data = bytes([0xAB, len(body)]) + body
|
||||||
scripted = _parse_response_scripting(data)
|
scripted = _parse_response_scripting(data)
|
||||||
self.assertIsNotNone(scripted)
|
self.assertIsNotNone(scripted)
|
||||||
count, sw, listing = scripted
|
self.assertEqual((scripted['count'], scripted['sw']), (1, '9000'))
|
||||||
self.assertEqual((count, sw), (1, '9000'))
|
listing = scripted['data']
|
||||||
self.assertTrue(listing.startswith('10A1130001'), listing[:20])
|
self.assertTrue(listing.startswith('10A1130001'), listing[:20])
|
||||||
self.assertIn('F0414C46416101', listing)
|
self.assertIn('F0414C46416101', listing)
|
||||||
|
|
||||||
def test_indefinite_template_and_plain_data(self):
|
def test_indefinite_template_and_plain_data(self):
|
||||||
body = bytes([0x80, 0x01, 0x02, 0x23, 0x04, 0xAA, 0xBB, 0x90, 0x00])
|
body = bytes([0x80, 0x01, 0x02, 0x23, 0x04, 0xAA, 0xBB, 0x90, 0x00])
|
||||||
data = bytes([0xAF, 0x80]) + body + b'\x00\x00'
|
data = bytes([0xAF, 0x80]) + body + b'\x00\x00'
|
||||||
count, sw, listing = _parse_response_scripting(data)
|
r = _parse_response_scripting(data)
|
||||||
self.assertEqual((count, sw, listing), (2, '9000', 'AABB'))
|
self.assertEqual((r['count'], r['sw'], r['data']), (2, '9000', 'AABB'))
|
||||||
# a compact response is not a scripting template
|
# a compact response is not a scripting template
|
||||||
self.assertIsNone(_parse_response_scripting(bytes.fromhex('027100000263100BD2')))
|
self.assertIsNone(_parse_response_scripting(bytes.fromhex('027100000263100BD2')))
|
||||||
self.assertIsNone(_parse_response_scripting(b''))
|
self.assertIsNone(_parse_response_scripting(b''))
|
||||||
|
|
||||||
|
def test_bad_format_tlv(self):
|
||||||
|
# the live ISD's answer to a definite-length scripting template: zero
|
||||||
|
# executed commands plus the Bad format TLV, error type 02 = wrong
|
||||||
|
# length (TS 102 226 5.2.2, table 5.12)
|
||||||
|
r = _parse_response_scripting(bytes.fromhex('ab06800100900102'))
|
||||||
|
self.assertEqual(r, {'count': 0, 'sw': None, 'data': '', 'bad_format': '02'})
|
||||||
|
# the plain (CR-flag cleared) tag style is accepted like everywhere else
|
||||||
|
self.assertEqual(_parse_response_scripting(bytes.fromhex('ab06800100100102'))['bad_format'],
|
||||||
|
'02')
|
||||||
|
# a plain R-APDU template carries no bad format
|
||||||
|
self.assertIsNone(_parse_response_scripting(bytes.fromhex('ab0780010123029000'))['bad_format'])
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class RamSendGpApduLoggingTest(unittest.TestCase):
|
class RamSendGpApduLoggingTest(unittest.TestCase):
|
||||||
@@ -1709,17 +1725,13 @@ class CntrLowGuardTests(unittest.TestCase):
|
|||||||
warning ENVELOPE answer still gets its PoR polled."""
|
warning ENVELOPE answer still gets its PoR polled."""
|
||||||
|
|
||||||
def test_cntr_low_fields(self):
|
def test_cntr_low_fields(self):
|
||||||
self.assertEqual(_cntr_low_fields('cntr_low', '00000000BB'),
|
# the verdict is a flag: the card's counter is not derivable from the
|
||||||
{'cntr_low': True, 'card_cntr': '00000000BB',
|
# PoR, whose CNTR is a copy of the command's counter (TS 102 225 5.2)
|
||||||
'suggested_cntr': '00000000BC'})
|
self.assertEqual(_cntr_low_fields('cntr_low'), {'cntr_low': True})
|
||||||
# the counter field is fixed-width: the wrap keeps it
|
|
||||||
self.assertEqual(_cntr_low_fields('cntr_low', 'FFFFFFFFFF'),
|
|
||||||
{'cntr_low': True, 'card_cntr': 'FFFFFFFFFF',
|
|
||||||
'suggested_cntr': '0000000000'})
|
|
||||||
# only the low-counter verdict counts
|
# only the low-counter verdict counts
|
||||||
self.assertEqual(_cntr_low_fields('por_ok', '01'), {})
|
self.assertEqual(_cntr_low_fields('por_ok'), {})
|
||||||
self.assertEqual(_cntr_low_fields('cntr_high', '01'), {})
|
self.assertEqual(_cntr_low_fields('cntr_high'), {})
|
||||||
self.assertEqual(_cntr_low_fields(None, None), {})
|
self.assertEqual(_cntr_low_fields(None), {})
|
||||||
|
|
||||||
def test_a_warning_envelope_answer_polls_for_the_por(self):
|
def test_a_warning_envelope_answer_polls_for_the_por(self):
|
||||||
from pysim_simple_server import server as srv
|
from pysim_simple_server import server as srv
|
||||||
@@ -2233,5 +2245,148 @@ class NoSecurityAndCounterTrackingTests(unittest.TestCase):
|
|||||||
store.set_counter.assert_called_once_with('pid', '0000000002', 'send-ota', 2)
|
store.set_counter.assert_called_once_with('pid', '0000000002', 'send-ota', 2)
|
||||||
|
|
||||||
|
|
||||||
|
class ExpandedRemoteTests(unittest.TestCase):
|
||||||
|
"""Expanded remote-management format (TS 102 226 5.2.1/5.2.2): the two
|
||||||
|
scripting templates, the Bad format TLV and the format helper."""
|
||||||
|
|
||||||
|
def test_wrap_expanded_apdu_forms(self):
|
||||||
|
# SELECT MF (the TAR probe's command) in both codings; the indefinite
|
||||||
|
# form is the one the spec recommends for RAM/RFM over HTTPS
|
||||||
|
self.assertEqual(_wrap_expanded_apdu('00A40000023F00'),
|
||||||
|
'AA09220700A40000023F00')
|
||||||
|
self.assertEqual(_wrap_expanded_apdu('00A40000023F00', 'indefinite'),
|
||||||
|
'AE80220700A40000023F000000')
|
||||||
|
self.assertEqual(_ram_format_apdu('00A40000023F00', 'compact'),
|
||||||
|
'00A40000023F00')
|
||||||
|
|
||||||
|
def test_format_apdu_uses_the_le_form_for_expanded_probes(self):
|
||||||
|
# the expanded format does not use GET RESPONSE (5.2.1.1): the probe
|
||||||
|
# drops the chained GET RESPONSE of the compact form and sets Le='00'
|
||||||
|
self.assertEqual(_ram_format_apdu('80F28000024F0000', 'expanded'),
|
||||||
|
'AA0A220880F28000024F0000')
|
||||||
|
self.assertEqual(_ram_format_apdu('80F28000024F0000', 'expanded-ae'),
|
||||||
|
'AE80220880F28000024F00000000')
|
||||||
|
|
||||||
|
def test_ram_normalize_format(self):
|
||||||
|
for v in ('auto', 'compact', 'expanded', 'expanded-ae', 'AUTO', ' expanded-ae '):
|
||||||
|
self.assertEqual(_ram_normalize_format(v), v.strip().lower())
|
||||||
|
for v in ('', None, 'bogus', 'AA'):
|
||||||
|
self.assertEqual(_ram_normalize_format(v), 'auto')
|
||||||
|
|
||||||
|
def test_live_bad_format_response_is_decoded_as_scripting(self):
|
||||||
|
# the live ISD's answer to a definite-length scripting template: zero
|
||||||
|
# executed commands plus the Bad format TLV (error type 02 = wrong
|
||||||
|
# length); it must not fall through to the compact decoder
|
||||||
|
r = _decode_por('16', '21', '25', '25', '0001000002', K, K,
|
||||||
|
'02710000130a00000000010000020000ab06800100900102')
|
||||||
|
self.assertEqual(r['response_status'], 'por_ok')
|
||||||
|
self.assertEqual(r['response_type'], 'scripting')
|
||||||
|
self.assertEqual(r['bad_format'], '02')
|
||||||
|
self.assertEqual(r['bad_format_name'], 'wrong length')
|
||||||
|
self.assertEqual(r['decoded']['number_of_commands'], 0)
|
||||||
|
|
||||||
|
def test_ram_step_result_treats_a_bad_format_as_failure(self):
|
||||||
|
por = {'response_status': 'por_ok', 'response_type': 'scripting',
|
||||||
|
'bad_format': '02', 'bad_format_name': 'wrong length',
|
||||||
|
'decoded': {'number_of_commands': 0, 'last_status_word': None,
|
||||||
|
'last_response_data': ''}}
|
||||||
|
step, error = _ram_step_result('FORMAT CHECK (expanded)', '9000', por, 'aabb', 42, 1)
|
||||||
|
self.assertEqual(step['por_bad_format'], '02')
|
||||||
|
self.assertIn('bad format 02', error)
|
||||||
|
self.assertIn('wrong length', error)
|
||||||
|
|
||||||
|
|
||||||
|
class CounterProbeTests(unittest.TestCase):
|
||||||
|
"""The bounded counter synchronisation probe (v3.9.x)."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _preset(cntr='0000000C2D'):
|
||||||
|
return {'id': 'p1', 'name': 'test card', 'spi1': '16', 'spi2': '21',
|
||||||
|
'tar': '000000',
|
||||||
|
'keysets': [{'kic': '25', 'kid': '25', 'kicKey': '00' * 16,
|
||||||
|
'kidKey': '11' * 16, 'cntr': cntr}]}
|
||||||
|
|
||||||
|
def test_candidates_double_and_stop_at_the_ceiling(self):
|
||||||
|
cands, reason = _counter_probe_candidates('0000000C2D', '0000FFFF', 40)
|
||||||
|
self.assertEqual(cands[:4], ['0000000C2E', '0000000C30', '0000000C34', '0000000C3C'])
|
||||||
|
self.assertTrue(all(int(c, 16) <= 0xFFFF for c in cands), cands)
|
||||||
|
self.assertEqual(reason, 'ceiling')
|
||||||
|
# the attempt budget caps a far-away ceiling
|
||||||
|
cands, reason = _counter_probe_candidates('0000000000', 'FFFFFFFFFF', 5)
|
||||||
|
self.assertEqual(len(cands), 5)
|
||||||
|
self.assertEqual(reason, 'attempts')
|
||||||
|
# nothing to try when the start is already at the ceiling
|
||||||
|
self.assertEqual(_counter_probe_candidates('0000FFFF', '0000FFFF', 40), ([], 'ceiling'))
|
||||||
|
|
||||||
|
def test_probe_walks_up_and_persists_accepted_plus_one(self):
|
||||||
|
persisted = []
|
||||||
|
|
||||||
|
class Store:
|
||||||
|
def set_counter(self, pid, cntr, source, kvn=None):
|
||||||
|
persisted.append((pid, cntr, source, kvn))
|
||||||
|
|
||||||
|
server = types.SimpleNamespace(card_presets=Store())
|
||||||
|
verdicts = ['cntr_low', 'cntr_low', 'por_ok']
|
||||||
|
|
||||||
|
def send_fn(p, cntr):
|
||||||
|
status = verdicts.pop(0)
|
||||||
|
return ({'success': status == 'por_ok', 'sw': '9000', 'bytes': 42, 'segments': 1},
|
||||||
|
{'response_status': status,
|
||||||
|
'decoded': {'last_status_word': '9000', 'last_response_data': ''}})
|
||||||
|
|
||||||
|
res = _counter_probe(server, None, self._preset(), {}, send_fn=send_fn)
|
||||||
|
self.assertTrue(res['success'])
|
||||||
|
self.assertEqual([a['cntr'] for a in res['attempts']],
|
||||||
|
['0000000C2E', '0000000C30', '0000000C34'])
|
||||||
|
self.assertEqual(res['accepted_cntr'], '0000000C34')
|
||||||
|
self.assertEqual(res['stored_cntr'], '0000000C35')
|
||||||
|
self.assertEqual(res['packets'], 3)
|
||||||
|
self.assertEqual(persisted, [('p1', '0000000C35', 'counter-probe', 2)])
|
||||||
|
|
||||||
|
def test_probe_stops_on_a_non_counter_error(self):
|
||||||
|
def send_fn(p, cntr):
|
||||||
|
return ({'success': False, 'sw': '6200', 'error': 'ENVELOPE failed (6200)'},
|
||||||
|
{'response_status': 'refused'})
|
||||||
|
|
||||||
|
res = _counter_probe(None, None, self._preset(), {'max_attempts': 4},
|
||||||
|
send_fn=send_fn)
|
||||||
|
self.assertFalse(res['success'])
|
||||||
|
self.assertEqual(res['packets'], 1)
|
||||||
|
self.assertEqual(res['stopped'], 'error')
|
||||||
|
self.assertIn('6200', res['error'])
|
||||||
|
|
||||||
|
def test_probe_reports_a_ceiling_overrun(self):
|
||||||
|
def send_fn(p, cntr):
|
||||||
|
return ({'success': False, 'sw': '9000'}, {'response_status': 'cntr_low'})
|
||||||
|
|
||||||
|
res = _counter_probe(None, None, self._preset('0000FFFC'),
|
||||||
|
{'ceiling': '0000FFFF', 'max_attempts': 40}, send_fn=send_fn)
|
||||||
|
self.assertFalse(res['success'])
|
||||||
|
self.assertEqual(res['stopped'], 'ceiling')
|
||||||
|
self.assertLessEqual(len(res['attempts']), 3)
|
||||||
|
self.assertIn('raise the preset counter manually', res['error'])
|
||||||
|
|
||||||
|
def test_probe_params_validation(self):
|
||||||
|
# a keyset number the preset does not define
|
||||||
|
with self.assertRaises(ValueError) as cm:
|
||||||
|
_counter_probe_params(self._preset(), {'kvn': 5})
|
||||||
|
self.assertIn('not defined', str(cm.exception))
|
||||||
|
# SPI1 without a counter check cannot probe
|
||||||
|
no_check = self._preset()
|
||||||
|
no_check['spi1'] = '06' # ciphering+CC but b5b4 = 00: no counter field
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
_counter_probe_params(no_check, {})
|
||||||
|
# the ceiling must stay below the 40-bit maximum
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
_counter_probe_params(self._preset(), {'ceiling': 'FFFFFFFFFF'})
|
||||||
|
|
||||||
|
def test_cntr_low_fields_is_a_flag_only(self):
|
||||||
|
# the card's counter is not derivable from the PoR (TS 102 225 5.2):
|
||||||
|
# the verdict is stated without a value
|
||||||
|
self.assertEqual(_cntr_low_fields('cntr_low'), {'cntr_low': True})
|
||||||
|
self.assertEqual(_cntr_low_fields('por_ok'), {})
|
||||||
|
self.assertEqual(_cntr_low_fields(None), {})
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -100,13 +100,13 @@ class TagVariantMatrixTests(unittest.TestCase):
|
|||||||
|
|
||||||
def test_response_scripting_accepts_both_rapdu_tags(self):
|
def test_response_scripting_accepts_both_rapdu_tags(self):
|
||||||
definite = bytes.fromhex('ab0780010123029000')
|
definite = bytes.fromhex('ab0780010123029000')
|
||||||
self.assertEqual(_parse_response_scripting(definite), (1, '9000', ''))
|
parsed = lambda d: (lambda r: (r['count'], r['sw'], r['data'], r['bad_format']))(
|
||||||
self.assertEqual(_parse_response_scripting(bytes.fromhex('ab07800101a3029000')),
|
_parse_response_scripting(d))
|
||||||
(1, '9000', ''))
|
self.assertEqual(parsed(definite), (1, '9000', '', None))
|
||||||
|
self.assertEqual(parsed(bytes.fromhex('ab07800101a3029000')), (1, '9000', '', None))
|
||||||
indefinite = bytes.fromhex('af80800101230290000000')
|
indefinite = bytes.fromhex('af80800101230290000000')
|
||||||
self.assertEqual(_parse_response_scripting(indefinite), (1, '9000', ''))
|
self.assertEqual(parsed(indefinite), (1, '9000', '', None))
|
||||||
self.assertEqual(_parse_response_scripting(bytes.fromhex('af80800101a30290000000')),
|
self.assertEqual(parsed(bytes.fromhex('af80800101a30290000000')), (1, '9000', '', None))
|
||||||
(1, '9000', ''))
|
|
||||||
# the SCP81 R-APDU list parser reads the same templates
|
# the SCP81 R-APDU list parser reads the same templates
|
||||||
self.assertEqual(_scp81_parse_response(definite), (1, [(b'', '9000')]))
|
self.assertEqual(_scp81_parse_response(definite), (1, [(b'', '9000')]))
|
||||||
self.assertEqual(_scp81_parse_response(bytes.fromhex('ab07800101a3029000')),
|
self.assertEqual(_scp81_parse_response(bytes.fromhex('ab07800101a3029000')),
|
||||||
|
|||||||
Reference in New Issue
Block a user