scp81: decoded script-results panel in the tab (v2.1.7)

- results carry the originating APDU, so auto-continued SW CAFE pages group
  under their logical command
- new tab panel: memory pages decode to applets / free NV / free volatile,
  GET STATUS pages decode to AID + lifecycle + privileges (via the existing
  decodePrivileges) + module AIDs, truncated page tails are skipped
- tests: decoders and grouping (frontend 342, python 192)
- service worker v144
This commit is contained in:
2026-09-16 01:59:04 +03:00
parent a352d8fe3f
commit e255677dd9
5 changed files with 176 additions and 5 deletions
+124 -1
View File
@@ -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.6</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.7</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>
@@ -1000,6 +1000,12 @@
<div id="scp81-msg" class="text-xs mt-2 hidden"></div>
<div class="mt-2 text-xs text-gray-500 dark:text-slate-400" data-l10n="The key is only sent to the local server and is never stored or logged.">The key is only sent to the local server and is never stored or logged.</div>
</div>
<div class="border border-gray-200 dark:border-slate-700 rounded p-3 mb-3">
<div class="flex justify-between items-center mb-2">
<span class="text-sm text-gray-500 dark:text-slate-400" data-l10n="Script results (R-APDUs)">Script results (R-APDUs)</span>
</div>
<div id="scp81-results" class="text-sm font-mono text-gray-600 dark:text-slate-400 max-h-[35vh] overflow-auto"></div>
</div>
<div class="border border-gray-200 dark:border-slate-700 rounded p-3">
<div class="flex justify-between items-center mb-2">
<span class="text-sm text-gray-500 dark:text-slate-400" data-l10n="HTTP OTA log">HTTP OTA log</span>
@@ -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 <len>
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 = '<span class="text-gray-400">' + t('No entries yet.') + '</span>';
return;
}
el.innerHTML = groups.map(g => {
const lines = scp81ResultLines(g);
const head = g.apdu + (g.results.length > 1 ? ' (' + g.results.length + ' pages)' : '');
return '<div class="py-1 border-b border-gray-100 dark:border-slate-700/50">' +
'<div class="text-gray-500 dark:text-slate-500">' + esc(head) + '</div>' +
lines.map(l => '<div class="break-all">' + esc(l) + '</div>').join('') + '</div>';
}).join('');
} catch (err) {
el.innerHTML = '<span class="text-red-500">Error: ' + esc(String(err.message || err)) + '</span>';
}
}
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);
}
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'otaman-v143';
const CACHE = 'otaman-v144';
const URLS = [
'index.html',
'help.html',
+47
View File
@@ -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;
}
});
+1 -1
View File
@@ -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.
+3 -2
View File
@@ -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: