scp81: correct GET STATUS pagination and registry P1s (v2.1.16)

- continuation repeats the SAME GET STATUS command with P2.b1 set (the
  pagination state lives in the card); changing the 4F criterion is a match
  filter, not a position - P2=03 with the last AID is rejected with 6A80 and
  P2=02 with it returns that single match (the earlier duplicate)
- handle the standard "more data available" warning SW 63 10 (Table 11-38)
  in addition to the live card's proprietary CA FE
- explore script: P1=40 is applications+SDs, P1=20 the ELF registry, P1=10
  ELF+modules (Table 11-33) - the ELF-only registry was never queried, which
  hid the installed package; labels and the results decoder show C4 (ELF AID)
  and CC (SD AID) too
- UICC_SPECS.md: GET STATUS P1/P2 tables made explicit with the pagination
  rule, plus BER length coding notes for the scripting templates and the
  TS 102 223 channel data TLV (the two >127-byte traps)

201 python + 346 frontend; service worker v153
This commit is contained in:
2026-09-16 08:22:59 +03:00
parent 3403abd6b9
commit c341571300
7 changed files with 94 additions and 95 deletions
+15 -3
View File
@@ -255,9 +255,21 @@ installed/registered entries were invisible (the installed package
`AA1902BC225801` was missing from the ELF registry). The correct value is `AA1902BC225801` was missing from the ELF registry). The correct value is
`P2=03` = "**Get next occurrence(s)**". `P2=03` = "**Get next occurrence(s)**".
**Fix:** `_scp81_continuation` emits `80F2 <P1> 03 <Lc> 4F <len> <lastAID> 00` **Fix:** the continuation repeats the *same* GET STATUS command with P2.b1
and the earlier duplicate entry per page is gone (the criterion entry is no set (`80F2 <P1> 03 <same data> 00`) - the pagination state lives in the card.
longer re-returned). A changed `4F` criterion is a match filter, not a position: `P2=03` combined
with the last AID as criterion is rejected with SW 6A80, and `P2=02` with it
returns that single match (the duplicate seen earlier). The card's
truncation warning is its proprietary `CA FE`; GP defines `63 10` (Table
11-38) and both trigger the continuation.
**Also fixed (same week):** the explore script's P1 values - per Table 11-33
`P1=40` is *applications and supplementary security domains*, `P1=20` the
*ELF registry* and `P1=10` *ELF+modules*; the script never queried the
ELF-only registry, which is why the installed package `AA1902BC225801` was
invisible. Labels/decoder updated; the remote APDU script builder's P1 map
(0x02 load / 0x0C install / 0x08 make-selectable / 0x40 reg-update / 0x10
extradition) was already correct.
## Next tests / work ## Next tests / work
+9 -5
View File
@@ -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.15</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.16</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>
@@ -7170,7 +7170,7 @@ function scp81DecodeGetStatus(hex) {
if (ln === 0x81 && i + 3 <= bytes.length) { ln = bytes[i + 2]; off = i + 3; } if (ln === 0x81 && i + 3 <= bytes.length) { ln = bytes[i + 2]; off = i + 3; }
if (off + ln > bytes.length) break; if (off + ln > bytes.length) break;
const content = bytes.slice(off, off + ln); const content = bytes.slice(off, off + ln);
const entry = { aid: null, lifecycle: null, privileges: null, modules: [] }; const entry = { aid: null, lifecycle: null, privileges: null, modules: [], elf: null, sd: null };
const toHex = v => v.map(b => b.toString(16).padStart(2, '0')).join('').toUpperCase(); const toHex = v => v.map(b => b.toString(16).padStart(2, '0')).join('').toUpperCase();
let k = 0; let k = 0;
while (k + 2 <= content.length) { while (k + 2 <= content.length) {
@@ -7186,6 +7186,8 @@ function scp81DecodeGetStatus(hex) {
else if (tag === 0x9F70) entry.lifecycle = val.length ? val[val.length - 1].toString(16).padStart(2, '0').toUpperCase() : null; else if (tag === 0x9F70) entry.lifecycle = val.length ? val[val.length - 1].toString(16).padStart(2, '0').toUpperCase() : null;
else if (tag === 0xC5) entry.privileges = toHex(val); else if (tag === 0xC5) entry.privileges = toHex(val);
else if (tag === 0x84) entry.modules.push(toHex(val)); else if (tag === 0x84) entry.modules.push(toHex(val));
else if (tag === 0xC4) entry.elf = toHex(val);
else if (tag === 0xCC) entry.sd = toHex(val);
k += hdr + tlen; k += hdr + tlen;
} }
if (entry.aid) out.push(entry); if (entry.aid) out.push(entry);
@@ -7199,8 +7201,9 @@ function scp81CmdLabel(apdu) {
if (a.startsWith('80CAFF21')) return 'GET DATA FF21 (extended card resources)'; if (a.startsWith('80CAFF21')) return 'GET DATA FF21 (extended card resources)';
if (a.startsWith('80CA0085')) return 'GET DATA 0085 (HTTP administration parameters)'; if (a.startsWith('80CA0085')) return 'GET DATA 0085 (HTTP administration parameters)';
if (a.startsWith('80F280')) return 'GET STATUS P1=80 (Issuer Security Domain)'; if (a.startsWith('80F280')) return 'GET STATUS P1=80 (Issuer Security Domain)';
if (a.startsWith('80F240')) return 'GET STATUS P1=40 (executable load files)'; if (a.startsWith('80F240')) return 'GET STATUS P1=40 (applications and security domains)';
if (a.startsWith('80F210')) return 'GET STATUS P1=10 (applications)'; if (a.startsWith('80F220')) return 'GET STATUS P1=20 (executable load files)';
if (a.startsWith('80F210')) return 'GET STATUS P1=10 (executable load files and modules)';
if (a.startsWith('80E6')) return 'INSTALL'; if (a.startsWith('80E6')) return 'INSTALL';
if (a.startsWith('80E8')) return 'LOAD'; if (a.startsWith('80E8')) return 'LOAD';
if (a.startsWith('80CA')) return 'GET DATA ' + a.slice(4, 8); if (a.startsWith('80CA')) return 'GET DATA ' + a.slice(4, 8);
@@ -7350,7 +7353,8 @@ function scp81ResultLines(group) {
unique.forEach(e => { unique.forEach(e => {
const priv = (typeof decodePrivileges === 'function' && e.privileges) ? decodePrivileges(e.privileges) : (e.privileges || ''); const priv = (typeof decodePrivileges === 'function' && e.privileges) ? decodePrivileges(e.privileges) : (e.privileges || '');
lines.push(e.aid + (e.lifecycle ? ' life=' + e.lifecycle : '') + (priv ? ' [' + priv + ']' : '') + lines.push(e.aid + (e.lifecycle ? ' life=' + e.lifecycle : '') + (priv ? ' [' + priv + ']' : '') +
(e.modules.length ? ' module=' + e.modules.join(',') : '')); (e.elf ? ' elf=' + e.elf : '') + (e.modules.length ? ' module=' + e.modules.join(',') : '') +
(e.sd ? ' sd=' + e.sd : ''));
}); });
const bad = group.results.filter(r => r.sw && r.sw !== '9000' && r.sw !== 'CAFE'); const bad = group.results.filter(r => r.sw && r.sw !== '9000' && r.sw !== 'CAFE');
bad.forEach(r => lines.push('SW ' + r.sw)); bad.forEach(r => lines.push('SW ' + r.sw));
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'otaman-v152'; const CACHE = 'otaman-v153';
const URLS = [ const URLS = [
'index.html', 'index.html',
'help.html', 'help.html',
+3 -2
View File
@@ -124,7 +124,8 @@ test('scp81DecodeAdminParams decodes the stored 0085 answer', () => {
test('scp81CmdLabel names the explore commands', () => { test('scp81CmdLabel names the explore commands', () => {
assert.strictEqual(scp81CmdLabel('80CAFF2100'), 'GET DATA FF21 (extended card resources)'); assert.strictEqual(scp81CmdLabel('80CAFF2100'), 'GET DATA FF21 (extended card resources)');
assert.strictEqual(scp81CmdLabel('80F24002024F0000'), 'GET STATUS P1=40 (executable load files)'); assert.strictEqual(scp81CmdLabel('80F24002024F0000'), 'GET STATUS P1=40 (applications and security domains)');
assert.strictEqual(scp81CmdLabel('80F21002024F0000'), 'GET STATUS P1=10 (applications)'); assert.strictEqual(scp81CmdLabel('80F22002024F0000'), 'GET STATUS P1=20 (executable load files)');
assert.strictEqual(scp81CmdLabel('80F21002024F0000'), 'GET STATUS P1=10 (executable load files and modules)');
assert.strictEqual(scp81CmdLabel('80E8800000'), 'LOAD'); assert.strictEqual(scp81CmdLabel('80E8800000'), 'LOAD');
}); });
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "pysim-otaman-server" name = "pysim-otaman-server"
version = "2.1.15" version = "2.1.16"
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.
+22 -53
View File
@@ -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.15' VERSION = '2.1.16'
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
@@ -1380,12 +1380,14 @@ _SCP81_SCRIPTS = {
# The command sequence of the reference administration server # The command sequence of the reference administration server
# (samples/HTTP_OTA/httpota_adminserver_php_v2, get_next_apdu), extended # (samples/HTTP_OTA/httpota_adminserver_php_v2, get_next_apdu), extended
# with the registries: GET DATA FF21 (extended card resources / free # with the registries: GET DATA FF21 (extended card resources / free
# memory), GET STATUS P1=80 (Issuer Security Domain), GET DATA 0085, # memory), GET DATA 0085, then GET STATUS with P2=02 (TLV structure,
# GET STATUS P1=40 (executable load files / ELF), GET STATUS P1=10 # 'first or all') and data '4F00' (match all): P1=80 (Issuer Security
# (applications/applets); P2=02 with data '4F00' selects the TLV format, # Domain), P1=40 (applications and supplementary security domains),
# Le=00 so no GET RESPONSE is needed. # P1=20 (executable load files), P1=10 (ELF and their modules);
# Le=00 so no GET RESPONSE is needed. Long listings answer SW CAFE and
# are auto-continued with the same command carrying P2.b1=1 ('next').
'explore': ['80CAFF2100', '80F28002024F0000', '80CA008500', 'explore': ['80CAFF2100', '80F28002024F0000', '80CA008500',
'80F24002024F0000', '80F21002024F0000'], '80F24002024F0000', '80F22002024F0000', '80F21002024F0000'],
'none': [], 'none': [],
} }
_SCP81_SCRIPT = list(_SCP81_SCRIPTS['explore']) _SCP81_SCRIPT = list(_SCP81_SCRIPTS['explore'])
@@ -1508,51 +1510,19 @@ def _scp81_decode_memory(rapdu):
return out or None return out or None
def _scp81_last_aid(rapdu): def _scp81_continuation(apdu):
"""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. """Continuation APDU for a truncated GET STATUS page, or None.
GET STATUS P2=02 with the last returned AID as search criterion asks the GET STATUS P2.b1 distinguishes first/all (0) from the *next* batch (1)
card for the next occurrence (GP GET STATUS, next-occurrence mode).""" of the matches for the SAME search criteria; the pagination state lives
in the card, so the continuation is the same command with P2.b1 set.
Using a changed search criterion (the last returned AID) was rejected
with SW 6A80 - the criterion is a match filter, not a position."""
u = apdu.upper() u = apdu.upper()
if not u.startswith('80F2'): if not u.startswith('80F2') or len(u) < 8:
return None return None
aid = _scp81_last_aid(rapdu) p2 = int(u[6:8], 16) | 0x01
if not aid: return '%s%02X%s' % (u[:6], p2, u[8:])
return None
lc = 2 + len(aid)
# P2=03 = "get next occurrence(s)" (Table 11-34); P2=02 ("first or all")
# made the card return the first listing again, so every continuation
# page repeated its search criterion and the scan stopped early - the
# newly installed package never appeared in the registry.
return '80F2%s03%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):
@@ -1579,12 +1549,11 @@ 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: # '63 10' = "more data available" (GP Table 11-38); the live
cont = _scp81_continuation(apdu, rapdus[-1][0]) # card uses a proprietary 'CA FE' for the same condition.
if cont and cont in _SCP81_SCRIPT_INSERTED: if rapdus[-1][1].upper() in ('CAFE', '6310') and _SCP81_PAGES < SCP81_MAX_PAGES:
# The card returned the same page again: stop paging. cont = _scp81_continuation(apdu)
_BIP.log('script-page-stalled', index=index, apdu=cont) if cont:
elif cont:
_SCP81_PAGES += 1 _SCP81_PAGES += 1
_SCP81_SCRIPT.insert(_SCP81_SCRIPT_SENT, cont) _SCP81_SCRIPT.insert(_SCP81_SCRIPT_SENT, cont)
_SCP81_SCRIPT_INSERTED.append(cont) _SCP81_SCRIPT_INSERTED.append(cont)
+43 -30
View File
@@ -667,20 +667,13 @@ 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): def test_continuation_sets_p2_next_bit(self):
page = bytes.fromhex( # P2.b1: 0 = first/all, 1 = next batch of the SAME search criteria
'FC' # live prefix self.assertEqual(server._scp81_continuation('80F24002024F0000'),
'E3114F08A0000000030000009F70010FC50100' # entry 1 '80F24003024F0000')
'E3104F07A00000015153509F700107C50104' # entry 2 self.assertEqual(server._scp81_continuation('80F21002024F0000'),
'E3204F08D27600') # truncated '80F21003024F0000')
self.assertEqual(server._scp81_last_aid(page), self.assertIsNone(server._scp81_continuation('80CAFF2100'))
bytes.fromhex('A0000001515350'))
def test_continuation_builds_next_occurrence_apdu(self):
page = bytes.fromhex('E3114F08A0000000030000009F70010FC50100')
self.assertEqual(server._scp81_continuation('80F24002024F0000', page),
'80F240030A4F08A00000000300000000')
self.assertIsNone(server._scp81_continuation('80CAFF2100', page))
def test_cafe_page_auto_continuation(self): def test_cafe_page_auto_continuation(self):
server._SCP81_SCRIPT = ['80F24002024F0000'] server._SCP81_SCRIPT = ['80F24002024F0000']
@@ -689,18 +682,15 @@ class TargetedAppTest(unittest.TestCase):
server._SCP81_SCRIPT_INSERTED = [] server._SCP81_SCRIPT_INSERTED = []
server._SCP81_PAGES = 0 server._SCP81_PAGES = 0
try: try:
page = (bytes.fromhex('E3114F08A0000000030000009F70010FC50100') page = bytes.fromhex('E3114F08A0000000030000009F70010FC50100')
+ bytes.fromhex('E3104F07A00000015153509F700107C50104')
+ bytes.fromhex('E3204F08D27600'))
tlv = bytes([0x23, len(page) + 2]) + page + b'\xCA\xFE' tlv = bytes([0x23, len(page) + 2]) + page + b'\xCA\xFE'
body = b'\xAF\x80' + tlv + b'\x00\x00' body = b'\xAF\x80' + tlv + b'\x00\x00'
status, headers, out = server._scp81_script_responder( status, headers, out = server._scp81_script_responder(
'POST', '/api/scp81?req=1', {'x-admin-script-status': 'ok'}, body) 'POST', '/api/scp81?req=1', {'x-admin-script-status': 'ok'}, body)
# The continuation was appended and sent as the next command. # The continuation was appended and sent as the next command.
self.assertEqual(server._SCP81_SCRIPT[1], self.assertEqual(server._SCP81_SCRIPT[1], '80F24003024F0000')
'80F24003094F07A000000151535000')
self.assertEqual(status, 200) self.assertEqual(status, 200)
self.assertIn(bytes.fromhex('80F24003094F07A000000151535000'), out) self.assertIn(bytes.fromhex('80F24003024F0000'), out)
finally: finally:
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
@@ -708,21 +698,44 @@ class TargetedAppTest(unittest.TestCase):
server._SCP81_SCRIPT_INSERTED = [] server._SCP81_SCRIPT_INSERTED = []
server._SCP81_PAGES = 0 server._SCP81_PAGES = 0
def test_repeated_page_stalls(self): def test_standard_more_data_sw_also_pages(self):
# GP Table 11-38: SW '63 10' = more data available, continue with
# GET STATUS [next occurrence] - same handling as the card's 'CA FE'.
server._SCP81_SCRIPT = ['80F2 4002 024F 0000'.replace(' ', '')]
server._SCP81_SCRIPT_SENT = 1
server._SCP81_SCRIPT_RESULTS = []
server._SCP81_SCRIPT_INSERTED = []
server._SCP81_PAGES = 0
try:
page = bytes.fromhex('E3114F08A0000000030000009F70010FC50100')
tlv = bytes([0x23, len(page) + 2]) + page + b'\x63\x10'
body = b'\xAF\x80' + tlv + b'\x00\x00'
server._scp81_script_responder(
'POST', '/api/scp81?req=1', {'x-admin-script-status': 'ok'}, body)
self.assertEqual(server._SCP81_SCRIPT[1], '80F24003024F0000')
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_pages_keep_paging(self):
# The continuation is stateful (P2=03): the same APDU legitimately
# repeats until the card answers 9000; the page counter caps it.
server._SCP81_SCRIPT = ['80F24002024F0000'] server._SCP81_SCRIPT = ['80F24002024F0000']
server._SCP81_SCRIPT_SENT = 1 server._SCP81_SCRIPT_SENT = 1
server._SCP81_SCRIPT_RESULTS = [] server._SCP81_SCRIPT_RESULTS = []
server._SCP81_SCRIPT_INSERTED = ['80F240030A4F08A00000000300000000'] server._SCP81_SCRIPT_INSERTED = []
server._SCP81_PAGES = 1 server._SCP81_PAGES = 0
try: try:
page = bytes.fromhex('E3114F08A0000000030000009F70010FC50100') page = bytes.fromhex('E3114F08A0000000030000009F70010FC50100')
tlv = bytes([0x23, len(page) + 2]) + page + b'\xCA\xFE' tlv = bytes([0x23, len(page) + 2]) + page + b'\xCA\xFE'
body = b'\xAF\x80' + tlv + b'\x00\x00' body = b'\xAF\x80' + tlv + b'\x00\x00'
server._scp81_script_responder( for _ in range(3):
'POST', '/api/scp81?req=2', {'x-admin-script-status': 'ok'}, body) server._scp81_script_responder(
# Same page again: no new continuation inserted. 'POST', '/api/scp81?req=2', {'x-admin-script-status': 'ok'}, body)
self.assertEqual(_count := len(server._SCP81_SCRIPT_INSERTED), 1) self.assertEqual(server._SCP81_PAGES, 3)
self.assertEqual(server._SCP81_PAGES, 1)
finally: finally:
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
@@ -731,8 +744,8 @@ class TargetedAppTest(unittest.TestCase):
server._SCP81_PAGES = 0 server._SCP81_PAGES = 0
def test_new_session_drops_inserted_pages(self): def test_new_session_drops_inserted_pages(self):
server._SCP81_SCRIPT = ['80F24002024F0000', '80F24002094F07A000000151535000'] server._SCP81_SCRIPT = ['80F24002024F0000', '80F24003024F0000']
server._SCP81_SCRIPT_INSERTED = ['80F24002094F07A000000151535000'] server._SCP81_SCRIPT_INSERTED = ['80F24003024F0000']
server._SCP81_SCRIPT_SENT = 2 server._SCP81_SCRIPT_SENT = 2
try: try:
server._scp81_script_responder('POST', '/api/scp81', {}, b'') server._scp81_script_responder('POST', '/api/scp81', {}, b'')