From 85cc4b95350b0a7b12377a0eac82c6be486718e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D1=82=D0=BE=D0=BD=20=D0=A2=D1=80=D0=BE=D1=88?= =?UTF-8?q?=D0=B8=D0=BD?= Date: Mon, 5 Oct 2026 22:22:50 +0300 Subject: [PATCH] v3.20.0: the server-owned SCP80 counter (an untouched field = the preset's next value) The counter an operation consumed was re-entered by the PWA on every send (the form field was filled from the preset and posted back), so a stale tab could send a value the card rejects (cntr_low), and typing in the field silently wrote the preset. Now the store is the single source of truth and the form field is a per-send override (Option B). - _preset_counter_seed: an explicit `cntr` in the request wins (a hand send, the counter probe or a pre-built packet may deliberately pick one); otherwise the preset store's value for the packet's keyset. Wired into /api/send-ota (the APDU path; the pre-built `sp` path keeps the caller's bookkeeping value - the packet carries its own counter), /api/ram-install, /api/ram-install-app, /api/cap-compat, /api/tar-probe and /api/sp-verify (the reference must use the counter the JS packet was built with). - the send-ota response echoes the effective `cntr` (a caller that omitted one learns what was used) and the OTA SEND log line prints it. - persistence is unchanged and monotonic (presets.set_counter refuses backwards writes), pinned by tests: an explicit lower counter is used for the packet but never lowers the store, a higher accepted one is saved. - PWA: the Secured packet counter field starts empty with the preset's next value as its placeholder (auto: ); typing affects that send only and is never written to the preset (spCntrSyncPreset is gone), spParams / getRamSpParams omit an empty counter, the RAM/send flows sync only the local preset copy (forward-only) instead of filling the field, the +/- buttons start from the effective value, and the preview/verify packet is built from the effective counter (genSp). Placeholder translated EN/RU. - the runner and /api/counter-probe keep their existing seeding (the probe already starts at the failed value and walks above it). - docs: api.md's optional-`cntr` contract + seeding notes on every endpoint, help EN/RU 3.1. Tests: 822 frontend + 734 Python; sw cache simple-v393. --- docs/api.md | 34 +++-- frontend/help-ru.html | 4 +- frontend/help.html | 4 +- frontend/index.html | 158 +++++++++++++++--------- frontend/sw.js | 2 +- frontend/tests/cards_counter.test.js | 39 ++++-- frontend/tests/cards_form.test.js | 27 ++-- frontend/tests/ram.test.js | 3 +- frontend/tests/ram_counter_flow.test.js | 5 +- frontend/tests/sp.test.js | 2 + pyproject.toml | 2 +- pysim_simple_server/server.py | 60 +++++++-- tests/test_fetch_routes.py | 54 ++++++++ tests/test_ota_helpers.py | 38 ++++++ 14 files changed, 331 insertions(+), 101 deletions(-) diff --git a/docs/api.md b/docs/api.md index 4dd40bf..7744247 100644 --- a/docs/api.md +++ b/docs/api.md @@ -341,7 +341,9 @@ resolves to neither is refused with a hint **before any packet is sent**. The resolved SPI1 must carry a counter check (b5b4 ≠ 00) and ask for a 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 or the named preset does not define the keyset number. +check or the named preset does not define the keyset number. A missing +`cntr` is seeded from that keyset (v3.20.0; an explicit value wins but never +lowers the stored counter). ```json {"preset_id": "…", "kic": "25", "kid": "25", "kicKey": "…", "kidKey": "…", @@ -506,8 +508,24 @@ scripts) must not be wrapped. `final_cntr` is reported for the plain `sp` path too (a pre-built packet): the counter that was sent, advanced by one when the card accepted the packet. -A request without a `cntr` (nothing to advance) simply reports no -`final_cntr` — it never fails the send. With `preset_id` the server persists + +**`cntr` is optional (v3.20.0)** — the preset store is the source of truth: + +- with `apdu` (the server builds the packet) a missing `cntr` is seeded from + the named preset's keyset (the one the packet's KIc/KID resolve to); the + effective value is echoed back as `cntr`, so a caller that omits it learns + what was used; +- an explicit `cntr` always wins — the packet uses it verbatim (a hand send, + the counter probe or a pre-built `sp` may deliberately pick one); +- the store only moves **forward**: an explicit value (or its next counter) + below the stored one is used for the packet but never lowers the store; a + higher accepted value is persisted; +- the `sp` (pre-built packet) path never seeds — the packet carries its own + counter — so pass `cntr` with it to get `final_cntr` / persistence. A + request with neither simply reports no `final_cntr` — it never fails the + send. + +With `preset_id` the server persists the counter into the keyset the packet used (see *Card presets*), so a lost response or a closed tab cannot lose the increment; the request is refused when KIc/KID carry different keyset numbers (TS 102 225 A.2) @@ -771,7 +789,7 @@ Clears a finished run report (409 while a run is active). ### `POST /api/ram-install` -Install a Java Card `.cap` file on the card via GlobalPlatform commands (INSTALL[for load] → LOAD ×N → INSTALL[for install (+ make selectable)]) wrapped in SCP80 secured packets. Each step is sent via ENVELOPE; the PoR verdict and the remote command's own status word are both checked and the sequence aborts on the first failure (a non-`por_ok` PoR, a remote SW outside the success set, or an undecodable PoR). The counter advances only for a packet the card accepted (PoR `por_ok`); `final_cntr` is returned on success **and** on failure, so the caller keeps the card's consumed counter (a rejected packet leaves it unchanged). The RAM command format is detected per operation: a read-only `GET STATUS [ISD]` probe (`FORMAT CHECK (compact)`, then `FORMAT CHECK (expanded)`) decides between the compact C-APDU and the expanded `AA`/`22` form (TS 102 226 §5.2.1), and every INSTALL/LOAD step of the chain then uses the detected format — the response carries `ram_format`. Pass `ram_format` (`compact`/`expanded`) to pin it. The **NV footprint** is measured around the chain: a best-effort `GET DATA FF21` (Extended Card Resources, TS 102 226 §8.2.1.7.2) read before the first step and again after the last one (also on failure) reports the free non-volatile memory and its delta — the read is silent (no step record, no failure) and a card without FF21 simply yields no fields. The `.cap` archive (a ZIP of nested components) is parsed server-side in `_cap_parse`; no external tooling is required. +Install a Java Card `.cap` file on the card via GlobalPlatform commands (INSTALL[for load] → LOAD ×N → INSTALL[for install (+ make selectable)]) wrapped in SCP80 secured packets. Each step is sent via ENVELOPE; the PoR verdict and the remote command's own status word are both checked and the sequence aborts on the first failure (a non-`por_ok` PoR, a remote SW outside the success set, or an undecodable PoR). The counter advances only for a packet the card accepted (PoR `por_ok`); `final_cntr` is returned on success **and** on failure, so the caller keeps the card's consumed counter (a rejected packet leaves it unchanged). A missing `cntr` is seeded from the preset's keyset (v3.20.0); an explicit value wins but never lowers the stored counter (only forward moves are persisted). The RAM command format is detected per operation: a read-only `GET STATUS [ISD]` probe (`FORMAT CHECK (compact)`, then `FORMAT CHECK (expanded)`) decides between the compact C-APDU and the expanded `AA`/`22` form (TS 102 226 §5.2.1), and every INSTALL/LOAD step of the chain then uses the detected format — the response carries `ram_format`. Pass `ram_format` (`compact`/`expanded`) to pin it. The **NV footprint** is measured around the chain: a best-effort `GET DATA FF21` (Extended Card Resources, TS 102 226 §8.2.1.7.2) read before the first step and again after the last one (also on failure) reports the free non-volatile memory and its delta — the read is silent (no step record, no failure) and a card without FF21 simply yields no fields. The `.cap` archive (a ZIP of nested components) is parsed server-side in `_cap_parse`; no external tooling is required. **Request body:** ```json @@ -890,13 +908,13 @@ Run the single **INSTALL [for install]** (or **INSTALL [for make selectable]**) | `install_params` / `stk_params` | no | As in `/api/ram-install` (the PWA composes `C9`+`EF`+raw and appends the STK part) | | `ram_format` | no | As in `/api/ram-install`: `auto` (default — a read-only probe step decides), `compact` or `expanded` | -**Response:** `{"success": bool, "steps": [...], "final_cntr": "...", "ram_format": "...", "error": "...", "failed_step": N}` — the same step records as `/api/ram-install`. +**Response:** `{"success": bool, "steps": [...], "final_cntr": "...", "ram_format": "...", "error": "...", "failed_step": N}` — the same step records as `/api/ram-install`. A missing `cntr` is seeded from the preset's keyset (v3.20.0; an explicit value wins but never lowers the stored counter). ### `POST /api/cap-compat` Run the **CAP compatibility test**: the format check, `INSTALL [for load]` and the LOAD blocks only up to the block that completes the **Import** component (the point where the JCRE verifies the import list, JC VM spec 4.5.2), then stop — no last-block flag and no `INSTALL [for install]`, so nothing is committed and nothing has to be deleted afterwards. The verdict (`imports_ok`) covers the **LOAD/import gate only**; the link gate is what a real install checks at `INSTALL [for install]`. -**Request body:** as `/api/ram-install` (`cap_hex`, the SCP80 fields, optional `load_block_size` and `ram_format`), plus: +**Request body:** as `/api/ram-install` (`cap_hex`, the SCP80 fields, optional `load_block_size` and `ram_format`) — a missing `cntr` is seeded from the preset's keyset (v3.20.0) — plus: | Field | Req | Description | |---|---|---| @@ -911,7 +929,9 @@ Run the **CAP compatibility test**: the format check, `INSTALL [for load]` and t Cross-check a secured packet against pySim's `OtaDialectSms.encode_cmd` reference. Returns the JS-generated packet, pySim reference, a match flag, -and the decoded SPI fields. +and the decoded SPI fields. A missing `cntr` is seeded from the named +preset's keyset (v3.20.0) — the same rule the PWA's builder uses, so a +reference for a packet built from an untouched counter field matches. ```json {"spi1": "16", "spi2": "01", "kic": "15", "kid": "15", "tar": "b00000", diff --git a/frontend/help-ru.html b/frontend/help-ru.html index 6b68e1b..b56c91b 100644 --- a/frontend/help-ru.html +++ b/frontend/help-ru.html @@ -282,7 +282,7 @@

