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
|
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.
|
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
|
## Next tests / work
|
||||||
|
|
||||||
1. **Continuation pages:** follow `SW CAFE` (127-byte listing pages) with
|
1. **UI:** group the per-page R-APDUs under their logical command in the
|
||||||
GET STATUS P1=40/10 P2=02 using the last returned AID as the search
|
SCP81 tab (page merging/decoding for ELF and application listings);
|
||||||
criterion, and append the pages to the result set (memory + full ELF and
|
expose the framing options in the tab.
|
||||||
application registries).
|
2. **Load/store over SCP81:** RAM INSTALL/LOAD via command scripts using the
|
||||||
2. **UI:** show the decoded memory/applications results (and page merging) in
|
same recipe (one C-APDU per POST, pagination for long responses).
|
||||||
the SCP81 tab; expose the framing options there.
|
|
||||||
3. Load/store operations (RAM INSTALL/LOAD) over SCP81 using the same recipe.
|
|
||||||
|
|
||||||
## Tooling
|
## Tooling
|
||||||
|
|
||||||
|
|||||||
+4
-2
@@ -18,7 +18,7 @@
|
|||||||
<div class="max-w-7xl mx-auto px-6 py-2">
|
<div class="max-w-7xl mx-auto px-6 py-2">
|
||||||
|
|
||||||
<div class="flex items-center justify-between mb-3">
|
<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">
|
<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" 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>
|
<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) {
|
function scp81LogLine(e) {
|
||||||
const parts = [String(e.seq), e.kind];
|
const parts = [String(e.seq), e.kind];
|
||||||
if (e.index) parts.push('#' + e.index);
|
if (e.index) parts.push('#' + e.index);
|
||||||
|
if (e.page) parts.push('page ' + e.page);
|
||||||
if (e.apdu) parts.push(e.apdu);
|
if (e.apdu) parts.push(e.apdu);
|
||||||
if (e.sw) parts.push('SW ' + e.sw);
|
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.applets !== undefined) parts.push('applets=' + e.applets);
|
||||||
if (e.free_nv !== undefined) parts.push('free NV=' + e.free_nv);
|
if (e.free_nv !== undefined) parts.push('free NV=' + e.free_nv);
|
||||||
if (e.free_volatile !== undefined) parts.push('free vol=' + e.free_volatile);
|
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.agent) parts.push('from=' + e.agent);
|
||||||
if (e.method) parts.push(e.method);
|
if (e.method) parts.push(e.method);
|
||||||
if (e.uri) parts.push(e.uri);
|
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.cipher) parts.push(e.cipher);
|
||||||
if (e.reason) parts.push('(' + e.reason + ')');
|
if (e.reason) parts.push('(' + e.reason + ')');
|
||||||
if (e.error) parts.push(e.error);
|
if (e.error) parts.push(e.error);
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
const CACHE = 'otaman-v141';
|
const CACHE = 'otaman-v143';
|
||||||
const URLS = [
|
const URLS = [
|
||||||
'index.html',
|
'index.html',
|
||||||
'help.html',
|
'help.html',
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "pysim-otaman-server"
|
name = "pysim-otaman-server"
|
||||||
version = "2.1.5"
|
version = "2.1.6"
|
||||||
description = "HTTP REST server wrapping pysim for the OTAMan PWA"
|
description = "HTTP REST server wrapping pysim for the OTAMan PWA"
|
||||||
requires-python = ">=3.8"
|
requires-python = ">=3.8"
|
||||||
# pysim is a git-only dependency installed explicitly by setup.bat/setup.sh.
|
# 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
|
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
|
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 = list(_SCP81_SCRIPTS['explore'])
|
||||||
_SCP81_SCRIPT_SENT = 0
|
_SCP81_SCRIPT_SENT = 0
|
||||||
_SCP81_SCRIPT_RESULTS = []
|
_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_TEMPLATE = 'indefinite'
|
||||||
_SCP81_SCRIPT_CR_TAG = False
|
_SCP81_SCRIPT_CR_TAG = False
|
||||||
# None = short per-command Next-URI ('/N'); '' = omit the header (spec: the
|
# 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
|
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):
|
def _scp81_script_responder(method, target, headers, body):
|
||||||
"""Remote Administration Server side of the administration session: send
|
"""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)."""
|
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')
|
status = headers.get('x-admin-script-status')
|
||||||
if status is not None:
|
if status is not None:
|
||||||
index = _SCP81_SCRIPT_SENT
|
index = _SCP81_SCRIPT_SENT
|
||||||
@@ -1462,10 +1512,27 @@ def _scp81_script_responder(method, target, headers, body):
|
|||||||
decoded = _scp81_decode_memory(rapdus[-1][0])
|
decoded = _scp81_decode_memory(rapdus[-1][0])
|
||||||
if decoded:
|
if decoded:
|
||||||
_BIP.log('script-memory', **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:
|
else:
|
||||||
# First (or resumed) POST of a session: run the script from the start.
|
# 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_SENT = 0
|
||||||
_SCP81_SCRIPT_RESULTS = []
|
_SCP81_SCRIPT_RESULTS = []
|
||||||
|
_SCP81_PAGES = 0
|
||||||
if _SCP81_SCRIPT_SENT < len(_SCP81_SCRIPT):
|
if _SCP81_SCRIPT_SENT < len(_SCP81_SCRIPT):
|
||||||
apdu = _SCP81_SCRIPT[_SCP81_SCRIPT_SENT]
|
apdu = _SCP81_SCRIPT[_SCP81_SCRIPT_SENT]
|
||||||
_SCP81_SCRIPT_SENT += 1
|
_SCP81_SCRIPT_SENT += 1
|
||||||
|
|||||||
@@ -667,6 +667,82 @@ class TargetedAppTest(unittest.TestCase):
|
|||||||
server._SCP81_SCRIPT = list(server._SCP81_SCRIPTS['explore'])
|
server._SCP81_SCRIPT = list(server._SCP81_SCRIPTS['explore'])
|
||||||
server._SCP81_SCRIPT_SENT = 0
|
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):
|
def test_exact_wire_bodies_from_reference_log(self):
|
||||||
# De-chunked bodies captured in adminserver.log (2019-09-05).
|
# De-chunked bodies captured in adminserver.log (2019-09-05).
|
||||||
count, rapdus = server._scp81_parse_response(bytes.fromhex(
|
count, rapdus = server._scp81_parse_response(bytes.fromhex(
|
||||||
|
|||||||
Reference in New Issue
Block a user