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/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/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/profiles` | GET | Installed eSIM profiles with their metadata |
| `/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": […]}`;
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`
Probe which OTA applications the card has registered: one **secured packet per
+2 -2
View File
@@ -279,7 +279,7 @@
</section>
<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>
<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>
<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>
<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>
<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>Padding byte configurable (<code class="font-mono text-sm">00</code> default, or <code class="font-mono text-sm">FF</code>)</li>
</ul>
<p class="text-sm mb-3">A &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>
<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>
<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>
</select>
</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>
</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 {
const data = await pysimFetch('/api/send-ota', { sp, ...spParams() });
// low counter: not recoverable by retrying (v3.6.58)
const low = spCntrLow(data);
if (low) {
sendResultEl.textContent = t('Low counter') + ' — ' + t('the card rejected the packet') +
(low.card_cntr ? ' (' + t('card counter') + ' ' + low.card_cntr + ')' : '');
if (spCntrLow(data)) {
sendResultEl.textContent = t('Low counter') + ' — ' + t('the card rejected the packet');
sendResultEl.classList.remove('text-gray-500', 'text-green-600');
sendResultEl.classList.add('text-red-600');
ramShowCntrLow(low);
ramShowCntrLow();
return;
}
if (data.success) {
@@ -8851,6 +8858,7 @@ function getRamSpParams(selId) {
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, ''),
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
// 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.
let cntrLow = null;
let cntrLow = false;
async function sendOta(apdu, params) {
const res = await ramSendOta(apdu, params);
if (!cntrLow) {
const low = spCntrLow(res);
if (low) cntrLow = low;
}
if (!cntrLow && spCntrLow(res)) cntrLow = true;
return res;
}
@@ -9900,7 +9905,7 @@ async function ramExplore(sp) {
// the run was stopped: state the verdict, do not retry
resultEl.textContent = t('Low counter') + ' — ' + t('the operation was stopped');
resultEl.classList.remove('hidden', 'text-green-600'); resultEl.classList.add('text-red-600');
ramShowCntrLow(cntrLow);
ramShowCntrLow();
} else if (errors.length) {
resultEl.textContent = t('Partial') + ' — ' + errors.join('; ');
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);
ramHideProgress();
// low counter: stop - retrying cannot help (v3.6.58)
const low = spCntrLow(res);
if (low) {
if (spCntrLow(res)) {
const el = document.getElementById('ram-result');
el.textContent = t('Low counter') + ' — ' + t('the operation was stopped');
el.classList.remove('hidden', 'text-green-600'); el.classList.add('text-red-600');
ramShowCntrLow(low);
ramShowCntrLow();
return;
}
// 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);
if (probeNote) modal += '<div class="mt-2 text-gray-500 dark:text-slate-400">' + esc(probeNote) + '</div>';
modal += ramCompatDetailHtml(data, mem);
const low = spCntrLow(data);
if (low) {
modal += ramCntrLowHtml(low, ramCntrLowPresetIdx());
ramShowCntrLow(low);
if (spCntrLow(data)) {
modal += ramCntrLowHtml(ramCntrLowPresetIdx());
ramShowCntrLow();
}
ramOpModalFinish(verdict, !!data.imports_ok, modal, true);
}
@@ -10808,27 +10811,27 @@ function ramCntrLowPresetIdx() {
return ram >= 0 ? ram : pick('sp-card-sel');
}
// Low-counter notice (pure HTML): the facts plus a button that opens the
// failed operation's preset in the Cards tab, where the user raises the
// counter - nothing is written automatically.
function ramCntrLowHtml(info, presetIdx) {
if (!info) return '';
// Low-counter notice (pure HTML): the facts plus two actions - a bounded
// counter sync probe and a button that opens the failed operation's preset in
// the Cards tab, where the counter can be raised by hand.
function ramCntrLowHtml(presetIdx) {
const idx = Number.isInteger(presetIdx) && presetIdx >= 0 ? presetIdx : -1;
let text = t('Low counter') + ' \u2014 ' + t('the card rejected the packet');
if (info.card_cntr) text += ' (' + t('card counter') + ' ' + esc(info.card_cntr) + ')';
if (info.suggested) text += ' \u2014 ' + t('the counter must be above it');
const text = t('Low counter') + ' \u2014 ' + t('the card rejected the packet');
const btnCls = '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';
return '<div class="flex items-center gap-3 text-sm text-red-600 dark:text-red-400">' +
'<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>';
}
// Show the low-counter notice in the RAM result area (and, when the operation
// modal is still open, in its detail block).
function ramShowCntrLow(info, presetIdx) {
if (!info) return;
function ramShowCntrLow(presetIdx) {
const idx = presetIdx === undefined ? ramCntrLowPresetIdx() : presetIdx;
const html = ramCntrLowHtml(info, idx);
const html = ramCntrLowHtml(idx);
const el = document.getElementById('ram-cntr-low');
if (el && el.classList) {
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):
// select it, scroll to the counter field and focus it.
function ramGoToPreset(idx) {
@@ -10904,12 +10955,11 @@ function ramShowInstallResult(data, okText, capMem) {
// successful run and for failures that are not LOAD/INSTALL [for load]).
let modal = esc(txt);
modal += ramCompatDetailHtml(data, capMem || null);
// low counter: not recoverable by retrying - state it and point at the
// preset, where the user raises the counter (v3.6.58)
const low = spCntrLow(data);
if (low) {
modal += ramCntrLowHtml(low, ramCntrLowPresetIdx());
ramShowCntrLow(low);
// low counter: not recoverable by retrying - state it and offer the
// bounded sync probe or the preset jump (v3.6.58)
if (spCntrLow(data)) {
modal += ramCntrLowHtml(ramCntrLowPresetIdx());
ramShowCntrLow();
}
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
// 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.
// 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) {
if (!res) return null;
if (!res) return false;
const por = res.por || null;
const status = (por && por.response_status) || '';
if (!res.cntr_low && status !== 'cntr_low') return null;
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 };
return !!(res.cntr_low || status === 'cntr_low');
}
// Remote-command status words that count as success (mirrors the server's
@@ -20264,8 +20307,17 @@ const LANG_RU = {
'request error': 'ошибка запроса',
'Low counter': 'Низкий счётчик',
'the card rejected the packet': 'карта отклонила пакет',
'card counter': 'счётчик карты',
'the counter must be above it': 'счётчик должен быть больше',
'Sync counter': 'Синхронизировать счётчик',
'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': 'К пресету',
'first page only': 'только первая страница',
'the operation was stopped': 'операция остановлена',
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'simple-v351';
const CACHE = 'simple-v352';
const URLS = [
'index.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', () => {
// the server's explicit fields
const a = spCntrLow({ cntr_low: true, card_cntr: '00000000BB', suggested_cntr: '00000000BC' });
assert.deepStrictEqual(a, { card_cntr: '00000000BB', suggested: '00000000BC' });
// the server's explicit flag
assert.strictEqual(spCntrLow({ cntr_low: true }), true);
// 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.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');
assert.strictEqual(spCntrLow({ por: { response_status: 'cntr_low', cntr: '00000000BB' } }), true);
// 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: 'cntr_high' } }), null);
assert.strictEqual(spCntrLow({}), null);
assert.strictEqual(spCntrLow(null), null);
assert.strictEqual(spCntrLow({ por: { response_status: 'por_ok', cntr: '01' } }), false);
assert.strictEqual(spCntrLow({ por: { response_status: 'cntr_high' } }), false);
assert.strictEqual(spCntrLow({}), false);
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;
const out = ramCntrLowHtml({ card_cntr: '00000000BB', suggested: '00000000BC' }, 2);
const noPreset = ramCntrLowHtml({ card_cntr: '00000000BB' }, -1);
const out = ramCntrLowHtml(2);
const noPreset = ramCntrLowHtml(-1);
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('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.strictEqual(ramCntrLowHtml(null, 0), '');
assert.ok(noPreset.includes('onclick="ramSyncCounter(-1)"'), noPreset);
});
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');
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('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
assert.ok(extractFunc(html, 'ramDeleteFromExplorer').includes('spCntrLow(res)'),
'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('cardsEdit('), 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', () => {
+320 -61
View File
@@ -1407,25 +1407,20 @@ def _por_remote_sw(por):
return ''
def _cntr_low_fields(status, cntr_hex):
"""The card's `cntr_low` verdict as plain data: the card's own counter and
the next valid value (the packet counter must be above it). {} when the
verdict is not a low counter.
def _cntr_low_fields(status):
"""The card's `cntr_low` verdict as a flag - the packet's counter was not
above the card's for that key version - or {} for any other verdict.
A low counter is NOT recoverable by retrying: every further packet with a
counter <= the card's is rejected again, so operations must stop and the
user must raise the preset counter (v3.6.58)."""
The card's own counter is deliberately not reported: the response
packet's CNTR is "a copy of the contents of the CNTR in the Command
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':
return {}
card = str(cntr_hex or '').upper()
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}
return {'cntr_low': True}
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
# later, so every operation re-checks (v3.6.24).
_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):
"""Wrap a C-APDU in the expanded remote-management format: the Command
TLV '22' inside the 'AA' template (TS 102 226 5.2.1), the exact form of
the reference trace `AA0A220880F24000024F0000`."""
def _wrap_expanded_apdu(apdu_hex, form='definite'):
"""Wrap a C-APDU in the expanded remote-management format (TS 102 226
5.2.1): the Command TLV '22' inside the Command Scripting template.
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
if form == 'indefinite':
return ('AE80' + cmd + '0000').upper()
return ('AA' + _ber_len(len(cmd) // 2) + cmd).upper()
def _ram_format_apdu(apdu_hex, ram_format):
"""Apply the RAM command format ('compact' or 'expanded')."""
return _wrap_expanded_apdu(apdu_hex) if ram_format == 'expanded' else apdu_hex
"""Apply the RAM command format (TS 102 226 5.2.1): the bare C-APDU
(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):
"""'auto' | 'compact' | 'expanded'; anything else (or missing) is
'auto' for callers that opted in to the format handling."""
"""'auto' | 'compact' | 'expanded' | 'expanded-ae'; anything else (or
missing) is 'auto' for callers that opted in to the format handling."""
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):
"""Detect the card's RAM command format for one operation: send the
read-only GET STATUS [ISD] probe compact, then wrapped; the first format
whose remote SW succeeds wins (compact preferred). 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)."""
read-only GET STATUS [ISD] probe compact, then in the two expanded
codings; the first format whose remote SW succeeds wins (compact
preferred). Cards implement different subsets - the live ISD answers the
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),
('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': {},
'cntr': state['cntr'], 'preset_id': state.get('preset_id')}
ok = _ram_send_gp_apdu(server, scc, sp, scratch,
@@ -1555,6 +1573,187 @@ def _ram_detect_format(server, scc, sp, state):
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
# 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
@@ -1607,7 +1806,13 @@ def _ram_step_result(step_name, last_sw, por, por_hex, bytes_, segments):
step['por_raw'] = por.get('raw')
if 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')
elif remote_sw and not _ram_remote_sw_ok(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}
_BAD_FORMAT_NAMES = {
'01': 'unknown tag',
'02': 'wrong length',
'03': 'length not found',
}
def _parse_response_scripting(data):
"""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),
containing the executed-command-count TLV `80` and one or more R-APDU
TLVs `23` (COMPREHENSION-TLV; the last two bytes are SW1 SW2).
containing the executed-command-count TLV `80`, one or more R-APDU TLVs
`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
data is not a scripting template."""
Returns {'count', 'sw', 'data', 'bad_format'} or None when the data is
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:
return None
if data[0] == 0xAF:
@@ -1778,6 +1994,7 @@ def _parse_response_scripting(data):
return None
count = None
last = None
bad = None
off = 0
while off < len(body) - 1:
tag = body[off]
@@ -1789,10 +2006,16 @@ def _parse_response_scripting(data):
count = int.from_bytes(val, 'big')
elif tag in (0x23, 0xA3) and len(val) >= 2:
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
if last is None:
if last is None and bad is 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):
@@ -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']):
scripted = _parse_response_scripting(bytes(res['secured_data']))
if scripted is not None:
count, sw, data_hex = scripted
out['response_type'] = 'scripting'
out['decoded'] = {
'number_of_commands': count,
'last_status_word': sw,
'last_response_data': data_hex,
'number_of_commands': scripted['count'],
'last_status_word': scripted['sw'],
'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:
out['response_type'] = 'compact'
out['decoded'] = {
@@ -5239,7 +5467,7 @@ _TEST_BLOCKED_PATHS = frozenset([
'/api/command', '/api/cardinfo', '/api/tree', '/api/select', '/api/read',
'/api/write', '/api/apdu', '/api/verify-adm', '/api/send-ota',
'/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/menu-respond', '/api/event-send', '/api/net-sim',
'/api/net-state-refresh', '/api/status-poll', '/api/rescue',
@@ -7061,13 +7289,11 @@ class PysimHandler(BaseHTTPRequestHandler):
body.get('kidKey', ''), failed_por_hex)
if failed_por:
resp['por'] = failed_por
low = _cntr_low_fields(failed_por.get('response_status'),
failed_por.get('cntr'))
low = _cntr_low_fields(failed_por.get('response_status'))
if low:
resp.update(low)
sys.stderr.write('OTA CNTR-LOW: card counter %s '
'(the packet counter must be above it)\n'
% low['card_cntr'])
sys.stderr.write('OTA CNTR-LOW: the packet counter is not '
'above the card\'s\n')
sys.stderr.write('OTA SEND FAILED: %s\n' % result.get('error'))
else:
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'))
if 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:
# a 9000/91xx ENVELOPE can still carry a
# cntr_low PoR (the live card does)
resp.update(low)
sys.stderr.write('OTA CNTR-LOW: card counter %s '
'(the packet counter must be above it)\n'
% low['card_cntr'])
sys.stderr.write('OTA CNTR-LOW: the packet counter is not '
'above the card\'s\n')
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', '?'),
por['decoded'].get('last_status_word', '?'))
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}
resp.update(_ram_nv_fields(nv_before, nv_after))
failed_rec = steps[-1] if steps else {}
resp.update(_cntr_low_fields(failed_rec.get('por_status'),
failed_rec.get('por_cntr')))
resp.update(_cntr_low_fields(failed_rec.get('por_status')))
if resp.get('cntr_low'):
sys.stderr.write('RAM-INSTALL: low counter - card counter %s, '
'the preset counter must be above it\n'
% resp.get('card_cntr'))
sys.stderr.write('RAM-INSTALL: low counter - the packet '
'counter is not above the card\'s\n')
self._send_json(resp)
self._log_resp(resp)
return
@@ -7408,8 +7635,7 @@ class PysimHandler(BaseHTTPRequestHandler):
or ('%s failed' % step_name))
resp['failed_step'] = len(state['steps'])
failed_rec = state['steps'][-1] if state['steps'] else {}
resp.update(_cntr_low_fields(failed_rec.get('por_status'),
failed_rec.get('por_cntr')))
resp.update(_cntr_low_fields(failed_rec.get('por_status')))
self._send_json(resp)
self._log_resp(resp)
except Exception as e:
@@ -7535,8 +7761,7 @@ class PysimHandler(BaseHTTPRequestHandler):
or 'the card rejected the import list')
resp['failed_step'] = len(steps)
failed_rec = steps[-1] if steps else {}
resp.update(_cntr_low_fields(failed_rec.get('por_status'),
failed_rec.get('por_cntr')))
resp.update(_cntr_low_fields(failed_rec.get('por_status')))
sys.stderr.write('CAP-COMPAT: %s — imports %s (LOADS to %d/%d)\n' % (
loadfile_aid, 'OK' if ok else 'REJECTED', boundary, total_blocks))
self._send_json(resp)
@@ -7748,6 +7973,40 @@ class PysimHandler(BaseHTTPRequestHandler):
'spi1': spi1, 'spi2': spi2, 'apdu': apdu}
self._send_json(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':
body = self._read_body()
self._log_req(body)
+8 -4
View File
@@ -389,8 +389,10 @@ class CntrLowGuardHttpTests(unittest.TestCase):
p.stop()
self.assertEqual(status, 200, resp)
self.assertTrue(resp['cntr_low'], resp)
self.assertEqual(resp['card_cntr'], '00000000BB')
self.assertEqual(resp['suggested_cntr'], '00000000BC')
# the PoR's CNTR is a copy of the command's counter, not the card's:
# 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):
calls = []
@@ -425,8 +427,10 @@ class CntrLowGuardHttpTests(unittest.TestCase):
self.assertFalse(resp['success'], resp)
self.assertEqual(len(calls), 2, calls) # nothing after the verdict
self.assertTrue(resp['cntr_low'], resp)
self.assertEqual(resp['card_cntr'], '00000000BB')
self.assertEqual(resp['suggested_cntr'], '00000000BC')
# the PoR's CNTR is a copy of the command's counter, not the card's:
# the verdict is reported without a value (v3.9.x)
self.assertNotIn('card_cntr', resp)
self.assertNotIn('suggested_cntr', resp)
class TarProbeListTests(unittest.TestCase):
+169 -14
View File
@@ -33,6 +33,10 @@ from pysim_simple_server.server import (
_calc_ud_offset,
_find_sms_tpdu,
_cntr_low_fields,
_counter_probe,
_counter_probe_candidates,
_counter_probe_params,
_ram_normalize_format,
_parse_proactive_header,
_parse_display_text,
_tlv_map,
@@ -1653,20 +1657,32 @@ class ResponseScriptingTest(unittest.TestCase):
data = bytes([0xAB, len(body)]) + body
scripted = _parse_response_scripting(data)
self.assertIsNotNone(scripted)
count, sw, listing = scripted
self.assertEqual((count, sw), (1, '9000'))
self.assertEqual((scripted['count'], scripted['sw']), (1, '9000'))
listing = scripted['data']
self.assertTrue(listing.startswith('10A1130001'), listing[:20])
self.assertIn('F0414C46416101', listing)
def test_indefinite_template_and_plain_data(self):
body = bytes([0x80, 0x01, 0x02, 0x23, 0x04, 0xAA, 0xBB, 0x90, 0x00])
data = bytes([0xAF, 0x80]) + body + b'\x00\x00'
count, sw, listing = _parse_response_scripting(data)
self.assertEqual((count, sw, listing), (2, '9000', 'AABB'))
r = _parse_response_scripting(data)
self.assertEqual((r['count'], r['sw'], r['data']), (2, '9000', 'AABB'))
# a compact response is not a scripting template
self.assertIsNone(_parse_response_scripting(bytes.fromhex('027100000263100BD2')))
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):
@@ -1709,17 +1725,13 @@ class CntrLowGuardTests(unittest.TestCase):
warning ENVELOPE answer still gets its PoR polled."""
def test_cntr_low_fields(self):
self.assertEqual(_cntr_low_fields('cntr_low', '00000000BB'),
{'cntr_low': True, 'card_cntr': '00000000BB',
'suggested_cntr': '00000000BC'})
# 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'})
# the verdict is a flag: the card's counter is not derivable from the
# PoR, whose CNTR is a copy of the command's counter (TS 102 225 5.2)
self.assertEqual(_cntr_low_fields('cntr_low'), {'cntr_low': True})
# only the low-counter verdict counts
self.assertEqual(_cntr_low_fields('por_ok', '01'), {})
self.assertEqual(_cntr_low_fields('cntr_high', '01'), {})
self.assertEqual(_cntr_low_fields(None, None), {})
self.assertEqual(_cntr_low_fields('por_ok'), {})
self.assertEqual(_cntr_low_fields('cntr_high'), {})
self.assertEqual(_cntr_low_fields(None), {})
def test_a_warning_envelope_answer_polls_for_the_por(self):
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)
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__':
unittest.main()
+6 -6
View File
@@ -100,13 +100,13 @@ class TagVariantMatrixTests(unittest.TestCase):
def test_response_scripting_accepts_both_rapdu_tags(self):
definite = bytes.fromhex('ab0780010123029000')
self.assertEqual(_parse_response_scripting(definite), (1, '9000', ''))
self.assertEqual(_parse_response_scripting(bytes.fromhex('ab07800101a3029000')),
(1, '9000', ''))
parsed = lambda d: (lambda r: (r['count'], r['sw'], r['data'], r['bad_format']))(
_parse_response_scripting(d))
self.assertEqual(parsed(definite), (1, '9000', '', None))
self.assertEqual(parsed(bytes.fromhex('ab07800101a3029000')), (1, '9000', '', None))
indefinite = bytes.fromhex('af80800101230290000000')
self.assertEqual(_parse_response_scripting(indefinite), (1, '9000', ''))
self.assertEqual(_parse_response_scripting(bytes.fromhex('af80800101a30290000000')),
(1, '9000', ''))
self.assertEqual(parsed(indefinite), (1, '9000', '', None))
self.assertEqual(parsed(bytes.fromhex('af80800101a30290000000')), (1, '9000', '', None))
# the SCP81 R-APDU list parser reads the same templates
self.assertEqual(_scp81_parse_response(definite), (1, [(b'', '9000')]))
self.assertEqual(_scp81_parse_response(bytes.fromhex('ab07800101a3029000')),