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:
+15
-3
@@ -255,9 +255,21 @@ installed/registered entries were invisible (the installed package
|
||||
`AA1902BC225801` was missing from the ELF registry). The correct value is
|
||||
`P2=03` = "**Get next occurrence(s)**".
|
||||
|
||||
**Fix:** `_scp81_continuation` emits `80F2 <P1> 03 <Lc> 4F <len> <lastAID> 00`
|
||||
and the earlier duplicate entry per page is gone (the criterion entry is no
|
||||
longer re-returned).
|
||||
**Fix:** the continuation repeats the *same* GET STATUS command with P2.b1
|
||||
set (`80F2 <P1> 03 <same data> 00`) - the pagination state lives in the card.
|
||||
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
|
||||
|
||||
|
||||
+9
-5
@@ -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.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">
|
||||
<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>
|
||||
@@ -7170,7 +7170,7 @@ function scp81DecodeGetStatus(hex) {
|
||||
if (ln === 0x81 && i + 3 <= bytes.length) { ln = bytes[i + 2]; off = i + 3; }
|
||||
if (off + ln > bytes.length) break;
|
||||
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();
|
||||
let k = 0;
|
||||
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 === 0xC5) entry.privileges = 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;
|
||||
}
|
||||
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('80CA0085')) return 'GET DATA 0085 (HTTP administration parameters)';
|
||||
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('80F210')) return 'GET STATUS P1=10 (applications)';
|
||||
if (a.startsWith('80F240')) return 'GET STATUS P1=40 (applications and security domains)';
|
||||
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('80E8')) return 'LOAD';
|
||||
if (a.startsWith('80CA')) return 'GET DATA ' + a.slice(4, 8);
|
||||
@@ -7350,7 +7353,8 @@ function scp81ResultLines(group) {
|
||||
unique.forEach(e => {
|
||||
const priv = (typeof decodePrivileges === 'function' && e.privileges) ? decodePrivileges(e.privileges) : (e.privileges || '');
|
||||
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');
|
||||
bad.forEach(r => lines.push('SW ' + r.sw));
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
const CACHE = 'otaman-v152';
|
||||
const CACHE = 'otaman-v153';
|
||||
const URLS = [
|
||||
'index.html',
|
||||
'help.html',
|
||||
|
||||
@@ -124,7 +124,8 @@ test('scp81DecodeAdminParams decodes the stored 0085 answer', () => {
|
||||
|
||||
test('scp81CmdLabel names the explore commands', () => {
|
||||
assert.strictEqual(scp81CmdLabel('80CAFF2100'), 'GET DATA FF21 (extended card resources)');
|
||||
assert.strictEqual(scp81CmdLabel('80F24002024F0000'), 'GET STATUS P1=40 (executable load files)');
|
||||
assert.strictEqual(scp81CmdLabel('80F21002024F0000'), 'GET STATUS P1=10 (applications)');
|
||||
assert.strictEqual(scp81CmdLabel('80F24002024F0000'), 'GET STATUS P1=40 (applications and security domains)');
|
||||
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');
|
||||
});
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "pysim-otaman-server"
|
||||
version = "2.1.15"
|
||||
version = "2.1.16"
|
||||
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.15'
|
||||
VERSION = '2.1.16'
|
||||
|
||||
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
|
||||
# (samples/HTTP_OTA/httpota_adminserver_php_v2, get_next_apdu), extended
|
||||
# with the registries: GET DATA FF21 (extended card resources / free
|
||||
# memory), GET STATUS P1=80 (Issuer Security Domain), GET DATA 0085,
|
||||
# GET STATUS P1=40 (executable load files / ELF), GET STATUS P1=10
|
||||
# (applications/applets); P2=02 with data '4F00' selects the TLV format,
|
||||
# Le=00 so no GET RESPONSE is needed.
|
||||
# memory), GET DATA 0085, then GET STATUS with P2=02 (TLV structure,
|
||||
# 'first or all') and data '4F00' (match all): P1=80 (Issuer Security
|
||||
# Domain), P1=40 (applications and supplementary security domains),
|
||||
# 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',
|
||||
'80F24002024F0000', '80F21002024F0000'],
|
||||
'80F24002024F0000', '80F22002024F0000', '80F21002024F0000'],
|
||||
'none': [],
|
||||
}
|
||||
_SCP81_SCRIPT = list(_SCP81_SCRIPTS['explore'])
|
||||
@@ -1508,51 +1510,19 @@ 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):
|
||||
def _scp81_continuation(apdu):
|
||||
"""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)."""
|
||||
GET STATUS P2.b1 distinguishes first/all (0) from the *next* batch (1)
|
||||
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()
|
||||
if not u.startswith('80F2'):
|
||||
if not u.startswith('80F2') or len(u) < 8:
|
||||
return None
|
||||
aid = _scp81_last_aid(rapdu)
|
||||
if not aid:
|
||||
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())
|
||||
p2 = int(u[6:8], 16) | 0x01
|
||||
return '%s%02X%s' % (u[:6], p2, u[8:])
|
||||
|
||||
|
||||
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])
|
||||
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:
|
||||
# '63 10' = "more data available" (GP Table 11-38); the live
|
||||
# card uses a proprietary 'CA FE' for the same condition.
|
||||
if rapdus[-1][1].upper() in ('CAFE', '6310') and _SCP81_PAGES < SCP81_MAX_PAGES:
|
||||
cont = _scp81_continuation(apdu)
|
||||
if cont:
|
||||
_SCP81_PAGES += 1
|
||||
_SCP81_SCRIPT.insert(_SCP81_SCRIPT_SENT, cont)
|
||||
_SCP81_SCRIPT_INSERTED.append(cont)
|
||||
|
||||
+41
-28
@@ -667,20 +667,13 @@ 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),
|
||||
'80F240030A4F08A00000000300000000')
|
||||
self.assertIsNone(server._scp81_continuation('80CAFF2100', page))
|
||||
def test_continuation_sets_p2_next_bit(self):
|
||||
# P2.b1: 0 = first/all, 1 = next batch of the SAME search criteria
|
||||
self.assertEqual(server._scp81_continuation('80F24002024F0000'),
|
||||
'80F24003024F0000')
|
||||
self.assertEqual(server._scp81_continuation('80F21002024F0000'),
|
||||
'80F21003024F0000')
|
||||
self.assertIsNone(server._scp81_continuation('80CAFF2100'))
|
||||
|
||||
def test_cafe_page_auto_continuation(self):
|
||||
server._SCP81_SCRIPT = ['80F24002024F0000']
|
||||
@@ -689,18 +682,15 @@ class TargetedAppTest(unittest.TestCase):
|
||||
server._SCP81_SCRIPT_INSERTED = []
|
||||
server._SCP81_PAGES = 0
|
||||
try:
|
||||
page = (bytes.fromhex('E3114F08A0000000030000009F70010FC50100')
|
||||
+ bytes.fromhex('E3104F07A00000015153509F700107C50104')
|
||||
+ bytes.fromhex('E3204F08D27600'))
|
||||
page = bytes.fromhex('E3114F08A0000000030000009F70010FC50100')
|
||||
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],
|
||||
'80F24003094F07A000000151535000')
|
||||
self.assertEqual(server._SCP81_SCRIPT[1], '80F24003024F0000')
|
||||
self.assertEqual(status, 200)
|
||||
self.assertIn(bytes.fromhex('80F24003094F07A000000151535000'), out)
|
||||
self.assertIn(bytes.fromhex('80F24003024F0000'), out)
|
||||
finally:
|
||||
server._SCP81_SCRIPT = list(server._SCP81_SCRIPTS['explore'])
|
||||
server._SCP81_SCRIPT_SENT = 0
|
||||
@@ -708,21 +698,44 @@ class TargetedAppTest(unittest.TestCase):
|
||||
server._SCP81_SCRIPT_INSERTED = []
|
||||
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_SENT = 1
|
||||
server._SCP81_SCRIPT_RESULTS = []
|
||||
server._SCP81_SCRIPT_INSERTED = ['80F240030A4F08A00000000300000000']
|
||||
server._SCP81_PAGES = 1
|
||||
server._SCP81_SCRIPT_INSERTED = []
|
||||
server._SCP81_PAGES = 0
|
||||
try:
|
||||
page = bytes.fromhex('E3114F08A0000000030000009F70010FC50100')
|
||||
tlv = bytes([0x23, len(page) + 2]) + page + b'\xCA\xFE'
|
||||
body = b'\xAF\x80' + tlv + b'\x00\x00'
|
||||
for _ in range(3):
|
||||
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)
|
||||
self.assertEqual(server._SCP81_PAGES, 3)
|
||||
finally:
|
||||
server._SCP81_SCRIPT = list(server._SCP81_SCRIPTS['explore'])
|
||||
server._SCP81_SCRIPT_SENT = 0
|
||||
@@ -731,8 +744,8 @@ class TargetedAppTest(unittest.TestCase):
|
||||
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 = ['80F24002024F0000', '80F24003024F0000']
|
||||
server._SCP81_SCRIPT_INSERTED = ['80F24003024F0000']
|
||||
server._SCP81_SCRIPT_SENT = 2
|
||||
try:
|
||||
server._scp81_script_responder('POST', '/api/scp81', {}, b'')
|
||||
|
||||
Reference in New Issue
Block a user