diff --git a/frontend/help-ru.html b/frontend/help-ru.html index 0b653fb..9a42eb8 100644 --- a/frontend/help-ru.html +++ b/frontend/help-ru.html @@ -279,7 +279,7 @@

3. Вкладка SCP80

-

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

+

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

3.1 Secured Packet

Собирает защищённые пакеты SCP80 по ETSI TS 102 225.

diff --git a/frontend/help.html b/frontend/help.html index a06dfec..b5eb0b1 100644 --- a/frontend/help.html +++ b/frontend/help.html @@ -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 and clears the packet.

    +

    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 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.

    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).

    diff --git a/frontend/index.html b/frontend/index.html index 99cfd37..f9283b4 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -898,6 +898,7 @@ + @@ -1749,7 +1750,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.6.57'; +const SIMPLE_VERSION = '3.6.58'; document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION; // ===== Tab switching ===== @@ -8176,6 +8177,16 @@ async function pysimSendOta() { porStatusEl.classList.add('hidden'); 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 + ')' : ''); + sendResultEl.classList.remove('text-gray-500', 'text-green-600'); + sendResultEl.classList.add('text-red-600'); + ramShowCntrLow(low); + return; + } if (data.success) { if (data.por && data.por.decoded) { document.getElementById('resp-sw').value = data.por.decoded.last_status_word || ''; @@ -8403,6 +8414,8 @@ function ramClearResults() { document.getElementById('ram-explorer').innerHTML = ''; document.getElementById('ram-steps').classList.add('hidden'); document.getElementById('ram-steps').textContent = ''; + const cntrLowEl = document.getElementById('ram-cntr-low'); + if (cntrLowEl) { cntrLowEl.innerHTML = ''; cntrLowEl.classList.add('hidden'); } } // Read a File as a hex string via FileReader (client-side, no upload) @@ -9397,6 +9410,19 @@ async function ramExplore(sp) { // outcome report shown in the explorer. let expandedForm = null; const expandedReport = []; + // Low counter: the card rejected a packet whose counter is not above its + // 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; + async function sendOta(apdu, params) { + const res = await ramSendOta(apdu, params); + if (!cntrLow) { + const low = spCntrLow(res); + if (low) cntrLow = low; + } + return res; + } // One expanded query with the form ladder (see RAM_EXPANDED_FORMS): try // the remembered form first, then the others. The ISD gets a single @@ -9415,6 +9441,7 @@ async function ramExplore(sp) { let lastSw = ''; let lastTransport = false; for (const form of forms) { + if (cntrLow) break; let data = ''; let sw = ''; let accepted = true; @@ -9440,7 +9467,7 @@ async function ramExplore(sp) { const apdu = ramExpandedQueryApdu(p1, form, page > 1); const what = page > 1 ? 'P2=03 (' + form + ')' : 'P2=02 (' + form + ')'; data += await sendOne(apdu, what); - if (!accepted) break; + if (cntrLow || !accepted) break; // '61xx': the card holds the response data - the GET RESPONSE // must ride in the same compact command string (a standalone // one answers 6700 on the live card); the expanded AA/22 @@ -9448,7 +9475,7 @@ async function ramExplore(sp) { const pending = /^61[0-9A-F]{2}$/.test(sw); if (pending && sp.ram_format !== 'expanded') { data += await sendOne(ramChainGetResponse(apdu), what + ' + GET RESPONSE'); - if (!accepted) break; + if (cntrLow || !accepted) break; } // the ISD is a single occurrence (GP 11.4.2.2); '6310' means // more occurrences, and a '61xx' page may mask that signal @@ -9475,6 +9502,7 @@ async function ramExplore(sp) { } async function paginate(p1, collector, parser, label, quiet) { + if (cntrLow) return; // the run was stopped by a low counter // Compact listing format (GP 11.4.2.2: P2.b2=0) with the chained GET // RESPONSE - see ramGetStatusApdu(). The card's SCP80 layer runs both // commands, so the PoR carries the listing; ramListingSpi2() picks the @@ -9487,7 +9515,8 @@ async function ramExplore(sp) { while (guard++ < 32) { const apdu = ramGetStatusApdu(p1, p2); ramShowProgress(label + ' P1=' + p1 + ' P2=' + p2 + '...'); - const res = await ramSendOta(apdu, Object.assign({}, sp, { cntr, spi2 })); + const res = await sendOta(apdu, Object.assign({}, sp, { cntr, spi2 })); + if (cntrLow) break; // Reuse the format detected for the first packet of this run if (res.ram_format) sp.ram_format = res.ram_format; // A packet the card accepted advances the counter even when the @@ -9561,7 +9590,8 @@ async function ramExplore(sp) { ramShowProgress(t('Card resources (GET DATA FF21)') + '...'); try { - const memRes = await ramSendOta('80CAFF2100', Object.assign({}, sp, { spi2: '01' })); + if (cntrLow) throw new Error(t('Low counter')); + const memRes = await sendOta('80CAFF2100', Object.assign({}, sp, { spi2: '01' })); if (memRes.ram_format) sp.ram_format = memRes.ram_format; if (memRes.success && memRes.por && memRes.por.response_status === 'por_ok') { cntr = memRes.final_cntr || ramIncrementCntr(cntr); @@ -9606,7 +9636,12 @@ async function ramExplore(sp) { if (cntr !== sp.cntr) ramSaveCntr(cntr); ramHideProgress(); - if (errors.length) { + if (cntrLow) { + // 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); + } else if (errors.length) { resultEl.textContent = t('Partial') + ' — ' + errors.join('; '); resultEl.classList.remove('hidden', 'text-green-600'); resultEl.classList.add('text-red-600'); stepsEl.classList.remove('hidden'); @@ -9707,6 +9742,15 @@ async function ramDeleteFromExplorer(aid, withCascade) { ramShowProgress(label + ' ' + aid + '...'); const res = await ramSendOta(apdu, sp); ramHideProgress(); + // low counter: stop - retrying cannot help (v3.6.58) + const low = spCntrLow(res); + if (low) { + 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); + return; + } // The card consumes the counter as soon as it accepts the packet, even // when the remote command fails; a rejected packet (cntr_low, PoR error) // leaves it untouched. @@ -10128,6 +10172,11 @@ 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); + } ramOpModalFinish(verdict, !!data.imports_ok, modal, true); } @@ -10239,6 +10288,64 @@ function ramNvLine(data) { return label + ' ~' + Math.abs(used) + ' B (' + t('free') + ' ' + data.nv_before + ' \u2192 ' + data.nv_after + ')'; } +// The preset the failed operation used (the RAM form's selection, else the +// SCP80 form's) - the "Go to preset" target. +function ramCntrLowPresetIdx() { + const pick = id => { + const el = document.getElementById(id); + const i = el && el.value !== '' ? parseInt(el.value, 10) : NaN; + return (!isNaN(i) && cards[i]) ? i : -1; + }; + const ram = pick('ram-card-sel'); + 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 ''; + 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'); + 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; + const idx = presetIdx === undefined ? ramCntrLowPresetIdx() : presetIdx; + const html = ramCntrLowHtml(info, idx); + const el = document.getElementById('ram-cntr-low'); + if (el && el.classList) { + el.innerHTML = html; + el.classList.remove('hidden'); + } + if (typeof _ramOpActive !== 'undefined' && _ramOpActive) { + const steps = document.getElementById('ram-op-steps'); + if (steps) steps.innerHTML += html; + } +} + +// 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) { + switchTab('cards'); + const i = Number(idx); + if (Number.isInteger(i) && i >= 0 && cards[i]) cardsEdit(i); + const el = document.getElementById('cards-cntr'); + if (el) { + if (el.scrollIntoView) el.scrollIntoView({ block: 'center' }); + el.focus(); + if (el.select) el.select(); + } +} + // Shared result rendering for /api/ram-install and /api/ram-install-app: the // per-step lines, the counter the card consumed, and the verdict. A failure // at a LOAD / INSTALL [for load] step also gets the same block analysis as @@ -10280,6 +10387,13 @@ 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); + } ramOpModalFinish(resultEl.textContent, !!data.success, modal, true); } @@ -10989,6 +11103,27 @@ function spPorAccepted(por) { por.response_status === 'actual_response_sms_submit'; } +// 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. +function spCntrLow(res) { + if (!res) return null; + 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 }; +} + // Remote-command status words that count as success (mirrors the server's // `_ram_remote_sw_ok`): 9000 normal; 61xx more data available; 62xx/63xx // warnings; CAFE GlobalPlatform "more data available" (GET STATUS pages). @@ -18779,6 +18914,12 @@ const LANG_RU = { 'Package versions are only in the tag-list forms of the expanded registry query \u2014 this card accepted only the no-tag-list form.': 'Версии пакетов есть только в формах расширенного запроса со списком тегов \u2014 эта карта приняла только форму без списка тегов.', 'from the app instance': 'от экземпляра апплета', 'request error': 'ошибка запроса', + 'Low counter': 'Низкий счётчик', + 'the card rejected the packet': 'карта отклонила пакет', + 'card counter': 'счётчик карты', + 'the counter must be above it': 'счётчик должен быть больше', + 'Go to preset': 'К пресету', + 'the operation was stopped': 'операция остановлена', 'STK menu entry:': 'Пункт меню STK:', 'position': 'позиция', 'Other registry data:': 'Прочие данные реестра:', diff --git a/frontend/sw.js b/frontend/sw.js index 4e5ee01..2628401 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'simple-v330'; +const CACHE = 'simple-v331'; const URLS = [ 'index.html', 'help.html', diff --git a/frontend/tests/ram.test.js b/frontend/tests/ram.test.js index 4058fd0..ad88ba6 100644 --- a/frontend/tests/ram.test.js +++ b/frontend/tests/ram.test.js @@ -26,6 +26,7 @@ const FNS = ['berLenStr', 'buildApdu', 'escHtml', 'esc', 'chainInit', 'chainRamB 'stkParamsBuild', 'ramRemoteSwOk', 'spPorAccepted', 'ramIncrementCntr', 'ramDeleteFromExplorer', 'ramListingSpi2', 'ramRemoveFromExplorer', 'ramHasInstance', 'ramExpandedQueryApdu', '_parseRawElfEntry', '_parseRawAppEntry', 'ramParseElfStatus', 'ramParseAppStatus', 'ramParseModuleAids', 'parseTLV', '_parseE3Entry', '_parseMenuEntries', 'ramExpandedReport', 'ramExpandedTags', 'ramExpandedGroups', 'ramExpandedElfForm', 'ramElfVersionHint', 'ramChainGetResponse', 'ramDeriveElfVersions', + 'spCntrLow', 'ramCntrLowHtml', 'ramCntrLowPresetIdx', 'ramShowCntrLow', 'ramCardIdxAfterRemove', 'ramClearResults', 'ramHideProgress', 'ramOpChanged', 'ramRender', 'ramApplyCard', 'ramExecute', 'decodePrivileges', 'ramActionBtn', 'ramCapToolkitMode', 'ramOpProgressText', 'jcAidNorm', 'jcAidName', 'jcAidSuffix', 'jcAidHtml']; let code = ''; @@ -703,6 +704,52 @@ test('the explorer hints why the package versions are missing', () => { assert.ok(!withVer.includes('Package versions'), withVer); }); +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' }); + // 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'); + // 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); +}); + +test('ramCntrLowHtml states the facts and offers the preset jump', () => { + globalThis.t = s => s; + const out = ramCntrLowHtml({ card_cntr: '00000000BB', suggested: '00000000BC' }, 2); + const noPreset = ramCntrLowHtml({ card_cntr: '00000000BB' }, -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('onclick="ramGoToPreset(2)"'), out); + assert.ok(out.includes('Go to preset'), out); + // without a known preset the button still opens the tab (-1) + assert.ok(noPreset.includes('onclick="ramGoToPreset(-1)"'), noPreset); + assert.strictEqual(ramCntrLowHtml(null, 0), ''); +}); + +test('every SCP80/RAM flow checks the low counter and stops', () => { + // the Explore: the guarded sender, the loop guards and the final branch + 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'); + // the delete flow and the plain send check it too + assert.ok(extractFunc(html, 'ramDeleteFromExplorer').includes('spCntrLow(res)'), + 'the delete flow must stop'); + // the preset jump opens the Cards tab, selects the preset and focuses the counter + const go = extractFunc(html, 'ramGoToPreset'); + assert.ok(go.includes("switchTab('cards')"), go); + assert.ok(go.includes('cardsEdit('), go); + assert.ok(go.includes("getElementById('cards-cntr')"), go); +}); + test('the Explore detects the expanded query form and reports it', () => { const src = extractFunc(html, 'ramExplore'); assert.ok(src.includes('RAM_EXPANDED_FORMS'), 'the form ladder must be used'); diff --git a/frontend/tests/ram_counter_flow.test.js b/frontend/tests/ram_counter_flow.test.js index a32d8c6..de4917f 100644 --- a/frontend/tests/ram_counter_flow.test.js +++ b/frontend/tests/ram_counter_flow.test.js @@ -29,7 +29,9 @@ function extractFunc(src, name) { let code = ''; for (const fn of ['berLenStr', 'ramDeleteApdu', 'ramIncrementCntr', 'ramSaveCntr', 'getRamSpParams', 'spPorAccepted', 'ramRemoteSwOk', 'ramShowProgress', - 'ramHideProgress', 'ramDeleteFromExplorer']) { + 'ramHideProgress', 'ramDeleteFromExplorer', + 'spCntrLow', 'ramCntrLowHtml', 'ramCntrLowPresetIdx', 'ramShowCntrLow', + 'escHtml', 'esc']) { code += extractFunc(html, fn) + '\n'; } eval(code); diff --git a/pyproject.toml b/pyproject.toml index 3076de6..c721212 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pysim-simple-server" -version = "3.6.57" +version = "3.6.58" 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 3004073..d1a344a 100644 --- a/pysim_simple_server/server.py +++ b/pysim_simple_server/server.py @@ -31,7 +31,7 @@ from osmocom.tlv import BER_TLV_IE from osmocom.utils import rpad -VERSION = '3.6.57' +VERSION = '3.6.58' MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE @@ -1136,8 +1136,12 @@ def _send_envelope(tpdu_hex, scc, sm_sc='12345678912', submit_handler=None, # Late-PoR fallback: only after the last segment of a packet (a # concatenated download cannot have produced its PoR before the card got # every part, and polling mid-packet made spurious STATUS exchanges). - if (handle_proactive and poll_status and sw == '9000' and submit_handler - and not submit_handler.submit_tpdu_hex and not data): + if (handle_proactive and poll_status and submit_handler + and not submit_handler.submit_tpdu_hex and not data + and (sw == '9000' or sw[:2] in ('62', '63'))): + # A warning answer (the live card answers 6200 to a low-counter + # packet) can hold the PoR too: poll once so the card's verdict is + # captured instead of a bare 'ENVELOPE failed' (v3.6.58). sys.stderr.write('STATUS poll (PoR not captured)\n') st_data, st_sw = _send_status(scc) sys.stderr.write('STATUS -> %s\n' % st_sw) @@ -1337,8 +1341,13 @@ def _send_secured_packet(scc, sp_hex, oa_number, sm_sc=None, include_cpi=True, handle_proactive=handle_proactive, poll_status=(i == total - 1)) if sw != '9000' and not sw.startswith('91'): + # A warning (62xx/63xx, e.g. the live card's 6200 for a low + # counter) may still hold the PoR: pass it on so the caller can + # name the card's verdict instead of a bare ENVELOPE failure. + late_por = _sms_submit_por(submit_handler) if submit_handler else '' return {'success': False, 'sw': sw, 'bytes': len(pkt), 'segments': total, + 'response_data': (data or late_por or None), 'error': 'ENVELOPE failed at segment %d' % (i + 1)} return {'success': True, 'bytes': len(pkt), 'segments': total, 'sw': sw, 'response_data': data if data else None} @@ -1370,6 +1379,27 @@ def _por_remote_sw(por): return '' +def _cntr_low_fields(status, cntr_hex): + """The card's `cntr_low` verdict as plain data: the card's own counter and + the next valid value (the packet counter must be above it). {} when the + verdict is not a low counter. + + A low counter is NOT recoverable by retrying: every further packet with a + counter <= the card's is rejected again, so operations must stop and the + user must raise the preset counter (v3.6.58).""" + if str(status or '') != 'cntr_low': + return {} + card = str(cntr_hex or '').upper() + suggested = None + if card: + try: + width = 4 * len(card) + suggested = '%0*X' % (len(card), (int(card, 16) + 1) % (1 << width)) + except ValueError: + suggested = None + return {'cntr_low': True, 'card_cntr': card, 'suggested_cntr': suggested} + + def _ram_next_cntr(cntr, advance): """Advance the SCP80 counter by one only when the card accepted the packet (PoR ok / no PoR expected): a rejected packet (cntr_low, @@ -6698,6 +6728,25 @@ class PysimHandler(BaseHTTPRequestHandler): if ram_format is not None: resp['ram_format'] = ram_format resp['final_cntr'] = cntr + # A warning ENVELOPE may still carry the PoR (the + # card's counter verdict): decode it so the caller + # sees 'cntr_low' instead of a bare failure. + failed_por_hex = result.get('response_data') or '' + if not failed_por_hex and submit_handler: + failed_por_hex = _sms_submit_por(submit_handler) + failed_por = _decode_por(body.get('spi1', ''), body.get('spi2', ''), + body.get('kic', ''), body.get('kid', ''), + body.get('cntr', ''), body.get('kicKey', ''), + body.get('kidKey', ''), failed_por_hex) + if failed_por: + resp['por'] = failed_por + low = _cntr_low_fields(failed_por.get('response_status'), + failed_por.get('cntr')) + if low: + resp.update(low) + sys.stderr.write('OTA CNTR-LOW: card counter %s ' + '(the packet counter must be above it)\n' + % low['card_cntr']) sys.stderr.write('OTA SEND FAILED: %s\n' % result.get('error')) else: resp = {'success': True, 'sw': result['sw'], @@ -6728,6 +6777,14 @@ class PysimHandler(BaseHTTPRequestHandler): sys.stderr.write('RAM RESPONSE-PACKET: %s\n' % (por_hex if por_hex else 'empty')) if por: resp['por'] = por + low = _cntr_low_fields(por.get('response_status'), por.get('cntr')) + if low: + # a 9000/91xx ENVELOPE can still carry a + # cntr_low PoR (the live card does) + resp.update(low) + sys.stderr.write('OTA CNTR-LOW: card counter %s ' + '(the packet counter must be above it)\n' + % low['card_cntr']) extra = '' if por.get('decoded'): extra = ' (compact: %s cmd, last SW %s)' % (por['decoded'].get('number_of_commands', '?'), @@ -6895,6 +6952,13 @@ class PysimHandler(BaseHTTPRequestHandler): 'load_block_size_requested': block_size_req, 'load_block_size_auto': not block_size_req} resp.update(_ram_nv_fields(nv_before, nv_after)) + failed_rec = steps[-1] if steps else {} + resp.update(_cntr_low_fields(failed_rec.get('por_status'), + failed_rec.get('por_cntr'))) + if resp.get('cntr_low'): + sys.stderr.write('RAM-INSTALL: low counter - card counter %s, ' + 'the preset counter must be above it\n' + % resp.get('card_cntr')) self._send_json(resp) self._log_resp(resp) return @@ -6989,6 +7053,9 @@ class PysimHandler(BaseHTTPRequestHandler): resp['error'] = (state['encode_error'] or state['failure'].get('error') or ('%s failed' % step_name)) resp['failed_step'] = len(state['steps']) + failed_rec = state['steps'][-1] if state['steps'] else {} + resp.update(_cntr_low_fields(failed_rec.get('por_status'), + failed_rec.get('por_cntr'))) self._send_json(resp) self._log_resp(resp) except Exception as e: @@ -7106,6 +7173,9 @@ class PysimHandler(BaseHTTPRequestHandler): resp['error'] = (state['encode_error'] or state['failure'].get('error') or 'the card rejected the import list') resp['failed_step'] = len(steps) + failed_rec = steps[-1] if steps else {} + resp.update(_cntr_low_fields(failed_rec.get('por_status'), + failed_rec.get('por_cntr'))) sys.stderr.write('CAP-COMPAT: %s — imports %s (LOADS to %d/%d)\n' % ( loadfile_aid, 'OK' if ok else 'REJECTED', boundary, total_blocks)) self._send_json(resp) diff --git a/tests/test_fetch_routes.py b/tests/test_fetch_routes.py index 04aa50f..a854542 100644 --- a/tests/test_fetch_routes.py +++ b/tests/test_fetch_routes.py @@ -334,5 +334,98 @@ class RamInstallNvFootprintHttpTests(unittest.TestCase): self.assertNotIn(key, resp) +class CntrLowGuardHttpTests(unittest.TestCase): + """The low-counter guard on the HTTP paths (v3.6.58): /api/send-ota + reports the card's verdict as data, and a RAM chain stops at it.""" + + def setUp(self): + from pysim_simple_server import server as srv + self.srv = srv + self.server = _build_http_server('127.0.0.1', 0, PysimHandler) + self.server.log_requests = False + self.server.app = None + self.server.sl = None + self.server.scc = object() + self.server.sms_oa = '12345' + self.server.sms_sc = '12345678912' + threading.Thread(target=self.server.serve_forever, daemon=True).start() + self.port = self.server.server_address[1] + + def tearDown(self): + self.server.shutdown() + self.server.server_close() + + def _post(self, path, body): + req = urllib.request.Request( + 'http://127.0.0.1:%d%s' % (self.port, path), + data=json.dumps(body).encode(), + headers={'Content-Type': 'application/json'}, method='POST') + try: + with urllib.request.urlopen(req, timeout=5) as res: + return res.status, json.loads(res.read() or b'{}') + except urllib.error.HTTPError as e: + return e.code, json.loads(e.read() or b'{}') + + def test_send_ota_reports_the_low_counter_verdict(self): + patches = [ + mock.patch.object(self.srv, '_send_secured_packet', lambda *a, **k: { + 'success': True, 'sw': '9000', 'bytes': 34, 'segments': 1, + 'response_data': '027100000b0a00000000000000bb0002'}), + mock.patch.object(self.srv, '_decode_por', lambda *a, **k: { + 'response_status': 'cntr_low', 'tar': '000000', 'cntr': '00000000BB', + 'pcntr': 0, 'rpl': 11, 'rhl': 10, 'raw': ''}), + ] + self.server.app = object() # the send path only checks truthiness + for p in patches: + p.start() + try: + status, resp = self._post('/api/send-ota', { + 'sp': '00', 'spi1': '16', 'spi2': '01', 'kic': '15', 'kid': '15', + 'tar': '000000', 'cntr': '00000000BB'}) + finally: + for p in reversed(patches): + p.stop() + self.assertEqual(status, 200, resp) + self.assertTrue(resp['cntr_low'], resp) + self.assertEqual(resp['card_cntr'], '00000000BB') + self.assertEqual(resp['suggested_cntr'], '00000000BC') + + def test_the_ram_chain_stops_at_a_low_counter(self): + calls = [] + + def fake_send(server, scc, sp, state, name, apdu, silent=False): + calls.append(name) + if len(calls) == 2: + # the card's verdict: stop - no further step may be sent + state['steps'].append({'name': name, 'por_status': 'cntr_low', + 'por_cntr': '00000000BB'}) + return False + state['steps'].append({'name': name, 'por_status': 'por_ok', 'por_sw': '9000'}) + return True + + patches = [ + mock.patch.object(self.srv, '_cap_parse', + lambda cap_hex: ('F0414C46416101', 'F0414C4641610101', b'\x01\x02')), + mock.patch.object(self.srv, '_cap_apdu_sequence', + lambda *a, **k: ['80E60200', '80E80000', '80E80000', '80E60000']), + mock.patch.object(self.srv, '_ram_detect_format', lambda *a, **k: 'compact'), + mock.patch.object(self.srv, '_ram_send_gp_apdu', fake_send), + mock.patch.object(self.srv, '_ram_read_ff21', lambda *a, **k: None), + ] + for p in patches: + p.start() + try: + status, resp = self._post('/api/ram-install', {'cap_hex': 'AA'}) + finally: + for p in reversed(patches): + p.stop() + self.assertEqual(status, 200, resp) + self.assertFalse(resp['success'], resp) + self.assertEqual(len(calls), 2, calls) # nothing after the verdict + self.assertTrue(resp['cntr_low'], resp) + self.assertEqual(resp['card_cntr'], '00000000BB') + self.assertEqual(resp['suggested_cntr'], '00000000BC') + + if __name__ == '__main__': unittest.main() diff --git a/tests/test_ota_helpers.py b/tests/test_ota_helpers.py index 9b65d14..2563b76 100644 --- a/tests/test_ota_helpers.py +++ b/tests/test_ota_helpers.py @@ -32,6 +32,7 @@ from pysim_simple_server.server import ( _parse_setup_menu_items, _calc_ud_offset, _find_sms_tpdu, + _cntr_low_fields, _parse_proactive_header, _parse_display_text, _tlv_map, @@ -1591,6 +1592,61 @@ class RamSendGpApduLoggingTest(unittest.TestCase): self.assertIn('RAM SECURED-PACKET (LOAD (1/2)): AABB', out) +class CntrLowGuardTests(unittest.TestCase): + """The low-counter guard (v3.6.58): the card's verdict is plain data and a + warning ENVELOPE answer still gets its PoR polled.""" + + def test_cntr_low_fields(self): + self.assertEqual(_cntr_low_fields('cntr_low', '00000000BB'), + {'cntr_low': True, 'card_cntr': '00000000BB', + 'suggested_cntr': '00000000BC'}) + # the counter field is fixed-width: the wrap keeps it + self.assertEqual(_cntr_low_fields('cntr_low', 'FFFFFFFFFF'), + {'cntr_low': True, 'card_cntr': 'FFFFFFFFFF', + 'suggested_cntr': '0000000000'}) + # only the low-counter verdict counts + self.assertEqual(_cntr_low_fields('por_ok', '01'), {}) + self.assertEqual(_cntr_low_fields('cntr_high', '01'), {}) + self.assertEqual(_cntr_low_fields(None, None), {}) + + def test_a_warning_envelope_answer_polls_for_the_por(self): + from pysim_simple_server import server as srv + # the live PoR TPDU (response status 02 = cntr_low, card counter BB) + tpdu = '410005812143f500f610027100000b0a00000000000000bb0002' + fetch = 'd02e8103011300820281838607919733824009f08b1a' + tpdu + + class FakeTp: + def __init__(self): + self.sent = [] + self.proactive_handler = None + self.status_calls = 0 + + def send_apdu(self, apdu): + self.sent.append(apdu) + if apdu.startswith('80c20000'): + return ('', '6200') # the live warning answer + if apdu.startswith('80f2000c'): + self.status_calls += 1 + return ('', '9130' if self.status_calls == 1 else '9000') + if apdu.startswith('80120000'): + return (fetch, '9000') + return ('', '9000') + + class FakeScc: + cat_cla = '80' + + def __init__(self): + self._tp = FakeTp() + + scc = FakeScc() + handler = srv.PoRSubmitHandler() + _data, sw = srv._send_envelope(tpdu, scc, submit_handler=handler) + self.assertEqual(sw, '6200') # the ENVELOPE's own answer + self.assertIn('80f2000c00', scc._tp.sent) # the late-PoR poll ran + self.assertTrue(handler.submit_tpdu_hex, scc._tp.sent) + self.assertIn('bb0002', handler.submit_tpdu_hex.lower()) + + class ProactiveLongTlvTests(unittest.TestCase): """BER long-form lengths in proactive command TLVs (v3.6.57).