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:
+22
-7
@@ -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
@@ -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
@@ -1,4 +1,4 @@
|
||||
const CACHE = 'otaman-v141';
|
||||
const CACHE = 'otaman-v143';
|
||||
const URLS = [
|
||||
'index.html',
|
||||
'help.html',
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user