Верхнеуровневая вкладка SCP80 объединяет разделы, связанные с SCP80. Переключение — тремя переключателями: Secured Packet, RAM и TAR-сканер. Собирает защищённые пакеты SCP80 по ETSI TS 102 225. При подключении карты сервер читает её EF.ICCID (2FE2); если в пресете указан тот же номер (цифрами или сырым hex из EF), он автоматически выбирается во всех трёх представлениях SCP80. Вердикт низкого счётчика (cntr_low или предупреждающий ответ ENVELOPE, в котором всё ещё есть такой PoR) не исправляется повторной отправкой: любая операция SCP80/RAM немедленно останавливается. Карта не сообщает свой счётчик (поле CNTR в PoR — копия счётчика пакета), поэтому в уведомлении есть ограниченный пробник «Синхронизировать счётчик» — read-only команда с возрастающими значениями счётчика (шаг удваивается, потолок 32 бита, не более 40 пакетов), пока карта не примет одно; затем сохраняется принятое+1 (поиск, остановленный вердиктом безопасности после отказа по счётчику, тоже сохраняет значение — с янтарным предупреждением проверить настройки безопасности) — и кнопка К пресету для ручного повышения; больше ничего автоматически не записывается.

3.1 Secured Packet

-

Собирает защищённые пакеты SCP80 по ETSI TS 102 225. Селектор Keyset рядом с выбором пресета указывает, из какого keyset берутся ключи и счётчик; отправка отклоняется, если в пресете нет такого номера keyset или KIc/KID несут разные номера (TS 102 225 A.2). Селектор недоступен, пока не выбран пресет карты. Кнопка запуска недоступна, пока не выбран пресет карты и один из его keysets. Поле TAR предлагает записи TAR пресета (с описанием и MSL); при применении пресета TAR операции подставляется, а SPI1 берётся из его MSL (минимальный SPI1, TS 102 226 §8.2.1.3.2.4 — карта проверяет его до обработки защиты и отвечает 0A, если SPI1 пакета ниже). Значение ниже MSL показывает жёлтое предупреждение под формой; введённый вручную TAR сохраняет SPI1 оператора.

+

Собирает защищённые пакеты SCP80 по ETSI TS 102 225. Селектор Keyset рядом с выбором пресета указывает, из какого keyset берутся ключи и счётчик; отправка отклоняется, если в пресете нет такого номера keyset или KIc/KID несут разные номера (TS 102 225 A.2). Селектор недоступен, пока не выбран пресет карты. Кнопка запуска недоступна, пока не выбран пресет карты и один из его keysets. Поле TAR предлагает записи TAR пресета (с описанием и MSL); при применении пресета TAR операции подставляется, а SPI1 берётся из его MSL (минимальный SPI1, TS 102 226 §8.2.1.3.2.4 — карта проверяет его до обработки защиты и отвечает 0A, если SPI1 пакета ниже). Значение ниже MSL показывает жёлтое предупреждение под формой; введённый вручную TAR сохраняет SPI1 оператора. Поле Счётчик — это переопределение: оно пустое, а следующий счётчик пресета показывается подсказкой (placeholder) — пустое поле позволяет серверу взять счётчик из хранилища и сохранить принятое картой значение; введённое значение используется только для этой отправки и никогда не записывается в пресет, а меньшее значение не откатывает сохранённый счётчик (сохраняются только движения вперёд).

Структура пакета

