From 0fd91d77714b6417cd9e9886d91908190518b9a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D1=82=D0=BE=D0=BD=20=D0=A2=D1=80=D0=BE=D1=88?= =?UTF-8?q?=D0=B8=D0=BD?= Date: Fri, 21 Aug 2026 22:53:16 +0300 Subject: [PATCH] C-APDU parser + BER fixes: DCS prefix, qualifier tables, EFRMA refs, CLA labels; v1.9.0 Parser + BER constructor (TS 102 223 / TS 102 226): - DCS byte: genBerPcValue prepends DCS to 8D (00=GSM7, 08=UCS2, 04=unpacked) - decodeTextData: consume DCS first, legacy no-DCS fallback kept - BER_QUAL.display['80']: 'Clear after delay' -> 'Wait for user to clear message' - BER_QUAL.tone: 'Monotonous/Alternating' -> 'Vibrate alert' names - describeProactiveCommand: map type byte -> correct BER_QUAL key (01->refresh, 21->display, 20->tone); proper command names - parseActionRow: values 01-7F -> 'Reference to EFRMA record' - parseOneApdu: CLA 84-87 -> 'GlobalPlatform (secure messaging)' - PARSE_INS: added GET RESPONSE (INS C0) Version: 1.8.1 -> 1.9.0, SW cache otaman-v9 -> otaman-v10 --- docs/api.md | 2 +- frontend/index.html | 59 ++++++++++++++++++++++++++++------- frontend/sw.js | 2 +- frontend/tests/ber.test.js | 6 ++-- pyproject.toml | 2 +- pysim_otaman_server/server.py | 2 +- 6 files changed, 55 insertions(+), 18 deletions(-) diff --git a/docs/api.md b/docs/api.md index 6eb788f..4b0c553 100644 --- a/docs/api.md +++ b/docs/api.md @@ -54,7 +54,7 @@ Returns server version for compatibility checking. **Example response:** ```json -{"version": "1.8.1"} +{"version": "1.9.0"} ``` ### `GET /api/status` diff --git a/frontend/index.html b/frontend/index.html index 8010243..6d76b03 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -17,7 +17,7 @@
-

OTAMan SIM OTA with a Human Face v1.8.1

+

OTAMan SIM OTA with a Human Face v1.9.0

