diff --git a/frontend/index.html b/frontend/index.html index c89b638..5da717a 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -18,7 +18,7 @@
-

OTAMan SIM OTA with a Human Face v2.1.6

+

OTAMan SIM OTA with a Human Face v2.1.7

@@ -1000,6 +1000,12 @@
The key is only sent to the local server and is never stored or logged.
+
+
+ Script results (R-APDUs) +
+
+
HTTP OTA log @@ -7059,6 +7065,119 @@ function scp81LogLine(e) { return parts.join(' '); } +function scp81DecodeGetStatus(hex) { + // GET STATUS TLV listing: a stream of E3 entries { 4F aid, 9F70 lifecycle, + // C5 privileges, 84 module AID, CC associated SD AID }; only complete + // entries are decoded (pages can end mid-entry). + const bytes = []; + const clean = (hex || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase(); + for (let i = 0; i + 1 < clean.length; i += 2) bytes.push(parseInt(clean.substr(i, 2), 16)); + const out = []; + let i = 0; + while (i + 2 <= bytes.length) { + if (bytes[i] !== 0xE3) { i += 1; continue; } + let ln = bytes[i + 1], off = i + 2; + 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 toHex = v => v.map(b => b.toString(16).padStart(2, '0')).join('').toUpperCase(); + let k = 0; + while (k + 2 <= content.length) { + let tag = content[k], hdr = 2, tlen = content[k + 1]; + if (tag === 0x9F && k + 3 <= content.length) { + tag = 0x9F70; + tlen = content[k + 2]; + hdr = 3; + } + if (k + hdr + tlen > content.length) break; + const val = content.slice(k + hdr, k + hdr + tlen); + if (tag === 0x4F) entry.aid = toHex(val); + 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)); + k += hdr + tlen; + } + if (entry.aid) out.push(entry); + i = off + ln; + } + return out; +} + +function scp81GroupResults(script) { + // Group R-APDU results by their originating command (first three bytes): + // auto-continued SW CAFE pages end up under the same command. + const groups = []; + const byKey = {}; + (script.results || []).forEach(r => { + const apdu = (r.apdu || '').toUpperCase(); + const key = apdu.slice(0, 6) || 'unknown'; + if (!byKey[key]) { byKey[key] = { key: key, apdu: apdu, results: [] }; groups.push(byKey[key]); } + byKey[key].results.push(r); + }); + return groups; +} + +function scp81ResultLines(group) { + const first = group.apdu || ''; + const lines = []; + if (first.startsWith('80CAFF21')) { + group.results.forEach(r => { + const hex = r.rapdu || ''; + if (hex.startsWith('FF21')) { + const b = []; + for (let i = 0; i + 1 < hex.length; i += 2) b.push(parseInt(hex.substr(i, 2), 16)); + const vals = {}; + let k = 3; // skip FF 21 + while (k + 2 <= b.length) { + const tag = b[k], ln = b[k + 1], val = b.slice(k + 2, k + 2 + ln); + if (val.length < ln) break; + vals[tag] = val.reduce((acc, x) => acc * 256 + x, 0); + k += 2 + ln; + } + lines.push('applets=' + (vals[0x81] || 0) + ' free NV=' + (vals[0x82] || 0) + ' B free vol=' + (vals[0x83] || 0) + ' B'); + } else { + lines.push(hex.slice(0, 64) + (r.sw && r.sw !== '9000' ? ' SW ' + r.sw : '')); + } + }); + } else if (first.startsWith('80F2')) { + const entries = []; + group.results.forEach(r => entries.push.apply(entries, scp81DecodeGetStatus(r.rapdu || ''))); + entries.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(',') : '')); + }); + const bad = group.results.filter(r => r.sw && r.sw !== '9000' && r.sw !== 'CAFE'); + bad.forEach(r => lines.push('SW ' + r.sw)); + } else { + group.results.forEach(r => lines.push((r.rapdu || '').slice(0, 64) + (r.sw ? ' SW ' + r.sw : ''))); + } + return lines; +} + +async function scp81ResultsRefresh() { + const el = document.getElementById('scp81-results'); + if (!el) return; + try { + const script = await pysimFetch('/api/scp81/script'); + const groups = scp81GroupResults(script); + if (!groups.length) { + el.innerHTML = '' + t('No entries yet.') + ''; + return; + } + el.innerHTML = groups.map(g => { + const lines = scp81ResultLines(g); + const head = g.apdu + (g.results.length > 1 ? ' (' + g.results.length + ' pages)' : ''); + return '
' + + '
' + esc(head) + '
' + + lines.map(l => '
' + esc(l) + '
').join('') + '
'; + }).join(''); + } catch (err) { + el.innerHTML = 'Error: ' + esc(String(err.message || err)) + ''; + } +} + async function scp81StatusRefresh() { const el = document.getElementById('scp81-state'); try { @@ -7126,6 +7245,7 @@ async function scp81Start() { else scp81Msg(resp.error || t('Error'), 'text-red-500'); scp81StatusRefresh(); scp81LogRefresh(); + scp81ResultsRefresh(); } catch (err) { scp81Msg(String(err.message || err), 'text-red-500'); } @@ -7137,6 +7257,7 @@ async function scp81Stop() { scp81Msg(t('Stopped.'), 'text-gray-500 dark:text-slate-400'); scp81StatusRefresh(); scp81LogRefresh(); + scp81ResultsRefresh(); } catch (err) { scp81Msg(String(err.message || err), 'text-red-500'); } @@ -7146,6 +7267,7 @@ async function scp81LogClear() { try { await pysimFetch('/api/scp81/log-clear', {}); scp81LogRefresh(); + scp81ResultsRefresh(); } catch (err) { scp81Msg(String(err.message || err), 'text-red-500'); } @@ -7160,6 +7282,7 @@ function scp81Enter() { if (document.getElementById('tab-scp81').classList.contains('hidden')) return; scp81StatusRefresh(); scp81LogRefresh(); + scp81ResultsRefresh(); }, 2000); } diff --git a/frontend/sw.js b/frontend/sw.js index 41702f6..ffc7ca7 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'otaman-v143'; +const CACHE = 'otaman-v144'; const URLS = [ 'index.html', 'help.html', diff --git a/frontend/tests/scp81.test.js b/frontend/tests/scp81.test.js index caeb2b3..fe82cff 100644 --- a/frontend/tests/scp81.test.js +++ b/frontend/tests/scp81.test.js @@ -57,3 +57,50 @@ test('scp81LogLine renders script entries', () => { scp81LogLine({ seq: 13, kind: 'script-memory', applets: 4, free_nv: 61600, free_volatile: 2048 }), '13 script-memory applets=4 free NV=61600 free vol=2048'); }); + +eval(extractFunc(html, 'scp81DecodeGetStatus')); +eval(extractFunc(html, 'scp81GroupResults')); +eval(extractFunc(html, 'scp81ResultLines')); + +test('scp81DecodeGetStatus decodes complete entries', () => { + const entries = scp81DecodeGetStatus('E32A4F08A0000000030000009F70010FC50380DE00C40BD276000005AAFFCAFE0010CC08A000000003000000'); + assert.strictEqual(entries.length, 1); + assert.strictEqual(entries[0].aid, 'A000000003000000'); + assert.strictEqual(entries[0].lifecycle, '0F'); + assert.strictEqual(entries[0].privileges, '80DE00'); +}); + +test('scp81DecodeGetStatus reads module AIDs and skips truncated tails', () => { + const entries = scp81DecodeGetStatus('E31B4F07A00000015153509F700101CE0201008408A000000151535041' + 'E3204F08D27600'); + assert.strictEqual(entries.length, 1); + assert.strictEqual(entries[0].aid, 'A0000001515350'); + assert.strictEqual(entries[0].modules[0], 'A000000151535041'); +}); + +test('scp81GroupResults merges pages under one command', () => { + const groups = scp81GroupResults({ results: [ + { index: 4, apdu: '80F24002024F0000', sw: 'CAFE', rapdu: 'E3114F08A0000000030000009F70010FC50100' }, + { index: 5, apdu: '80F24002114F0F', sw: '9000', rapdu: 'E3114F08A0000000030000009F70010FC50100' }, + { index: 1, apdu: '80CAFF2100', sw: '9000', rapdu: 'FF210B81010D8202C5D683020962' }, + ] }); + assert.strictEqual(groups.length, 2); + assert.strictEqual(groups[0].results.length, 2); + assert.strictEqual(groups[1].key, '80CAFF'); +}); + +test('scp81ResultLines decodes the memory page', () => { + const lines = scp81ResultLines({ apdu: '80CAFF2100', results: [ + { rapdu: 'FF210B81010D8202C5D683020962', sw: '9000' } ] }); + assert.strictEqual(lines[0], 'applets=13 free NV=50646 B free vol=2402 B'); +}); + +test('scp81ResultLines decodes GET STATUS entries', () => { + global.decodePrivileges = () => 'Security Domain'; + try { + const lines = scp81ResultLines({ apdu: '80F24002024F0000', results: [ + { rapdu: 'E3114F08A0000000030000009F70010FC50100', sw: '9000' } ] }); + assert.strictEqual(lines[0], 'A000000003000000 life=0F [Security Domain]'); + } finally { + delete global.decodePrivileges; + } +}); diff --git a/pyproject.toml b/pyproject.toml index 898899a..af00cf6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pysim-otaman-server" -version = "2.1.6" +version = "2.1.7" 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. diff --git a/pysim_otaman_server/server.py b/pysim_otaman_server/server.py index 9f84e9f..12d2024 100644 --- a/pysim_otaman_server/server.py +++ b/pysim_otaman_server/server.py @@ -21,7 +21,7 @@ from osmocom.construct import GsmOrUcs2Adapter from osmocom.tlv import BER_TLV_IE -VERSION = '2.1.6' +VERSION = '2.1.7' MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE @@ -1501,13 +1501,14 @@ def _scp81_script_responder(method, target, headers, body): _BIP.log('script-status', index=index, status=status) else: count, rapdus = _scp81_parse_response(body) + apdu = _SCP81_SCRIPT[index - 1].upper() if (_SCP81_SCRIPT and index >= 1) else '' for rapdu, sw in rapdus: _BIP.log('script-rapdu', index=index, sw=sw, bytes=len(rapdu), hex=rapdu.hex().upper()[:2000]) _SCP81_SCRIPT_RESULTS.append({'index': index, 'sw': sw, + 'apdu': apdu, 'rapdu': rapdu.hex().upper()}) if rapdus and _SCP81_SCRIPT and index >= 1: - apdu = _SCP81_SCRIPT[index - 1].upper() if apdu.startswith('80CAFF21'): decoded = _scp81_decode_memory(rapdus[-1][0]) if decoded: