From 36d2f71bc7cc44a311fb45eaa7ecc3e7a8aa4e77 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: Sun, 27 Sep 2026 23:21:56 +0300 Subject: [PATCH] fix: report the real PoR/remote-SW result of every RAM install step (v3.6.2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The RAM installer read `por['decoded']['response_status']`, but the compact response decoder's `decoded` only carries {number_of_commands, last_status_word, last_response_data} - so the suffix was always empty and every step showed the useless `por_error_`, even when the PoR was por_ok. The PoR verdict is the top-level `response_status`. With that fixed, the decoded details also show that the remote command's own status word was the real verdict all along: a captured live install returned por_ok with `last_status_word` 6700/6F00 (the card rejected every RAM APDU) while the installer reported success. - new `_ram_step_result()`/`_por_remote_sw()`/`_ram_remote_sw_ok()`: a step fails on a non-por_ok PoR, on a remote SW outside the success set (9000, 61xx more data, 62xx/63xx warnings, CAFE GP "more data"), or on an undecodable PoR (a 9000 transport SW with no PoR at all is `no_por`, not a failure). A decoded 61xx is explicitly success, per GP/ISO. - step records gain por_sw/por_type/por_cntr/por_data/por_raw and `por_error`; the response carries `failed_step` and a detailed error such as `LOAD (1/9): remote SW 6700`; the log line now prints `status=por_ok remote_sw=6700`. - PWA: new pure `ramStepLine()` renders e.g. `❌ Шаг 2: LOAD (1/9) — PoR ok · remote SW 6700 (Wrong length in Lc) · 274 B / 3 SMS` (SW meaning via the existing lookupSw decoder) and the transport SW only when it is not 9000. - tests: tests/test_ota_helpers.py RamPorStepTest (success set incl. 61xx, the captured 6700 failure, no-PoR/undecodable, expanded responses); ram.test.js ramStepLine cases. - docs/api.md RAM install step fields + failure semantics; AGENTS updated. 603 frontend / 487 Python green; version 3.6.2; sw simple-v275. --- docs/api.md | 21 ++++++-- frontend/index.html | 42 +++++++++++++--- frontend/sw.js | 2 +- frontend/tests/ram.test.js | 21 +++++++- pyproject.toml | 2 +- pysim_simple_server/server.py | 92 ++++++++++++++++++++++++++++++----- tests/test_ota_helpers.py | 62 +++++++++++++++++++++++ 7 files changed, 215 insertions(+), 27 deletions(-) diff --git a/docs/api.md b/docs/api.md index 8c170aa..7321580 100644 --- a/docs/api.md +++ b/docs/api.md @@ -477,7 +477,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 and its PoR is checked; the sequence aborts on the first PoR error. 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 `.cap` archive (a ZIP of nested components) is parsed server-side in `_cap_parse`; no external tooling is required. **Request body:** ```json @@ -511,9 +511,9 @@ Install a Java Card `.cap` file on the card via GlobalPlatform commands (INSTALL **Response (success):** ```json {"success": true, "failed_step": null, - "steps": [{"name": "install_for_load", "apdu": "80E60200...", "por_status": "por_ok", "sw": "9000", "bytes": 58, "segments": 1}, - {"name": "load_0", "apdu": "80E80000...", "por_status": "por_ok", "sw": "9000", "bytes": 274, "segments": 3}, - {"name": "install_for_install", "apdu": "80E60C00...", "por_status": "por_ok", "sw": "9000", "bytes": 66, "segments": 1}], + "steps": [{"name": "INSTALL [for load]", "por_status": "por_ok", "por_sw": "9000", "por_type": "compact", "por_cntr": "000000011B", "sw": "9000", "bytes": 58, "segments": 1}, + {"name": "LOAD (1/9)", "por_status": "por_ok", "por_sw": "9000", "por_type": "compact", "sw": "9000", "bytes": 274, "segments": 3}, + {"name": "INSTALL [for install]", "por_status": "por_ok", "por_sw": "9000", "por_type": "compact", "sw": "9000", "bytes": 66, "segments": 1}], "final_cntr": "0000000004", "load_file_aid": "A000000003000000", "module_aid": "A000000003000000", @@ -527,7 +527,18 @@ Install a Java Card `.cap` file on the card via GlobalPlatform commands (INSTALL default), `load_block_size_requested` echoes an explicit `load_block_size` (null = the default was used) and `load_block_size_auto` marks that default. Each step reports the secured packet size `bytes` and the number of SMS -`segments` it took. +`segments` it took. `sw` is the transport (ENVELOPE/GET RESPONSE) status +word; the RAM results are in `por_status` (the PoR verdict: `por_ok`, +`rc_cc_ds_failed`, `cntr_low`, ... or `no_por` when the card sent none) and +`por_sw` (the remote command's own status word from the compact/expanded +response, with `por_type`/`por_cntr`/`por_data`/`por_raw` for context). + +A step fails when the PoR is not `por_ok`, when `por_sw` is outside the +success set (`9000`, `61xx` more data, `62xx`/`63xx` warnings, `CAFE` GP +"more data"), or when response data arrived but could not be decoded. The +failing step carries `por_error` (e.g. `remote SW 6700`) and the response +sets `success: false`, `failed_step` and a detailed `error` such as +`LOAD (1/9): remote SW 6700`. **Response (failure):** ```json diff --git a/frontend/index.html b/frontend/index.html index 4c2f703..abc0e0c 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1665,7 +1665,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.1'; +const SIMPLE_VERSION = '3.6.2'; document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION; // ===== Tab switching ===== @@ -8859,6 +8859,33 @@ async function ramDeleteFromExplorer(aid, withCascade) { await ramExplore(sp); } +// One RAM install step line: the PoR verdict plus the remote command's +// status word (9000/61xx/62xx/63xx/CAFE are success; anything else is the +// card's error) - the details that used to be lost behind a bare +// "por_error_" label. The envelope/GET RESPONSE SW is only shown when it is +// not a plain 9000. +function ramStepLine(s, idx) { + const ok = !s.por_error && (s.por_status === 'por_ok' || s.por_status === 'no_por'); + let line = (ok ? '\u2705' : '\u274c') + ' ' + t('Step') + ' ' + (idx + 1) + ': ' + + s.name + ' \u2014 '; + if (s.por_status === 'por_ok') line += t('PoR ok'); + else if (s.por_status === 'no_por') line += t('no PoR'); + else if (s.por_status && s.por_status.indexOf('por_error') === 0) { + const legacy = s.por_status.replace(/^por_error_?/, ''); + line += t('PoR error') + (legacy ? ' ' + legacy : ''); + } else if (s.por_status) line += t('PoR error') + ' ' + s.por_status; + else line += t('PoR unknown'); + if (s.por_sw) { + const name = lookupSw(s.por_sw.substr(0, 2), s.por_sw.substr(2, 2), 'gp'); + line += ' \u00b7 ' + t('remote SW') + ' ' + s.por_sw + (name ? ' (' + name + ')' : ''); + } + if (s.sw && s.sw !== '9000') line += ' \u00b7 ' + t('transport SW') + ' ' + s.sw; + if (s.bytes && s.segments) { + line += ' \u00b7 ' + s.bytes + ' ' + t('bytes') + ' / ' + s.segments + ' SMS'; + } + return line; +} + async function ramInstallCap(sp) { const fileInput = document.getElementById('ram-cap-file'); const file = fileInput.files[0]; @@ -8894,12 +8921,7 @@ async function ramInstallCap(sp) { stepsEl.classList.remove('hidden'); let txt = ''; - (data.steps || []).forEach((s, idx) => { - const mark = s.por_status === 'por_ok' ? '✅' : '❌'; - let line = mark + ' ' + t('Step') + ' ' + (idx + 1) + ': ' + s.name + ' — ' + s.por_status + ' (SW ' + s.sw + ')'; - if (s.bytes && s.segments) line += ' · ' + s.bytes + ' ' + t('bytes') + ' / ' + s.segments + ' SMS'; - txt += line + '\n'; - }); + (data.steps || []).forEach((s, idx) => { txt += ramStepLine(s, idx) + '\n'; }); stepsEl.textContent = txt; if (data.success) { @@ -16787,6 +16809,12 @@ const LANG_RU = { 'default: 9000 (91?? when polling)': 'по умолчанию: 9000 (91?? при опросе)', 'Expected data (hex, optional)': 'Ожидаемые данные (hex, опционально)', 'PoR check': 'Проверка PoR', + 'PoR ok': 'PoR: успешно', + 'no PoR': 'PoR: нет', + 'PoR error': 'Ошибка PoR', + 'PoR unknown': 'PoR: неизвестно', + 'remote SW': 'SW команды', + 'transport SW': 'транспортный SW', 'Any': 'Любой', 'No PoR expected': 'PoR не ожидается', 'On mismatch': 'При несовпадении', diff --git a/frontend/sw.js b/frontend/sw.js index 4ddf0ea..ccf8244 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'simple-v274'; +const CACHE = 'simple-v275'; const URLS = [ 'index.html', 'help.html', diff --git a/frontend/tests/ram.test.js b/frontend/tests/ram.test.js index 69eb545..3dd816d 100644 --- a/frontend/tests/ram.test.js +++ b/frontend/tests/ram.test.js @@ -22,7 +22,7 @@ function extractFunc(src, name) { } // Extract chain builder functions and dependencies -const FNS = ['berLenStr', 'buildApdu', 'escHtml', 'esc', 'chainInit', 'chainRamBuildRowHex', 'ramFmtLifecycle', 'ramFmtPrivileges', 'ramRenderExploreHtml', +const FNS = ['berLenStr', 'buildApdu', 'escHtml', 'esc', 'chainInit', 'chainRamBuildRowHex', 'ramFmtLifecycle', 'ramFmtPrivileges', 'ramRenderExploreHtml', 'ramStepLine', 'ramCardIdxAfterRemove', 'ramClearResults', 'ramHideProgress', 'ramOpChanged', 'ramRender', 'ramApplyCard', 'ramExecute', 'jcAidNorm', 'jcAidName', 'jcAidSuffix', 'jcAidHtml']; let code = ''; @@ -259,6 +259,25 @@ function fakeRamDocument(ids) { return els; } +test('ramStepLine shows the PoR verdict and the remote status word', () => { + globalThis.t = s => s; + globalThis.lookupSw = (a, b) => (a + b === '6700' ? 'Wrong length in Lc' : ''); + const okLine = ramStepLine({ name: 'INSTALL [for load]', por_status: 'por_ok', + por_sw: '9000', sw: '9000', bytes: 50, segments: 1 }, 0); + assert.ok(okLine.startsWith('\u2705'), okLine); + assert.ok(okLine.includes('PoR ok'), okLine); + assert.ok(okLine.includes('remote SW 9000'), okLine); + assert.ok(okLine.includes('50 bytes / 1 SMS'), okLine); + const badLine = ramStepLine({ name: 'LOAD (1/9)', por_status: 'por_ok', por_sw: '6700', + por_error: 'remote SW 6700', sw: '9000', bytes: 274, segments: 3 }, 1); + assert.ok(badLine.startsWith('\u274c'), badLine); + assert.ok(badLine.includes('remote SW 6700 (Wrong length in Lc)'), badLine); + const porLine = ramStepLine({ name: 'LOAD', por_status: 'por_error_cntr_low' }, 2); + assert.ok(porLine.includes('PoR error cntr_low'), porLine); + const noPor = ramStepLine({ name: 'LOAD', por_status: 'no_por' }, 3); + assert.ok(noPor.startsWith('\u2705') && noPor.includes('no PoR'), noPor); +}); + test('ramOpChanged clears the executed status only on a real op change', () => { const els = fakeRamDocument(['ram-op', 'ram-install-params', 'ram-result', 'ram-explorer', 'ram-steps', 'ram-progress']); _ramOpLast = null; diff --git a/pyproject.toml b/pyproject.toml index 39599f6..c989067 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pysim-simple-server" -version = "3.6.1" +version = "3.6.2" 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 0fd4683..ddab64f 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.1' +VERSION = '3.6.2' MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE @@ -1095,6 +1095,69 @@ def _send_secured_packet(scc, sp_hex, oa_number, sm_sc=None, include_cpi=True, 'response_data': data if data else None} +# Remote-command status words that count as success in the RAM dialog: +# 9000 = normal; 61xx = more data available (GET RESPONSE); 62xx/63xx = +# warnings (63xx/63Cx often carry "more data available" too); CAFE = +# GlobalPlatform "more data available" (GET STATUS pages). +_RAM_REMOTE_SW_OK = ('9000', 'CAFE') + + +def _ram_remote_sw_ok(sw): + s = str(sw or '').upper() + return bool(s) and (s in _RAM_REMOTE_SW_OK or s[:2] in ('61', '62', '63')) + + +def _por_remote_sw(por): + """Last remote-command status word from a decoded PoR, '' when none: + compact responses carry last_status_word, expanded ones a status word + per command.""" + dec = (por or {}).get('decoded') or {} + sw = str(dec.get('last_status_word') or '').upper() + if sw: + return sw + for r in reversed((por or {}).get('responses') or []): + if r.get('status_word'): + return str(r['status_word']).upper() + return '' + + +def _ram_step_result(step_name, last_sw, por, por_hex, bytes_, segments): + """Assemble a RAM-install step record and its failure reason. + + The PoR verdict is the top-level `response_status`; the remote command's + own status word lives in `decoded.last_status_word` (compact) or in the + expanded `responses` list. A step fails on a non-por_ok PoR, on a remote + SW outside `_RAM_REMOTE_SW_OK`, or when a PoR arrived but could not be + decoded (a 9000 transport SW with no PoR at all is 'no_por', not a + failure).""" + step = {'name': step_name, 'sw': last_sw, 'bytes': bytes_, 'segments': segments} + error = None + if por: + pstatus = str(por.get('response_status') or '') + remote_sw = _por_remote_sw(por) + step['por_status'] = pstatus or 'unknown' + step['por_type'] = por.get('response_type') + step['por_cntr'] = por.get('cntr') + step['por_data'] = (por.get('decoded') or {}).get('last_response_data', '') + step['por_raw'] = por.get('raw') + if remote_sw: + step['por_sw'] = remote_sw + if pstatus != 'por_ok': + error = 'PoR %s' % (pstatus or 'unknown') + elif remote_sw and not _ram_remote_sw_ok(remote_sw): + error = 'remote SW %s' % remote_sw + elif last_sw == '9000' and not por_hex: + step['por_status'] = 'no_por' + else: + step['por_status'] = 'unknown' + if por_hex: + step['por_raw'] = por_hex + error = 'PoR undecodable' + if error: + step['por_error'] = error + return step, error + + def _decode_por(spi1, spi2, kic, kid, cntr_hex, kic_key_hex, kid_key_hex, response_hex): from pySim.ota import OtaDialectSms, CompactRemoteResp from osmocom.utils import h2b, b2h @@ -5990,6 +6053,8 @@ class PysimHandler(BaseHTTPRequestHandler): spi2_val = int(spi2, 16) por_in_submit = bool(spi2_val & 0x20) + failure = {} + def _send_gp_apdu(apdu_hex, step_name): nonlocal cntr, encode_error try: @@ -6031,18 +6096,20 @@ class PysimHandler(BaseHTTPRequestHandler): por_hex = tpdu_b[idx:].hex() por_src = 'sms-submit' por = _decode_por(spi1, spi2, kic, kid, cntr, kic_key, kid_key, por_hex) - por_status = 'unknown' - if por and por.get('decoded'): - ps = por['decoded'].get('response_status', '') - por_status = 'por_ok' if ps == '9100' else 'por_error_%s' % ps - sys.stderr.write('RAM-INSTALL: %s PoR[%s] status=%s (%d B, %d SM)\n' % ( - step_name, por_src, ps, result['bytes'], result['segments'])) - elif last_sw == '9000' and not por_hex: - por_status = 'no_por' - steps.append({'name': step_name, 'por_status': por_status, 'sw': last_sw, - 'bytes': result['bytes'], 'segments': result['segments']}) + step, step_error = _ram_step_result( + step_name, last_sw, por, por_hex, + result['bytes'], result['segments']) + steps.append(step) + sys.stderr.write('RAM-INSTALL: %s PoR[%s] status=%s remote_sw=%s%s (%d B, %d SM)\n' % ( + step_name, por_src, step.get('por_status', '?'), + step.get('por_sw', '-'), + (' error=%s' % step_error) if step_error else '', + result['bytes'], result['segments'])) # Increment counter cntr = '%010X' % ((int(cntr, 16) + 1) % (2 ** 32)) + if step_error: + failure['error'] = '%s: %s' % (step_name, step_error) + return False return True finally: if submit_handler and hasattr(scc, '_tp'): @@ -6065,7 +6132,8 @@ class PysimHandler(BaseHTTPRequestHandler): step_name = 'LOAD (%d/%d)' % (apdu_idx, len(seq) - 2) if not _send_gp_apdu(gp_apdu, step_name): resp = {'success': False, 'steps': steps, 'failed_step': len(steps), - 'error': encode_error or ('%s failed' % step_name), + 'error': (encode_error or failure.get('error') + or ('%s failed' % step_name)), 'load_file_aid': loadfile_aid, 'module_aid': module_aid, 'load_block_size': block_size, 'load_block_size_requested': block_size_req, diff --git a/tests/test_ota_helpers.py b/tests/test_ota_helpers.py index 9422bd6..410aca9 100644 --- a/tests/test_ota_helpers.py +++ b/tests/test_ota_helpers.py @@ -30,6 +30,9 @@ from pysim_simple_server.server import ( _ota_reference, _parse_select_item, _parse_setup_menu_items, + _por_remote_sw, + _ram_remote_sw_ok, + _ram_step_result, _record_tr, _send_secured_packet, _spi_from_bytes, @@ -1243,3 +1246,62 @@ class TerminalProfileTest(unittest.TestCase): self.assertEqual(server_obj.event_list, [0x09]) self.assertEqual(server_obj.sim_menu, 'menu') self.assertTrue(resp['menu']) + + +class RamPorStepTest(unittest.TestCase): + """The RAM install reports the PoR verdict and the remote command's own + status word. Vectors: a captured live response where every remote + command returned SW 6700/6F00 while the PoR itself was por_ok, and + synthetic success responses (9000, 6113).""" + + # 02 71 00 | 000e 0a | TAR b00011/000000 | CNTR | PCNT | STS=00 | compact + POR_INSTALL = '027100000e0a000000000000011b0000026700' + POR_INSTALL_OK = '027100000e0ab0001100000000000000019000' + POR_INSTALL_61XX = '027100000e0ab0001100000000000000016113' + + @staticmethod + def _por(hexstr): + return _decode_por('00', '00', '01', '01', '0000000000', + '00' * 16, '00' * 16, hexstr) + + def test_remote_sw_success_set(self): + for sw in ('9000', '6113', '62F1', '6310', 'CAFE'): + self.assertTrue(_ram_remote_sw_ok(sw), sw) + for sw in ('6700', '6F00', '6A82', '6400', '', None): + self.assertFalse(_ram_remote_sw_ok(sw), sw) + + def test_step_reports_the_remote_sw_failure(self): + por = self._por(self.POR_INSTALL) + self.assertEqual(por['response_status'], 'por_ok') + step, err = _ram_step_result('LOAD (1/9)', '9000', por, + self.POR_INSTALL, 274, 3) + self.assertEqual(step['por_status'], 'por_ok') + self.assertEqual(step['por_sw'], '6700') + self.assertEqual(step['por_type'], 'compact') + self.assertEqual(err, 'remote SW 6700') + self.assertEqual(step['por_error'], 'remote SW 6700') + + def test_step_accepts_9000_and_61xx_remote_sw(self): + for hexstr, sw in ((self.POR_INSTALL_OK, '9000'), + (self.POR_INSTALL_61XX, '6113')): + por = self._por(hexstr) + step, err = _ram_step_result('LOAD (1/9)', '9000', por, hexstr, 10, 1) + self.assertIsNone(err, sw) + self.assertNotIn('por_error', step) + self.assertEqual(step['por_sw'], sw) + + def test_step_no_por_and_undecodable(self): + # transport SW 9000 with no response data at all: no PoR expected + step, err = _ram_step_result('LOAD', '9000', None, '', 10, 1) + self.assertIsNone(err) + self.assertEqual(step['por_status'], 'no_por') + # response data present but undecodable: a failure with the raw kept + step, err = _ram_step_result('LOAD', '9000', None, 'DEADBEEF', 10, 1) + self.assertEqual(err, 'PoR undecodable') + self.assertEqual(step['por_raw'], 'DEADBEEF') + + def test_remote_sw_from_expanded_response(self): + por = {'response_status': 'por_ok', 'decoded': {}, + 'responses': [{'status_word': '9000'}, {'status_word': '6A82'}]} + self.assertEqual(_por_remote_sw(por), '6A82') +