From 14c43f37b3b6ba92f0dd25a7d1bc9a9f69f7e698 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, 4 Oct 2026 22:03:25 +0300 Subject: [PATCH] v3.19.1: the compact-PoR decode guard (applet TAR responses reported as data) pySim's CompactRemoteResp was applied to every response packet, so a third-party applet's own TAR response parsed as count=128/SW=0110/data='' (live 2026-10-04: the alfa-dsa applet's `80 01 10`) - a fabricated status word and a lost response. _decode_por now takes the plaintext command script's length (cmd_len): the compact structure's command count cannot exceed it (TS 102 226 5.1.2 Table 5.1 - each command needs at least one byte), so a larger count is classified `response_type: 'raw'`, the secured data becomes `decoded.last_response_data` (a por.data check matches it) and no SW is decoded; `secured_data` is exposed in every case. Callers pass cmd_len where they know the plaintext: the test runner's scp80 step (None for a pre-built packet; carried in ctx['last_scp80'] for the submit-PoR path), /api/send-ota (both paths), the RAM step sender and the counter probe; a caller without it keeps the previous parse. Docs: test-scripts.md and api.md describe the raw fallback. Tests: 821 frontend + 729 Python; sw cache simple-v390. --- docs/api.md | 7 ++++ docs/test-scripts.md | 7 +++- frontend/index.html | 2 +- frontend/sw.js | 2 +- pyproject.toml | 2 +- pysim_simple_server/server.py | 69 ++++++++++++++++++++++++----------- tests/test_ota_helpers.py | 28 ++++++++++++++ tests/test_testscript.py | 26 +++++++++++++ 8 files changed, 118 insertions(+), 25 deletions(-) diff --git a/docs/api.md b/docs/api.md index f52f8a3..4dd40bf 100644 --- a/docs/api.md +++ b/docs/api.md @@ -558,6 +558,13 @@ response carries them too, plus an `error`). fetched via a proactive command (FETCH). The response contains the same `por` structure if decoding succeeds. +When the secured data cannot be the TS 102 226 compact remote response (its +command count exceeds the command script that was sent - a third-party +applet's own TAR answers with application bytes), `por.response_type` is +`raw`, the whole secured data is reported as `decoded.last_response_data` +and no status word is decoded; `secured_data` carries the response bytes in +every case. + The SPI2 `por_in_submit` bit (0x20) selects submit-mode PoR. ### `POST /api/cap-info` diff --git a/docs/test-scripts.md b/docs/test-scripts.md index e18520b..4e25401 100644 --- a/docs/test-scripts.md +++ b/docs/test-scripts.md @@ -284,7 +284,12 @@ SHORT MESSAGE is decoded with that step's keyset, SPI and counter. `status` is the decoded response status name (`por_ok`, `cntr_low`, `rc_cc_ds_failed`, …; `ok` is accepted for `por_ok`); `sw` is the R-APDU status word and `data` the R-APDU response data (exact/mask) - both come from -the decoded response (scripting `AB`/`AF`/compact forms). A PoR split over +the decoded response (scripting `AB`/`AF`/compact forms). An applet's **own +TAR** answers with its application-defined bytes: when the data cannot be the +compact remote response (its command count exceeds the command script that +was sent), the whole secured data is reported as `data` and no `sw` is +decoded - assert such a response with `data` (the action's `por` object +follows the same rule). A PoR split over several SMS parts is accumulated across SEND SHORT MESSAGE expectations: put the `por` check on the step that completes the sequence (intermediate steps should use `raw` or no PoR check). diff --git a/frontend/index.html b/frontend/index.html index db3047d..be51cc6 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -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.0'; +const SIMPLE_VERSION = '3.19.1'; document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION; // ===== Tab switching ===== diff --git a/frontend/sw.js b/frontend/sw.js index b3bf866..0ab551a 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'simple-v389'; +const CACHE = 'simple-v390'; const URLS = [ 'index.html', 'help.html', diff --git a/pyproject.toml b/pyproject.toml index 50168d2..7f8c8b1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pysim-simple-server" -version = "3.19.0" +version = "3.19.1" 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 3ffd3f4..ccc4c00 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.0' +VERSION = '3.19.1' MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE @@ -1793,7 +1793,8 @@ def _counter_probe(server, scc, preset, body, send_fn=None): if submit_hex: por_hex = submit_hex por = _decode_por(p['spi1'], p['spi2'], p['kic'], p['kid'], cntr, - p['kic_key'], p['kid_key'], por_hex) if por_hex else None + p['kic_key'], p['kid_key'], por_hex, + cmd_len=len(p['apdu']) // 2) if por_hex else None pstatus = str((por or {}).get('response_status') or '') out['packets'] += 1 attempt = {'cntr': cntr, 'por_status': pstatus or None, @@ -2003,7 +2004,8 @@ def _ram_send_gp_apdu(server, scc, sp, state, step_name, apdu_hex, silent=False) por_hex = tpdu_b[idx:].hex() por_src = 'sms-submit' por = _decode_por(spi1, spi2, sp['kic'], sp['kid'], state['cntr'], - sp['kic_key'], sp['kid_key'], por_hex) + sp['kic_key'], sp['kid_key'], por_hex, + cmd_len=len(apdu_hex) // 2) step, step_error = _ram_step_result(step_name, last_sw, por, por_hex, result['bytes'], result['segments']) state['last_step'] = step @@ -2144,7 +2146,13 @@ def _sms_submit_por(submit_handler): return '027100' + ud_hex -def _decode_por(spi1, spi2, kic, kid, cntr_hex, kic_key_hex, kid_key_hex, response_hex): +def _decode_por(spi1, spi2, kic, kid, cntr_hex, kic_key_hex, kid_key_hex, + response_hex, cmd_len=None): + """Decode a response packet (PoR). `cmd_len` is the length in bytes of the + plaintext command script the packet carried (None when unknown): the + TS 102 226 5.1.2 compact response's command count cannot exceed it, so a + larger count means the data is the application's own (a third-party + applet's TAR) and is reported as `raw` - no status word is invented.""" from pySim.ota import OtaDialectSms, CompactRemoteResp from osmocom.utils import h2b, b2h if not response_hex: @@ -2175,7 +2183,18 @@ def _decode_por(spi1, spi2, kic, kid, cntr_hex, kic_key_hex, kid_key_hex, respon 'cc_rc': res['cc_rc'].hex().upper(), 'raw': response_hex, } - + secured_hex = (bytes(res['secured_data']).hex().upper() + if len(res['secured_data']) else '') + if secured_hex: + out['secured_data'] = secured_hex + # The compact structure's first byte (TS 102 226 5.1.2 Table 5.1) is the + # number of commands executed within the command script; each command + # needs at least one byte, so a count above the script's length proves + # the data is not a compact remote response. + compact_ok = (dec is not None + and (cmd_len is None + or int(dec.number_of_commands) <= int(cmd_len))) + # TS 102 226 5.2.2 Response Scripting template (AB/AF): cards wrap the # R-APDU(s) of the executed remote command(s) this way instead of the # plain compact response. The R-APDU's own SW and data are the useful @@ -2196,7 +2215,7 @@ def _decode_por(spi1, spi2, kic, kid, cntr_hex, kic_key_hex, kid_key_hex, respon out['bad_format'] = scripted['bad_format'] out['bad_format_name'] = _BAD_FORMAT_NAMES.get(scripted['bad_format'], 'unknown error type') - elif dec is not None: + elif compact_ok: out['response_type'] = 'compact' out['decoded'] = { 'number_of_commands': dec.number_of_commands, @@ -2204,17 +2223,18 @@ def _decode_por(spi1, spi2, kic, kid, cntr_hex, kic_key_hex, kid_key_hex, respon 'last_response_data': str(dec.last_response_data), } else: - out['response_type'] = 'none' - elif dec is not None: - out['response_type'] = 'compact' - out['decoded'] = { - 'number_of_commands': dec.number_of_commands, - 'last_status_word': str(dec.last_status_word), - 'last_response_data': str(dec.last_response_data), - } + # Not a compact remote response - the application's own bytes + # (e.g. a third-party applet's TAR); expose them as the response + # data, with no status word. + out['response_type'] = 'raw' + out['decoded'] = { + 'number_of_commands': None, + 'last_status_word': '', + 'last_response_data': secured_hex, + } else: out['response_type'] = 'none' - + return out @@ -5956,10 +5976,13 @@ def _test_run_scp80(server, step, ctx): counter = counter or '00000000' # The PoR of this exchange may arrive as a SEND SHORT MESSAGE (PoR-in- # submit); the expect step decodes it with the parameters the packet was - # built from, so remember them. + # built from, so remember them. `cmd_len` is the plaintext command + # script's length (before any format wrapping) - the compact PoR's + # command-count bound, None for a pre-built packet. + cmd_len = len(p.get('apdu') or '') // 2 or None ctx['last_scp80'] = {'spi1': spi1, 'spi2': spi2, 'kic': kic, 'kid': kid, 'counter': counter, 'kicKey': kic_key, 'kidKey': kid_key, - 'tar': tar} + 'tar': tar, 'cmd_len': cmd_len} fmt = p.get('format') or 'compact' apdu_hex = p.get('apdu') or '' if p.get('sp'): @@ -5990,7 +6013,8 @@ def _test_run_scp80(server, step, ctx): ' (%d segment(s))' % segments if segments > 1 else '')) por = None if data: - por = _decode_por(spi1, spi2, kic, kid, counter, kic_key, kid_key, data) + por = _decode_por(spi1, spi2, kic, kid, counter, kic_key, kid_key, data, + cmd_len=cmd_len) if por: dec = por.get('decoded') or {} extra = '' @@ -6221,7 +6245,8 @@ def _test_expect_por(ctx, raw): return None por_hex = '027100' + ud_hex return _decode_por(last['spi1'], last['spi2'], last['kic'], last['kid'], - last['counter'], last['kicKey'], last['kidKey'], por_hex) + last['counter'], last['kicKey'], last['kidKey'], por_hex, + cmd_len=last.get('cmd_len')) def _test_run_expect(server, step, pending, ctx=None): @@ -7818,7 +7843,8 @@ class PysimHandler(BaseHTTPRequestHandler): failed_por = _decode_por(spi1, spi2, body.get('kic', ''), body.get('kid', ''), body.get('cntr', ''), body.get('kicKey', ''), - body.get('kidKey', ''), failed_por_hex) + body.get('kidKey', ''), failed_por_hex, + cmd_len=len(body.get('apdu') or '') // 2 or None) if failed_por: resp['por'] = failed_por low = _cntr_low_fields(failed_por.get('response_status')) @@ -7855,7 +7881,8 @@ class PysimHandler(BaseHTTPRequestHandler): por_src = 'sms-submit' por = _decode_por(spi1, spi2, body.get('kic', ''), body.get('kid', ''), body.get('cntr', ''), body.get('kicKey', ''), - body.get('kidKey', ''), por_hex) + body.get('kidKey', ''), por_hex, + cmd_len=len(body.get('apdu') or '') // 2 or None) # Check for SPI2=0x21 (PoR required) but got 9000 with no PoR → card refuses PoR por_required = bool(spi2_val & 0x01) no_por_received = not por_hex and not (submit_handler and submit_handler.submit_tpdu_hex) diff --git a/tests/test_ota_helpers.py b/tests/test_ota_helpers.py index 4c625b0..09a1a2c 100644 --- a/tests/test_ota_helpers.py +++ b/tests/test_ota_helpers.py @@ -507,6 +507,34 @@ class TestDecodePor(unittest.TestCase): msg='expected None for %r' % bad) + def test_applet_response_falls_back_to_raw(self): + # A third-party applet's own TAR answers with application bytes; + # `80 01 10` cannot be a compact response for a 1-byte command script + # (count 0x80 exceeds it), so the whole secured data is the response + # data and no status word is invented (live 2026-10-04). + raw = '027100000e0aaf4d0100020000190000800110' + r = _decode_por('16', '01', '15', '15', '0000000001', K, K, raw, cmd_len=1) + self.assertEqual(r['response_type'], 'raw') + self.assertEqual(r['secured_data'], '800110') + self.assertEqual(r['decoded']['last_status_word'], '') + self.assertEqual(r['decoded']['last_response_data'], '800110') + self.assertIsNone(r['decoded']['number_of_commands']) + # without the command length the compact parse is kept (unknown + # context - the pre-fix behaviour) + r = _decode_por('16', '01', '15', '15', '0000000001', K, K, raw) + self.assertEqual(r['response_type'], 'compact') + self.assertEqual(r['decoded']['number_of_commands'], 128) + + def test_rfm_chain_response_stays_compact(self): + # a chained RFM command script is long enough for its count: the real + # SW (6a86) is decoded as before + raw = '027100000e0ab0000000020000180000016a86' + r = _decode_por('16', '01', '15', '15', '0000000001', K, K, raw, cmd_len=68) + self.assertEqual(r['response_type'], 'compact') + self.assertEqual(r['decoded']['last_status_word'], '6a86') + self.assertEqual(r['secured_data'], '016A86') + + class TestProactiveDecode(unittest.TestCase): """Server-side proactive command/TR decode helpers (v1.8.0 log feature).""" diff --git a/tests/test_testscript.py b/tests/test_testscript.py index 8876ecb..cb73f28 100644 --- a/tests/test_testscript.py +++ b/tests/test_testscript.py @@ -1097,6 +1097,32 @@ class TestRunnerActions(RunnerTestCase): self.assertIn('TEST-RUN step 2: SMS DA=79332505884 PID=00 DCS=00 UDL=8 ' 'UD=4B1C12579C9D83', log) + # the live alfa-dsa applet's response packet (2026-10-04): the secured + # data `80 01 10` is the applet's own - the compact structure's command + # count (0x80) cannot fit the 1-byte command script + APP_POR_RAW = '027100000e0aaf4d0100020000190000800110' + + def test_scp80_por_object_check_on_an_applet_response(self): + # the live alfa-dsa applet's response (2026-10-04): the secured data + # `80 01 10` is the applet's own, so the compact parse must not claim + # 128 commands - the runner exposes it as the PoR data + preset = {'kic': '15', 'kid': '15', 'kicKey': 'AA' * 16, + 'kidKey': 'BB' * 16, 'counter': '00000001', + 'tars': preset_tars()} + with mock.patch.object(S, '_build_secured_packet', return_value=('AA' * 10, {})), \ + mock.patch.object(S, '_send_secured_packet', + return_value={'success': True, 'sw': '9000', + 'response_data': self.APP_POR_RAW}): + run = self.run_script(FakeServer(FakeScc()), [ + {'type': 'action', 'kind': 'scp80', 'params': {'apdu': '01'}, + 'check': {'por': {'status': 'por_ok', 'data': '800110'}}}, + ], preset) + self.assertEqual(run['status'], 'ok', run['steps']) + labels = {c['label']: c for c in run['steps'][0]['checks']} + self.assertTrue(labels['PoR status']['ok'], labels) + self.assertTrue(labels['PoR data']['ok'], labels) + self.assertEqual(run['steps'][0]['por']['response_type'], 'raw') + def test_scp80_format_wraps_the_apdu(self): # `format` wraps the C-APDU in the expanded Command Scripting template # (TS 102 226 5.2.1): `expanded` = AA//22/..., `expanded-ae` =