scp81: auto-continue SW CAFE listing pages (v2.1.6)

Long GET STATUS listings answer SW CAFE with 127-byte pages. The script
responder now extracts the last complete AID from the page and inserts a
next-occurrence GET STATUS (80F2 <P1> 02 <Lc> 4F <len> <AID> 00) as the
next command, until the listing ends. Pages are logged (script-page), a
repeated page logs script-page-stalled and stops, inserted continuations are
dropped at session start.

Live-verified: ELF registry and applications collected completely in two
pages each (7/7 commands, all script-status ok).

- tests: page parsing (truncated tails, live FC-prefixed junk), continuation
  bytes, auto-insert, stall guard, per-session purge (192 python + 337 frontend)
- UI: page number and script-status log rendering; service worker v143
This commit is contained in:
2026-09-16 01:56:29 +03:00
parent 175ca934d8
commit a352d8fe3f
6 changed files with 173 additions and 13 deletions
+22 -7
View File
@@ -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 <P1> 02 <Lc> 4F <len> <last-complete-AID> 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
+4 -2
View File
@@ -18,7 +18,7 @@
<div class="max-w-7xl mx-auto px-6 py-2">
<div class="flex items-center justify-between mb-3">
<h1 class="text-2xl font-bold text-heading">OTAMan <span id="slogan" class="text-sm font-normal text-gray-500 dark:text-slate-400 ml-2" data-l10n="SIM OTA with a Human Face">SIM OTA with a Human Face</span> <span class="text-xs text-gray-400 dark:text-slate-500 ml-1">v2.1.5</span></h1>
<h1 class="text-2xl font-bold text-heading">OTAMan <span id="slogan" class="text-sm font-normal text-gray-500 dark:text-slate-400 ml-2" data-l10n="SIM OTA with a Human Face">SIM OTA with a Human Face</span> <span class="text-xs text-gray-400 dark:text-slate-500 ml-1">v2.1.6</span></h1>
<div class="flex items-center gap-4">
<span id="state-indicator" class="flex items-center select-none" style="cursor:default" title="Connecting...">
<span id="state-indicator-dot" class="text-xs text-gray-400" title="Connecting..."></span>
@@ -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);
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'otaman-v141';
const CACHE = 'otaman-v143';
const URLS = [
'index.html',
'help.html',
+1 -1
View File
@@ -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.
+69 -2
View File
@@ -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
+76
View File
@@ -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(