diff --git a/frontend/index.html b/frontend/index.html
index 4a998c0..49db8de 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -18,7 +18,7 @@
-
OTAMan SIM OTA with a Human Face v2.1.9
+
OTAMan SIM OTA with a Human Face v2.1.10
●
@@ -7128,6 +7128,110 @@ function scp81DecodeGetStatus(hex) {
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) {
// Group R-APDU results by their originating command (first three bytes):
// 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 : ''));
}
});
+ } 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')) {
const entries = [];
+ const seen = {};
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 || '');
lines.push(e.aid + (e.lifecycle ? ' life=' + e.lifecycle : '') + (priv ? ' [' + priv + ']' : '') +
(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');
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 : '')));
+ group.results.forEach(r => lines.push((r.rapdu || '') + (r.sw ? ' SW ' + r.sw : '')));
}
return lines;
}
@@ -7192,7 +7306,9 @@ async function scp81ResultsRefresh() {
}
el.innerHTML = groups.map(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 '' +
'
' + esc(head) + '
' +
lines.map(l => '
' + esc(l) + '
').join('') + '
';
diff --git a/frontend/sw.js b/frontend/sw.js
index bcbe342..2780346 100644
--- a/frontend/sw.js
+++ b/frontend/sw.js
@@ -1,4 +1,4 @@
-const CACHE = 'otaman-v146';
+const CACHE = 'otaman-v147';
const URLS = [
'index.html',
'help.html',
diff --git a/frontend/tests/scp81.test.js b/frontend/tests/scp81.test.js
index fe82cff..f46b1f3 100644
--- a/frontend/tests/scp81.test.js
+++ b/frontend/tests/scp81.test.js
@@ -104,3 +104,27 @@ test('scp81ResultLines decodes GET STATUS entries', () => {
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');
+});
diff --git a/pyproject.toml b/pyproject.toml
index daf09b4..a948345 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "pysim-otaman-server"
-version = "2.1.9"
+version = "2.1.10"
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 f5e10bc..42b1d98 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.9'
+VERSION = '2.1.10'
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE