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:
2026-08-21 22:53:16 +03:00
parent 288c2e5954
commit 0fd91d7771
6 changed files with 55 additions and 18 deletions
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -1,4 +1,4 @@
const CACHE = 'otaman-v9'; const CACHE = 'otaman-v10';
const URLS = [ const URLS = [
'index.html', 'index.html',
'help.html', 'help.html',
+3 -3
View File
@@ -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
View File
@@ -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.
+1 -1
View File
@@ -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