feat: TAR probe + the SPI1 counter-tracking rule; keyless packets build again (v3.8.0)

Three things learned from probing a live card, plus the capability they enabled:

1. Counter tracking follows SPI1.b5b4 (TS 102 225 5.1.1): with 00 the counter
   is "present, ignored, never updated", so a packet without a counter check
   must leave every counter untouched.  _counter_tracked() now guards
   /api/send-ota (no final_cntr, no persistence, a log line), the RAM step
   sender and the test runner; _preset_counter_persist() requires a non-zero
   key version (the "single keyset" fallback could advance a keyset spuriously
   for a keyless send).  The PWA advances the form counter only when the server
   reports final_cntr and says "counter not tracked" otherwise.

2. The spec's no-security form builds again: _ota_keyset() refused the valid
   KIc/KID '00' (TS 102 225 A.2: '00' is legal with SPI1.b3=0 for KIc and
   SPI1.b2b1=00 for KID) because pySim wants an algorithm.  An unknown nibble
   is now refused only when the algorithm is actually used (ciphering / a
   ciphered PoR for KIc, RC/CC/DS / a MAC'd PoR for KID) and 'implicit' codes
   the nibble as 0 otherwise - POST /api/send-ota {apdu, spi1:"00", kic:"00",
   kid:"00"} now builds 0D00010000B0000000000000010000A40000, the exact packet
   the card accepted (a fake 15/15 was refused with 6200).  A no-security PoR
   is decodable again too, and a requested-but-missing PoR sets por_missing
   on the plain path as well.

3. TAR probe (POST /api/tar-probe, RAM op "Probe TARs"): one secured packet per
   TAR - the standard allocations of TS 101 220 V18.3.0 Annex D, Table D.1
   (TAR_PROBE_TARS), or a custom list - carrying a harmless C-APDU (SELECT MF)
   with the preset's keyset and SPI 16/01.  Each TAR is classified
   (registered / no_answer / no_por / refused) from the ENVELOPE verdict and
   the application's PoR, counters advance and persist normally, and the run is
   refused when SPI1 has no counter check.  Live on a card: 000000, B00000 and
   B00001 registered (compact-format applications, answering 6D00 / 6B00),
   the expanded-format TARs, USAT, multiplexing and CASD refused with 6200 -
   with the keyless form the same card accepted every TAR and answered no PoR.

Docs (api.md, help EN/RU, AGENTS), sw cache simple-v337.  Tests: 618 Python
(no-security build, the algorithm-only-when-used rule, the tracking rule, the
persist guard, the probe classification) and 736 Node (the probe renderer and
wiring, spCounterTracked).
This commit is contained in:
2026-09-30 21:17:59 +03:00
parent 386958b261
commit 6eeb8dacb3
10 changed files with 615 additions and 31 deletions
+40
View File
@@ -36,6 +36,7 @@ a 3.x PWA).
| `/api/presets/update` | POST | Update a preset (`{id, fields}`; the counter may be raised) |
| `/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/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) |
@@ -245,6 +246,35 @@ 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/tar-probe`
Probe which OTA applications the card has registered: one **secured packet per
TAR** (the standard allocations of TS 101 220 V18.3.0 Annex D, Table D.1 unless
a `tars` list is given), each carrying a harmless C-APDU (`SELECT MF` by
default), with the preset's keyset and SPI `16/01` (counter check + PoR).
Every accepted packet consumes a counter, which is persisted into the keyset
like any other operation, so the probe is refused when the SPI1 has no counter
check (b5b4 = 00) or the named preset does not define the key version.
```json
{"preset_id": "…", "kic": "25", "kid": "25", "kicKey": "…", "kidKey": "…",
"cntr": "0000000187", "apdu": "00A40000023F00", "tars": ["000000", "B00000"]}
```
**Response:** `{"success": true, "registered": N, "total": M,
"final_cntr": "…", "kvn": 2, "spi1": "16", "spi2": "01",
"results": [{"tar": "000000", "label": "Issuer Security Domain (compact)",
"verdict": "registered", "sw": "9000", "por_status": "por_ok",
"por_sw": "6D00", "por_data": ""}], "steps": [...]}`
Verdicts: `registered` (ENVELOPE 9000 + a PoR carrying the application's own
status word/data), `no_answer` (accepted, PoR without an R-APDU), `no_por`
(accepted, no PoR at all — the packet was silently dropped), `refused` (a
non-9000 ENVELOPE, typically `6200` for a TAR without a registered
application). Observed on a live card: `000000`, `B00000` and `B00001`
registered (compact-format applications), the expanded-format TARs, USAT,
multiplexing and CASD refused.
### eSIM / LPA (local ES10 operations)
These endpoints work only when the equipped card is an eUICC (SGP.22/32);
@@ -388,6 +418,16 @@ presets*), so a lost response or a closed tab cannot lose the increment; the
request is refused when KIc/KID carry different key versions (TS 102 225 A.2)
or a non-zero key version the preset does not define.
**Counter tracking follows SPI1.b5b4** (TS 102 225 §5.1.1): with `00` the
counter field is "present, ignored, never updated", so a packet whose SPI1 has
no counter check (the keyless `SPI1 00` form) reports **no** `final_cntr` and
persists nothing — a keyless send must leave every preset counter untouched.
The spec's no-security KIc/KID values `00/00` are accepted when the SPI says
no ciphering / no RC-CC-DS (A.2), so `{apdu, spi1: "00", kic: "00", kid: "00"}`
builds a keyless packet (the card accepted exactly that form; a fake `15/15`
was refused with `6200`). When a PoR was requested and the card did not
answer, the response carries `por_missing: true`.
**Request body:**
```json
{
+1
View File
@@ -323,6 +323,7 @@
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">Обзор карты (все данные GP)</td><td class="py-1 px-2">Запрос GET STATUS для ISD, приложений, ELF и модулей ELF, а также GET DATA FF21 (расширенные ресурсы карты: число установленных приложений и свободная энергонезависимая/энергозависимая память). Компактный листинг содержит только AID, состояние жизненного цикла и привилегии (версии в устаревшем формате нет); если карта отвергает его (<code class="font-mono text-sm">P2.b2=0</code>, устаревший по GP), запросы повторяются в расширенном формате <code class="font-mono text-sm">E3</code>, а флажок <strong>Запрашивать расширенные данные реестра (связи load-файла и SD)</strong> дополнительно запрашивает их и объединяет расширенные поля: версию исполняемого load-файла, AID load-файла, AID Security Domain, списки модулей, пункты меню STK апплета (SCP Registry Data, TS 102 226 8.2.1.6) и любые другие объекты реестра, которые вернула карта (показываются как есть, без потерь) &mdash; он стоит несколько дополнительных защищённых пакетов на каждый обзор и <strong>включён по умолчанию</strong> (запоминается в браузере; снимите флажок, чтобы пропустить). Карты различаются в том, что принимают, поэтому расширенный запрос пробуется в трёх формах &mdash; эталонный список тегов (с <code class="font-mono text-sm">EA</code> по TS 102 226), тот же список без <code class="font-mono text-sm">EA</code> и вовсе без списка тегов (форма GlobalPlatformPro) &mdash; а обзор сообщает, какая форма сработала и где выполнен откат к компактному списку (версии пакетов тогда недоступны). Результаты отображаются в обзоре (строка успеха минимальна, счётчики — в заголовках разделов) с кнопками <strong>Удалить</strong> для каждого элемента и определённым форматом команд RAM (подсказка при наведении объясняет compact и expanded).</td></tr>
<tr><td class="py-1 px-2">Установка пакета (.cap файл)</td><td class="py-1 px-2">Отправка <code class="font-mono text-sm">.cap</code> файла на карту через сервер: INSTALL[for load] &rarr; LOAD &times;N &rarr; INSTALL[for install (+make selectable)]. Load-файл делится на LOAD APDU размера <strong>размер блока LOAD</strong> (1&ndash;240 байт полезной нагрузки, по умолчанию 240); каждый защищённый пакет доставляется одним SMS или, если он больше, конкатенированной SMS-PP загрузкой до 5 сегментов, так что большой <code>.cap</code> просто занимает несколько SMS. Сразу после выбора файл проверяется и в форме показывается <strong>оценка требований к памяти</strong>: <strong>требование NVRAM</strong> (образ кода + постоянные данные, сумма в стиле C6+C8 из GP Card Spec) и оценка RAM. Там же показаны <strong>библиотеки, с которыми слинкован CAP</strong> (его компонент Import: имя библиотеки, AID и минимальная версия <code class="font-mono text-sm">≥ x.y</code> — имена стандартных библиотек и семейства подставляются, а где известно, даётся подсказка о версии Java Card), идентификаторы пакета и апплетов и разбивка по компонентам (размеры и доли в порядке load-файла) — всё сгруппировано по разделам <strong>Package</strong>, <strong>Requires</strong>, <strong>Memory</strong> и <strong>Components</strong>. Строка <strong>Platform</strong> показывает минимальный уровень Java Card, который должна предоставить карта (выводится из импорта <code class="font-mono text-sm">javacard.framework</code>; флаг <code class="font-mono text-sm">int</code> в заголовке дополнительно требует поддержки int — Java Card 2.2+), и <strong>релиз ETSI</strong>, из которого пришли импорты UICC API (версия <code class="font-mono text-sm">uicc.toolkit</code> &rarr; REL-N по корпусу <code class="font-mono text-sm">sim-tools</code> в рабочем каталоге; основание показано в скобках): JCRE проверяет каждый импорт по своим пакетам при верификации load-файла (JC VM spec 4.5.2), поэтому карта со старой платформой отклоняет LOAD сразу после завершения компонента <strong>Import</strong> (в строке шага виден remote SW карты и компонент, который завершил блок, например <em>завершает Import</em>) — меньший размер блока LOAD помогает только если блок превышает буфер карты. Повреждённый файл не проходит анализ, и <strong>Execute</strong> остаётся недоступной до успешного анализа. Оценка носит справочный характер и никогда не используется при установке: NVRAM — это образ кода плюс постоянные данные (сумма в стиле C6+C8 по GP Table 11-48; управление памятью карты, округление и запись в реестре не учитываются, а образ статических полей входит в обе части); рекомендуемый <strong>C6</strong> следует за load-файлом, а рекомендуемые квоты — это десятичные значения в байтах (шестнадцатеричное значение в скобках — это кодирование параметров установки C7/C8: C6 — энергонезависимый код, C7 — энергозависимая RAM, C8 — энергонезависимые данные).</td></tr>
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">Проверка совместимости CAP</td><td class="py-1 px-2">Отвечает на один вопрос, ничего не устанавливая: <strong>может ли эта карта удовлетворить список импортов CAP?</strong> Сервер выполняет проверку формата, INSTALL[for load] и блоки LOAD только до блока, завершающего компонент <strong>Import</strong> &mdash; именно там JCRE проверяет список импортов &mdash; и останавливается: без флага последнего блока и без INSTALL[for install], поэтому ничего не фиксируется и удалять после этого ничего не нужно. Список <strong>«Версии импортов для проверки»</strong> предзаполняется из анализа CAP: <code class="font-mono text-sm">AID=version</code> переписывает запрошенную версию импорта, <code class="font-mono text-sm">+AID=version</code> добавляет синтетический импорт (запрос возможностей карты: LOAD проходит, только если карта экспортирует этот пакет версии &ge; указанной), а <strong>«Все 0.0»</strong> снимает все требования. Использование в два шага: нажмите «Все 0.0» &mdash; если проверка проходит, причиной был список импортов; затем возвращайте по одной строке, чтобы найти пакет и версию, которых не хватает карте. Вердикт явно указывает, что проверяется только <strong>этап LOAD/импортов</strong>: этап связывания (наличие реально вызываемых API) проверяет настоящая установка на INSTALL[for install]. Запуск и результат показываются в окне (как при установке): строки шагов, вердикт и блок деталей по каждому AID &mdash; при отказе он называет раздел CAP, который покрывает неудачный блок (диапазон байтов load-файла и компоненты, и завершает ли этот блок <strong>Import</strong>), SW команды с его значением, список импортов ровно в том виде, в каком он проверялся (версии CAP, правки пробы, добавленные синтетические записи, строки без совпадений) и что означает состояние пробы; при успехе — список импортов, который прошёл проверку.</td></tr>
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">Проба TAR (зарегистрированные OTA-приложения)</td><td class="py-1 px-2">Отправляет <strong>по одному защищённому пакету на каждый TAR</strong> &mdash; стандартные значения из TS 101 220 Annex D, Table D.1, если список TAR не задан &mdash; с безобидной C-APDU (по умолчанию <code class="font-mono text-sm">SELECT MF</code>), набором ключей предустановки и SPI <code class="font-mono text-sm">16/01</code> (проверка счётчика + PoR), и классифицирует ответ карты: <strong>зарегистрировано</strong> (ENVELOPE 9000 + PoR со SW/данными приложения), <strong>принято, нет PoR</strong> (пакет молча отброшен), <strong>отклонено</strong> (обычно <code class="font-mono text-sm">6200</code>). Полезно для картирования OTA-приложений карты. Каждый принятый пакет расходует счётчик, который сохраняется в набор ключей &mdash; проба отклоняется, если в SPI1 нет проверки счётчика.</td></tr>
</tbody>
</table>
+1
View File
@@ -321,6 +321,7 @@
<tbody>
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">Explore Card (all GP data)</td><td class="py-1 px-2">Queries GET STATUS for ISD, Applications, ELFs, and ELF Modules, plus GET DATA FF21 (Extended Card Resources: the number of installed applications and the free non-volatile/volatile memory). The compact listing carries the AID, life cycle and privileges (the legacy format has no version); if a card rejects it (<code class="font-mono text-sm">P2.b2=0</code>, deprecated by GP) the queries are retried in the expanded <code class="font-mono text-sm">E3</code> form, and the <strong>Fetch expanded registry details (load-file / SD associations)</strong> checkbox also requests it as an extra pass and merges the expanded-only fields: the Executable Load File version, Load File AID, Security Domain AID, module lists, the applet STK menu entries (SCP Registry Data, TS 102 226 8.2.1.6) and any other registry data objects the card returns (shown raw, never dropped) &mdash; it costs a few extra secured packets per Explore and is <strong>on by default</strong> (remembered per browser; untick to skip). Cards differ in what they accept, so the expanded query is tried in three forms &mdash; the reference tag list (with the TS 102 226 <code class="font-mono text-sm">EA</code>), the same list without <code class="font-mono text-sm">EA</code>, and no tag list at all (the GlobalPlatformPro form) &mdash; and the explorer reports which form worked and where the run fell back to the compact listing (package versions are then unavailable). Results appear in an explorer view (the success line stays minimal; the counts are in the section headings) with per-item <strong>Delete</strong> buttons and the detected RAM command format (hover for what compact vs expanded means).</td></tr>
<tr><td class="py-1 px-2">Install Package (.cap file)</td><td class="py-1 px-2">Sends a <code class="font-mono text-sm">.cap</code> file to the card via the server: INSTALL[for load] &rarr; LOAD &times;N &rarr; INSTALL[for install (+make selectable)]. The load file is split into LOAD APDUs of the <strong>LOAD block size</strong> (1&ndash;240 bytes of payload, 240 by default); each secured packet is delivered as a single SMS or, when larger, as a concatenated SMS-PP download of up to 5 segments, so a large <code class="font-mono text-sm">.cap</code> simply takes several SMS. As soon as the file is selected it is validated and its <strong>memory requirements are estimated</strong> in the form: the <strong>NVRAM requirement</strong> (the code image + persistent data, the GlobalPlatform Card Spec C6+C8 style total) and the RAM estimate. The same box reports the <strong>libraries the CAP is linked against</strong> (its Import component: library name, AID and the minimum version <code class="font-mono text-sm">≥ x.y</code>, with standard names and families resolved and a Java Card SDK hint where known), the package/applet identity and the component breakdown (sizes and shares in load-file order) &mdash; grouped under the <strong>Package</strong>, <strong>Requires</strong>, <strong>Memory</strong> and <strong>Components</strong> headings. The <strong>Platform</strong> line names the minimum Java Card level the card must provide (derived from the <code class="font-mono text-sm">javacard.framework</code> import; the header <code class="font-mono text-sm">int</code> flag additionally requires int support, Java Card 2.2+) and the <strong>ETSI release</strong> the UICC API imports come from (<code class="font-mono text-sm">uicc.toolkit</code> version &rarr; REL-N, per the workspace <code class="font-mono text-sm">sim-tools</code> corpus; shown with its basis in brackets): the JCRE checks every import against its own packages when it verifies the load file (JC VM spec 4.5.2), so a card whose platform is older rejects the LOAD as soon as the <strong>Import</strong> component is complete (the step line carries the card's remote SW and names the component the block completed, e.g. <em>completes Import</em>) &mdash; a smaller LOAD block only helps when the block exceeds the card's buffer. A corrupt or wrong-format file fails the analysis, and <strong>Execute</strong> stays disabled until a successful analysis. The estimate is informational only and is never used for installation: NVRAM is the code image plus the persistent data (the GlobalPlatform C6+C8 style total, Table 11-48 &mdash; card memory management, rounding and the registry entry are not included, and the static field image is counted in both parts); the suggested <strong>C6</strong> follows the load file, and the suggested quotas are decimal byte counts (the hex in parentheses is how the C7/C8 install parameters encode them: C6 = non-volatile code, C7 = volatile RAM, C8 = non-volatile data).</td></tr>
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">Probe TARs (registered OTA applications)</td><td class="py-1 px-2">Sends <strong>one secured packet per TAR</strong> &mdash; the standard allocations of TS 101 220 Annex D, Table D.1, unless a TAR list is given &mdash; each carrying a harmless C-APDU (default <code class="font-mono text-sm">SELECT MF</code>) with the preset's keyset and SPI <code class="font-mono text-sm">16/01</code> (counter check + PoR), and classifies the card's answer: <strong>registered</strong> (ENVELOPE 9000 + a PoR with the application's own status word/data), <strong>accepted, no PoR</strong> (the packet was silently dropped), <strong>refused</strong> (typically <code class="font-mono text-sm">6200</code>). Useful to map which OTA applications a card carries. Every accepted packet consumes a counter, which is saved into the keyset &mdash; the probe is refused when the SPI1 has no counter check.</td></tr>
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">CAP compatibility test</td><td class="py-1 px-2">Answers one question without installing anything: <strong>can this card satisfy the CAP's import list?</strong> The server runs the format check, INSTALL[for load] and the LOAD blocks only up to the block that completes the <strong>Import</strong> component &mdash; the point where the JCRE verifies the import list &mdash; then stops: no last-block flag and no INSTALL[for install], so nothing is committed and nothing has to be deleted afterwards. The <strong>Import versions to test</strong> list is pre-filled from the CAP analysis: <code class="font-mono text-sm">AID=version</code> rewrites an import's requested version, <code class="font-mono text-sm">+AID=version</code> appends a synthetic import (a card-capability query: the LOAD passes only if the card exports that package at &ge; that version), and <strong>Set all 0.0</strong> drops every requirement. Two-step use: press Set all 0.0 &mdash; if the test passes, the import list was the blocker; then restore one line at a time to find the package and version the card lacks. The verdict states it covers the <strong>LOAD/import gate only</strong>: the link gate (whether the applet's actual API calls exist) is what a real install checks at INSTALL[for install]. The run and its result appear in a popup (like the install): the step lines, the verdict and a per-AID detail block &mdash; on a rejection it names the CAP section the failing block covers (load-file byte range and components, and whether that block completes <strong>Import</strong>), the remote status word with its meaning, the import list exactly as tested (CAP versions, probe rewrites, appended synthetic entries, unmatched lines) and what the probe state implies; on success it lists the import list that passed.</td></tr>
</tbody>
</table>
+140 -4
View File
@@ -705,6 +705,7 @@
<option value="compat" data-l10n="CAP compatibility test">CAP compatibility test</option>
<option value="install-app" data-l10n="INSTALL [for install] (loaded package)">INSTALL [for install] (loaded package)</option>
<option value="make-selectable" data-l10n="INSTALL [for make selectable]">INSTALL [for make selectable]</option>
<option value="tar-probe" data-l10n="Probe TARs (registered OTA applications)">Probe TARs (registered OTA applications)</option>
</select>
</div>
<button data-needs="card" 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>
@@ -721,6 +722,13 @@
<div id="ram-cap-body"></div>
</fieldset>
</div>
<div id="ram-tar-rows" class="hidden mb-3">
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300" data-l10n="Probe APDU (hex)">Probe APDU (hex)</label>
<input id="ram-tar-apdu" class="w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800 font-mono" value="00A40000023F00">
<label class="block mt-2 mb-1 text-sm font-medium text-gray-700 dark:text-slate-300" data-l10n="TARs to probe (hex, one per line; empty = all standard TARs of TS 101 220 Annex D)">TARs to probe (hex, one per line; empty = all standard TARs of TS 101 220 Annex D)</label>
<textarea id="ram-tar-list" rows="4" placeholder="000000&#10;B20100&#10;B00000&#10;…" class="w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800 font-mono"></textarea>
<p class="text-xs text-amber-600 dark:text-amber-400 mt-1" data-l10n="One secured packet per TAR: each packet the card accepts consumes a counter, which is saved to the preset.">One secured packet per TAR: each packet the card accepts consumes a counter, which is saved to the preset.</p>
</div>
<div id="ram-compat-rows" class="hidden mb-3">
<fieldset class="border border-gray-200 dark:border-slate-700 rounded p-3" data-l10n-title="Runs the load only as far as the block that completes the CAP's Import component: the card checks the import list there, so its answer says whether the card can satisfy it. Nothing is installed and nothing is committed - the link gate is the real install's business. Lines are AID=version (rewrites an import) or +AID=version (appends a synthetic import, to query the card for a package the CAP does not use).">
<legend class="px-1 text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="Import versions to test">Import versions to test</legend>
@@ -8293,13 +8301,25 @@ async function pysimSendOta() {
// "advance always" rule silently reverted preset edits).
if (spPorAccepted(por)) {
const cntrEl = document.getElementById('sp-cntr');
cntrEl.value = data.final_cntr || spNextCntr(cntrEl.value);
const spi1Now = (document.getElementById('sp-spi1') || {}).value;
if (data.final_cntr) {
// the server persisted the accepted counter; sync the display
cntrEl.value = data.final_cntr;
msg += ' | CNTR -> ' + cntrEl.value;
spCntrLocalSync(cntrEl.value);
} else if (spCounterTracked(spi1Now)) {
// an older server: advance the local working copy
cntrEl.value = spNextCntr(cntrEl.value);
msg += ' | CNTR -> ' + cntrEl.value;
spCntrLocalSync(cntrEl.value);
} else {
// SPI1 without a counter check: the card keeps no replay
// value, so nothing is advanced (keyless SPI1 00 packets)
msg += ' | ' + t('counter not tracked (SPI1 has no counter check)');
}
if (data.ram_format) _spRamFormat = data.ram_format;
msg += ' | CNTR -> ' + cntrEl.value;
document.getElementById('sp-result').value = '';
spShowSizeInfo();
// the server persisted the accepted counter; sync the display
spCntrLocalSync(cntrEl.value);
} else {
msg += ' | CNTR unchanged (' + ((por && por.response_status) || 'PoR error') + ')';
}
@@ -8392,6 +8412,7 @@ function ramOpChanged() {
const isCap = op === 'install-cap';
const isCompat = op === 'compat';
const isApp = op === 'install-app' || op === 'make-selectable';
const isProbe = op === 'tar-probe';
const setHidden = (id, hidden) => {
const el = document.getElementById(id);
if (el) el.classList.toggle('hidden', hidden);
@@ -8400,6 +8421,7 @@ function ramOpChanged() {
setHidden('ram-cap-rows', !(isCap || isCompat));
setHidden('ram-compat-rows', !isCompat);
setHidden('ram-app-rows', !isApp);
setHidden('ram-tar-rows', !isProbe);
// The Execute button's .cap gate applies to the install-cap and compat ops.
if (typeof pysimApplyAvailability === 'function') pysimApplyAvailability();
}
@@ -10464,6 +10486,95 @@ async function ramInstallApp(sp, op) {
ramShowInstallResult(data, t('OK'));
}
// ===== TAR probe (v3.8.0) =====
// One secured packet per TAR from the preset's keyset, classified by the
// card's ENVELOPE verdict and the application's PoR. The standard TAR list
// lives on the server (TS 101 220 V18.3.0 Annex D, Table D.1) and is used when
// the list field is empty. Each packet the card accepts consumes a counter,
// which the server persists into the keyset (one per accepted TAR).
function ramTarVerdictLabel(verdict) {
return { registered: 'registered', no_answer: 'accepted, no answer',
no_por: 'accepted, no PoR', refused: 'refused' }[verdict] || verdict;
}
// The probe result table (pure): TAR, application, ENVELOPE SW, verdict, the
// application's status word and data.
function ramTarProbeHtml(data) {
if (!data || !Array.isArray(data.results)) return '';
let html = '<div class="mb-2 text-sm">' + esc(t('Registered applications:')) + ' <b>' +
(data.registered || 0) + ' / ' + (data.total || 0) + '</b>' +
(data.final_cntr ? ' \u00b7 ' + esc(t('counter')) + ' ' + esc(data.final_cntr) : '') + '</div>';
html += '<table class="w-full text-xs"><thead><tr>';
['TAR', 'Application', 'ENVELOPE', 'Result', 'App SW', 'Data'].forEach(h => {
html += '<th class="text-left py-1 px-2 border-b border-gray-200 dark:border-slate-700">' + esc(t(h)) + '</th>';
});
html += '</tr></thead><tbody>';
(data.results || []).forEach(r => {
const tone = r.verdict === 'registered' ? 'text-emerald-700 dark:text-emerald-400'
: (r.verdict === 'refused' ? 'text-red-600 dark:text-red-400'
: 'text-amber-600 dark:text-amber-400');
html += '<tr class="border-b border-gray-100 dark:border-slate-700">' +
'<td class="py-1 px-2 font-mono">' + esc(r.tar) + '</td>' +
'<td class="py-1 px-2">' + esc(r.label || '') + '</td>' +
'<td class="py-1 px-2 font-mono">' + esc(r.sw || '-') + '</td>' +
'<td class="py-1 px-2 ' + tone + '">' + esc(t(ramTarVerdictLabel(r.verdict))) + '</td>' +
'<td class="py-1 px-2 font-mono">' + esc(r.por_sw || '-') + '</td>' +
'<td class="py-1 px-2 font-mono">' + esc(r.por_data || '-') + '</td></tr>';
});
html += '</tbody></table>';
return html;
}
async function ramTarProbe(sp) {
const apdu = (document.getElementById('ram-tar-apdu').value || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
if (!apdu) { alert(t('Specify the probe APDU')); return; }
const tars = (document.getElementById('ram-tar-list').value || '')
.split(/[\s,;]+/).map(s => s.trim().toUpperCase())
.filter(s => /^[0-9A-F]{6}$/.test(s));
const body = {
preset_id: sp.preset_id,
spi1: sp.spi1, spi2: '01',
kic: sp.kic, kid: sp.kid, kicKey: sp.kicKey, kidKey: sp.kidKey,
cntr: sp.cntr, apdu: apdu,
};
if (tars.length) body.tars = tars;
ramClearResults();
ramOpModalBegin(t('Probe TARs (registered OTA applications)'));
ramOpModalStatus(t('Sending to card...'));
let data;
try {
data = await pysimFetch('/api/tar-probe', body);
} catch (e) {
const msg = t('Error') + ': ' + e.message;
const el = document.getElementById('ram-result');
el.textContent = msg;
el.classList.remove('hidden', 'text-green-600');
el.classList.add('text-red-600');
ramOpModalFinish(msg, false, '');
return;
}
ramHideProgress();
if (data.final_cntr) ramSaveCntr(data.final_cntr);
if (!data.success) {
const msg = t('Error') + ': ' + (data.error || '?');
const el = document.getElementById('ram-result');
el.textContent = msg;
el.classList.remove('hidden', 'text-green-600');
el.classList.add('text-red-600');
ramOpModalFinish(msg, false, '');
return;
}
const html = ramTarProbeHtml(data);
const resultEl = document.getElementById('ram-result');
resultEl.textContent = t('Registered applications:') + ' ' + data.registered + ' / ' + data.total;
resultEl.classList.remove('hidden', 'text-red-600');
resultEl.classList.add('text-green-600');
const stepsEl = document.getElementById('ram-steps');
stepsEl.classList.add('hidden');
ramOpModalFinish(resultEl.textContent, true, html, true);
}
// Measured NV footprint of an install operation: the server reads GET DATA
// FF21 (free non-volatile memory) before and after the chain; the delta is
// what the operation consumed (negative = memory was freed). Cards without
@@ -10612,6 +10723,7 @@ async function ramExecute() {
}
try {
if (op === 'explore') await ramExplore(sp);
else if (op === 'tar-probe') await ramTarProbe(sp);
else if (op === 'install-cap') await ramInstallCap(sp);
else if (op === 'compat') await ramCompatTest(sp);
else if (op === 'install-app' || op === 'make-selectable') await ramInstallApp(sp, op);
@@ -11019,6 +11131,16 @@ function spKeysetKvnOf(hex) {
return (parseInt(s, 16) >> 4) & 0x0F;
}
// True when the packet asks the card to check the counter (TS 102 225 5.1.1,
// SPI1.b5b4 = 01/10/11). With b5b4 = 00 the field is "present, ignored,
// never updated": a keyless SPI1 0x00 packet leaves every counter untouched,
// so the form counter must not advance either.
function spCounterTracked(spi1Hex) {
const v = parseInt(String(spi1Hex || ''), 16);
if (isNaN(v)) return false;
return ((v >> 3) & 0x03) !== 0;
}
// The keysets of a preset (a v3.8.0 flat preset converts to a single one).
function spKeysetList(preset) {
if (!preset) return [];
@@ -19471,6 +19593,20 @@ const LANG_RU = {
'Duplicate key version in the keysets': 'Дублирующаяся версия ключа в наборах',
'Select a card preset - its keysets define the key versions': 'Выберите предустановку карты — её наборы ключей определяют версии ключей',
'SCP80 counters saved to the preset.': 'Счётчики SCP80 сохранены в предустановке.',
'counter not tracked (SPI1 has no counter check)': 'счётчик не отслеживается (в SPI1 нет проверки счётчика)',
'Probe TARs (registered OTA applications)': 'Проба TAR (зарегистрированные OTA-приложения)',
'Probe APDU (hex)': 'APDU пробы (hex)',
'TARs to probe (hex, one per line; empty = all standard TARs of TS 101 220 Annex D)': 'TAR для пробы (hex, по одному в строке; пусто = все стандартные TAR из TS 101 220 Annex D)',
'One secured packet per TAR: each packet the card accepts consumes a counter, which is saved to the preset.': 'Один защищённый пакет на каждый TAR: каждый принятый картой пакет расходует счётчик, который сохраняется в предустановке.',
'Specify the probe APDU': 'Укажите APDU пробы',
'Registered applications:': 'Зарегистрированные приложения:',
'Application': 'Приложение',
'ENVELOPE': 'ENVELOPE',
'App SW': 'SW приложения',
'Data': 'Данные',
'registered': 'зарегистрировано',
'accepted, no answer': 'принято, нет ответа',
'accepted, no PoR': 'принято, нет PoR',
'No card presets defined — the SCP80/RAM views have no keys for the equipped card. Add one in the Cards tab.': 'Предустановки карт не заданы — в представлениях SCP80/RAM нет ключей для подключённой карты. Добавьте её на вкладке «Карты».',
'The equipped card': 'Подключённая карта',
'does not match any card preset — the selected preset may belong to another card. Add it in the Cards tab (From card).': 'не совпадает ни с одной предустановкой — выбранная предустановка может принадлежать другой карте. Добавьте её на вкладке «Карты» (кнопка «С карты»).',
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'simple-v336';
const CACHE = 'simple-v337';
const URLS = [
'index.html',
'help.html',
+12 -1
View File
@@ -23,7 +23,7 @@ function extractFunc(src, name) {
let code = '';
for (const fn of ['spKeysetKvnOf', 'spKeysetList', 'spKeysetFor', 'spKeysetCheck',
'spKeysetOptionsHtml', 'esc', 'escHtml']) {
'spKeysetOptionsHtml', 'spCounterTracked', 'esc', 'escHtml']) {
code += extractFunc(html, fn) + '\n';
}
code += 'function t(s){return s;}\n';
@@ -120,3 +120,14 @@ test('the Cards form wires the keyset editor', () => {
assert.ok(html.includes('const ksErr = spKeysetGuard();'));
assert.ok(html.includes('const ksErr = ramKeysetGuard();'));
});
test('spCounterTracked follows SPI1 b5b4 (TS 102 225 5.1.1)', () => {
// b5b4 = 00: "present, ignored, never updated" - no counter to track
for (const spi1 of ['00', '01', '06', '']) {
assert.strictEqual(spCounterTracked(spi1), false, spi1);
}
for (const spi1 of ['08', '10', '16', '18', '1E']) {
assert.strictEqual(spCounterTracked(spi1), true, spi1);
}
assert.strictEqual(spCounterTracked('zz'), false);
});
+91
View File
@@ -0,0 +1,91 @@
const { test } = require('node:test');
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
function extractFunc(src, name) {
const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
const m = re.exec(src);
if (!m) throw new Error('function ' + name + ' not found');
let i = m.index + m[0].length - 1;
let depth = 0;
for (; i < src.length; i++) {
if (src[i] === '{') depth++;
else if (src[i] === '}') {
depth--;
if (depth === 0) break;
}
}
return src.slice(m.index, i + 1);
}
let code = '';
for (const fn of ['esc', 'escHtml', 'ramTarVerdictLabel', 'ramTarProbeHtml']) {
code += extractFunc(html, fn) + '\n';
}
code += 'function t(s){return s;}\n';
eval(code);
const DATA = {
success: true,
registered: 2,
total: 4,
final_cntr: '0000000187',
results: [
{ tar: '000000', label: 'Issuer Security Domain (compact)', verdict: 'registered',
sw: '9000', por_status: 'por_ok', por_sw: '6D00', por_data: '' },
{ tar: 'B00000', label: 'UICC shared file system RFM (compact)', verdict: 'registered',
sw: '9000', por_status: 'por_ok', por_sw: '6B00', por_data: '3F00' },
{ tar: 'B00001', label: 'ADF RFM (compact)', verdict: 'no_por',
sw: '9000', por_status: 'no_por', por_sw: null, por_data: null },
{ tar: 'B00200', label: 'RFU (control)', verdict: 'refused',
sw: '6200', por_status: 'envelope_error', por_sw: null, por_data: null },
],
};
test('ramTarVerdictLabel names every verdict', () => {
assert.strictEqual(ramTarVerdictLabel('registered'), 'registered');
assert.strictEqual(ramTarVerdictLabel('no_answer'), 'accepted, no answer');
assert.strictEqual(ramTarVerdictLabel('no_por'), 'accepted, no PoR');
assert.strictEqual(ramTarVerdictLabel('refused'), 'refused');
assert.strictEqual(ramTarVerdictLabel('something'), 'something');
});
test('ramTarProbeHtml renders the summary and one row per TAR', () => {
const out = ramTarProbeHtml(DATA);
assert.match(out, /Registered applications: <b>2 \/ 4<\/b>/);
assert.match(out, /counter 0000000187/);
for (const r of DATA.results) {
assert.ok(out.includes(r.tar), r.tar);
}
assert.ok(out.includes('6D00') && out.includes('6B00'), 'the application SWs are shown');
assert.ok(out.includes('6200'), 'the refused ENVELOPE SW is shown');
// the verdict colours: registered = green, no_por = amber, refused = red
assert.match(out, /text-emerald-700 dark:text-emerald-400">registered/);
assert.match(out, /text-amber-600 dark:text-amber-400">accepted, no PoR/);
assert.match(out, /text-red-600 dark:text-red-400">refused/);
// an empty payload renders nothing
assert.strictEqual(ramTarProbeHtml(null), '');
assert.strictEqual(ramTarProbeHtml({}), '');
});
test('the probe escapes what the card returned', () => {
const out = ramTarProbeHtml({ results: [
{ tar: '<b>x', label: '<i>y', verdict: 'registered', sw: '<', por_sw: '>', por_data: '&' },
], registered: 1, total: 1 });
assert.ok(!out.includes('<b>x'), 'the TAR must be escaped');
assert.ok(!out.includes('<i>y'), 'the label must be escaped');
});
test('the RAM form wires the TAR probe', () => {
assert.match(html, /<option value="tar-probe" data-l10n="Probe TARs \(registered OTA applications\)">/);
assert.match(html, /id="ram-tar-rows" class="hidden mb-3"/);
assert.match(html, /id="ram-tar-apdu"[^>]*value="00A40000023F00"/);
assert.match(html, /id="ram-tar-list"/);
assert.ok(html.includes("setHidden('ram-tar-rows', !isProbe);"));
assert.ok(html.includes("else if (op === 'tar-probe') await ramTarProbe(sp);"));
assert.ok(html.includes("pysimFetch('/api/tar-probe', body)"));
assert.ok(html.includes("if (data.final_cntr) ramSaveCntr(data.final_cntr);"));
});
+173 -25
View File
@@ -1176,17 +1176,44 @@ def _ota_keyset(spi1, spi2, kic, kid, cntr_hex, kic_key_hex, kid_key_hex):
from osmocom.utils import h2b
kic_b = int(kic, 16)
kid_b = int(kid, 16)
spi = _spi_from_bytes(int(spi1, 16), int(spi2, 16))
algo_crypt = _CRYPT_ALGO.get(kic_b & 0x0F)
algo_auth = _AUTH_ALGO.get(kid_b & 0x0F)
# TS 102 225 A.2: '00' is a valid KIc value when no ciphering is applied
# (SPI1.b3=0) and a valid KID value when no RC/CC/DS is applied
# (SPI1.b2b1=00) - a no-security packet carries no algorithm at all. The
# algorithm is only *needed* when it is actually used, so an unknown nibble
# is refused only then and a placeholder is passed to pySim otherwise (its
# key is never touched).
needs_crypt = spi['ciphering'] or spi['por_shall_be_ciphered']
needs_auth = (spi['rc_cc_ds'] != 'no_rc_cc_ds'
or spi['por_rc_cc_ds'] != 'no_rc_cc_ds')
if algo_crypt is None:
raise ValueError('Unsupported KIc algorithm nibble %02X' % (kic_b & 0x0F))
if needs_crypt or (kic_b & 0x0F):
raise ValueError('Unsupported KIc algorithm nibble %02X' % (kic_b & 0x0F))
# 'implicit' codes the nibble as 0, which is exactly the spec's value
algo_crypt = 'implicit'
if algo_auth is None:
raise ValueError('Unsupported KID algorithm nibble %02X' % (kid_b & 0x0F))
if needs_auth or (kid_b & 0x0F):
raise ValueError('Unsupported KID algorithm nibble %02X' % (kid_b & 0x0F))
algo_auth = 'implicit'
return OtaKeyset(algo_crypt=algo_crypt, kic_idx=kic_b >> 4, kic=h2b(kic_key_hex),
algo_auth=algo_auth, kid_idx=kid_b >> 4, kid=h2b(kid_key_hex),
cntr=int(cntr_hex, 16) if cntr_hex else 0)
def _counter_tracked(spi1_hex):
"""True when the packet asks the card to check the counter (TS 102 225
5.1.1, SPI1.b5b4 = 01/10/11). With b5b4 = 00 the field is "present,
ignored, never updated" - the card keeps no replay value for the packet,
so no counter may be advanced or persisted (a keyless SPI1 0x00 send must
leave every preset counter untouched)."""
try:
return ((int(spi1_hex, 16) >> 3) & 0x03) != 0
except (TypeError, ValueError):
return False
def _ota_reference(spi1, spi2, kic, kid, tar_hex, cntr_hex, apdu_hex, kic_key_hex, kid_key_hex):
from pySim.ota import OtaDialectSms
from osmocom.utils import h2b, b2h
@@ -1467,8 +1494,10 @@ def _preset_counter_persist(server, preset_id, cntr, source, kvn=None):
card state, so the server writes it with the operation that consumed it - a
closed tab, a lost response or a second browser window can no longer lose
an increment. The store itself is monotonic (a stale value never regresses
it). Best effort: a store failure must never fail the card operation."""
if not preset_id or not cntr:
it). A key version is mandatory: a packet with no key version (KIc/KID
'00', or a SPI1 without a counter check) has no counter to write. Best
effort: a store failure must never fail the card operation."""
if not preset_id or not cntr or not kvn:
return
store = getattr(server, 'card_presets', None)
if store is None:
@@ -1670,12 +1699,14 @@ def _ram_send_gp_apdu(server, scc, sp, state, step_name, apdu_hex, silent=False)
step_name, por_src, step.get('por_status', '?'), step.get('por_sw', '-'),
(' error=%s' % step_error) if step_error else '',
result['bytes'], result['segments']))
# Advance the counter only for an accepted packet
state['cntr'] = _ram_next_cntr(
state['cntr'], step.get('por_status') in ('por_ok', 'no_por'))
# ... and persist it right away: the server owns the counter now, so a
# crash mid-install cannot leave the preset behind the card (v3.8.0).
if step.get('por_status') in ('por_ok', 'no_por'):
# Advance the counter only for an accepted packet, and only when the
# packet asks the card to check it (TS 102 225 5.1.1 b5b4): a SPI1
# without a counter check leaves the card's replay value untouched, so
# the tool must not advance or persist anything either.
if step.get('por_status') in ('por_ok', 'no_por') and _counter_tracked(sp.get('spi1')):
state['cntr'] = _ram_next_cntr(state['cntr'], True)
# ... and persist it right away: the server owns the counter now,
# so a crash mid-install cannot leave the preset behind the card.
_preset_counter_persist(server, state.get('preset_id'),
state['cntr'], 'ram-%s' % step_name.split()[0].lower(),
state.get('kvn'))
@@ -5166,6 +5197,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/sp-verify', '/api/menu-select',
'/api/menu-respond', '/api/event-send', '/api/net-sim',
'/api/net-state-refresh', '/api/status-poll', '/api/rescue',
@@ -5189,6 +5221,40 @@ _CARD_FREE_GET = frozenset([
'/api/scp81/log', '/api/bip/log',
])
# TAR values allocated by ETSI (TS 101 220 V18.3.0 Annex D, Table D.1): the
# TAR probe sends one harmless command to each and reports the card's answer.
# 'B00200' is not an application - it is the start of the RFU range, kept as a
# control (a card must not answer it).
TAR_PROBE_TARS = [
('000000', 'Issuer Security Domain (compact)'),
('B20100', 'Issuer Security Domain (expanded)'),
('B00000', 'UICC shared file system RFM (compact)'),
('B00001', 'ADF RFM (compact)'),
('B00010', 'SIM file system RFM (compact)'),
('B00120', 'UICC shared file system RFM (expanded)'),
('B00130', 'SIM file system RFM (expanded)'),
('B00140', 'ADF RFM (expanded)'),
('B20000', 'USAT interpreter'),
('B20200', 'Multiplexing application'),
('B20201', 'Controlling Authority Security Domain'),
('B20202', 'OMA BCAST audience measurement'),
('B20203', 'OMA DM LWM2M application'),
('B00200', 'RFU (control)'),
]
TAR_PROBE_DEFAULT_APDU = '00A40000023F00' # SELECT MF: harmless, applet-visible
def _tar_probe_verdict(step):
"""Classify one TAR probe step: the card's ENVELOPE verdict plus the PoR
the registered application answered with."""
pstatus = str(step.get('por_status') or '')
if pstatus == 'por_ok':
return 'registered' if (step.get('por_sw') or step.get('por_data')) else 'no_answer'
if pstatus == 'no_por':
return 'no_por'
return 'refused'
_TEST_COMMAND_TYPES = {name.upper(): code for code, name in PROACTIVE_TYPE_NAMES.items()}
@@ -5424,11 +5490,12 @@ def _test_run_scp80(server, step, ctx):
sent = 'SCP80 %s TAR=%s SPI1=%s SPI2=%s cntr=%s%s' % (
source, tar or '-', spi1, spi2, counter,
(' kvn=%d' % kvn) if kvn else '')
if sw:
# The card answered, so the SCP80 counter was consumed: advance the
# working value of this key version and persist it into the server-side
# preset (v3.8.0 - the server owns the counters, so the run survives a
# lost response and the PWA's counter write-back dance is gone).
if sw and _counter_tracked(spi1):
# The card answered and the packet asks for a counter check, so the
# SCP80 counter was consumed: advance the working value of this key
# version and persist it into the server-side preset (v3.8.0 - the
# server owns the counters, so the run survives a lost response and the
# PWA's counter write-back dance is gone).
if kvn:
counters[kvn] = _increment_counter_hex(counter)
ctx['counter'] = counters[kvn]
@@ -6865,6 +6932,14 @@ class PysimHandler(BaseHTTPRequestHandler):
self._send_json(resp, 400)
self._log_resp(resp)
return
# Only a packet whose SPI1 asks the card to check the counter has a
# counter to track (TS 102 225 5.1.1 b5b4): with b5b4 = 00 (the
# keyless SPI1 00 case) the field is ignored and never updated, so
# no counter is advanced or persisted.
counter_tracked = _counter_tracked(body.get('spi1', ''))
if body.get('cntr') and not counter_tracked:
sys.stderr.write('OTA SEND: counter not tracked (SPI1 %s: '
'no counter check)\n' % body.get('spi1', ''))
include_cpi = body.get('includeCpi', True)
ram_format = None
try:
@@ -6929,7 +7004,7 @@ class PysimHandler(BaseHTTPRequestHandler):
resp = result
if ram_format is not None:
resp['ram_format'] = ram_format
if _counter_valid(cntr):
if _counter_valid(cntr) and counter_tracked:
resp['final_cntr'] = cntr
# A warning ENVELOPE may still carry the PoR (the
# card's counter verdict): decode it so the caller
@@ -6972,10 +7047,12 @@ class PysimHandler(BaseHTTPRequestHandler):
body.get('kid', ''), body.get('cntr', ''), body.get('kicKey', ''),
body.get('kidKey', ''), por_hex)
# Check for SPI2=0x21 (PoR required) but got 9000 with no PoR → card refuses PoR
is_ram = bool(apdu)
por_required = bool(spi2_val & 0x01)
no_por_received = not por_hex and not (submit_handler and submit_handler.submit_tpdu_hex)
if is_ram and por_required and result['sw'] == '9000' and no_por_received:
if por_required and result['sw'] == '9000' and no_por_received:
# the PoR was requested and the card did not answer -
# true for pre-built packets (the keyless form) too
resp['por_missing'] = True
sys.stderr.write('WARNING: Card refused to return PoR - ENVELOPE returned 9000 with no response data\n')
sys.stderr.write('RAM RESPONSE-PACKET: %s\n' % (por_hex if por_hex else 'empty'))
if por:
@@ -7005,17 +7082,19 @@ class PysimHandler(BaseHTTPRequestHandler):
if ram_format is not None:
resp['ram_format'] = ram_format
# The counter to use next, reported for the RAM and the
# plain SCP80 path alike; the server persists it (v3.8.0).
# A pre-built packet may come without a counter - then
# nothing is reported (and int('') never happens).
if _counter_valid(cntr):
# plain SCP80 path alike; the server persists it
# (v3.8.0). A pre-built packet may come without a
# counter, and a packet whose SPI1 does not ask the card
# to check it has none to track - nothing is reported
# then (and int('') never happens).
if _counter_valid(cntr) and counter_tracked:
resp['final_cntr'] = _ram_next_cntr(cntr, accepted)
finally:
if submit_handler and hasattr(scc, '_tp'):
scc._tp.proactive_handler = old_proactive
_preset_counter_persist(self.server, body.get('preset_id'),
resp.get('final_cntr'), 'send-ota',
kvn or None)
if counter_tracked:
_preset_counter_persist(self.server, body.get('preset_id'),
resp.get('final_cntr'), 'send-ota', kvn)
self._send_json(resp)
self._log_resp(resp)
except Exception as e:
@@ -7557,6 +7636,75 @@ class PysimHandler(BaseHTTPRequestHandler):
resp = _test_state_snapshot()
self._send_json(resp)
self._log_resp(resp)
elif self.path == '/api/tar-probe':
app = self.server.app
scc = self.server.scc
if not app or 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)
kic = body.get('kic', '')
kid = body.get('kid', '')
kvn, keyset_err = self._keyset_guard(body, kic, kid)
if keyset_err:
err = {'success': False, 'error': keyset_err}
self._send_json(err, 400)
self._log_resp(err)
return
# The probe sends real secured packets: it needs a counter check
# (every accepted packet advances and is persisted) and a PoR.
spi1 = str(body.get('spi1') or '16').strip().upper()
spi2 = str(body.get('spi2') or '01').strip().upper()
if not _counter_tracked(spi1):
err = {'success': False,
'error': 'the TAR probe needs a counter check - SPI1 %s has '
'none (b5b4 = 00)' % spi1}
self._send_json(err, 400)
self._log_resp(err)
return
apdu = (body.get('apdu') or TAR_PROBE_DEFAULT_APDU).replace(' ', '').upper()
labels = {tar: label for tar, label in TAR_PROBE_TARS}
wanted = body.get('tars')
if isinstance(wanted, list) and wanted:
entries = []
for t in wanted:
tar = str(t).replace(' ', '').upper()
entries.append((tar, labels.get(tar, '')))
else:
entries = list(TAR_PROBE_TARS)
state = {'steps': [], 'encode_error': None, 'failure': {},
'cntr': body.get('cntr', '00000000'),
'preset_id': body.get('preset_id'), 'kvn': kvn}
sp = {'spi1': spi1, 'spi2': spi2, 'kic': kic, 'kid': kid, 'tar': '000000',
'kic_key': body.get('kicKey', ''), 'kid_key': body.get('kidKey', ''),
'include_cpi': body.get('includeCpi', True)}
sys.stderr.write('TAR-PROBE: %d TAR(s), APDU %s, SPI %s/%s, kvn=%s\n'
% (len(entries), apdu, spi1, spi2, kvn))
_ram_progress_begin('tar-probe', len(entries))
try:
for index, (tar, _label) in enumerate(entries):
_ram_progress_step(index, 'TAR ' + tar)
sp['tar'] = tar
_ram_send_gp_apdu(self.server, scc, sp, state, 'TAR ' + tar, apdu)
finally:
_ram_progress_end()
results = []
for (tar, label), step in zip(entries, state['steps']):
results.append({
'tar': tar, 'label': label, 'verdict': _tar_probe_verdict(step),
'sw': step.get('sw'), 'por_status': step.get('por_status'),
'por_sw': step.get('por_sw'), 'por_data': step.get('por_data'),
'bytes': step.get('bytes'), 'segments': step.get('segments'),
})
registered = sum(1 for r in results if r['verdict'] == 'registered')
resp = {'success': True, 'results': results, 'steps': state['steps'],
'registered': registered, 'total': len(results),
'final_cntr': state['cntr'], 'kvn': kvn,
'spi1': spi1, 'spi2': spi2, 'apdu': apdu}
self._send_json(resp)
self._log_resp(resp)
elif self.path == '/api/presets':
body = self._read_body()
self._log_req(body)
+104
View File
@@ -646,6 +646,110 @@ class PresetStoreHttpTests(unittest.TestCase):
self.assertEqual(status, 400, resp)
self.assertIn('key version 3 is not defined', resp['error'])
def test_a_keyless_send_leaves_every_counter_untouched(self):
# SPI1 00: the counter is "present, ignored, never updated"
# (TS 102 225 5.1.1 b5b4=00) - the server must not report, advance or
# persist any counter for such a packet
p = self.store.add(self._preset(keysets=[
self._keyset(kic='15', kid='15', cntr='0000000005'),
self._keyset(kic='29', kid='29', kicKey='CC', kidKey='DD', cntr='0000000007')]))
before = [ks['cntr'] for ks in self.store.get(p['id'])['keysets']]
patches = [
mock.patch.object(self.srv, '_send_secured_packet', lambda *a, **k: {
'success': True, 'sw': '9000', 'bytes': 18, 'segments': 1,
'response_data': ''}),
mock.patch.object(self.srv, '_decode_por', lambda *a, **k: None),
]
self.server.app = object()
for patch in patches:
patch.start()
try:
status, resp = self._post('/api/send-ota', {
'sp': '00', 'spi1': '00', 'spi2': '01', 'kic': '00', 'kid': '00',
'tar': 'B00000', 'cntr': '00000000AA', 'preset_id': p['id']})
finally:
for patch in reversed(patches):
patch.stop()
self.assertEqual(status, 200, resp)
self.assertNotIn('final_cntr', resp)
self.assertEqual([ks['cntr'] for ks in self.store.get(p['id'])['keysets']], before)
self.assertFalse(self.store.audit_path.exists(),
'a keyless send must not write a counter at all')
def test_a_no_security_packet_is_built_with_zero_kic_kid(self):
# TS 102 225 A.2: KIc/KID '00' are valid with SPI1 00 - the server
# builds the packet with no keys at all (the card accepted this exact
# form; a fake KIc/KID 15/15 is refused with 6200)
sent = []
patches = [
mock.patch.object(self.srv, '_send_secured_packet',
lambda scc, sp_hex, **kw: (sent.append(sp_hex), {
'success': True, 'sw': '9000', 'bytes': 18,
'segments': 1, 'response_data': ''})[1]),
mock.patch.object(self.srv, '_decode_por', lambda *a, **k: None),
]
self.server.app = object()
for patch in patches:
patch.start()
try:
status, resp = self._post('/api/send-ota', {
'apdu': '00A40000023F00', 'spi1': '00', 'spi2': '01',
'kic': '00', 'kid': '00', 'tar': 'B00000', 'cntr': '0000000001',
'kicKey': '', 'kidKey': ''})
finally:
for patch in reversed(patches):
patch.stop()
self.assertEqual(status, 200, resp)
self.assertEqual(sent[0].lower(), '0d00010000b0000000000000010000a40000023f00')
def test_the_tar_probe_classifies_each_tar(self):
p = self.store.add(self._preset(iccid='8970119000004600098', keysets=[
self._keyset(kic='25', kid='25', kicKey='AA', kidKey='BB', cntr='0000000005')]))
seen = []
def fake_step(server, scc, sp, state, name, apdu, silent=False):
tar = sp['tar']
seen.append(tar)
if tar == '000000':
state['steps'].append({'name': name, 'sw': '9000', 'por_status': 'por_ok',
'por_sw': '6D00', 'por_data': ''})
elif tar == 'B00000':
state['steps'].append({'name': name, 'sw': '9000', 'por_status': 'por_ok',
'por_sw': '6B00', 'por_data': '3F00'})
elif tar == 'B00001':
state['steps'].append({'name': name, 'sw': '9000', 'por_status': 'no_por'})
else:
state['steps'].append({'name': name, 'sw': '6200', 'por_status': 'envelope_error'})
return True
self.server.app = object() # the probe only checks truthiness
with mock.patch.object(self.srv, '_ram_send_gp_apdu', fake_step):
status, resp = self._post('/api/tar-probe', {
'preset_id': p['id'], 'kic': '25', 'kid': '25',
'kicKey': 'AA', 'kidKey': 'BB', 'cntr': '0000000005',
'tars': ['000000', 'B00000', 'B00001', 'B00200']})
self.assertEqual(status, 200, resp)
self.assertEqual(seen, ['000000', 'B00000', 'B00001', 'B00200'])
verdicts = {r['tar']: r['verdict'] for r in resp['results']}
self.assertEqual(verdicts, {'000000': 'registered', 'B00000': 'registered',
'B00001': 'no_por', 'B00200': 'refused'})
self.assertEqual(resp['registered'], 2)
self.assertEqual(resp['total'], 4)
self.assertEqual(resp['kvn'], 2)
self.assertEqual(resp['results'][0]['label'], 'Issuer Security Domain (compact)')
self.assertEqual(resp['results'][0]['por_sw'], '6D00')
def test_the_tar_probe_needs_a_counter_check_and_a_defined_keyset(self):
self.server.app = object()
status, resp = self._post('/api/tar-probe', {'spi1': '00', 'kic': '15', 'kid': '15'})
self.assertEqual(status, 400, resp)
self.assertIn('counter check', resp['error'])
p = self.store.add(self._preset(iccid='8970119000004600098'))
status, resp = self._post('/api/tar-probe',
{'preset_id': p['id'], 'kic': '35', 'kid': '35'})
self.assertEqual(status, 400, resp)
self.assertIn('not defined', resp['error'])
def test_a_store_write_failure_answers_a_json_500(self):
with mock.patch.object(self.store, 'add', side_effect=OSError('read-only file system')):
status, resp = self._post('/api/presets', self._preset())
+52
View File
@@ -2071,3 +2071,55 @@ class RamProgressTests(unittest.TestCase):
self.assertGreaterEqual(p['elapsed'], 0)
srv._ram_progress_end()
self.assertFalse(srv._ram_progress_payload()['active'])
class NoSecurityAndCounterTrackingTests(unittest.TestCase):
"""The keyless packet form and the counter-tracking rule (v3.8.0).
TS 102 225 A.2: KIc '00' is valid when no ciphering is applied (SPI1.b3=0)
and KID '00' when no RC/CC/DS is applied (SPI1.b2b1=00) - a no-security
packet carries no algorithm and no keys. TS 102 225 5.1.1 b5b4: with '00'
the counter is "present, ignored, never updated", so no counter may be
advanced or persisted for such a packet."""
def test_zero_kic_kid_build_the_keyless_packet(self):
from pysim_simple_server import server as S
sp, spi = S._build_secured_packet('00', '01', '00', '00', 'B00000',
'0000000001', '00A40000', '', '')
self.assertEqual(sp.lower(), '0d00010000b0000000000000010000a40000')
self.assertFalse(spi['ciphering'])
self.assertEqual(spi['rc_cc_ds'], 'no_rc_cc_ds')
def test_the_algorithm_is_required_only_when_it_is_used(self):
from pysim_simple_server import server as S
# ciphering on (SPI1.b3) with an unknown KIc nibble: refused
with self.assertRaises(ValueError):
S._ota_keyset('04', '01', '00', '15', '0000000001', '', '')
# CC on (SPI1.b2b1) with an unknown KID nibble: refused
with self.assertRaises(ValueError):
S._ota_keyset('16', '01', '15', '00', '0000000001', '', '')
# a ciphered PoR needs the KIc key too
with self.assertRaises(ValueError):
S._ota_keyset('00', '11', '00', '15', '0000000001', '', '')
# ... the same nibbles are fine when unused
S._ota_keyset('00', '01', '00', '00', '0000000001', '', '')
def test_counter_tracking_follows_the_spi(self):
from pysim_simple_server import server as S
for spi1, tracked in (('00', False), ('06', False), ('01', False),
('08', True), ('10', True), ('16', True), ('18', True),
('', False), ('zz', False)):
self.assertEqual(S._counter_tracked(spi1), tracked, spi1)
def test_persist_needs_a_key_version(self):
from pysim_simple_server import server as S
store = mock.Mock()
server = types.SimpleNamespace(card_presets=store)
S._preset_counter_persist(server, 'pid', '0000000002', 'send-ota', None)
S._preset_counter_persist(server, 'pid', '0000000002', 'send-ota', 0)
self.assertFalse(store.set_counter.called, 'no key version -> no write')
S._preset_counter_persist(server, 'pid', '0000000002', 'send-ota', 2)
store.set_counter.assert_called_once_with('pid', '0000000002', 'send-ota', 2)
if __name__ == '__main__':
unittest.main()