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:
+124
-1
@@ -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
@@ -1,4 +1,4 @@
|
||||
const CACHE = 'otaman-v143';
|
||||
const CACHE = 'otaman-v144';
|
||||
const URLS = [
|
||||
'index.html',
|
||||
'help.html',
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user