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
This commit is contained in:
+1
-1
@@ -54,7 +54,7 @@ Returns server version for compatibility checking.
|
|||||||
|
|
||||||
**Example response:**
|
**Example response:**
|
||||||
```json
|
```json
|
||||||
{"version": "1.8.1"}
|
{"version": "1.9.0"}
|
||||||
```
|
```
|
||||||
|
|
||||||
### `GET /api/status`
|
### `GET /api/status`
|
||||||
|
|||||||
+48
-11
@@ -17,7 +17,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">v1.8.1</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">v1.9.0</span></h1>
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<button id="install-btn" class="px-2 py-1 text-xs rounded border border-gray-300 dark:border-slate-600 hover:bg-gray-200 dark:hover:bg-slate-700" style="display:none">INSTALL PWA [for offline use]</button>
|
<button id="install-btn" class="px-2 py-1 text-xs rounded border border-gray-300 dark:border-slate-600 hover:bg-gray-200 dark:hover:bg-slate-700" style="display:none">INSTALL PWA [for offline use]</button>
|
||||||
<a href="https://github.com/anttro/otaman" target="_blank" class="text-xs text-gray-400 hover:text-gray-600 dark:text-slate-500 dark:hover:text-slate-300">github</a>
|
<a href="https://github.com/anttro/otaman" target="_blank" class="text-xs text-gray-400 hover:text-gray-600 dark:text-slate-500 dark:hover:text-slate-300">github</a>
|
||||||
@@ -2134,11 +2134,11 @@ const BER_QUAL = {
|
|||||||
display: [
|
display: [
|
||||||
['00','Normal priority'],
|
['00','Normal priority'],
|
||||||
['01','High priority'],
|
['01','High priority'],
|
||||||
['80','Clear after delay']
|
['80','Wait for user to clear message']
|
||||||
],
|
],
|
||||||
tone: [
|
tone: [
|
||||||
['00','Monotonous tone'],
|
['00','Vibrate alert: up to terminal'],
|
||||||
['01','Alternating tone']
|
['01','Vibrate alert with tone']
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
const BER_TONES = [
|
const BER_TONES = [
|
||||||
@@ -2473,7 +2473,7 @@ function genBerPcValue(row, tag) {
|
|||||||
const enc = pc.querySelector('.ber-pc-enc').value;
|
const enc = pc.querySelector('.ber-pc-enc').value;
|
||||||
if (text) {
|
if (text) {
|
||||||
if (enc === 'ucs2') {
|
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); }
|
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('');
|
value += '8D' + berLenStr(buf.length) + buf.map(b=>b.toString(16).padStart(2,'0').toUpperCase()).join('');
|
||||||
} else {
|
} else {
|
||||||
@@ -2481,9 +2481,9 @@ function genBerPcValue(row, tag) {
|
|||||||
const septets = gsm7TextToSeptets(text);
|
const septets = gsm7TextToSeptets(text);
|
||||||
const packed = gsm7Encode(septets);
|
const packed = gsm7Encode(septets);
|
||||||
const inner = Array.from(packed).map(b=>b.toString(16).padStart(2,'0').toUpperCase());
|
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) {
|
} 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'},
|
'F0': {name:'SET STATUS', data:'lc'},
|
||||||
'82': {name:'EXTERNAL AUTHENTICATE', data:'lc'},
|
'82': {name:'EXTERNAL AUTHENTICATE', data:'lc'},
|
||||||
'88': {name:'INTERNAL AUTHENTICATE', data:'lc'},
|
'88': {name:'INTERNAL AUTHENTICATE', data:'lc'},
|
||||||
|
'C0': {name:'GET RESPONSE', data:'le'},
|
||||||
};
|
};
|
||||||
|
|
||||||
function parseBerLen(hex, i) {
|
function parseBerLen(hex, i) {
|
||||||
@@ -2638,6 +2639,31 @@ function gsm7Decode(octets) {
|
|||||||
|
|
||||||
function decodeTextData(hex) {
|
function decodeTextData(hex) {
|
||||||
if (!hex || hex.length < 2) return '?';
|
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) {
|
if (hex.length >= 4 && hex.length % 4 === 0) {
|
||||||
let allPrintable = true;
|
let allPrintable = true;
|
||||||
let ucs2 = '';
|
let ucs2 = '';
|
||||||
@@ -2745,11 +2771,18 @@ function describeProactiveCommand(tlvs) {
|
|||||||
let desc = '';
|
let desc = '';
|
||||||
if (base === '01') {
|
if (base === '01') {
|
||||||
const cmdNum = parseInt(tlv.value.substr(0, 2), 16);
|
const cmdNum = parseInt(tlv.value.substr(0, 2), 16);
|
||||||
|
const typeByte = tlv.value.substr(2, 2);
|
||||||
const qual = tlv.value.substr(4, 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;
|
let typeName = 'Command #' + cmdNum;
|
||||||
for (const [k, arr] of Object.entries(BER_QUAL)) {
|
if (typeKey && cmdName) {
|
||||||
const found = arr.find(([v]) => v === qual);
|
const arr = BER_QUAL[typeKey];
|
||||||
if (found) { typeName += ', ' + k.toUpperCase() + ' qual ' + qual + ' ' + found[1]; break; }
|
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;
|
desc = typeName;
|
||||||
} else if (base === '02') {
|
} else if (base === '02') {
|
||||||
@@ -2841,6 +2874,10 @@ function parseChainingFlags(hex) {
|
|||||||
function parseActionRow(hex, tag) {
|
function parseActionRow(hex, tag) {
|
||||||
if (!hex || hex === '00') return [{label:'No action', hex:hex||'', desc:tag === '82' ? 'No action (82 00)' : ''}];
|
if (!hex || hex === '00') return [{label:'No action', hex:hex||'', desc:tag === '82' ? 'No action (82 00)' : ''}];
|
||||||
if (hex.length === 2) {
|
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'};
|
const names = {'81':'Proactive session indication','82':'Early response'};
|
||||||
return [{label:'Action indicator', hex, desc:names[hex] || 'Value ' + hex}];
|
return [{label:'Action indicator', hex, desc:names[hex] || 'Value ' + hex}];
|
||||||
}
|
}
|
||||||
@@ -2861,7 +2898,7 @@ function parseOneApdu(hex) {
|
|||||||
const p3 = hex.substr(8, 2);
|
const p3 = hex.substr(8, 2);
|
||||||
const insInfo = PARSE_INS[ins];
|
const insInfo = PARSE_INS[ins];
|
||||||
const children = [
|
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:'INS', hex:ins, desc:insInfo ? insInfo.name : 'Unknown'},
|
||||||
{label:'P1', hex:p1, desc:''},
|
{label:'P1', hex:p1, desc:''},
|
||||||
{label:'P2', hex:p2, desc:''},
|
{label:'P2', hex:p2, desc:''},
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
const CACHE = 'otaman-v9';
|
const CACHE = 'otaman-v10';
|
||||||
const URLS = [
|
const URLS = [
|
||||||
'index.html',
|
'index.html',
|
||||||
'help.html',
|
'help.html',
|
||||||
|
|||||||
@@ -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-enc').value = 'gsm7';
|
||||||
pc.children.find(c => c.cls === 'ber-pc-dur-enable').checked = false;
|
pc.children.find(c => c.cls === 'ber-pc-dur-enable').checked = false;
|
||||||
const val = genBerPcValue({ querySelector: (s) => s === '.ber-pc' ? pc : null }, '81');
|
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', () => {
|
test('genBerPcValue DISPLAY TEXT UCS2 "HI" + 30s duration', () => {
|
||||||
const pc = buildPcHtml();
|
const pc = buildPcHtml();
|
||||||
const val = genBerPcValue({ querySelector: (s) => s === '.ber-pc' ? pc : null }, '81');
|
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
|
// 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', () => {
|
test('genBerPcValue duration disabled omits 84', () => {
|
||||||
@@ -208,7 +208,7 @@ test('genErrorActionValue proactive wraps proactive command', () => {
|
|||||||
row.children[1].children.push(pc);
|
row.children[1].children.push(pc);
|
||||||
const val = genErrorActionValue(row, '82');
|
const val = genErrorActionValue(row, '82');
|
||||||
// DISPLAY TEXT UCS2 "HI" + 30s inside Error Action
|
// DISPLAY TEXT UCS2 "HI" + 30s inside Error Action
|
||||||
assert.strictEqual(val, '82138103012101820281828D04004800498402011E');
|
assert.strictEqual(val, '82148103012101820281828D0508004800498402011E');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('genScriptChainingValue first emits 01', () => {
|
test('genScriptChainingValue first emits 01', () => {
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "pysim-otaman-server"
|
name = "pysim-otaman-server"
|
||||||
version = "1.8.1"
|
version = "1.9.0"
|
||||||
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.
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ from osmocom.construct import GsmOrUcs2Adapter
|
|||||||
from osmocom.tlv import BER_TLV_IE
|
from osmocom.tlv import BER_TLV_IE
|
||||||
|
|
||||||
|
|
||||||
VERSION = '1.8.1'
|
VERSION = '1.9.0'
|
||||||
|
|
||||||
|
|
||||||
# Static file serving (the PWA lives in <repo>/frontend, served by this server
|
# Static file serving (the PWA lives in <repo>/frontend, served by this server
|
||||||
|
|||||||
Reference in New Issue
Block a user