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: