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:
2026-10-02 00:15:40 +03:00
parent ad2a253f2a
commit 391d4a322d
10 changed files with 683 additions and 160 deletions
+35
View File
@@ -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
+2 -2
View File
@@ -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, где счётчик нужно поднять выше &mdash; автоматически ничего не записывается.</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 &sect;4.3) &mdash; пакет делится на части SMS-пользовательских данных (первое SMS 132 октета, последующие по 134; первое дополнительно несёт информационные элементы конкатенации и CPI), и сегменты отправляются по порядку. Более 5 сегментов отклоняется: это практический предел буфера конкатенации карты. Полученный Proof of Receipt декодируется и показывается строкой статуса PoR (статус, TAR, счётчик, сырой PoR); статусное слово и данные ответа последней команды подставляются в подвкладку <strong>&laquo;Разбор R-APDU&raquo;</strong> (Remote APDU &rarr; &laquo;Разбор&raquo;), а успешный PoR увеличивает счётчик повторов (когда в SPI1 есть проверка счётчика &mdash; b5 b4 &ne; 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 &sect;4.3) &mdash; пакет делится на части SMS-пользовательских данных (первое SMS 132 октета, последующие по 134; первое дополнительно несёт информационные элементы конкатенации и CPI), и сегменты отправляются по порядку. Более 5 сегментов отклоняется: это практический предел буфера конкатенации карты. Полученный Proof of Receipt декодируется и показывается строкой статуса PoR (статус, TAR, счётчик, сырой PoR); статусное слово и данные ответа последней команды подставляются в подвкладку <strong>&laquo;Разбор R-APDU&raquo;</strong> (Remote APDU &rarr; &laquo;Разбор&raquo;), а успешный PoR увеличивает счётчик повторов (когда в SPI1 есть проверка счётчика &mdash; b5 b4 &ne; 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 &sect;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
View File
@@ -310,10 +310,10 @@
<li>AES requires a replay-protected counter: SPI1 bits b5&nbsp;b4 must be <code class="font-mono text-sm">10</code> (counter higher) or <code class="font-mono text-sm">11</code> (counter +1) per TS 102 225 &sect;5.1.2/&sect;5.1.3.1</li> <li>AES requires a replay-protected counter: SPI1 bits b5&nbsp;b4 must be <code class="font-mono text-sm">10</code> (counter higher) or <code class="font-mono text-sm">11</code> (counter +1) per TS 102 225 &sect;5.1.2/&sect;5.1.3.1</li>
<li>Padding byte configurable (<code class="font-mono text-sm">00</code> default, or <code class="font-mono text-sm">FF</code>)</li> <li>Padding byte configurable (<code class="font-mono text-sm">00</code> default, or <code class="font-mono text-sm">FF</code>)</li>
</ul> </ul>
<p class="text-sm mb-3">A &ldquo;Verify vs pySim&rdquo; button cross-checks the assembled packet against pySim&rsquo;s reference <code class="font-mono text-sm">OtaDialectSms.encode_cmd</code>. A &ldquo;Send to Card&rdquo; button delivers it via SMS-PP-DOWNLOAD ENVELOPE (when connected to the server). The 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&rsquo;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 &sect;4.3) &mdash; the packet is split into SMS user-data parts (first SM 132 octets, following ones 134; the first one additionally carries the concatenation and CPI information elements) and the segments are sent in order. More than 5 segments is refused, that being the practical limit of the card&rsquo;s concatenation buffer. The returned Proof of Receipt is decoded and shown as a PoR status line (status, TAR, counter, raw PoR); the last command&rsquo;s status word and response data are filled into the <strong>R-APDU parser</strong> panel, and a successful PoR advances the replay counter (when SPI1 carries a counter check &mdash; b5 b4 &ne; 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 &mdash; nothing is written automatically.</p> <p class="text-sm mb-3">A &ldquo;Verify vs pySim&rdquo; button cross-checks the assembled packet against pySim&rsquo;s reference <code class="font-mono text-sm">OtaDialectSms.encode_cmd</code>. A &ldquo;Send to Card&rdquo; button delivers it via SMS-PP-DOWNLOAD ENVELOPE (when connected to the server). 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&rsquo;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 &sect;4.3) &mdash; the packet is split into SMS user-data parts (first SM 132 octets, following ones 134; the first one additionally carries the concatenation and CPI information elements) and the segments are sent in order. More than 5 segments is refused, that being the practical limit of the card&rsquo;s concatenation buffer. The returned Proof of Receipt is decoded and shown as a PoR status line (status, TAR, counter, raw PoR); the last command&rsquo;s status word and response data are filled into the <strong>R-APDU parser</strong> panel, and a successful PoR advances the replay counter (when SPI1 carries a counter check &mdash; b5 b4 &ne; 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&rsquo;s CNTR field is a copy of the packet&rsquo;s), so the notice offers a bounded <strong>Sync counter</strong> probe &mdash; 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 &mdash; 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 &sect;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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+8 -4
View File
@@ -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
View File
@@ -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()
+6 -6
View File
@@ -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')),