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,