diff --git a/docs/api.md b/docs/api.md index e428525..ab417a5 100644 --- a/docs/api.md +++ b/docs/api.md @@ -37,6 +37,7 @@ a 3.x PWA). | `/api/presets/delete` | POST | Delete a preset (`{id}`) | | `/api/presets/import` | POST | Import presets (`{presets: [...], mode: merge\|replace}`) | | `/api/tar-probe` | POST | Probe the card's registered OTA applications (one secured packet per TAR, TS 101 220 Annex D) | +| `/api/counter-probe` | POST | Bounded counter sync: probe increasing counter values (start+1, +2, +4, …) with a read-only command until the card accepts one (ceiling/attempt-capped), then persist accepted+1 | | `/api/esim/chip` | GET | eUICC chip details (EID, EUICCInfo1/2, configured addresses) | | `/api/esim/profiles` | GET | Installed eSIM profiles with their metadata | | `/api/esim/notifications` | GET | Pending eSIM notifications (read-only) | @@ -246,6 +247,40 @@ import also accepts presets exported by the older localStorage-based builds. `"replace"`) → `{"ok": true, "added": N, "skipped": M, "errors": […]}`; invalid entries and duplicates are skipped and reported, never fatal. +### `POST /api/counter-probe` + +Sync a preset's SCP80 counter with the card. The card accepts only a counter +*above* its own (TS 102 225 §5.1.1, SPI1 b5b4 = `10`) and updates it only when +the packet is accepted, so rejected attempts change nothing; the probe walks a +doubling ladder (`start+1`, `+2`, `+4`, …) with a read-only command until one +is accepted, then persists `accepted+1` (monotonic) into the keyset. The +search stops at a ceiling and after a bounded number of packets, so it can +never run towards the 40-bit maximum, where the card blocks the counter. The +card's own counter is never reported by the card (the PoR's CNTR is a copy of +the packet's, TS 102 225 §5.2 Table 3), which is why this probe exists. + +```json +{"preset_id": "…", "kvn": 2, "tar": "000000", "spi1": "16", "spi2": "21", + "cntr": "0001000000", "ceiling": "FFFFFFFF", "max_attempts": 40, + "apdu": "00A40000023F00"} +``` + +All fields except `preset_id` are optional: `kvn` picks a keyset (the first +otherwise), `tar`/`spi1`/`spi2` default to the preset's values, `cntr` to the +keyset's counter, `ceiling` to `FFFFFFFF` (must stay below `FFFFFFFFFF`), +`max_attempts` to 40 (clamped to 1–64), and `apdu` to a read-only `SELECT MF` +(the probe command should never modify anything). A SPI1 without a counter +check is refused (400). + +**Response:** `{"success": true, "accepted_cntr": "0001000000", + "stored_cntr": "0001000001", "packets": 3, "stopped": "accepted", + "attempts": [{"cntr": "…", "por_status": "cntr_low"}, …], + "start": "…", "ceiling": "FFFFFFFF", "kvn": 2}` + +`stopped` is `accepted`, `ceiling`, `attempts` or `error`; on a non-`cntr_low` +failure (refused ENVELOPE, wrong TAR, security error) the probe stops at the +first attempt instead of hammering the card. + ### `POST /api/tar-probe` Probe which OTA applications the card has registered: one **secured packet per diff --git a/frontend/help-ru.html b/frontend/help-ru.html index c881cf5..4622540 100644 --- a/frontend/help-ru.html +++ b/frontend/help-ru.html @@ -279,7 +279,7 @@

3. Вкладка SCP80

-

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

+

Верхнеуровневая вкладка 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.

@@ -314,7 +314,7 @@

Кнопка Проверить в 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). Пресет карты со вкладки Карты обеспечивает SPI, ключи, TAR и счётчик (RAM использует ISD TAR пресета, по умолчанию 000000). Селектор Keyset рядом с выбором пресета указывает, из какого keyset берутся ключи и счётчик операции. Селектор недоступен, пока не выбран пресет карты. Кнопка запуска недоступна, пока не выбран пресет карты и один из его keysets.

+

Выполняет операции удалённого управления приложениями (Remote Application Management) как защищённые пакеты SCP80 через SMS-PP-DOWNLOAD ENVELOPE. Карта должна поддерживать SCP03 (AES или 3DES). Пресет карты со вкладки Карты обеспечивает SPI, ключи, TAR и счётчик (RAM использует ISD TAR пресета, по умолчанию 000000). Селектор 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 — операция показывает это как ошибку шага).

Операции

