From d20aac4aeb71cfc612e8cab4d28e88d081ee35d3 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, 11 Oct 2026 21:02:35 +0300 Subject: [PATCH] v3.25.7: the ENVELOPE response-bytes fetch The live report: an SPI2 01 secured packet reported no PoR. For such a packet TS 31.115 4.2/4.4 (b6=0) has the Response Packet retrieved by the ME from the ENVELOPE exchange (SEND SHORT MESSAGE is the b6=1 case), but _send_envelope only asked for response bytes after a 61xx answer - the card signals them with 62xx/63xx instead (the "more data" quirk the remote-command SWs already acknowledge, _ram_remote_sw_ok), so the whole PoR stayed on the card and the late-PoR STATUS poll found nothing. _envelope_get_response (shared with _send_event_download, which had the same 61xx-only code): a 61xx names the GET RESPONSE length; a 62xx/63xx without data gets Le 256 (00c0000000 - a bare 61 no longer emits the odd-length '%02x' % 0x100 APDU). The result is adopted when bytes were delivered (or for a 61xx); otherwise the warning stays, so the v3.6.58 SEND-SM PoR path (the STATUS poll) keeps working. Tests: the 61xx length, the 63xx+PoR fetch (Le 256), the bare-61 encoding, the warning-kept path (the poll still runs) and the event-download sharing. 887 frontend + 880 Python; sw cache simple-v431. --- frontend/index.html | 2 +- frontend/sw.js | 2 +- pyproject.toml | 2 +- pysim_simple_server/server.py | 41 ++++++++++++++++---- tests/test_ota_helpers.py | 72 +++++++++++++++++++++++++++++++++++ 5 files changed, 108 insertions(+), 11 deletions(-) diff --git a/frontend/index.html b/frontend/index.html index 7d4430b..df602c3 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1906,7 +1906,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.25.6'; +const SIMPLE_VERSION = '3.25.7'; document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION; // ===== view history (the browser Back/Forward buttons) ===== diff --git a/frontend/sw.js b/frontend/sw.js index 719a611..b7f6815 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'simple-v430'; +const CACHE = 'simple-v431'; const URLS = [ 'index.html', 'help.html', diff --git a/pyproject.toml b/pyproject.toml index 1b4e247..6a6dfc5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pysim-simple-server" -version = "3.25.6" +version = "3.25.7" 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 54dfda3..684ea38 100644 --- a/pysim_simple_server/server.py +++ b/pysim_simple_server/server.py @@ -36,7 +36,7 @@ from osmocom.tlv import BER_TLV_IE from osmocom.utils import rpad -VERSION = '3.25.6' +VERSION = '3.25.7' MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE @@ -1270,6 +1270,32 @@ def _build_sms_tpdu(chunk_hex, chunk_total=1, chunk_num=1, oa_number='12345', in return tpdu.hex() +def _envelope_get_response(scc, data, sw): + """Fetch response bytes the card holds after an ENVELOPE. + + ISO 7816-4: a 61xx answer promises response bytes - GET RESPONSE with the + named length (Le 256 is coded 00). A 62xx/63xx answer *without* data can + mean the same thing on cards that do not use 61xx for it - the quirk the + remote-command status words already acknowledge (_ram_remote_sw_ok; live + 2026-10-11: an SPI2 01 secured packet - TS 31.115 4.2/4.4 b6=0, the PoR + retrieved by the ME from the ENVELOPE - answered 63xx with the whole PoR + behind one GET RESPONSE, and the operation reported no PoR at all). + + The GET RESPONSE's result is adopted when it delivered bytes (or for a + 61xx, which promised them). Otherwise the original answer is kept: a + 62xx/63xx whose GET RESPONSE brings nothing was a real warning, and its + PoR may still arrive later via SEND SHORT MESSAGE - the v3.6.58 path (the + STATUS poll below) must stay intact.""" + if not (sw.startswith('61') or (not data and sw[:2] in ('62', '63'))): + return data, sw + get_len = int(sw[2:], 16) if sw.startswith('61') and len(sw) == 4 else 0x100 + get_le = '%02x' % get_len if get_len <= 0xFF else '00' # Le 256 = 00 + gdata, gsw = scc._tp.send_apdu('00c00000' + get_le) + if gdata or sw.startswith('61'): + return gdata, gsw + return data, sw + + def _send_envelope(tpdu_hex, scc, sm_sc='12345678912', submit_handler=None, handle_proactive=True, poll_status=True): from pySim.ts_31_102 import SMSPPDownload @@ -1318,10 +1344,11 @@ def _send_envelope(tpdu_hex, scc, sm_sc='12345678912', submit_handler=None, sms_dl = SMSPPDownload(children=[dev_ids, address, raw_tpdu]) env_hex = '%sc20000%02x%s' % (scc.cat_cla, len(sms_dl.to_tlv()), b2h(sms_dl.to_tlv())) data, sw = scc._tp.send_apdu(env_hex) - if sw.startswith('61'): - get_len = int(sw[2:], 16) if len(sw) == 4 else 0x100 - data, sw = scc._tp.send_apdu('00c00000%02x' % get_len) - elif handle_proactive and sw.startswith('91'): + # 61xx promises response bytes; a 62xx/63xx without data may hold them too + # (the card's own "more data" quirk) - for an SPI2 b6=0 packet the PoR is + # retrieved from this exchange (TS 31.115 4.2/4.4, v3.25.7). + data, sw = _envelope_get_response(scc, data, sw) + if handle_proactive and sw.startswith('91'): _handle_proactive_chain(scc, sw, _capture_sms_tpdu) data, sw = '', '9000' # Late-PoR fallback: only after the last segment of a packet (a @@ -5099,9 +5126,7 @@ def _send_event_download(scc, event_type, event_data=None, drain=True, src=None, if log: sys.stderr.write('ENVELOPE(Event Download): type=0x%02x data=%s\n' % (event_type, event_data.hex() if event_data else '(none)')) data, sw = scc._tp.send_apdu(env_hex) - if sw.startswith('61'): - get_len = int(sw[2:], 16) if len(sw) == 4 else 0x100 - data, sw = scc._tp.send_apdu('00c00000%02x' % get_len) + data, sw = _envelope_get_response(scc, data, sw) if log: sys.stderr.write('ENVELOPE SW: %s\n' % sw) if drain and sw.startswith('91'): diff --git a/tests/test_ota_helpers.py b/tests/test_ota_helpers.py index 4b2a243..8d93f70 100644 --- a/tests/test_ota_helpers.py +++ b/tests/test_ota_helpers.py @@ -441,6 +441,78 @@ class TestSmsConcatenation(unittest.TestCase): self.assertEqual(SCP80_MAX_SEGMENTS, 5) +class TestEnvelopeGetResponse(unittest.TestCase): + """The ENVELOPE's response bytes: 61xx promises them (ISO 7816-4), and a + 62xx/63xx without data may hold them too - the card's own "more data" + quirk. v3.25.7, live 2026-10-11: an SPI2 01 secured packet (TS 31.115 + 4.2/4.4 b6=0 - the PoR retrieved by the ME from the ENVELOPE) answered + 63xx with the whole PoR behind one GET RESPONSE, and the operation + reported no PoR at all.""" + + class FakeTp: + def __init__(self, answers): + self.answers = list(answers) + self.sent = [] + def send_apdu(self, apdu): + self.sent.append(apdu) + return self.answers.pop(0) + + def _scc(self, answers): + class FakeScc: + cat_cla = '80' + scc = FakeScc() + scc._tp = self.FakeTp(answers) + return scc + + def test_61xx_fetches_the_named_length(self): + import pysim_simple_server.server as srv + scc = self._scc([('', '610B'), ('00112233445566778899AA', '9000')]) + data, sw = srv._send_envelope('00', scc, handle_proactive=False) + self.assertEqual(scc._tp.sent[1], '00c000000b') + self.assertEqual((data, sw), ('00112233445566778899AA', '9000')) + + def test_a_warning_with_pending_bytes_fetches_the_por(self): + import pysim_simple_server.server as srv + por = '00' * 30 + scc = self._scc([('', '6310'), (por, '9000')]) + data, sw = srv._send_envelope('00', scc, handle_proactive=False) + self.assertEqual(scc._tp.sent[1], '00c0000000') # Le 256 + self.assertEqual((data, sw), (por, '9000')) + + def test_a_short_61_sw_uses_le_256_not_an_odd_apdu(self): + # '%02x' % 0x100 emitted a 3-digit (odd-length) APDU + import pysim_simple_server.server as srv + scc = self._scc([('', '61'), ('AABB', '9000')]) + data, sw = srv._send_envelope('00', scc, handle_proactive=False) + self.assertEqual(scc._tp.sent[1], '00c0000000') + self.assertEqual((data, sw), ('AABB', '9000')) + + def test_a_warning_without_pending_bytes_keeps_the_warning(self): + # nothing to fetch: the warning stays, so the late-PoR STATUS poll + # (the SEND SHORT MESSAGE path, v3.6.58) still runs + import pysim_simple_server.server as srv + + class FakeSubmit: + sms_segments = [] + submit_tpdu_hex = None + submit_ud_hex = None + + scc = self._scc([('', '6200'), ('', '6A86')]) + with mock.patch.object(srv, '_send_status', return_value=('', '9000')) as status, \ + mock.patch.object(srv, '_handle_proactive_chain'): + data, sw = srv._send_envelope('00', scc, submit_handler=FakeSubmit()) + self.assertEqual(scc._tp.sent[1], '00c0000000') + self.assertEqual((data, sw), ('', '6200')) + self.assertTrue(status.called) + + def test_the_event_envelope_uses_the_same_fetch(self): + import pysim_simple_server.server as srv + scc = self._scc([('', '6310'), ('AABB', '9000')]) + data, sw = srv._send_event_download(scc, 0x03, log=False) + self.assertEqual(scc._tp.sent[1], '00c0000000') + self.assertEqual((data, sw), ('AABB', '9000')) + + class TestDecodePor(unittest.TestCase): def test_plaintext_no_cc_synthetic(self): r = _decode_por('02', '01', '15', '15', '0000000001', K, K,