diff --git a/docs/scp81-findings.md b/docs/scp81-findings.md index 34bdbc8..31f7116 100644 --- a/docs/scp81-findings.md +++ b/docs/scp81-findings.md @@ -180,15 +180,30 @@ card's POST which RFC 5246 leaves open in practice) cannot be done with CPython's `ssl`: `SSLSocket.unwrap()` with a short timeout raises and poisons the session (tested), so the `half_close` option is a documented no-op. +## RESOLVED 2026-09-16b: SW CAFE continuation pages + +**Implemented:** the script responder auto-follows a truncated listing page +(`SW CAFE`, 127 bytes) by inserting a continuation GET STATUS +(`80F2 02 4F 00`, next-occurrence mode) +as the next command. The last AID comes from the last complete `E3` entry in +the page (truncated tails and the live `FC`-prefixed junk are skipped). +Logged as `script-page`; a repeated page logs `script-page-stalled` and +stops; max 24 pages; inserted continuations are dropped at session start. + +**Live-verified (2026-09-16):** ELF registry: page 1 `SW CAFE` -> +continuation with `D276000005AA060200000000B00000` -> page 2 `SW 9000` +(complete, 2 entries). Applications: page 1 `SW CAFE` -> continuation with +`D276000005AAFFCAFE0010` -> page 2 `SW 9000` (complete, incl. +`D276000005AAFFCAFE0001/0010`, `A0000001515350`, `A000000151535041`). +Full session: 7/7 commands, all `X-Admin-Script-Status: ok`. + ## Next tests / work -1. **Continuation pages:** follow `SW CAFE` (127-byte listing pages) with - GET STATUS P1=40/10 P2=02 using the last returned AID as the search - criterion, and append the pages to the result set (memory + full ELF and - application registries). -2. **UI:** show the decoded memory/applications results (and page merging) in - the SCP81 tab; expose the framing options there. -3. Load/store operations (RAM INSTALL/LOAD) over SCP81 using the same recipe. +1. **UI:** group the per-page R-APDUs under their logical command in the + SCP81 tab (page merging/decoding for ELF and application listings); + expose the framing options in the tab. +2. **Load/store over SCP81:** RAM INSTALL/LOAD via command scripts using the + same recipe (one C-APDU per POST, pagination for long responses). ## Tooling diff --git a/frontend/index.html b/frontend/index.html index 0f9ce0d..c89b638 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -18,7 +18,7 @@
-

OTAMan SIM OTA with a Human Face v2.1.5

+

OTAMan SIM OTA with a Human Face v2.1.6

