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
+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);"));
});