github @@ -2134,11 +2134,11 @@ const BER_QUAL = { display: [ ['00','Normal priority'], ['01','High priority'], - ['80','Clear after delay'] + ['80','Wait for user to clear message'] ], tone: [ - ['00','Monotonous tone'], - ['01','Alternating tone'] + ['00','Vibrate alert: up to terminal'], + ['01','Vibrate alert with tone'] ] }; const BER_TONES = [ @@ -2473,7 +2473,7 @@ function genBerPcValue(row, tag) { const enc = pc.querySelector('.ber-pc-enc').value; if (text) { if (enc === 'ucs2') { - const buf = []; + const buf = [0x08]; for (const c of text) { const cd=c.charCodeAt(0); buf.push((cd>>8)&0xFF,cd&0xFF); } value += '8D' + berLenStr(buf.length) + buf.map(b=>b.toString(16).padStart(2,'0').toUpperCase()).join(''); } else { @@ -2481,9 +2481,9 @@ function genBerPcValue(row, tag) { const septets = gsm7TextToSeptets(text); const packed = gsm7Encode(septets); const inner = Array.from(packed).map(b=>b.toString(16).padStart(2,'0').toUpperCase()); - value += '8D' + berLenStr(inner.length) + inner.join(''); + value += '8D' + berLenStr(inner.length + 1) + '00' + inner.join(''); } catch(e) { - value += '8D' + berLenStr(text.length) + text.split('').map(c=>c.charCodeAt(0).toString(16).padStart(2,'0').toUpperCase()).join(''); + value += '8D' + berLenStr(text.length + 1) + '04' + text.split('').map(c=>c.charCodeAt(0).toString(16).padStart(2,'0').toUpperCase()).join(''); } } } @@ -2552,6 +2552,7 @@ const PARSE_INS = { 'F0': {name:'SET STATUS', data:'lc'}, '82': {name:'EXTERNAL AUTHENTICATE', data:'lc'}, '88': {name:'INTERNAL AUTHENTICATE', data:'lc'}, + 'C0': {name:'GET RESPONSE', data:'le'}, }; function parseBerLen(hex, i) { @@ -2638,6 +2639,31 @@ function gsm7Decode(octets) { function decodeTextData(hex) { if (!hex || hex.length < 2) return '?'; + const dcs = parseInt(hex.substr(0, 2), 16); + const rest = hex.substr(2); + if (dcs === 0x08 && rest.length >= 4 && rest.length % 4 === 0) { + let ucs2 = ''; + for (let i = 0; i < rest.length; i += 4) { + const cp = parseInt(rest.substr(i, 4), 16); + if (cp < 0x20 || cp > 0x10FFFF) return '?'; + ucs2 += String.fromCodePoint(cp); + } + if (ucs2.length > 0) return ucs2; + } + if (dcs === 0x00 || dcs === 0x04) { + try { + const bytes = new Uint8Array(rest.length / 2); + for (let i = 0; i < bytes.length; i++) bytes[i] = parseInt(rest.substr(i * 2, 2), 16); + if (dcs === 0x00) { + const gsm = gsm7Decode(bytes); + if (gsm.length > 0 && !gsm.includes('\uFFFD')) return gsm; + } else { + let s = ''; + for (const b of bytes) { if (b < 0x20 || b > 0x7E) return '?'; s += String.fromCharCode(b); } + if (s.length > 0) return s; + } + } catch (e) {} + } if (hex.length >= 4 && hex.length % 4 === 0) { let allPrintable = true; let ucs2 = ''; @@ -2745,11 +2771,18 @@ function describeProactiveCommand(tlvs) { let desc = ''; if (base === '01') { const cmdNum = parseInt(tlv.value.substr(0, 2), 16); + const typeByte = tlv.value.substr(2, 2); const qual = tlv.value.substr(4, 2); + const typeKey = { '01': 'refresh', '21': 'display', '20': 'tone' }[typeByte]; + const cmdName = { refresh: 'REFRESH', display: 'DISPLAY TEXT', tone: 'PLAY TONE' }[typeKey]; let typeName = 'Command #' + cmdNum; - for (const [k, arr] of Object.entries(BER_QUAL)) { - const found = arr.find(([v]) => v === qual); - if (found) { typeName += ', ' + k.toUpperCase() + ' qual ' + qual + ' ' + found[1]; break; } + if (typeKey && cmdName) { + const arr = BER_QUAL[typeKey]; + const found = arr ? arr.find(([v]) => v === qual) : null; + if (found) typeName += ', ' + cmdName + ' qual ' + qual + ' ' + found[1]; + else typeName += ', ' + cmdName + ' qual ' + qual; + } else { + typeName += ', type ' + typeByte + ' qual ' + qual; } desc = typeName; } else if (base === '02') { @@ -2841,6 +2874,10 @@ function parseChainingFlags(hex) { function parseActionRow(hex, tag) { if (!hex || hex === '00') return [{label:'No action', hex:hex||'', desc:tag === '82' ? 'No action (82 00)' : ''}]; if (hex.length === 2) { + const val = parseInt(hex, 16); + if (val >= 0x01 && val <= 0x7F) { + return [{label:'Reference to EFRMA record', hex, desc:'Record 0x' + hex}]; + } const names = {'81':'Proactive session indication','82':'Early response'}; return [{label:'Action indicator', hex, desc:names[hex] || 'Value ' + hex}]; } @@ -2861,7 +2898,7 @@ function parseOneApdu(hex) { const p3 = hex.substr(8, 2); const insInfo = PARSE_INS[ins]; const children = [ - {label:'CLA', hex:cla, desc:cla === '80' ? 'GP/RAM' : cla === 'A0' ? 'SIM' : 'UICC'}, + {label:'CLA', hex:cla, desc:cla === '80' ? 'GP/RAM' : cla === 'A0' ? 'SIM' : parseInt(cla,16) >= 0x84 && parseInt(cla,16) <= 0x87 ? 'GlobalPlatform (secure messaging)' : 'UICC'}, {label:'INS', hex:ins, desc:insInfo ? insInfo.name : 'Unknown'}, {label:'P1', hex:p1, desc:''}, {label:'P2', hex:p2, desc:''}, diff --git a/frontend/sw.js b/frontend/sw.js index eac5e3c..eaf7ea7 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'otaman-v9'; +const CACHE = 'otaman-v10'; const URLS = [ 'index.html', 'help.html', diff --git a/frontend/tests/ber.test.js b/frontend/tests/ber.test.js index bdadd12..e6972ab 100644 --- a/frontend/tests/ber.test.js +++ b/frontend/tests/ber.test.js @@ -148,14 +148,14 @@ test('genBerPcValue DISPLAY TEXT GSM7 "HI"', () => { pc.children.find(c => c.cls === 'ber-pc-enc').value = 'gsm7'; pc.children.find(c => c.cls === 'ber-pc-dur-enable').checked = false; const val = genBerPcValue({ querySelector: (s) => s === '.ber-pc' ? pc : null }, '81'); - assert.strictEqual(val, '810D8103012101820281828D02C824'); + assert.strictEqual(val, '810E8103012101820281828D0300C824'); }); test('genBerPcValue DISPLAY TEXT UCS2 "HI" + 30s duration', () => { const pc = buildPcHtml(); const val = genBerPcValue({ querySelector: (s) => s === '.ber-pc' ? pc : null }, '81'); // 81 13 8103 01 21 01 8202 81 82 8D 04 0048 0049 84 02 01 1E - assert.strictEqual(val, '81138103012101820281828D04004800498402011E'); + assert.strictEqual(val, '81148103012101820281828D0508004800498402011E'); }); test('genBerPcValue duration disabled omits 84', () => { @@ -208,7 +208,7 @@ test('genErrorActionValue proactive wraps proactive command', () => { row.children[1].children.push(pc); const val = genErrorActionValue(row, '82'); // DISPLAY TEXT UCS2 "HI" + 30s inside Error Action - assert.strictEqual(val, '82138103012101820281828D04004800498402011E'); + assert.strictEqual(val, '82148103012101820281828D0508004800498402011E'); }); test('genScriptChainingValue first emits 01', () => { diff --git a/pyproject.toml b/pyproject.toml index 6413756..1391e6f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pysim-otaman-server" -version = "1.8.1" +version = "1.9.0" 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 8ed9015..82f2776 100644 --- a/pysim_otaman_server/server.py +++ b/pysim_otaman_server/server.py @@ -18,7 +18,7 @@ from osmocom.construct import GsmOrUcs2Adapter from osmocom.tlv import BER_TLV_IE -VERSION = '1.8.1' +VERSION = '1.9.0' # Static file serving (the PWA lives in /frontend, served by this server