@@ -311,7 +311,7 @@
  • AES требует счётчик с защитой от повтора: биты SPI1 b5 b4 должны быть 10 (счётчик больше) или 11 (счётчик +1) согласно TS 102 225 §5.1.2/§5.1.3.1
  • Байт паддинга настраивается (00 по умолчанию или FF)
  • -

    Кнопка Проверить в pySim сверяет собранный пакет с эталонной реализацией OtaDialectSms.encode_cmd. Кнопка Отправить на карту доставляет пакет через ENVELOPE SMS-PP-DOWNLOAD (при подключении к серверу). 2-байтовый префикс CPL передаётся, когда пакет зашифрован или несёт RC/CC/DS (входит в их входные данные), а также при конкатенации SMS; одиночное незащищённое SMS сохраняет форму pySim, начинающуюся с CHL (TS 31.115 Table 1 NOTE). Под полем пакета показывается его размер и число SMS; пакет, не помещающийся в одно SMS, отправляется конкатенированной загрузкой (TS 31.115 §4.3) — пакет делится на части SMS-пользовательских данных (первое SMS 132 октета, последующие по 134; первое дополнительно несёт информационные элементы конкатенации и CPI), и сегменты отправляются по порядку. Более 5 сегментов отклоняется: это практический предел буфера конкатенации карты. Полученный Proof of Receipt декодируется и показывается строкой статуса PoR (статус, TAR, счётчик, сырой PoR); статусное слово и данные ответа последней команды подставляются в подвкладку «Разбор R-APDU» (Remote APDU → «Разбор»), а успешный PoR увеличивает счётчик повторов (когда в SPI1 есть проверка счётчика — b5 b4 ≠ 00; без неё счётчик не увеличивается и не сохраняется) и очищает пакет.

    +

    Кнопка Проверить в pySim сверяет собранный пакет с эталонной реализацией OtaDialectSms.encode_cmd. Кнопка Отправить на карту доставляет пакет через ENVELOPE SMS-PP-DOWNLOAD (при подключении к серверу). 2-байтовый префикс CPL передаётся, когда пакет зашифрован или несёт RC/CC/DS (входит в их входные данные), а также при конкатенации SMS; одиночное незащищённое SMS сохраняет форму pySim, начинающуюся с CHL (TS 31.115 Table 1 NOTE). Под полем пакета показывается его размер и число SMS; пакет, не помещающийся в одно SMS, отправляется конкатенированной загрузкой (TS 31.115 §4.3) — пакет делится на части SMS-пользовательских данных (первое SMS 132 октета, последующие по 134; первое дополнительно несёт информационные элементы конкатенации и CPI), и сегменты отправляются по порядку. Более 5 сегментов отклоняется: это практический предел буфера конкатенации карты. Полученный Proof of Receipt декодируется и показывается строкой статуса PoR (статус, TAR, счётчик, сырой PoR); статусное слово и данные ответа последней команды подставляются в подвкладку «Разбор R-APDU» (Remote APDU → «Разбор»), а успешный PoR увеличивает счётчик повторов (когда в SPI1 есть проверка счётчика — b5 b4 ≠ 00; без неё счётчик не увеличивается и не сохраняется) и очищает пакет — сервер сохраняет принятый счётчик в пресет, а поле счётчика остаётся пустым (подсказка следует за сохранённым значением), если оператор не ввёл переопределение.

    3.2 RAM

    Выполняет операции удалённого управления приложениями (Remote Application Management) как защищённые пакеты SCP80 через SMS-PP-DOWNLOAD ENVELOPE. Карта должна поддерживать SCP03 (AES или 3DES). Пресет карты со вкладки Карты обеспечивает ключи, таблицу TAR и счётчик (RAM использует ISD TAR пресета, по умолчанию 000000; SPI1 берётся из MSL этого TAR). Селектор Keyset рядом с выбором пресета указывает, из какого keyset берутся ключи и счётчик операции. Селектор недоступен, пока не выбран пресет карты. Кнопка запуска недоступна, пока не выбран пресет карты и один из его keysets. Селектор Формат фиксирует формат удалённой команды: Авто (определение) опрашивает read-only команду GET STATUS в компактном виде и в обоих expanded-кодированиях и оставляет первое, которое карта выполнила; Компактный отправляет голую C-APDU; Expanded (AA, определённая длина) / Expanded (AE 80, неопределённая длина) заворачивают её в шаблон Command Scripting из TS 102 226 §5.2.1 (на живой карте выполняются обе; шаблон, у которого заявленная длина не совпадает с телом, получает Bad format TLV — операция показывает это как ошибку шага). Запросы листингов и реестра следуют этому формату: компактные листинги несут сцепленный GET RESPONSE, а expanded-прогон отправляет те же запросы P2=00 обёрнутыми и без цепочки (форма эталонного загрузчика TCA; Le=’00’ возвращает листинг сразу), и запрос реестра, отклонённый картой в текущем формате, повторяется в остальных — отчёт называет тот, который сработал. Страница следующего вхождения (P2=03), отклонённая форматом, тоже повторяется в остальных форматах — сначала expanded-кодирования (первым то, что обслужило предыдущую страницу, если оно expanded), компактная командная строка последней: компактная попытка страницы может израсходовать страницу, не доставив её (карта отвечает 61xx, а сцепленный GET RESPONSE отклоняется — все последующие форматы видят вхождение уже использованным) — некоторые карты отдают expanded-страницу только в шаблоне AA — и пометка (только первая страница) ставится, только если карта обещала продолжение (6310), а ни один формат не смог его получить; в режиме Авто проводник также опрашивает по AID записи, оставшиеся без деталей (несколько дополнительных пакетов). Блок ресурсов карты также называет предполагаемый релиз ETSI карты (версия uicc.toolkit → REL-N, то же сопоставление, что и в анализе CAP; показывается, только если расширенный реестр вернул версии). Пакет, чей собственный листинг не несёт версии, берёт её у своего экземпляра апплета, если он есть — модульные AID пакета это AID экземпляров апплета, и строка получает соответствующую пометку — так что карта, отклоняющая все формы со списком тегов, всё равно показывает версии пакетов везде, где есть экземпляр апплета.

    diff --git a/frontend/help.html b/frontend/help.html index 33d6e3e..1be3517 100644 --- a/frontend/help.html +++ b/frontend/help.html @@ -281,7 +281,7 @@

    The SCP80 top-level tab groups the SCP80-related views. It is switched by three pills: Secured Packet, RAM and TAR probe. Assembles SCP80 secured packets per ETSI TS 102 225. When a card is equipped the server reads its EF.ICCID (2FE2); if a card preset carries the same number (digits, or the raw EF hex), it is selected automatically in all three SCP80 views.

    3.1 Secured Packet

    -

    Builds SCP80 secured packets per ETSI TS 102 225. The Keyset selector next to the card preset selector picks which keyset of the preset supplies the keys and the counter; the send is refused when the preset does not define that keyset number or when its KIc/KID carry different numbers (TS 102 225 A.2). It is disabled until a card preset is selected. The start button stays disabled until a card preset and one of its keysets are selected. The packet's TAR field suggests the preset's TAR entries (each with its description and MSL); applying a preset sets the operation's TAR and seeds the SPI1 from that TAR's MSL (Minimum SPI1, TS 102 226 §8.2.1.3.2.4 — the card checks it before the security processing and answers 0A when the packet's SPI1 is below it). A value below the MSL shows an amber warning under the form; a hand-typed TAR keeps the operator's SPI1.

    +

    Builds SCP80 secured packets per ETSI TS 102 225. The Keyset selector next to the card preset selector picks which keyset of the preset supplies the keys and the counter; the send is refused when the preset does not define that keyset number or when its KIc/KID carry different numbers (TS 102 225 A.2). It is disabled until a card preset is selected. The start button stays disabled until a card preset and one of its keysets are selected. The packet's TAR field suggests the preset's TAR entries (each with its description and MSL); applying a preset sets the operation's TAR and seeds the SPI1 from that TAR's MSL (Minimum SPI1, TS 102 226 §8.2.1.3.2.4 — the card checks it before the security processing and answers 0A when the packet's SPI1 is below it). A value below the MSL shows an amber warning under the form; a hand-typed TAR keeps the operator's SPI1. The Counter field is an override: it starts empty and shows the preset's next counter as its placeholder — an empty field lets the server seed the packet from the stored counter and persist the value the card accepted; a typed value is used for that send only, is never written to the preset, and a lower one never rewinds the stored counter (only forward moves are persisted).

    Packet structure

    ПолеРазмерОписание
    @@ -310,7 +310,7 @@
  • AES requires a replay-protected counter: SPI1 bits b5 b4 must be 10 (counter higher) or 11 (counter +1) per TS 102 225 §5.1.2/§5.1.3.1
  • Padding byte configurable (00 default, or FF)
  • -

    A “Verify vs pySim” button cross-checks the assembled packet against pySim’s reference OtaDialectSms.encode_cmd. A “Send to Card” button delivers it via SMS-PP-DOWNLOAD ENVELOPE (when connected to the server). The 2-byte CPL prefix is transmitted whenever the packet is ciphered or carries RC/CC/DS (it is part of their input) and whenever the packet is concatenated; a single unprotected SMS keeps pySim’s CHL-first form (TS 31.115 Table 1 NOTE). Under the packet field the app reports its size and how many SMS it takes; a packet that does not fit one SMS is sent as a concatenated download (TS 31.115 §4.3) — the packet is split into SMS user-data parts (first SM 132 octets, following ones 134; the first one additionally carries the concatenation and CPI information elements) and the segments are sent in order. More than 5 segments is refused, that being the practical limit of the card’s concatenation buffer. The returned Proof of Receipt is decoded and shown as a PoR status line (status, TAR, counter, raw PoR); the last command’s status word and response data are filled into the R-APDU parser panel, and a successful PoR advances the replay counter (when SPI1 carries a counter check — b5 b4 ≠ 00; without one no counter is advanced or saved) and clears the packet. A low-counter verdict (cntr_low, or a warning ENVELOPE answer that still carries such a PoR) is not recoverable by retrying: every SCP80/RAM operation stops immediately. The card never reports its counter (the PoR’s CNTR field is a copy of the packet’s), so the notice offers a bounded Sync counter probe — a read-only command with increasing counter values (doubling steps, a 32-bit ceiling, at most 40 packets) until the card accepts one, then accepted+1 is stored (a search stopped by a security verdict after a counter rejection saves the value too, with an amber warning to check the security settings) — and a Go to preset button for raising the counter by hand; nothing else is written automatically.

    +

    A “Verify vs pySim” button cross-checks the assembled packet against pySim’s reference OtaDialectSms.encode_cmd. A “Send to Card” button delivers it via SMS-PP-DOWNLOAD ENVELOPE (when connected to the server). The 2-byte CPL prefix is transmitted whenever the packet is ciphered or carries RC/CC/DS (it is part of their input) and whenever the packet is concatenated; a single unprotected SMS keeps pySim’s CHL-first form (TS 31.115 Table 1 NOTE). Under the packet field the app reports its size and how many SMS it takes; a packet that does not fit one SMS is sent as a concatenated download (TS 31.115 §4.3) — the packet is split into SMS user-data parts (first SM 132 octets, following ones 134; the first one additionally carries the concatenation and CPI information elements) and the segments are sent in order. More than 5 segments is refused, that being the practical limit of the card’s concatenation buffer. The returned Proof of Receipt is decoded and shown as a PoR status line (status, TAR, counter, raw PoR); the last command’s status word and response data are filled into the R-APDU parser panel, and a successful PoR advances the replay counter (when SPI1 carries a counter check — b5 b4 ≠ 00; without one no counter is advanced or saved) and clears the packet — the server persists the accepted counter into the preset, the form’s counter field stays empty (its placeholder follows the stored value) unless the operator typed an override. A low-counter verdict (cntr_low, or a warning ENVELOPE answer that still carries such a PoR) is not recoverable by retrying: every SCP80/RAM operation stops immediately. The card never reports its counter (the PoR’s CNTR field is a copy of the packet’s), so the notice offers a bounded Sync counter probe — a read-only command with increasing counter values (doubling steps, a 32-bit ceiling, at most 40 packets) until the card accepts one, then accepted+1 is stored (a search stopped by a security verdict after a counter rejection saves the value too, with an amber warning to check the security settings) — and a Go to preset button for raising the counter by hand; nothing else is written automatically.

    3.2 RAM

    Delivers Remote Application Management operations as SCP80 secured packets via SMS-PP-DOWNLOAD ENVELOPE. The card must support SCP03 (AES or 3DES). A saved card preset from the Cards tab provides the keys, the TAR table and the counter (RAM uses the preset's ISD TAR, 000000 by default; the SPI1 is derived from that TAR's MSL). The Keyset selector next to the preset selector picks the keyset whose keys and counter the operation uses. It is disabled until a card preset is selected. The start button stays disabled until a card preset and one of its keysets are selected. The Format selector pins the remote command format: Auto (detect) probes the read-only GET STATUS command compact and in both expanded codings and keeps the first that the card executes, Compact sends the bare C-APDU, and Expanded (AA, definite length) / Expanded (AE 80, indefinite length) wrap it in the TS 102 226 §5.2.1 scripting template (both execute on the live ISD; a template whose declared lengths do not match its body draws a Bad format TLV, which the operation shows as the step failure). The listing and registry queries follow that format: compact listings keep the chained GET RESPONSE, while an expanded run sends the same P2=00 queries wrapped and unchained (the reference TCA loader’s form, Le=’00’ returns the listing directly), and a registry query the card refuses in the current format is retried in the other formats — the report names the one that served it. A next-occurrence page (P2=03) that a format refuses is retried in the other formats too — the expanded codings first (the serving one first when it is expanded), the compact command string last, because a compact page attempt can consume the page without delivering it (the card answers 61xx and the chained GET RESPONSE fails; every later format then sees the occurrence already gone) — some cards serve the expanded next page only wrapped in the AA template — and a listing is marked (first page only) only when the card promised more (6310) and no format could fetch the follow-up; in Auto the explorer also queries entries the bulk listing left without details individually by AID (a few extra packets). The card-resources block also names the card’s inferred ETSI release (uicc.toolkit version → REL-N, the same mapping as the CAP analysis; shown only when the expanded registry returned the versions). A package whose own listing carries no version derives it from its applet instance when one exists — the package’s module AIDs are the applet instance AIDs, and the row is annotated as coming from the app instance — so a card that refuses every tag-list form still shows package versions wherever an applet instance exists.

    diff --git a/frontend/index.html b/frontend/index.html index 6dd6078..fcb0337 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -658,7 +658,7 @@
    - +
    @@ -1877,7 +1877,7 @@ // ===== Version ===== // Single source of truth for the PWA version: shown in the header and used // by the server version check in pysimConnect(). -const SIMPLE_VERSION = '3.19.2'; +const SIMPLE_VERSION = '3.20.0'; document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION; // ===== Tab switching ===== @@ -4822,7 +4822,9 @@ function packToSp(sourceId) { } function spCntrAdjust(delta) { const el = document.getElementById('sp-cntr'); - let val = parseInt(el.value.replace(/[^0-9a-fA-F]/g, ''), 16) || 0; + // The ± buttons start from the effective counter (typed override, else the + // preset's next value) and turn it into an explicit override. + let val = parseInt(spCntrEffective(), 16) || 0; val = Math.max(0, val + delta) % 0x10000000000; // wrap the 5-byte counter el.value = val.toString(16).padStart(10, '0').toUpperCase(); } @@ -5099,6 +5101,9 @@ function spShowSizeInfo() { } function _genSpBuild() { + // The counter the packet carries: the typed override, else the preset's + // next value (the server seeds the same way when the counter is omitted). + const cntrHex = spCntrEffective(); const apduHex = (document.getElementById('sp-apdu').value || '').replace(/[^0-9a-fA-F]/g, ''); if (!apduHex) { document.getElementById('sp-result').value = 'Error: specify APDU'; return; } @@ -5107,7 +5112,6 @@ function _genSpBuild() { const kicHex = document.getElementById('sp-kic-hex').value; const kidHex = document.getElementById('sp-kid-hex').value; const tarHex = (document.getElementById('sp-tar').value || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase().padEnd(6, '0').slice(0, 6); - const cntrHex = (document.getElementById('sp-cntr').value || '').replace(/[^0-9a-fA-F]/g, '').padStart(10, '0').slice(0, 10); const kicKeyHex = (document.getElementById('sp-kic-key').value || '').replace(/[^0-9a-fA-F]/g, ''); const kidKeyHex = (document.getElementById('sp-kid-key').value || '').replace(/[^0-9a-fA-F]/g, ''); const padByte = parseInt(document.getElementById('sp-padding').value, 16); @@ -8569,42 +8573,58 @@ function spPresetIdx() { return (!isNaN(idx) && cards[idx]) ? idx : -1; } -// The SCP80 form's counter input: a deliberate human edit, written to the -// server-side preset (v3.8.0). The counter an *operation* accepted is -// persisted by the server itself - the send/RAM flows only sync the local copy -// through spCntrLocalSync(). -function spCntrSyncPreset() { +// The SCP80 form's counter input is a per-send override (v3.20.0): an empty +// field means "let the server seed the counter from the preset store" - the +// store is the source of truth and advances monotonically on accepted +// packets; typing a value overrides it for that send only and is never +// written to the preset. The preset's next value shows as the placeholder. +function spPresetCounterValue() { const idx = spPresetIdx(); - if (idx < 0) return false; - const preset = cards[idx]; - const value = (document.getElementById('sp-cntr').value || '').trim().toUpperCase(); + if (idx < 0) return ''; const kvn = spKeysetKvnOf((document.getElementById('sp-kic-hex') || {}).value) || spKeysetKvnOf((document.getElementById('sp-kid-hex') || {}).value) || 0; - const ks = spKeysetFor(preset, kvn); - if (ks) ks.cntr = value; - cardsRender(); - if (preset.id && ks) { - pysimFetch('/api/presets/update', { id: preset.id, fields: { keysets: spKeysetList(preset) } }) - .then(res => { - if (res && res.error) throw new Error(res.error); - // the store normalises the counter (fixed 10-hex form) - if (res && res.preset) { cards[idx] = res.preset; cardsRender(); spKeysetSync(); } - }) - .catch(e => ioStatus('cards-io', t('Counter not saved:') + ' ' + e.message)); - } - return true; + // the same fallback cardsApplyFields uses: the matching keyset, else the + // preset's first one (an undefined duplicate would otherwise leave the + // placeholder empty) + const ks = (kvn && spKeysetFor(cards[idx], kvn)) || spKeysetList(cards[idx])[0] || null; + return (ks && ks.cntr) || ''; } -// Keep the selected preset in step with a counter the server already persisted -// (the operations report it as final_cntr): display only, no write. +function spCntrPlaceholder() { + const el = document.getElementById('sp-cntr'); + if (!el) return; + const v = spPresetCounterValue(); + el.placeholder = v ? t('auto') + ': ' + v : t('auto'); +} + +// The counter the packet is built with (preview / verify): the typed +// override, else the preset's next value, else the historic 1 default. +function spCntrEffective() { + const el = document.getElementById('sp-cntr'); + const typed = ((el && el.value) || '').replace(/[^0-9a-fA-F]/g, ''); + const v = typed || spPresetCounterValue() || '1'; + return v.toUpperCase().padStart(10, '0').slice(0, 10); +} + +// Numeric "b is ahead of a" for two hex counters (the store is monotonic). +function spCntrAhead(a, b) { + try { return parseInt(String(b), 16) > parseInt(String(a), 16); } + catch (e) { return false; } +} + +// Keep the selected preset's copy in step with a counter the server already +// persisted (the operations report it as final_cntr): display only, no write, +// and the copy only moves forward (the store is monotonic). function spCntrLocalSync(value) { const idx = spPresetIdx(); if (idx < 0) return; const kvn = spKeysetKvnOf((document.getElementById('sp-kic-hex') || {}).value) || spKeysetKvnOf((document.getElementById('sp-kid-hex') || {}).value) || 0; const ks = spKeysetFor(cards[idx], kvn); - if (ks) ks.cntr = value; + const v = String(value || '').toUpperCase(); + if (ks && v && (!ks.cntr || spCntrAhead(ks.cntr, v))) ks.cntr = v; cardsRender(); + spCntrPlaceholder(); } function spParams() { @@ -8616,11 +8636,14 @@ function spParams() { kic: document.getElementById('sp-kic-hex').value, kid: document.getElementById('sp-kid-hex').value, tar: (document.getElementById('sp-tar').value || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase().padEnd(6, '0').slice(0, 6), - cntr: document.getElementById('sp-cntr').value, kicKey: document.getElementById('sp-kic-key').value, kidKey: document.getElementById('sp-kid-key').value, apdu: document.getElementById('sp-apdu').value, }; + // The counter is sent only when the field carries an explicit override; + // empty = the server seeds it from the preset store (v3.20.0). + const cntr = (document.getElementById('sp-cntr').value || '').replace(/[^0-9a-fA-F]/g, ''); + if (cntr) p.cntr = cntr; if (_spRamFormat) p.ram_format = _spRamFormat; return p; } @@ -8714,18 +8737,19 @@ async function pysimSendOta() { // untouched - the card did not consume the packet (v3.6.3; the old // "advance always" rule silently reverted preset edits). if (spPorAccepted(por)) { - const cntrEl = document.getElementById('sp-cntr'); const spi1Now = (document.getElementById('sp-spi1') || {}).value; + let next = ''; 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); + // the server persisted the accepted counter; sync the local + // copy (display only - the form field is an override) + next = String(data.final_cntr); } else if (spCounterTracked(spi1Now)) { // an older server: advance the local working copy - cntrEl.value = spNextCntr(cntrEl.value); - msg += ' | CNTR -> ' + cntrEl.value; - spCntrLocalSync(cntrEl.value); + next = spNextCntr(spCntrEffective()); + } + if (next) { + msg += ' | CNTR -> ' + next; + spCntrLocalSync(next); } else { // SPI1 without a counter check: the card keeps no replay // value, so nothing is advanced (keyless SPI1 00 packets) @@ -9217,18 +9241,22 @@ function getRamSpParams(selId) { const sel = document.getElementById(selId || 'ram-card-sel'); const idx = sel ? parseInt(sel.value, 10) : NaN; const preset = (!isNaN(idx) && cards[idx]) ? cards[idx] : null; - return { + const p = { preset_id: (preset && preset.id) || '', spi1: document.getElementById('sp-spi1').value, spi2: document.getElementById('sp-spi2-hex').value, kic: document.getElementById('sp-kic-hex').value, kid: document.getElementById('sp-kid-hex').value, tar: (document.getElementById('sp-tar').value || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase().padEnd(6, '0').slice(0, 6), - cntr: (document.getElementById('sp-cntr').value || '0000000001').replace(/[^0-9a-fA-F]/g, ''), kicKey: (document.getElementById('sp-kic-key').value || '').replace(/[^0-9a-fA-F]/g, ''), kidKey: (document.getElementById('sp-kid-key').value || '').replace(/[^0-9a-fA-F]/g, ''), ram_format: (document.getElementById('ram-format') || {}).value || 'auto', }; + // Override only when typed; empty = the server seeds the counter from the + // preset store (v3.20.0). + const cntr = (document.getElementById('sp-cntr').value || '').replace(/[^0-9a-fA-F]/g, ''); + if (cntr) p.cntr = cntr; + return p; } function ramIncrementCntr(cntr) { @@ -9238,18 +9266,20 @@ function ramIncrementCntr(cntr) { function ramSaveCntr(cntr) { // Keep the local copy in step with the counter the server persisted for the - // operation (v3.8.0): display only - the store already holds it. - const el = document.getElementById('sp-cntr'); - if (el) el.value = cntr; + // operation (v3.8.0): display only - the store already holds it, and the + // copy only moves forward (the store is monotonic). The form's counter + // field stays an explicit override; empty = the server seeds it. const selIdx = parseInt((document.getElementById('ram-card-sel') || {}).value, 10); if (!isNaN(selIdx) && cards[selIdx]) { const kvn = spKeysetKvnOf((document.getElementById('sp-kic-hex') || {}).value) || spKeysetKvnOf((document.getElementById('sp-kid-hex') || {}).value) || 0; const ks = spKeysetFor(cards[selIdx], kvn); - if (ks) ks.cntr = cntr; + const v = String(cntr || '').toUpperCase(); + if (ks && v && (!ks.cntr || spCntrAhead(ks.cntr, v))) ks.cntr = v; cardsRender(); ramRender(); } + spCntrPlaceholder(); } // Send a single GP APDU wrapped in SCP80 via /api/send-ota @@ -11609,8 +11639,8 @@ async function ramSyncCounter(presetIdx, view) { await cardsFetch(); // the keyset selects' labels carry the counter - refresh them too spKeysetSync(); - const cntrEl = document.getElementById('sp-cntr'); - if (cntrEl && data.stored_cntr) cntrEl.value = data.stored_cntr; + // the field stays an override; its placeholder shows the stored value + spCntrPlaceholder(); } if (data.success) { const msg = t('Counter synced') + ': ' + esc(data.accepted_cntr) + ' \u2192 ' + @@ -12498,7 +12528,10 @@ function spKeysetApply(ks) { updateSpKid(); set('sp-kic-key', ks.kicKey || ''); set('sp-kid-key', ks.kidKey || ''); - set('sp-cntr', ks.cntr || ''); + // The counter field is an override: a keyset switch clears it (empty = + // the server seeds the packet counter, v3.20.0). + set('sp-cntr', ''); + spCntrPlaceholder(); spInvalidate(); updateSp(); } @@ -12950,7 +12983,7 @@ function cardsAutoSelectByIccid(iccid) { // packet TAR alone: the plain SCP80 send re-reads only the preset-owned // counter/keys/SPI, a manually entered target TAR (push/link triggers, // applet OTA) must survive. -function cardsApplyFields(idx, keepTar) { +function cardsApplyFields(idx, keepTar, keepCntr) { const c = cards[parseInt(idx)]; if (!c) return false; // The TAR of the current operation (ISD for RAM/GP, the RFM TAR for the RFM @@ -12986,10 +13019,18 @@ function cardsApplyFields(idx, keepTar) { document.getElementById('sp-kid-idx').value = ((kidByte >> 4) & 0x0F).toString(16).toUpperCase(); document.getElementById('sp-kid-alg').value = (kidByte & 0x0F).toString(16).padStart(2, '0'); updateSpKid(); - document.getElementById('sp-cntr').value = ks.cntr || ''; document.getElementById('sp-kic-key').value = ks.kicKey || ''; document.getElementById('sp-kid-key').value = ks.kidKey || ''; } + // The counter field is an override: applying a preset clears it (empty = + // auto, the server seeds from the store); the pre-send refresh keeps a + // typed override (`keepCntr`). The placeholder always shows the preset's + // next value. + if (!keepCntr) { + const cntrEl = document.getElementById('sp-cntr'); + if (cntrEl) cntrEl.value = ''; + } + spCntrPlaceholder(); if (!keepTar) document.getElementById('sp-tar').value = cardsRoleTar(c, _spTarKey); spTarListRender(c); spInvalidate(); @@ -13000,8 +13041,8 @@ function cardsApplyFields(idx, keepTar) { return true; } -function cardsApply(idx, keepTar) { - if (!cardsApplyFields(idx, keepTar)) { +function cardsApply(idx, keepTar, keepCntr) { + if (!cardsApplyFields(idx, keepTar, keepCntr)) { // "— Select card —": there is no keyset to choose, and the preset // warning must not outlive the selection it described. spKeysetSync(); @@ -13014,16 +13055,18 @@ function cardsApply(idx, keepTar) { } // SCP80/RAM operations re-read the selected preset before starting: the -// Cards tab (or a counter write-back) may have changed it after the form was -// filled, and a stale counter is rejected by the card (cntr_low) and would -// then be written back over the preset. Returns the preset counter ('' when -// no preset is selected). +// Cards tab (or another window) may have changed the keys, SPI or the +// counter the server will seed. A typed counter override survives the +// refresh - it is the user's explicit choice for the send. Returns the +// preset's counter ('' when no preset is selected). function spRefreshFromPreset(selId, keepTar) { const sel = document.getElementById(selId || 'sp-card-sel'); const idx = sel ? parseInt(sel.value, 10) : NaN; if (isNaN(idx) || !cards[idx]) return ''; if (selId === 'ram-card-sel') _spTarKey = 'isd'; - cardsApply(idx, keepTar); + // keepCntr: the form field is an override; do not re-fill it from the + // preset (empty = the server seeds the packet counter, v3.20.0). + cardsApply(idx, keepTar, true); return cards[idx].cntr; } @@ -21244,6 +21287,7 @@ const LANG_RU = { 'Test script running': 'Выполняется тест-скрипт', 'Preset': 'Пресет', 'Counter': 'Счётчик', + 'auto': 'авто', 'Counter (hex)': 'Счётчик (hex)', 'No run yet.': 'Запусков пока нет.', 'running': 'выполняется', @@ -21774,6 +21818,8 @@ function refreshDynamicI18n() { if (isViewVisible('profiler-scan-modal')) profilerScanRefreshOptions(); pysimUpdateCardActionLabels(); pysimUpdateStateIndicator(); + // the counter field's placeholder carries a t() string (auto / авто) + spCntrPlaceholder(); if (isViewVisible('tab-cards')) { cardsRender(); cardsSetFormMode(); @@ -21841,6 +21887,8 @@ chainInit('chain-ram'); ramResetGrants(); updateInstallParamsHex(); translatePage(); +// the counter field's placeholder carries a t() string (auto / авто) +spCntrPlaceholder(); // PWA let deferredPrompt; const installBtn = document.getElementById('install-btn'); diff --git a/frontend/sw.js b/frontend/sw.js index f675425..abc4cac 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'simple-v392'; +const CACHE = 'simple-v393'; const URLS = [ 'index.html', 'help.html', diff --git a/frontend/tests/cards_counter.test.js b/frontend/tests/cards_counter.test.js index 3eeaae0..6d75040 100644 --- a/frontend/tests/cards_counter.test.js +++ b/frontend/tests/cards_counter.test.js @@ -31,6 +31,8 @@ let code = ''; for (const fn of ['cardsTarList', 'cardsRoleDefault', 'cardsRoleTar', 'cardsTarEntry', 'cardsTarMsl', 'cardsApplyFields', 'cardsApply', 'spRefreshFromPreset', 'spPorAccepted', 'spNextCntr', 'spPresetIdx', + 'spPresetCounterValue', 'spCntrPlaceholder', 'spCntrEffective', 'spCntrAhead', + 'spCntrAdjust', 'spKeysetKvnOf', 'spKeysetList', 'spKeysetFor', 'spKeysetCheck', 'spKeysetOptionsHtml', 'cardsKeysetRowHtml', 'cardsKeysetRowsRender', 'cardsKeysetAdd', 'cardsKeysetRemove', 'cardsKeysetKvnUpdate', @@ -51,6 +53,7 @@ eval(code); globalThis._genSpBuild = () => {}; globalThis.spShowSizeInfo = () => {}; globalThis.spKeysetSync = globalThis.spKeysetSync || (() => {}); +globalThis.t = s => s; // The SP form is a working copy: the preset is the source of truth, and // every SCP80/RAM operation re-reads it before starting (v3.6.3). The plain @@ -81,16 +84,23 @@ function fakeEnv(selValue, selId, presetCntr) { return { els, gen: () => gen }; } -test('spRefreshFromPreset re-reads the edited preset before an operation', () => { +test('spRefreshFromPreset keeps an explicit counter override, empty = auto', () => { const env = fakeEnv('0', 'sp-card-sel', '00000000AA'); - env.els['sp-cntr'].value = '0000000001'; // stale form copy + env.els['sp-cntr'].value = '0000000001'; // explicit override assert.strictEqual(spRefreshFromPreset('sp-card-sel'), '00000000AA'); - assert.strictEqual(env.els['sp-cntr'].value, '00000000AA'); - assert.ok(env.gen() > 0, 'the packet must be regenerated from the new counter'); + assert.strictEqual(env.els['sp-cntr'].value, '0000000001', + 'a typed counter survives the pre-send refresh'); + assert.ok(env.gen() > 0, 'the packet must be regenerated from the preset'); + // an empty field means "auto": the server seeds the counter, and the + // preset's next value is shown as the placeholder (v3.20.0) + env.els['sp-cntr'].value = ''; + spRefreshFromPreset('sp-card-sel'); + assert.strictEqual(env.els['sp-cntr'].value, ''); + assert.match(env.els['sp-cntr'].placeholder, /00000000AA/); // no preset selected: the manual form is left alone globalThis.cards = []; assert.strictEqual(spRefreshFromPreset('sp-card-sel'), ''); - assert.strictEqual(env.els['sp-cntr'].value, '00000000AA'); + assert.strictEqual(env.els['sp-cntr'].value, ''); }); test('spRefreshFromPreset for the RAM selector targets the ISD TAR', () => { @@ -105,23 +115,34 @@ test('the pre-send refresh keeps a hand-edited packet TAR (v3.6.21)', () => { env.els['sp-tar'].value = 'AF4D01'; // e.g. a push/link trigger target assert.strictEqual(spRefreshFromPreset('sp-card-sel', true), '0000000020'); assert.strictEqual(env.els['sp-tar'].value, 'AF4D01', 'the typed TAR must survive the send'); - assert.strictEqual(env.els['sp-cntr'].value, '0000000020', 'the counter still refreshes'); + assert.strictEqual(env.els['sp-cntr'].value, '', 'the counter field stays an override'); + assert.match(env.els['sp-cntr'].placeholder, /0000000020/); // an explicit preset apply (or a pack) still sets the TAR cardsApplyFields(0); assert.strictEqual(env.els['sp-tar'].value, 'B00000'); }); -test('cardsApplyFields fills the form without generating a packet', () => { +test('cardsApplyFields leaves the counter to the server (auto) and shows it as a placeholder', () => { const env = fakeEnv('0', 'sp-card-sel', '0000000007'); - env.els['sp-cntr'].value = 'nonsense'; + env.els['sp-cntr'].value = 'nonsense'; // an old override assert.strictEqual(cardsApplyFields(0), true); - assert.strictEqual(env.els['sp-cntr'].value, '0000000007'); + assert.strictEqual(env.els['sp-cntr'].value, '', + 'applying a preset clears the override (empty = the server seeds it)'); + assert.match(env.els['sp-cntr'].placeholder, /0000000007/); assert.strictEqual(env.gen(), 0, 'a plain field refresh must not rebuild the packet'); cardsApply(0); assert.strictEqual(env.gen(), 1); assert.strictEqual(cardsApplyFields(3), false); }); +test('the counter +/- buttons start from the preset value and create an override', () => { + const env = fakeEnv('0', 'sp-card-sel', '0000000020'); + spCntrAdjust(1); + assert.strictEqual(env.els['sp-cntr'].value, '0000000021'); + spCntrAdjust(-1); + assert.strictEqual(env.els['sp-cntr'].value, '0000000020'); +}); + test('spPorAccepted treats a missing PoR and por_ok as accepted', () => { assert.strictEqual(spPorAccepted(undefined), true); assert.strictEqual(spPorAccepted({ response_status: 'por_ok' }), true); diff --git a/frontend/tests/cards_form.test.js b/frontend/tests/cards_form.test.js index 6f49ef0..14a5d2e 100644 --- a/frontend/tests/cards_form.test.js +++ b/frontend/tests/cards_form.test.js @@ -31,7 +31,8 @@ let code = ''; const ASYNC_FNS = ['cardsAdd', 'cardsImport', 'cardsRemove']; for (const fn of ['cardsFormValues', 'cardsClearForm', 'cardsEdit', 'cardsAdd', 'cardsImport', 'cardsRemove', 'ramCardIdxAfterRemove', - 'spPresetIdx', 'spCntrSyncPreset', 'spCntrLocalSync', 'ramSaveCntr', + 'spPresetIdx', 'spPresetCounterValue', 'spCntrPlaceholder', 'spCntrEffective', + 'spCntrAhead', 'spCntrLocalSync', 'ramSaveCntr', 'cardsApplyFields', 'cardsApply', 'ramApplyCard', 'cardsScp80Complete', 'cardsScp81Complete', 'cardsAdmPresent', 'spTarKeyForPack', 'spPresetTar', 'spTarListRender', 'packToSp', @@ -415,29 +416,25 @@ test('import posts the entries to the server store', async () => { assert.strictEqual(apiCalls.length, 0); }); -test('the SCP80 counter edit is written to the store, a reported counter only locally', () => { +test('the SCP80 counter field is an override - an edit is never written to the store', () => { const els = setup(); globalThis.cards = [{ id: 'abc123', name: 'C', keysets: [ { kic: '15', kid: '15', kicKey: 'AA', kidKey: 'BB', cntr: '0000000001' }] }]; els['sp-card-sel'].value = '0'; els['sp-kic-hex'].value = '15'; els['sp-kid-hex'].value = '15'; - els['sp-cntr'].value = '0000000009'; - assert.strictEqual(spCntrSyncPreset(), true); - assert.strictEqual(cards[0].keysets[0].cntr, '0000000009'); - assert.strictEqual(apiCalls.length, 1); - assert.strictEqual(apiCalls[0].path, '/api/presets/update'); - assert.strictEqual(apiCalls[0].body.id, 'abc123'); - assert.strictEqual(apiCalls[0].body.fields.keysets[0].cntr, '0000000009'); - // a counter the server already persisted: display only, no second write - apiCalls = []; + els['sp-cntr'].value = '0000000009'; // an explicit override + // typing alone writes nothing (no spCntrSyncPreset anymore, v3.20.0) + assert.strictEqual(apiCalls.length, 0); + // a counter the server persisted: the local copy moves forward, display only spCntrLocalSync('000000000A'); assert.strictEqual(cards[0].keysets[0].cntr, '000000000A'); assert.strictEqual(apiCalls.length, 0); - // no preset selected: nothing to write - els['sp-card-sel'].value = ''; - assert.strictEqual(spCntrSyncPreset(), false); - assert.strictEqual(apiCalls.length, 0); + // and the copy never regresses (the store is monotonic) + spCntrLocalSync('0000000005'); + assert.strictEqual(cards[0].keysets[0].cntr, '000000000A'); + // the override in the field is untouched by the copy sync + assert.strictEqual(els['sp-cntr'].value, '0000000009'); }); test('applying a preset uses the TAR of the current operation and seeds the SPI1 from its MSL', () => { diff --git a/frontend/tests/ram.test.js b/frontend/tests/ram.test.js index 3c712a0..319060b 100644 --- a/frontend/tests/ram.test.js +++ b/frontend/tests/ram.test.js @@ -979,8 +979,9 @@ test('every SCP80/RAM flow checks the low counter and stops', () => { assert.ok(sync.includes("own = view === 'sp'"), sync); assert.ok(sync.includes('kvnOf(own)'), sync); assert.ok(sync.includes('kvnOf(other)'), sync); - assert.ok(sync.includes("getElementById('sp-cntr')"), sync); assert.ok(sync.includes('spKeysetSync()'), 'the keyset labels must refresh'); + assert.ok(sync.includes('spCntrPlaceholder()'), + 'the stored value must refresh the field placeholder (the field stays an override)'); assert.ok(sync.includes("ramListingSpi2('40')"), 'the RAM view must probe with the RAM listing transport (a PoR is required)'); assert.ok(sync.includes('data.counter_saved'), diff --git a/frontend/tests/ram_counter_flow.test.js b/frontend/tests/ram_counter_flow.test.js index 42935ac..0e49c0b 100644 --- a/frontend/tests/ram_counter_flow.test.js +++ b/frontend/tests/ram_counter_flow.test.js @@ -29,6 +29,7 @@ function extractFunc(src, name) { let code = ''; for (const fn of ['berLenStr', 'ramDeleteApdu', 'ramIncrementCntr', 'ramSaveCntr', 'getRamSpParams', 'spPorAccepted', 'ramRemoteSwOk', 'ramShowProgress', + 'spPresetIdx', 'spPresetCounterValue', 'spCntrPlaceholder', 'spCntrAhead', 'ramHideProgress', 'ramDeleteFromExplorer', 'spCntrLow', 'ramCntrLowHtml', 'ramCntrLowPresetIdx', 'ramCntrLowView', 'ramShowCntrLow', 'escHtml', 'esc', @@ -94,7 +95,9 @@ test('accepted delete persists the consumed counter and drops the record', async assert.strictEqual(calls.sent.spi2, '01', 'the computed SPI2 byte must be used'); assert.strictEqual(cards[0].keysets[0].cntr, '0000000006', 'the preset must carry the counter the card consumed'); - assert.strictEqual(els['sp-cntr'].value, '0000000006'); + // the form field is an override (empty = auto, the server seeds it); the + // operation only syncs the local preset copy, never the field (v3.20.0) + assert.strictEqual(els['sp-cntr'].value, '0000000005'); assert.strictEqual(calls.sent.preset_id, 'preset-1', 'the operation must name the server-side preset that persists the counter'); assert.deepStrictEqual(calls.removed, { aid: 'F0414C46416101', cascade: false }); diff --git a/frontend/tests/sp.test.js b/frontend/tests/sp.test.js index f941883..208ee27 100644 --- a/frontend/tests/sp.test.js +++ b/frontend/tests/sp.test.js @@ -30,6 +30,8 @@ const FNS = ['hexToBytes', 'bytesToHex', 'des3Keys', 'des3EncryptBlock', 'des3Cb 'xorBytes', 'zeroPad', 'crc32Bytes', 'cbcMac', 'aesCbcEncrypt', 'aesShiftLeft1', 'aesCmacSubkeys', 'aesCmac', '_genSpBuild', 'genSp', 'spNextCntr', 'scp80SegmentInfo', 'spSizeInfoText', 'spShowSizeInfo', + // the counter of a built packet: typed override, else the preset's (v3.20.0) + 'spCntrEffective', 'spPresetCounterValue', // the key-version guard genSp runs before building (TS 102 225 A.2) 'spKeysetKvnOf', 'spKeysetList', 'spKeysetFor', 'spKeysetCheck', 'spPresetIdx', 'spKeysetGuard']; diff --git a/pyproject.toml b/pyproject.toml index 44ffc42..21a7d35 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pysim-simple-server" -version = "3.19.2" +version = "3.20.0" description = "HTTP REST server wrapping pysim for the SIMple PWA" requires-python = ">=3.8" # pysim is a git-only dependency installed explicitly by setup.bat/setup.sh. diff --git a/pysim_simple_server/server.py b/pysim_simple_server/server.py index 8610329..28f3f01 100644 --- a/pysim_simple_server/server.py +++ b/pysim_simple_server/server.py @@ -34,7 +34,7 @@ from osmocom.tlv import BER_TLV_IE from osmocom.utils import rpad -VERSION = '3.19.2' +VERSION = '3.20.0' MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE @@ -1514,6 +1514,24 @@ def _preset_by_id(server, preset_id): return store.get(preset_id) +def _preset_counter_seed(server, body, kvn): + """The effective SCP80 counter for an operation: an explicit `cntr` in the + request wins (a hand send or a probe may deliberately pick one); otherwise + the counter the preset store holds for the packet's keyset - the operation + persists the next value on acceptance, so the store *is* the next counter + (v3.20.0). '' when neither is available (the packet then carries 0 and + nothing is tracked).""" + explicit = str((body or {}).get('cntr') or '').strip().upper() + if explicit: + return explicit + preset = _preset_by_id(server, (body or {}).get('preset_id')) + if preset is None or not kvn: + return '' + keyset = next((ks for ks in _preset_keysets(preset) + if presets.keyset_kvn(ks) == kvn), None) + return str((keyset or {}).get('cntr') or '').strip().upper() + + def _hex_lt(a, b): """Numeric ``a < b`` for two hex byte strings; invalid input is never a comparison (the packet builder's own validation reports it).""" @@ -7795,9 +7813,12 @@ class PysimHandler(BaseHTTPRequestHandler): kic = body.get('kic', '25') kid = body.get('kid', '25') tar = body.get('tar', '000000') - cntr = body.get('cntr', '') kic_key = body.get('kicKey', '') kid_key = body.get('kidKey', '') + # The counter: an explicit `cntr` wins; otherwise the + # preset store's value for the packet's keyset - the + # server owns the counters (v3.20.0). + cntr = _preset_counter_seed(self.server, body, kvn) # RAM command format: only when the caller opted in with a # ram_format value (the RAM views do; applet-directed or # pre-built payloads must not be wrapped). The probe(s) @@ -7818,9 +7839,12 @@ class PysimHandler(BaseHTTPRequestHandler): apdu = _ram_format_apdu(apdu, ram_format) sp_hex, _ = _build_secured_packet(spi1, spi2, kic, kid, tar, cntr, apdu, kic_key, kid_key) else: - # Regular SCP80: use pre-built secured packet + # Regular SCP80: use pre-built secured packet. The packet + # carries its own counter; `cntr` here is the caller's + # bookkeeping value for `final_cntr`/persistence (no + # preset seed - the packet may hold anything). sp_hex = sp - cntr = body.get('cntr', '') + cntr = str(body.get('cntr') or '').strip().upper() spi2_val = int(spi2, 16) # Capture the PoR from either transport: inline in the # ENVELOPE response or as a proactive SEND SHORT MESSAGE (some @@ -7835,7 +7859,7 @@ class PysimHandler(BaseHTTPRequestHandler): try: sys.stderr.write('OTA SEND: SPI %s %s KIc %s KID %s TAR %s CNTR %s LEN %dB\n' % ( spi1, spi2, body.get('kic', ''), - body.get('kid', ''), body.get('tar', ''), body.get('cntr', ''), + body.get('kid', ''), body.get('tar', ''), cntr, len(sp_hex) // 2)) sys.stderr.write('RAM C-APDU: %s\n' % apdu if apdu else sp) sys.stderr.write('RAM SECURED-PACKET: %s\n' % sp_hex) @@ -7948,6 +7972,10 @@ class PysimHandler(BaseHTTPRequestHandler): finally: if submit_handler and hasattr(scc, '_tp'): scc._tp.proactive_handler = old_proactive + if cntr: + # echo the effective counter (a request without one got + # the preset's value - v3.20.0) + resp['cntr'] = cntr if counter_tracked: _preset_counter_persist(self.server, body.get('preset_id'), resp.get('final_cntr'), 'send-ota', kvn) @@ -7966,8 +7994,13 @@ class PysimHandler(BaseHTTPRequestHandler): body = self._read_body() self._log_req(body) try: + # The reference must be built from the same counter the JS + # packet used: an explicit body value wins, otherwise the + # preset's (v3.20.0 - the PWA omits an untouched counter). + kvn_ref = _kvn_of(body.get('kic', ''), body.get('kid', ''))[0] + cntr_ref = _preset_counter_seed(self.server, body, kvn_ref) ref, spi = _ota_reference(body.get('spi1', ''), body.get('spi2', ''), body.get('kic', ''), - body.get('kid', ''), body.get('tar', ''), body.get('cntr', ''), + body.get('kid', ''), body.get('tar', ''), cntr_ref, body.get('apdu', ''), body.get('kicKey', ''), body.get('kidKey', '')) js_sp = (body.get('sp', '') or '').replace(' ', '').lower() ref_l = ref.lower() @@ -8039,6 +8072,10 @@ class PysimHandler(BaseHTTPRequestHandler): self._log_resp(err) return + # The counter: explicit `cntr` wins, else the preset's value + # for this keyset (the server owns the counters, v3.20.0). + cntr = _preset_counter_seed(self.server, body, kvn) or cntr + # LOAD block size: the default 240-byte payload cannot be sent # over SCP80 (pySim refuses a secured packet above one SMS). # Fit it automatically, or honour an explicit override capped @@ -8220,6 +8257,9 @@ class PysimHandler(BaseHTTPRequestHandler): self._send_json(err, 400) self._log_resp(err) return + # The counter: explicit `cntr` wins, else the preset's value + # for this keyset (the server owns the counters, v3.20.0). + sp_state['cntr'] = _preset_counter_seed(self.server, body, kvn) or sp_state['cntr'] state = {'steps': [], 'encode_error': None, 'failure': {}, 'cntr': sp_state['cntr'], 'preset_id': body.get('preset_id'), 'kvn': kvn} @@ -8341,6 +8381,9 @@ class PysimHandler(BaseHTTPRequestHandler): self._send_json(err, 400) self._log_resp(err) return + # The counter: explicit `cntr` wins, else the preset's value + # for this keyset (the server owns the counters, v3.20.0). + cntr = _preset_counter_seed(self.server, body, kvn) or cntr state = {'steps': steps, 'encode_error': None, 'failure': {}, 'cntr': cntr, 'preset_id': body.get('preset_id'), 'kvn': kvn} @@ -8671,7 +8714,10 @@ class PysimHandler(BaseHTTPRequestHandler): return resolved.append((tar, label, tar_spi1)) state = {'steps': [], 'encode_error': None, 'failure': {}, - 'cntr': body.get('cntr', '00000000'), + # the counter: explicit `cntr` wins, else the preset's + # value for this keyset (the server owns the counters, + # v3.20.0) + 'cntr': _preset_counter_seed(self.server, body, kvn) or '00000000', 'preset_id': body.get('preset_id'), 'kvn': kvn} sp = {'spi1': spi1_base, 'spi2': spi2, 'kic': kic, 'kid': kid, 'tar': '000000', 'kic_key': body.get('kicKey', ''), 'kid_key': body.get('kidKey', ''), diff --git a/tests/test_fetch_routes.py b/tests/test_fetch_routes.py index 8e66a0d..02fc4ea 100644 --- a/tests/test_fetch_routes.py +++ b/tests/test_fetch_routes.py @@ -580,10 +580,64 @@ class PresetStoreHttpTests(unittest.TestCase): status, resp = self._post('/api/counter-probe', {'preset_id': pid}) self.assertEqual(status, 200, resp) self.assertTrue(resp['success'], resp) + self.assertEqual(resp['start'], '0000000001') # seeded from the keyset self.assertEqual(resp['accepted_cntr'], '0000000002') self.assertEqual(resp['stored_cntr'], '0000000003') self.assertEqual(self.store.get(pid)['keysets'][0]['cntr'], '0000000003') + def test_send_ota_without_cntr_seeds_the_preset_counter(self): + """v3.20.0: the PWA's send buttons omit an untouched counter; the + server then uses the preset store's value for the packet's keyset.""" + status, resp = self._post('/api/presets', self._preset(iccid='8970119000004600098')) + self.assertEqual(status, 200, resp) + pid = resp['preset']['id'] + self.server.app = object() # /api/send-ota refuses an uninitialized app + with mock.patch.object(self.srv, '_build_secured_packet', + return_value=('00' * 20, {})) as build, \ + mock.patch.object(self.srv, '_send_secured_packet', return_value={ + 'success': True, 'sw': '9000', 'bytes': 34, 'segments': 1, + 'response_data': '027100000e0ab0000000000000010000019000'}), \ + mock.patch.object(self.srv, '_decode_por', + return_value={'response_status': 'por_ok'}): + status, resp = self._post('/api/send-ota', { + 'preset_id': pid, 'apdu': '80E2900000', 'tar': 'B00001', + 'spi1': '16', 'spi2': '01', 'kic': '15', 'kid': '15', + 'kicKey': 'AA', 'kidKey': 'BB'}) + self.assertEqual(status, 200, resp) + self.assertTrue(resp.get('success'), resp) + self.assertEqual(build.call_args[0][5], '0000000001') # seeded + self.assertEqual(resp['cntr'], '0000000001') + self.assertEqual(resp['final_cntr'], '0000000002') + self.assertEqual(self.store.get(pid)['keysets'][0]['cntr'], '0000000002') + + def test_send_ota_explicit_lower_counter_does_not_regress_the_store(self): + """An explicit counter is used for the packet (a hand send), but the + store keeps its higher value (monotonic persistence, v3.20.0).""" + status, resp = self._post('/api/presets', self._preset(iccid='8970119000004600098')) + self.assertEqual(status, 200, resp) + pid = resp['preset']['id'] + keysets = [dict(ks, cntr='0000000010') for ks in resp['preset']['keysets']] + status, resp = self._post('/api/presets/update', + {'id': pid, 'fields': {'keysets': keysets}}) + self.assertEqual(status, 200, resp) + self.server.app = object() # /api/send-ota refuses an uninitialized app + with mock.patch.object(self.srv, '_build_secured_packet', + return_value=('00' * 20, {})) as build, \ + mock.patch.object(self.srv, '_send_secured_packet', return_value={ + 'success': True, 'sw': '9000', 'bytes': 34, 'segments': 1, + 'response_data': '027100000e0ab0000000000000010000019000'}), \ + mock.patch.object(self.srv, '_decode_por', + return_value={'response_status': 'por_ok'}): + status, resp = self._post('/api/send-ota', { + 'preset_id': pid, 'apdu': '80E2900000', 'tar': 'B00001', + 'spi1': '16', 'spi2': '01', 'kic': '15', 'kid': '15', + 'cntr': '0000000005', 'kicKey': 'AA', 'kidKey': 'BB'}) + self.assertEqual(status, 200, resp) + self.assertEqual(build.call_args[0][5], '0000000005') # explicit wins + self.assertEqual(resp['final_cntr'], '0000000006') + # the lower value never regresses the store + self.assertEqual(self.store.get(pid)['keysets'][0]['cntr'], '0000000010') + def test_create_update_delete_round_trip(self): status, resp = self._post('/api/presets', self._preset(iccid='8970119000004600098')) self.assertEqual(status, 200, resp) diff --git a/tests/test_ota_helpers.py b/tests/test_ota_helpers.py index 09a1a2c..7124caa 100644 --- a/tests/test_ota_helpers.py +++ b/tests/test_ota_helpers.py @@ -36,6 +36,7 @@ from pysim_simple_server.server import ( _counter_probe, _counter_probe_candidates, _counter_probe_params, + _preset_counter_seed, _ram_normalize_format, _parse_proactive_header, _parse_display_text, @@ -535,6 +536,43 @@ class TestDecodePor(unittest.TestCase): self.assertEqual(r['secured_data'], '016A86') +class TestPresetCounterSeed(unittest.TestCase): + """v3.20.0: the effective counter of an operation - explicit `cntr` wins, + otherwise the preset store's value for the packet's keyset.""" + + PRESET = {'keysets': [ + {'kic': '15', 'kid': '15', 'kicKey': 'AA', 'kidKey': 'BB', + 'cntr': '0000000001'}, + {'kic': '25', 'kid': '25', 'kicKey': 'CC', 'kidKey': 'DD', + 'cntr': '0000000020'}]} + + def _server(self): + preset = self.PRESET + + class Store: + def get(self, pid): + return preset if pid == 'p1' else None + + return types.SimpleNamespace(card_presets=Store()) + + def test_explicit_counter_wins(self): + self.assertEqual(_preset_counter_seed(self._server(), + {'cntr': ' 0000000009 '}, 1), + '0000000009') + # an explicit counter is used even without a preset + self.assertEqual(_preset_counter_seed(self._server(), {'cntr': '0000000009'}, 0), + '0000000009') + + def test_missing_counter_seeds_from_the_keyset(self): + self.assertEqual(_preset_counter_seed(self._server(), {'preset_id': 'p1'}, 2), + '0000000020') + # no/unknown preset, unknown keyset or no kvn: nothing to seed + self.assertEqual(_preset_counter_seed(self._server(), {}, 1), '') + self.assertEqual(_preset_counter_seed(self._server(), {'preset_id': 'nope'}, 1), '') + self.assertEqual(_preset_counter_seed(self._server(), {'preset_id': 'p1'}, 3), '') + self.assertEqual(_preset_counter_seed(self._server(), {'preset_id': 'p1'}, 0), '') + + class TestProactiveDecode(unittest.TestCase): """Server-side proactive command/TR decode helpers (v1.8.0 log feature)."""
    FieldSizeDescription