scp81: results panel polish - labels, dedupe, 0085 decode (v2.1.10)

- each result group shows the command label (GET DATA FF21, GET STATUS
  P1=80/40/10, GET DATA 0085, INSTALL/LOAD)
- GET STATUS pages deduplicate by AID: the continuation page re-includes its
  search criterion, which made the last entry of every listing appear twice
- the GET DATA 0085 answer is decoded (host/agent/uri, PSK identity +
  KVN/KID from the unframed [14][id][02 KVN/KID] security TLV, retry counter
  and timer, connection block with APN and destination address) instead of a
  truncated hex dump; undecodable results show the full hex now
- tests: admin-params decode with the live sample, command labels
  (346 frontend, 196 python); service worker v147
This commit is contained in:
2026-09-16 07:35:11 +03:00
parent 4dd09740b7
commit f411f12c56
5 changed files with 147 additions and 7 deletions
+120 -4
View File
@@ -18,7 +18,7 @@
<div class="max-w-7xl mx-auto px-6 py-2"> <div class="max-w-7xl mx-auto px-6 py-2">
<div class="flex items-center justify-between mb-3"> <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.9</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.10</span></h1>
<div class="flex items-center gap-4"> <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" 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> <span id="state-indicator-dot" class="text-xs text-gray-400" title="Connecting..."></span>
@@ -7128,6 +7128,110 @@ function scp81DecodeGetStatus(hex) {
return out; return out;
} }
function scp81CmdLabel(apdu) {
const a = (apdu || '').toUpperCase();
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('80E6')) return 'INSTALL';
if (a.startsWith('80E8')) return 'LOAD';
if (a.startsWith('80CA')) return 'GET DATA ' + a.slice(4, 8);
return '';
}
function scp81Ascii(hex) {
let out = '';
for (let i = 0; i + 1 < hex.length; i += 2) {
const c = parseInt(hex.substr(i, 2), 16);
out += (c >= 0x20 && c < 0x7f) ? String.fromCharCode(c) : '.';
}
return out;
}
function scp81Bcd(hex) {
// Semi-octet BCD time (h:m:s as in GP retry policy / TS 123 040 TP-SCT)
let out = '';
for (let i = 0; i < hex.length; i++) out += (i && i % 2 === 0 ? ':' : '') + hex[i];
return out;
}
function scp81DecodeAdminParams(hex) {
// GET DATA 0085 answer (TS 102 226 / GP admin session parameters):
// '85' wrapping 84 connection / 85 security / 86 retry / 89 HTTP TLVs.
const clean = (hex || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
const bytes = [];
for (let i = 0; i + 1 < clean.length; i += 2) bytes.push(parseInt(clean.substr(i, 2), 16));
const toHex = v => v.map(b => b.toString(16).padStart(2, '0')).join('').toUpperCase();
const out = [];
const walk = (arr, inConnection) => {
let i = 0;
while (i + 2 <= arr.length) {
const tag = arr[i], ln = arr[i + 1];
if (i + 2 + ln > arr.length) break;
const val = arr.slice(i + 2, i + 2 + ln);
const vhex = toHex(val);
if (tag === 0x84) {
out.push('connection:');
} else if (inConnection) {
if (tag === 0xC7 && val.length >= 2) {
out.push(' apn=' + scp81Ascii(toHex(val.slice(1))));
} else if (tag === 0xBE && val.length >= 5 && val[0] === 0x21) {
out.push(' dest=' + val.slice(1, 5).join('.'));
} else {
out.push(' ' + tag.toString(16).toUpperCase().padStart(2, '0') + '=' + vhex);
}
} else if (tag === 0x85 && val.length >= 20) {
// Security parameters: an unframed [14] tag, the PSK identity
// (20 bytes, ASCII) and the [02] KVN/KID pair - exactly like
// the trigger's 85 TLV (see the reference '85 18' sample).
let raw = val;
if (raw[0] === 0x14 || raw[0] === 0x15) raw = raw.slice(1);
const ident = scp81Ascii(toHex(raw.slice(0, 20)));
const rest = raw.slice(20);
const keyset = (rest[0] === 0x02 && rest.length >= 3)
? toHex(rest.slice(1, 3)) : null;
out.push('PSK id=' + ident + ' KVN/KID=' +
(keyset ? keyset.slice(0, 2) + '/' + keyset.slice(2) : '?'));
} else if (tag === 0x86) {
const cntr = val.length >= 2 ? val[0] * 256 + val[1] : 0;
let timer = null, j = 2;
while (j + 2 <= val.length) {
if (val[j] === 0x25 && val[j + 1] === 3 && j + 5 <= val.length) {
timer = scp81Bcd(toHex(val.slice(j + 2, j + 5)));
}
j += 2 + val[j + 1];
}
out.push('retry counter=' + cntr + (timer ? ' timer=' + timer : ''));
} else if (tag === 0x89) {
let j = 0;
while (j + 2 <= val.length) {
const t2 = val[j], l2 = val[j + 1];
if (j + 2 + l2 > val.length) break;
const v2 = toHex(val.slice(j + 2, j + 2 + l2));
if (t2 === 0x8A) out.push('host=' + scp81Ascii(v2));
else if (t2 === 0x8B) out.push('agent=' + scp81Ascii(v2));
else if (t2 === 0x8C) out.push('uri=' + scp81Ascii(v2));
else out.push('http tlv ' + t2.toString(16).toUpperCase() + '=' + v2);
j += 2 + l2;
}
} else if (tag === 0xC7 && val.length >= 2) {
out.push('apn=' + scp81Ascii(toHex(val.slice(1))));
} else if (tag === 0xBE && val.length >= 5 && val[0] === 0x21) {
out.push('dest=' + val.slice(1, 5).join('.'));
} else {
out.push('tlv ' + tag.toString(16).toUpperCase().padStart(2, '0') + '=' + vhex);
}
if (tag === 0x84) walk(val, true);
i += 2 + ln;
}
};
if (bytes[0] === 0x85 && bytes.length > 2) walk(bytes.slice(2, 2 + bytes[1]), false);
else walk(bytes, false);
return out;
}
function scp81GroupResults(script) { function scp81GroupResults(script) {
// Group R-APDU results by their originating command (first three bytes): // Group R-APDU results by their originating command (first three bytes):
// auto-continued SW CAFE pages end up under the same command. // auto-continued SW CAFE pages end up under the same command.
@@ -7164,10 +7268,20 @@ function scp81ResultLines(group) {
lines.push(hex.slice(0, 64) + (r.sw && r.sw !== '9000' ? ' SW ' + r.sw : '')); lines.push(hex.slice(0, 64) + (r.sw && r.sw !== '9000' ? ' SW ' + r.sw : ''));
} }
}); });
} else if (first.startsWith('80CA0085')) {
const lines2 = [];
scp81DecodeAdminParams((group.results[0] || {}).rapdu || '').forEach(l => lines2.push(l));
return lines2.length ? lines2 : ['(no parameters decoded)'];
} else if (first.startsWith('80F2')) { } else if (first.startsWith('80F2')) {
const entries = []; const entries = [];
const seen = {};
group.results.forEach(r => entries.push.apply(entries, scp81DecodeGetStatus(r.rapdu || ''))); group.results.forEach(r => entries.push.apply(entries, scp81DecodeGetStatus(r.rapdu || '')));
entries.forEach(e => { const unique = entries.filter(e => {
if (seen[e.aid]) return false;
seen[e.aid] = true;
return true;
});
unique.forEach(e => {
const priv = (typeof decodePrivileges === 'function' && e.privileges) ? decodePrivileges(e.privileges) : (e.privileges || ''); const priv = (typeof decodePrivileges === 'function' && e.privileges) ? decodePrivileges(e.privileges) : (e.privileges || '');
lines.push(e.aid + (e.lifecycle ? ' life=' + e.lifecycle : '') + (priv ? ' [' + priv + ']' : '') + lines.push(e.aid + (e.lifecycle ? ' life=' + e.lifecycle : '') + (priv ? ' [' + priv + ']' : '') +
(e.modules.length ? ' module=' + e.modules.join(',') : '')); (e.modules.length ? ' module=' + e.modules.join(',') : ''));
@@ -7175,7 +7289,7 @@ function scp81ResultLines(group) {
const bad = group.results.filter(r => r.sw && r.sw !== '9000' && r.sw !== 'CAFE'); const bad = group.results.filter(r => r.sw && r.sw !== '9000' && r.sw !== 'CAFE');
bad.forEach(r => lines.push('SW ' + r.sw)); bad.forEach(r => lines.push('SW ' + r.sw));
} else { } else {
group.results.forEach(r => lines.push((r.rapdu || '').slice(0, 64) + (r.sw ? ' SW ' + r.sw : ''))); group.results.forEach(r => lines.push((r.rapdu || '') + (r.sw ? ' SW ' + r.sw : '')));
} }
return lines; return lines;
} }
@@ -7192,7 +7306,9 @@ async function scp81ResultsRefresh() {
} }
el.innerHTML = groups.map(g => { el.innerHTML = groups.map(g => {
const lines = scp81ResultLines(g); const lines = scp81ResultLines(g);
const head = g.apdu + (g.results.length > 1 ? ' (' + g.results.length + ' pages)' : ''); const label = scp81CmdLabel(g.apdu);
const head = g.apdu + (label ? ' ' + label : '') +
(g.results.length > 1 ? ' (' + g.results.length + ' pages)' : '');
return '<div class="py-1 border-b border-gray-100 dark:border-slate-700/50">' + 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>' + '<div class="text-gray-500 dark:text-slate-500">' + esc(head) + '</div>' +
lines.map(l => '<div class="break-all">' + esc(l) + '</div>').join('') + '</div>'; lines.map(l => '<div class="break-all">' + esc(l) + '</div>').join('') + '</div>';
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'otaman-v146'; const CACHE = 'otaman-v147';
const URLS = [ const URLS = [
'index.html', 'index.html',
'help.html', 'help.html',
+24
View File
@@ -104,3 +104,27 @@ test('scp81ResultLines decodes GET STATUS entries', () => {
delete global.decodePrivileges; delete global.decodePrivileges;
} }
}); });
eval(extractFunc(html, 'scp81Ascii'));
eval(extractFunc(html, 'scp81Bcd'));
eval(extractFunc(html, 'scp81DecodeAdminParams'));
eval(extractFunc(html, 'scp81CmdLabel'));
test('scp81DecodeAdminParams decodes the stored 0085 answer', () => {
const hex = '856F84248103014003820281828500B50103B902058EC70403475042BC03020582BE05215BD50502851814383937303178787878787878787878787878787802400186070001250300100089248A096C6F63616C686F73748B1438393730317878787878787878787878787878788C012F';
const lines = scp81DecodeAdminParams(hex);
assert.ok(lines.includes('PSK id=89701xxxxxxxxxxxxxxx KVN/KID=40/01'));
assert.ok(lines.includes('retry counter=1 timer=00:10:00'));
assert.ok(lines.includes('host=localhost'));
assert.ok(lines.includes('agent=89701xxxxxxxxxxxxxxx'));
assert.ok(lines.includes('uri=/'));
assert.ok(lines.includes(' apn=GPB'));
assert.ok(lines.includes(' dest=91.213.5.2'));
});
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('80E8800000'), 'LOAD');
});
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "pysim-otaman-server" name = "pysim-otaman-server"
version = "2.1.9" version = "2.1.10"
description = "HTTP REST server wrapping pysim for the OTAMan PWA" description = "HTTP REST server wrapping pysim for the OTAMan PWA"
requires-python = ">=3.8" requires-python = ">=3.8"
# pysim is a git-only dependency installed explicitly by setup.bat/setup.sh. # pysim is a git-only dependency installed explicitly by setup.bat/setup.sh.
+1 -1
View File
@@ -21,7 +21,7 @@ from osmocom.construct import GsmOrUcs2Adapter
from osmocom.tlv import BER_TLV_IE from osmocom.tlv import BER_TLV_IE
VERSION = '2.1.9' VERSION = '2.1.10'
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE