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