@@ -7036,8 +7036,10 @@ function scp81Msg(text, cls) { function scp81LogLine(e) { const parts = [String(e.seq), e.kind]; if (e.index) parts.push('#' + e.index); + if (e.page) parts.push('page ' + e.page); if (e.apdu) parts.push(e.apdu); if (e.sw) parts.push('SW ' + e.sw); + if (e.kind === 'script-status' && e.status) parts.push('status=' + e.status); if (e.applets !== undefined) parts.push('applets=' + e.applets); if (e.free_nv !== undefined) parts.push('free NV=' + e.free_nv); if (e.free_volatile !== undefined) parts.push('free vol=' + e.free_volatile); @@ -7049,7 +7051,7 @@ function scp81LogLine(e) { if (e.agent) parts.push('from=' + e.agent); if (e.method) parts.push(e.method); if (e.uri) parts.push(e.uri); - if (e.status) parts.push('HTTP ' + e.status); + if (e.status && e.kind !== 'script-status') parts.push('HTTP ' + e.status); if (e.cipher) parts.push(e.cipher); if (e.reason) parts.push('(' + e.reason + ')'); if (e.error) parts.push(e.error); diff --git a/frontend/sw.js b/frontend/sw.js index a4335e4..41702f6 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'otaman-v141'; +const CACHE = 'otaman-v143'; const URLS = [ 'index.html', 'help.html', diff --git a/pyproject.toml b/pyproject.toml index 62c190e..898899a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pysim-otaman-server" -version = "2.1.5" +version = "2.1.6" description = "HTTP REST server wrapping pysim for the OTAMan PWA" requires-python = ">=3.8" # pysim is a git-only dependency installed explicitly by setup.bat/setup.sh. diff --git a/pysim_otaman_server/server.py b/pysim_otaman_server/server.py index 7d7f96f..9f84e9f 100644 --- a/pysim_otaman_server/server.py +++ b/pysim_otaman_server/server.py @@ -21,7 +21,7 @@ from osmocom.construct import GsmOrUcs2Adapter from osmocom.tlv import BER_TLV_IE -VERSION = '2.1.5' +VERSION = '2.1.6' MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE @@ -1360,6 +1360,12 @@ _SCP81_SCRIPTS = { _SCP81_SCRIPT = list(_SCP81_SCRIPTS['explore']) _SCP81_SCRIPT_SENT = 0 _SCP81_SCRIPT_RESULTS = [] +# Continuation pages: long GET STATUS listings answer SW CAFE with 127-byte +# pages; the responder auto-inserts a GET STATUS (P2=02, last AID as search +# criterion) after each page until the listing ends. +_SCP81_SCRIPT_INSERTED = [] +_SCP81_PAGES = 0 +SCP81_MAX_PAGES = 24 _SCP81_SCRIPT_TEMPLATE = 'indefinite' _SCP81_SCRIPT_CR_TAG = False # None = short per-command Next-URI ('/N'); '' = omit the header (spec: the @@ -1440,10 +1446,54 @@ def _scp81_decode_memory(rapdu): return out or None +def _scp81_last_aid(rapdu): + """Last complete AID (the '4F' TLV of a GET STATUS entry) in a page. + + The page is a stream of 'E3' entries; a 127-byte page may end mid-entry, + so only complete entries count. Unknown leading bytes (seen in live + pages) are skipped.""" + last = None + i = 0 + while i + 2 <= len(rapdu): + if rapdu[i] != 0xE3: + i += 1 + continue + ln = rapdu[i + 1] + off = i + 2 + if ln == 0x81 and i + 3 <= len(rapdu): + ln = rapdu[i + 2] + off = i + 3 + if off + ln > len(rapdu): + break + content = rapdu[off:off + ln] + if len(content) >= 2 and content[0] == 0x4F: + alen = content[1] + if 2 + alen <= len(content): + last = content[2:2 + alen] + i = off + ln + return last + + +def _scp81_continuation(apdu, rapdu): + """Continuation APDU for a truncated GET STATUS page, or None. + + GET STATUS P2=02 with the last returned AID as search criterion asks the + card for the next occurrence (GP GET STATUS, next-occurrence mode).""" + u = apdu.upper() + if not u.startswith('80F2'): + return None + aid = _scp81_last_aid(rapdu) + if not aid: + return None + lc = 2 + len(aid) + return '80F2%s02%02X4F%02X%s00' % (u[4:6], lc, len(aid), aid.hex().upper()) + + def _scp81_script_responder(method, target, headers, body): """Remote Administration Server side of the administration session: send the next scripted C-APDU or close the session (TS 102 226 / GP 4.4.2).""" - global _SCP81_SCRIPT_SENT, _SCP81_SCRIPT_RESULTS + global _SCP81_SCRIPT_SENT, _SCP81_SCRIPT_RESULTS, _SCP81_PAGES + global _SCP81_SCRIPT_INSERTED status = headers.get('x-admin-script-status') if status is not None: index = _SCP81_SCRIPT_SENT @@ -1462,10 +1512,27 @@ def _scp81_script_responder(method, target, headers, body): decoded = _scp81_decode_memory(rapdus[-1][0]) if decoded: _BIP.log('script-memory', **decoded) + if rapdus[-1][1].upper() == 'CAFE' and _SCP81_PAGES < SCP81_MAX_PAGES: + cont = _scp81_continuation(apdu, rapdus[-1][0]) + if cont and cont in _SCP81_SCRIPT_INSERTED: + # The card returned the same page again: stop paging. + _BIP.log('script-page-stalled', index=index, apdu=cont) + elif cont: + _SCP81_PAGES += 1 + _SCP81_SCRIPT.insert(_SCP81_SCRIPT_SENT, cont) + _SCP81_SCRIPT_INSERTED.append(cont) + _BIP.log('script-page', index=index, page=_SCP81_PAGES, + apdu=cont) else: # First (or resumed) POST of a session: run the script from the start. + # Drop continuation pages inserted by a previous session. + if _SCP81_SCRIPT_INSERTED: + _SCP81_SCRIPT[:] = [a for a in _SCP81_SCRIPT + if a not in _SCP81_SCRIPT_INSERTED] + _SCP81_SCRIPT_INSERTED = [] _SCP81_SCRIPT_SENT = 0 _SCP81_SCRIPT_RESULTS = [] + _SCP81_PAGES = 0 if _SCP81_SCRIPT_SENT < len(_SCP81_SCRIPT): apdu = _SCP81_SCRIPT[_SCP81_SCRIPT_SENT] _SCP81_SCRIPT_SENT += 1 diff --git a/tests/test_scp81.py b/tests/test_scp81.py index a298421..2a093fb 100644 --- a/tests/test_scp81.py +++ b/tests/test_scp81.py @@ -667,6 +667,82 @@ class TargetedAppTest(unittest.TestCase): server._SCP81_SCRIPT = list(server._SCP81_SCRIPTS['explore']) server._SCP81_SCRIPT_SENT = 0 + def test_last_aid_parses_complete_entries_only(self): + page = bytes.fromhex( + 'FC' # live prefix + 'E3114F08A0000000030000009F70010FC50100' # entry 1 + 'E3104F07A00000015153509F700107C50104' # entry 2 + 'E3204F08D27600') # truncated + self.assertEqual(server._scp81_last_aid(page), + bytes.fromhex('A0000001515350')) + + def test_continuation_builds_next_occurrence_apdu(self): + page = bytes.fromhex('E3114F08A0000000030000009F70010FC50100') + self.assertEqual(server._scp81_continuation('80F24002024F0000', page), + '80F240020A4F08A00000000300000000') + self.assertIsNone(server._scp81_continuation('80CAFF2100', page)) + + def test_cafe_page_auto_continuation(self): + server._SCP81_SCRIPT = ['80F24002024F0000'] + server._SCP81_SCRIPT_SENT = 1 + server._SCP81_SCRIPT_RESULTS = [] + server._SCP81_SCRIPT_INSERTED = [] + server._SCP81_PAGES = 0 + try: + page = (bytes.fromhex('E3114F08A0000000030000009F70010FC50100') + + bytes.fromhex('E3104F07A00000015153509F700107C50104') + + bytes.fromhex('E3204F08D27600')) + tlv = bytes([0x23, len(page) + 2]) + page + b'\xCA\xFE' + body = b'\xAF\x80' + tlv + b'\x00\x00' + status, headers, out = server._scp81_script_responder( + 'POST', '/api/scp81?req=1', {'x-admin-script-status': 'ok'}, body) + # The continuation was appended and sent as the next command. + self.assertEqual(server._SCP81_SCRIPT[1], + '80F24002094F07A000000151535000') + self.assertEqual(status, 200) + self.assertIn(bytes.fromhex('80F24002094F07A000000151535000'), out) + finally: + server._SCP81_SCRIPT = list(server._SCP81_SCRIPTS['explore']) + server._SCP81_SCRIPT_SENT = 0 + server._SCP81_SCRIPT_RESULTS = [] + server._SCP81_SCRIPT_INSERTED = [] + server._SCP81_PAGES = 0 + + def test_repeated_page_stalls(self): + server._SCP81_SCRIPT = ['80F24002024F0000'] + server._SCP81_SCRIPT_SENT = 1 + server._SCP81_SCRIPT_RESULTS = [] + server._SCP81_SCRIPT_INSERTED = ['80F240020A4F08A00000000300000000'] + server._SCP81_PAGES = 1 + try: + page = bytes.fromhex('E3114F08A0000000030000009F70010FC50100') + tlv = bytes([0x23, len(page) + 2]) + page + b'\xCA\xFE' + body = b'\xAF\x80' + tlv + b'\x00\x00' + server._scp81_script_responder( + 'POST', '/api/scp81?req=2', {'x-admin-script-status': 'ok'}, body) + # Same page again: no new continuation inserted. + self.assertEqual(_count := len(server._SCP81_SCRIPT_INSERTED), 1) + self.assertEqual(server._SCP81_PAGES, 1) + finally: + server._SCP81_SCRIPT = list(server._SCP81_SCRIPTS['explore']) + server._SCP81_SCRIPT_SENT = 0 + server._SCP81_SCRIPT_RESULTS = [] + server._SCP81_SCRIPT_INSERTED = [] + server._SCP81_PAGES = 0 + + def test_new_session_drops_inserted_pages(self): + server._SCP81_SCRIPT = ['80F24002024F0000', '80F24002094F07A000000151535000'] + server._SCP81_SCRIPT_INSERTED = ['80F24002094F07A000000151535000'] + server._SCP81_SCRIPT_SENT = 2 + try: + server._scp81_script_responder('POST', '/api/scp81', {}, b'') + self.assertEqual(server._SCP81_SCRIPT, ['80F24002024F0000']) + self.assertEqual(server._SCP81_SCRIPT_SENT, 1) + finally: + server._SCP81_SCRIPT = list(server._SCP81_SCRIPTS['explore']) + server._SCP81_SCRIPT_SENT = 0 + server._SCP81_SCRIPT_INSERTED = [] + def test_exact_wire_bodies_from_reference_log(self): # De-chunked bodies captured in adminserver.log (2019-09-05). count, rapdus = server._scp81_parse_response(bytes.fromhex(