diff --git a/frontend/help.html b/frontend/help.html index cdc4108..aabd9d3 100644 --- a/frontend/help.html +++ b/frontend/help.html @@ -310,10 +310,10 @@
  • 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 and the result states the card's counter with a Go to preset button that opens the matching preset in the Cards tab, where the counter must be raised above it — nothing 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. 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 — 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 SPI, keys, TAR, and counter (RAM uses the preset's ISD TAR, 000000 by default). 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.

    +

    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 SPI, keys, TAR, and counter (RAM uses the preset's ISD TAR, 000000 by default). 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).

    Operations

    diff --git a/frontend/index.html b/frontend/index.html index 494e54f..8be371f 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -708,6 +708,15 @@ +
    + + +
    @@ -8304,13 +8313,11 @@ async function pysimSendOta() { try { const data = await pysimFetch('/api/send-ota', { sp, ...spParams() }); // low counter: not recoverable by retrying (v3.6.58) - const low = spCntrLow(data); - if (low) { - sendResultEl.textContent = t('Low counter') + ' — ' + t('the card rejected the packet') + - (low.card_cntr ? ' (' + t('card counter') + ' ' + low.card_cntr + ')' : ''); + if (spCntrLow(data)) { + sendResultEl.textContent = t('Low counter') + ' — ' + t('the card rejected the packet'); sendResultEl.classList.remove('text-gray-500', 'text-green-600'); sendResultEl.classList.add('text-red-600'); - ramShowCntrLow(low); + ramShowCntrLow(); return; } if (data.success) { @@ -8851,6 +8858,7 @@ function getRamSpParams(selId) { cntr: (document.getElementById('sp-cntr').value || '0000000001').replace(/[^0-9a-fA-F]/g, ''), kicKey: (document.getElementById('sp-kic-key').value || '').replace(/[^0-9a-fA-F]/g, ''), kidKey: (document.getElementById('sp-kid-key').value || '').replace(/[^0-9a-fA-F]/g, ''), + ram_format: (document.getElementById('ram-format') || {}).value || 'auto', }; } @@ -9599,13 +9607,10 @@ async function ramExplore(sp) { // own - a retry cannot help, so the run stops immediately and the user is // told to raise the preset counter (v3.6.58). Every send of the run goes // through sendOta() so the verdict is caught wherever it appears. - let cntrLow = null; + let cntrLow = false; async function sendOta(apdu, params) { const res = await ramSendOta(apdu, params); - if (!cntrLow) { - const low = spCntrLow(res); - if (low) cntrLow = low; - } + if (!cntrLow && spCntrLow(res)) cntrLow = true; return res; } @@ -9900,7 +9905,7 @@ async function ramExplore(sp) { // the run was stopped: state the verdict, do not retry resultEl.textContent = t('Low counter') + ' — ' + t('the operation was stopped'); resultEl.classList.remove('hidden', 'text-green-600'); resultEl.classList.add('text-red-600'); - ramShowCntrLow(cntrLow); + ramShowCntrLow(); } else if (errors.length) { resultEl.textContent = t('Partial') + ' — ' + errors.join('; '); resultEl.classList.remove('hidden', 'text-green-600'); resultEl.classList.add('text-red-600'); @@ -10005,12 +10010,11 @@ async function ramDeleteFromExplorer(aid, withCascade) { const res = await ramSendOta(apdu, sp); ramHideProgress(); // low counter: stop - retrying cannot help (v3.6.58) - const low = spCntrLow(res); - if (low) { + if (spCntrLow(res)) { const el = document.getElementById('ram-result'); el.textContent = t('Low counter') + ' — ' + t('the operation was stopped'); el.classList.remove('hidden', 'text-green-600'); el.classList.add('text-red-600'); - ramShowCntrLow(low); + ramShowCntrLow(); return; } // The card consumes the counter as soon as it accepts the packet, even @@ -10435,10 +10439,9 @@ async function ramCompatTest(sp) { let modal = esc(txt); if (probeNote) modal += '
    ' + esc(probeNote) + '
    '; modal += ramCompatDetailHtml(data, mem); - const low = spCntrLow(data); - if (low) { - modal += ramCntrLowHtml(low, ramCntrLowPresetIdx()); - ramShowCntrLow(low); + if (spCntrLow(data)) { + modal += ramCntrLowHtml(ramCntrLowPresetIdx()); + ramShowCntrLow(); } ramOpModalFinish(verdict, !!data.imports_ok, modal, true); } @@ -10808,27 +10811,27 @@ function ramCntrLowPresetIdx() { return ram >= 0 ? ram : pick('sp-card-sel'); } -// Low-counter notice (pure HTML): the facts plus a button that opens the -// failed operation's preset in the Cards tab, where the user raises the -// counter - nothing is written automatically. -function ramCntrLowHtml(info, presetIdx) { - if (!info) return ''; +// Low-counter notice (pure HTML): the facts plus two actions - a bounded +// counter sync probe and a button that opens the failed operation's preset in +// the Cards tab, where the counter can be raised by hand. +function ramCntrLowHtml(presetIdx) { const idx = Number.isInteger(presetIdx) && presetIdx >= 0 ? presetIdx : -1; - let text = t('Low counter') + ' \u2014 ' + t('the card rejected the packet'); - if (info.card_cntr) text += ' (' + t('card counter') + ' ' + esc(info.card_cntr) + ')'; - if (info.suggested) text += ' \u2014 ' + t('the counter must be above it'); + const text = t('Low counter') + ' \u2014 ' + t('the card rejected the packet'); + const btnCls = 'px-2 py-1 text-xs rounded border border-red-300 dark:border-red-700 ' + + 'hover:bg-red-100 dark:hover:bg-red-900/40 whitespace-nowrap'; return '
    ' + '' + text + '' + - '' + + '
    '; } // Show the low-counter notice in the RAM result area (and, when the operation // modal is still open, in its detail block). -function ramShowCntrLow(info, presetIdx) { - if (!info) return; +function ramShowCntrLow(presetIdx) { const idx = presetIdx === undefined ? ramCntrLowPresetIdx() : presetIdx; - const html = ramCntrLowHtml(info, idx); + const html = ramCntrLowHtml(idx); const el = document.getElementById('ram-cntr-low'); if (el && el.classList) { el.innerHTML = html; @@ -10840,6 +10843,54 @@ function ramShowCntrLow(info, presetIdx) { } } +// Sync the counter of the failed operation's preset with the card: the server +// probes with a read-only command and increasing counter values (start+1, +2, +// +4, ...) until the card accepts one, stops at a 32-bit ceiling and persists +// accepted+1. A rejected attempt changes nothing on the card, so only the +// accepted packet moves its counter (the card never reports its value). +async function ramSyncCounter(presetIdx) { + const i = Number(presetIdx); + const preset = (Number.isInteger(i) && i >= 0 && cards[i]) ? cards[i] : null; + const box = document.getElementById('ram-cntr-low'); + if (!preset) { alert(t('No card preset selected')); return; } + // The keyset the failed operation used: the RAM form's selector, else the + // SCP80 form's (both hold the keyset number as their option value). + const kvnOf = id => { + const sel = document.getElementById(id); + const v = sel && sel.value !== '' ? parseInt(sel.value, 10) : NaN; + return Number.isInteger(v) && v > 0 ? v : null; + }; + const val = id => (document.getElementById(id) || {}).value || ''; + const body = { preset_id: preset.id }; + const kvn = kvnOf('ram-keyset-sel') || kvnOf('sp-keyset-sel'); + if (kvn) body.kvn = kvn; + const tar = val('sp-tar').replace(/[^0-9a-fA-F]/g, '').toUpperCase(); + if (tar) body.tar = tar; + if (val('sp-spi1')) body.spi1 = val('sp-spi1'); + if (val('sp-spi2-hex')) body.spi2 = val('sp-spi2-hex'); + const cntr = val('sp-cntr').replace(/[^0-9a-fA-F]/g, ''); + if (cntr) body.cntr = cntr; + if (box) box.innerHTML = '
    ' + + esc(t('Syncing counter...')) + '
    '; + try { + const data = await pysimFetch('/api/counter-probe', body); + if (data.success) { + await cardsFetch(); + const cntrEl = document.getElementById('sp-cntr'); + if (cntrEl && data.stored_cntr) cntrEl.value = data.stored_cntr; + const msg = t('Counter synced') + ': ' + esc(data.accepted_cntr) + ' \u2192 ' + + esc(data.stored_cntr) + '. ' + t('Retry the operation.'); + if (box) box.innerHTML = '
    ' + msg + '
    '; + } else { + const msg = t('Counter sync failed') + ': ' + esc(data.error || ''); + if (box) box.innerHTML = '
    ' + msg + '
    '; + } + } catch (e) { + if (box) box.innerHTML = '
    ' + + esc(String(e.message || e)) + '
    '; + } +} + // Open a preset in the Cards tab for editing (the user fixes the counter): // select it, scroll to the counter field and focus it. function ramGoToPreset(idx) { @@ -10904,12 +10955,11 @@ function ramShowInstallResult(data, okText, capMem) { // successful run and for failures that are not LOAD/INSTALL [for load]). let modal = esc(txt); modal += ramCompatDetailHtml(data, capMem || null); - // low counter: not recoverable by retrying - state it and point at the - // preset, where the user raises the counter (v3.6.58) - const low = spCntrLow(data); - if (low) { - modal += ramCntrLowHtml(low, ramCntrLowPresetIdx()); - ramShowCntrLow(low); + // low counter: not recoverable by retrying - state it and offer the + // bounded sync probe or the preset jump (v3.6.58) + if (spCntrLow(data)) { + modal += ramCntrLowHtml(ramCntrLowPresetIdx()); + ramShowCntrLow(); } ramOpModalFinish(resultEl.textContent, !!data.success, modal, true); } @@ -12003,22 +12053,15 @@ function spPorAccepted(por) { // The card's low-counter verdict of a send response (v3.6.58). Every further // packet with a counter <= the card's is rejected again, so the operation must // stop and the preset counter must be raised - a retry cannot recover it. -// Returns {card_cntr, suggested} or null. +// Returns true for a cntr_low verdict, false otherwise. No value is reported: +// the PoR's CNTR is a copy of the command's counter (TS 102 225 5.2, table 3) +// and a rejection leaves it zeroed, so the card's counter is not observable - +// the server's counter probe or a manual raise are the ways forward. function spCntrLow(res) { - if (!res) return null; + if (!res) return false; const por = res.por || null; const status = (por && por.response_status) || ''; - if (!res.cntr_low && status !== 'cntr_low') return null; - const card = String(res.card_cntr || (por && por.cntr) || '').toUpperCase(); - let suggested = String(res.suggested_cntr || '').toUpperCase(); - if (!suggested && /^[0-9A-F]+$/.test(card)) { - try { - // the SCP80 counter field is fixed-width: wrap like the server does - const mod = 16n ** BigInt(card.length); - suggested = ((BigInt('0x' + card) + 1n) % mod).toString(16).toUpperCase().padStart(card.length, '0'); - } catch (e) { suggested = ''; } - } - return { card_cntr: card, suggested: suggested }; + return !!(res.cntr_low || status === 'cntr_low'); } // Remote-command status words that count as success (mirrors the server's @@ -20264,8 +20307,17 @@ const LANG_RU = { 'request error': 'ошибка запроса', 'Low counter': 'Низкий счётчик', 'the card rejected the packet': 'карта отклонила пакет', - 'card counter': 'счётчик карты', - 'the counter must be above it': 'счётчик должен быть больше', + 'Sync counter': 'Синхронизировать счётчик', + 'Syncing counter...': 'Синхронизация счётчика...', + 'Counter synced': 'Счётчик синхронизирован', + 'Counter sync failed': 'Не удалось синхронизировать счётчик', + 'Retry the operation.': 'Повторите операцию.', + 'No card preset selected': 'Пресет карты не выбран', + 'Format': 'Формат', + 'Auto (detect)': 'Авто (определение)', + 'Compact': 'Компактный', + 'Expanded (AA, definite length)': 'Expanded (AA, определённая длина)', + 'Expanded (AE 80, indefinite length)': 'Expanded (AE 80, неопределённая длина)', 'Go to preset': 'К пресету', 'first page only': 'только первая страница', 'the operation was stopped': 'операция остановлена', diff --git a/frontend/sw.js b/frontend/sw.js index 340599d..b7c7a95 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'simple-v351'; +const CACHE = 'simple-v352'; const URLS = [ 'index.html', 'help.html', diff --git a/frontend/tests/ram.test.js b/frontend/tests/ram.test.js index bb0079a..1e21b42 100644 --- a/frontend/tests/ram.test.js +++ b/frontend/tests/ram.test.js @@ -746,33 +746,33 @@ test('the explorer hints why the package versions are missing', () => { }); test('spCntrLow reads the low-counter verdict of a send response', () => { - // the server's explicit fields - const a = spCntrLow({ cntr_low: true, card_cntr: '00000000BB', suggested_cntr: '00000000BC' }); - assert.deepStrictEqual(a, { card_cntr: '00000000BB', suggested: '00000000BC' }); + // the server's explicit flag + assert.strictEqual(spCntrLow({ cntr_low: true }), true); // derived from the PoR alone (a 9000/91xx ENVELOPE can carry a cntr_low PoR) - const b = spCntrLow({ por: { response_status: 'cntr_low', cntr: '00000000BB' } }); - assert.deepStrictEqual(b, { card_cntr: '00000000BB', suggested: '00000000BC' }); - // the counter is 40-bit: the wrap keeps the width - const c = spCntrLow({ por: { response_status: 'cntr_low', cntr: 'FFFFFFFFFF' } }); - assert.strictEqual(c.suggested, '0000000000'); + assert.strictEqual(spCntrLow({ por: { response_status: 'cntr_low', cntr: '00000000BB' } }), true); // every other verdict (and a missing response) is not a low counter - assert.strictEqual(spCntrLow({ por: { response_status: 'por_ok', cntr: '01' } }), null); - assert.strictEqual(spCntrLow({ por: { response_status: 'cntr_high' } }), null); - assert.strictEqual(spCntrLow({}), null); - assert.strictEqual(spCntrLow(null), null); + assert.strictEqual(spCntrLow({ por: { response_status: 'por_ok', cntr: '01' } }), false); + assert.strictEqual(spCntrLow({ por: { response_status: 'cntr_high' } }), false); + assert.strictEqual(spCntrLow({}), false); + assert.strictEqual(spCntrLow(null), false); }); -test('ramCntrLowHtml states the facts and offers the preset jump', () => { +test('ramCntrLowHtml states the facts without a fabricated counter', () => { globalThis.t = s => s; - const out = ramCntrLowHtml({ card_cntr: '00000000BB', suggested: '00000000BC' }, 2); - const noPreset = ramCntrLowHtml({ card_cntr: '00000000BB' }, -1); + const out = ramCntrLowHtml(2); + const noPreset = ramCntrLowHtml(-1); delete globalThis.t; - assert.ok(out.includes('Low counter \u2014 the card rejected the packet (card counter 00000000BB) \u2014 the counter must be above it'), out); + assert.ok(out.includes('Low counter \u2014 the card rejected the packet'), out); + // the PoR's CNTR is a copy of the command's counter, never the card's: no + // value may be shown (v3.9.x), only the verdict and the two actions + assert.ok(!out.includes('card counter'), out); + assert.ok(out.includes('onclick="ramSyncCounter(2)"'), out); + assert.ok(out.includes('Sync counter'), out); assert.ok(out.includes('onclick="ramGoToPreset(2)"'), out); assert.ok(out.includes('Go to preset'), out); - // without a known preset the button still opens the tab (-1) + // without a known preset the buttons still work (-1) assert.ok(noPreset.includes('onclick="ramGoToPreset(-1)"'), noPreset); - assert.strictEqual(ramCntrLowHtml(null, 0), ''); + assert.ok(noPreset.includes('onclick="ramSyncCounter(-1)"'), noPreset); }); test('the explorer shows versions on app and ISD rows too', () => { @@ -804,7 +804,7 @@ test('every SCP80/RAM flow checks the low counter and stops', () => { const explore = extractFunc(html, 'ramExplore'); assert.ok(explore.includes('spCntrLow(res)'), 'the Explore must check every send'); assert.ok(explore.includes('if (cntrLow) break;'), 'the loops must stop'); - assert.ok(explore.includes('ramShowCntrLow(cntrLow)'), 'the verdict must be shown'); + assert.ok(explore.includes('ramShowCntrLow()'), 'the verdict must be shown'); // the delete flow and the plain send check it too assert.ok(extractFunc(html, 'ramDeleteFromExplorer').includes('spCntrLow(res)'), 'the delete flow must stop'); @@ -813,6 +813,24 @@ test('every SCP80/RAM flow checks the low counter and stops', () => { assert.ok(go.includes("switchTab('cards')"), go); assert.ok(go.includes('cardsEdit('), go); assert.ok(go.includes("getElementById('cards-ks-' + row + '-cntr')"), go); + // the notice's sync probe posts to the bounded server endpoint with the + // preset, the keyset the failed operation used and its SPI/TAR + const sync = extractFunc(html, 'ramSyncCounter'); + assert.ok(sync.includes("'/api/counter-probe'"), sync); + assert.ok(sync.includes("kvnOf('ram-keyset-sel')"), sync); + assert.ok(sync.includes("kvnOf('sp-keyset-sel')"), sync); + assert.ok(sync.includes("getElementById('sp-cntr')"), sync); +}); + +test('the RAM form offers the command format choice', () => { + // auto (detect) is the default; the two expanded codings can be pinned + assert.match(html, /