diff --git a/docs/api.md b/docs/api.md
index 412631b..ae0674e 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -54,7 +54,7 @@ Returns server version for compatibility checking.
**Example response:**
```json
-{"version": "1.9.1"}
+{"version": "1.9.2"}
```
### `GET /api/status`
diff --git a/frontend/index.html b/frontend/index.html
index 26a7eec..1b194a9 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -18,7 +18,7 @@
-
OTAMan SIM OTA with a Human Face v1.9.1
+
OTAMan SIM OTA with a Human Face v1.9.2
github
@@ -62,6 +62,9 @@
+
+
+
@@ -121,6 +124,17 @@
+
+
+
+
+
+
@@ -198,6 +212,9 @@
+
+
+
@@ -257,6 +274,17 @@
+
+
+
+
+
+
@@ -1104,7 +1132,7 @@ const BINARY_OPS = new Set(['update-binary', 'read-binary', 'erase-binary']);
const READ_OPS = new Set(['read-record', 'read-binary']);
const DATA_OPS = new Set(['update-record', 'update-binary']);
const RECSIZE_OPS = new Set(['update-record']);
-const PIN_OPS = new Set(['verify', 'change']);
+const PIN_OPS = new Set(['verify', 'change', 'disable', 'enable', 'unblock']);
function updateSimUsimFields(mode) {
if (!mode) return;
@@ -1115,9 +1143,15 @@ function updateSimUsimFields(mode) {
document.getElementById(mode + '-data-row').style.display = DATA_OPS.has(cmd) ? '' : 'none';
document.getElementById(mode + '-recsize-row').style.display = RECSIZE_OPS.has(cmd) ? '' : 'none';
document.getElementById(mode + '-pin-row').style.display = PIN_OPS.has(cmd) ? '' : 'none';
- document.getElementById(mode + '-pin-val-row').style.display = cmd === 'verify' ? '' : 'none';
- document.getElementById(mode + '-pin-old-row').style.display = cmd === 'change' ? '' : 'none';
- document.getElementById(mode + '-pin-new-row').style.display = cmd === 'change' ? '' : 'none';
+ const pinValCmds = cmd === 'verify' || cmd === 'disable' || cmd === 'enable';
+ document.getElementById(mode + '-pin-val-row').style.display = pinValCmds ? '' : 'none';
+ const oldNewCmds = cmd === 'change' || cmd === 'unblock';
+ document.getElementById(mode + '-pin-old-row').style.display = oldNewCmds ? '' : 'none';
+ document.getElementById(mode + '-pin-new-row').style.display = oldNewCmds ? '' : 'none';
+ if (oldNewCmds) {
+ document.querySelector('#' + mode + '-pin-old-row label').textContent = cmd === 'unblock' ? 'Unblock PIN' : 'Old PIN';
+ }
+ document.getElementById(mode + '-act-row').style.display = (cmd === 'activate-file' || cmd === 'deactivate-file') ? '' : 'none';
if (cmd === 'select') {
document.getElementById(mode + '-select-checkbox-row').style.display = 'none';
} else {
@@ -1135,6 +1169,11 @@ function updateSimSelectMethod(mode) {
document.getElementById(mode + '-sel-chain-row').style.display = method === 'chain' ? '' : 'none';
}
+function updateActFileInput(mode) {
+ const target = document.getElementById(mode + '-act-target').value;
+ document.getElementById(mode + '-act-file').style.display = target === 'current' ? 'none' : '';
+}
+
function toggleSimOverride(mode) {
const checked = document.getElementById(mode + '-override').checked;
document.getElementById(mode + '-p1').disabled = !checked;
@@ -1178,8 +1217,14 @@ function updateP1P2Display(mode) {
} else if (cmd === 'read-binary' || cmd === 'update-binary' || cmd === 'erase-binary') {
p1 = (offset >> 8) & 0xFF; p2 = offset & 0xFF;
} else if (cmd === 'activate-file' || cmd === 'deactivate-file') {
- p1 = 0x00; p2 = 0x00;
- } else if (cmd === 'verify' || cmd === 'change') {
+ const target = document.getElementById(mode + '-act-target').value;
+ if (target === 'current') {
+ p1 = 0x00; p2 = 0x00;
+ } else {
+ p1 = target === 'fid' ? 0x00 : (target === 'mfpath' ? 0x08 : 0x09);
+ p2 = 0x00;
+ }
+ } else if (cmd === 'verify' || cmd === 'change' || cmd === 'disable' || cmd === 'enable' || cmd === 'unblock') {
p1 = 0x00; p2 = parseInt(pin, 16);
} else {
p1 = 0x00; p2 = 0x00;
@@ -1417,6 +1462,9 @@ const OPS = {
'deactivate-file': {ins: 0x04},
'verify': {ins: 0x20},
'change': {ins: 0x24},
+ 'disable': {ins: 0x26},
+ 'enable': {ins: 0x28},
+ 'unblock': {ins: 0x2C},
};
function getSelValue(mode) {
@@ -1513,13 +1561,23 @@ function genSimUsim(mode) {
} else if (cmd === 'erase-binary') {
p1 = (offset >> 8) & 0xFF; p2 = offset & 0xFF; p3 = 0x00;
} else if (cmd === 'activate-file' || cmd === 'deactivate-file') {
- p1 = 0x00; p2 = 0x00; p3 = null;
- } else if (cmd === 'verify') {
+ const target = document.getElementById(mode + '-act-target').value;
+ if (target === 'current') {
+ p1 = 0x00; p2 = 0x00; p3 = null;
+ } else {
+ const fileHex = (document.getElementById(mode + '-act-file').value || '').replace(/[^0-9a-fA-F]/g, '');
+ if (!fileHex || fileHex.length % 2 !== 0) { resultEl.value = 'Error: specify FID or path (even hex length)'; return; }
+ p1 = target === 'fid' ? 0x00 : (target === 'mfpath' ? 0x08 : 0x09);
+ p2 = 0x00;
+ p3 = fileHex.length / 2;
+ data = fileHex;
+ }
+ } else if (cmd === 'verify' || cmd === 'disable' || cmd === 'enable') {
const pinVal = document.getElementById(mode + '-pin-val').value || '';
if (!pinVal) { resultEl.value = 'Error: specify PIN value'; return; }
const pinHex = Array.from(pinVal).map(c => c.charCodeAt(0).toString(16).padStart(2, '0')).join('').padEnd(16, 'F').slice(0, 16);
p1 = 0x00; p2 = parseInt(pin, 16); p3 = 0x08; data = pinHex;
- } else if (cmd === 'change') {
+ } else if (cmd === 'change' || cmd === 'unblock') {
const oldPin = document.getElementById(mode + '-pin-old').value || '';
const newPin = document.getElementById(mode + '-pin-new').value || '';
if (!oldPin || !newPin) { resultEl.value = 'Error: specify old and new PIN'; return; }
@@ -5716,6 +5774,12 @@ const LANG_RU = {
'ELF AID (hex)': 'ELF AID (hex)',
'Module AID (hex)': 'AID модуля (hex)',
'Security level (P1)': 'Уровень безопасности (P1)',
+ 'Target': 'Цель',
+ 'Current EF (case 1)': 'Текущий EF (case 1)',
+ 'By FID': 'По FID',
+ 'Path from MF': 'Путь от MF',
+ 'Path from current DF': 'Путь от текущего DF',
+ 'Unblock PIN': 'Разблокировка PIN',
'Byte 1': 'Байт 1',
'Byte 2': 'Байт 2',
'SIM/UICC Toolkit parameters': 'Параметры SIM/UICC Toolkit',
diff --git a/frontend/sw.js b/frontend/sw.js
index cf60d66..3c9fcbd 100644
--- a/frontend/sw.js
+++ b/frontend/sw.js
@@ -1,4 +1,4 @@
-const CACHE = 'otaman-v12';
+const CACHE = 'otaman-v13';
const URLS = [
'index.html',
'help.html',
diff --git a/frontend/tests/response_map.test.js b/frontend/tests/response_map.test.js
new file mode 100644
index 0000000..6b4665c
--- /dev/null
+++ b/frontend/tests/response_map.test.js
@@ -0,0 +1,57 @@
+const { test } = require('node:test');
+const assert = require('node:assert');
+const fs = require('node:fs');
+const path = require('node:path');
+
+const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
+
+function extractBlock(startMarker, endMarker) {
+ const start = html.indexOf(startMarker);
+ const end = html.indexOf(endMarker, start);
+ if (start < 0 || end < 0) throw new Error('block not found');
+ return html.slice(start, end);
+}
+
+// Response parser maps + lookupSw live between SW_MAP and updateRespCmd.
+// Rewrite top-level const -> var so the maps leak out of sloppy-mode eval.
+eval(extractBlock('const SW_MAP = {', 'function updateRespCmd').replace(/^const /gm, 'var '));
+
+test('lookupSw wildcard 91XX resolves proactive pending (any length)', () => {
+ const desc = lookupSw('91', '14', 'uicc');
+ assert.ok(desc.includes('proactive'), desc);
+ assert.ok(!desc.includes('no response data'));
+});
+
+test('lookupSw 63CX extracts retry counter', () => {
+ const desc = lookupSw('63', 'C3', 'uicc');
+ assert.ok(desc.includes('3'), desc);
+});
+
+test('SW_MAP.generic covers ISO 6A85/6A89/6A8A', () => {
+ assert.ok(SW_MAP.generic['6A85']);
+ assert.ok(SW_MAP.generic['6A89']);
+ assert.ok(SW_MAP.generic['6A8A']);
+});
+
+test('gp 6310 label does not reference removed P2=42 option', () => {
+ assert.ok(!SW_MAP.gp['6310'].includes('42'));
+});
+
+test('LIFECYCLE_MAP per GPC v2.3 life cycles', () => {
+ assert.strictEqual(LIFECYCLE_MAP['01'], 'OP_READY (card) / LOADED (ELF)');
+ assert.strictEqual(LIFECYCLE_MAP['03'], 'INSTALLED');
+ assert.strictEqual(LIFECYCLE_MAP['07'], 'SELECTABLE');
+ assert.strictEqual(LIFECYCLE_MAP['0F'], 'SECURED (card)');
+ assert.strictEqual(LIFECYCLE_MAP['1F'], 'PERSONALIZED (SD)');
+ assert.strictEqual(LIFECYCLE_MAP['7F'], 'CARD_LOCKED');
+ assert.strictEqual(LIFECYCLE_MAP['FF'], 'TERMINATED');
+ assert.strictEqual(LIFECYCLE_MAP['83'], 'LOCKED (SD)');
+});
+
+test('PRIVILEGE_NAMES matches GPC v2.3 Tables 11-7/11-8/11-9', () => {
+ assert.strictEqual(PRIVILEGE_NAMES[0][7], 'Mandated DAP Verification');
+ assert.strictEqual(PRIVILEGE_NAMES[1][2], 'Token Verification');
+ assert.strictEqual(PRIVILEGE_NAMES[1][7], 'Global Service');
+ assert.strictEqual(PRIVILEGE_NAMES[2][0], 'Receipt Generation');
+ assert.strictEqual(PRIVILEGE_NAMES[2][3], 'Contactless Self-Activation');
+});
\ No newline at end of file
diff --git a/frontend/tests/sim.test.js b/frontend/tests/sim.test.js
new file mode 100644
index 0000000..8f2ed08
--- /dev/null
+++ b/frontend/tests/sim.test.js
@@ -0,0 +1,156 @@
+const { test } = require('node:test');
+const assert = require('node:assert');
+const fs = require('node:fs');
+const path = require('node:path');
+
+const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
+
+function extractFunc(src, name) {
+ const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
+ const m = re.exec(src);
+ if (!m) throw new Error('function ' + name + ' not found');
+ let i = m.index + m[0].length - 1;
+ let depth = 0;
+ for (; i < src.length; i++) {
+ if (src[i] === '{') depth++;
+ else if (src[i] === '}') {
+ depth--;
+ if (depth === 0) break;
+ }
+ }
+ return src.slice(m.index, i + 1);
+}
+
+class StubEl {
+ constructor(value = '') {
+ this.value = value;
+ this.checked = false;
+ this.style = {};
+ this.disabled = false;
+ }
+}
+
+const els = {};
+const doc = {
+ getElementById: (id) => {
+ if (!els[id]) els[id] = new StubEl();
+ return els[id];
+ },
+ querySelector: () => new StubEl(),
+};
+global.document = doc;
+
+const FNS = ['buildApdu', 'buildOpNoCla', 'buildSelect', 'getSelValue', 'buildSelectApdu',
+ 'updateSimUsimFields', 'updateP1P2Display', 'genSimUsim', 'OPS'];
+let code = '';
+for (const f of FNS) {
+ if (f === 'OPS') {
+ const m = html.match(/const OPS = \{[\s\S]*?\n\};/);
+ code += m[0].replace(/^const /, 'var ') + '\n';
+ } else {
+ code += extractFunc(html, f) + '\n';
+ }
+}
+code += '\nvar SIM_PRESETS = {};';
+eval(code);
+
+function set(id, v) { els[id] = Object.assign(new StubEl(), { value: v }); }
+function setChecked(id, v) { els[id] = Object.assign(new StubEl(), { checked: v }); }
+
+function setup(mode, opts) {
+ for (const k of Object.keys(els)) delete els[k];
+ set(mode + '-cmd', opts.cmd);
+ setChecked(mode + '-select', !!opts.doSelect);
+ set(mode + '-sel-method', opts.selMethod || 'fid');
+ set(mode + '-sel-fid', opts.fid || '');
+ set(mode + '-sel-path', opts.path || '');
+ set(mode + '-sel-dfname', opts.dfname || '');
+ set(mode + '-sel-chain', opts.chain || '');
+ set(mode + '-record', opts.record || '1');
+ set(mode + '-rec-mode', opts.recMode || '04');
+ set(mode + '-offset', opts.offset || '0000');
+ set(mode + '-le', opts.le || '00');
+ set(mode + '-data', opts.data || '');
+ set(mode + '-recsize', opts.recsize || '0');
+ set(mode + '-pin', opts.pin || '01');
+ set(mode + '-pin-val', opts.pinVal || '');
+ set(mode + '-pin-old', opts.pinOld || '');
+ set(mode + '-pin-new', opts.pinNew || '');
+ set(mode + '-act-target', opts.actTarget || 'current');
+ set(mode + '-act-file', opts.actFile || '');
+ set(mode + '-override', '');
+ els[mode + '-result'] = new StubEl();
+ els[mode + '-pack-btn'] = new StubEl();
+ genSimUsim(mode);
+ return els[mode + '-result'].value;
+}
+
+test('VERIFY PIN FF-pads to 8 bytes (Lc=08)', () => {
+ const apdu = setup('usim', { cmd: 'verify', pin: '01', pinVal: '1234' });
+ assert.strictEqual(apdu, '002000010831323334FFFFFFFF');
+});
+
+test('CHANGE PIN emits two 8-byte fields (Lc=10)', () => {
+ const apdu = setup('usim', { cmd: 'change', pin: '01', pinOld: '1234', pinNew: '5678' });
+ assert.strictEqual(apdu, '002400011031323334FFFFFFFF35363738FFFFFFFF');
+});
+
+test('DISABLE PIN single padded PIN (INS 26)', () => {
+ const apdu = setup('usim', { cmd: 'disable', pin: '02', pinVal: '9999' });
+ assert.strictEqual(apdu, '002600020839393939FFFFFFFF');
+});
+
+test('ENABLE PIN single padded PIN (INS 28)', () => {
+ const apdu = setup('usim', { cmd: 'enable', pin: '01', pinVal: '1111' });
+ assert.strictEqual(apdu, '002800010831313131FFFFFFFF');
+});
+
+test('UNBLOCK PIN two padded PINs (INS 2C)', () => {
+ const apdu = setup('usim', { cmd: 'unblock', pin: '02', pinOld: '12345678', pinNew: '4321' });
+ assert.strictEqual(apdu, '002C000210313233343536373834333231FFFFFFFF');
+});
+
+test('ACTIVATE FILE case-1 (no data, no Le)', () => {
+ const apdu = setup('usim', { cmd: 'activate-file' });
+ assert.strictEqual(apdu, '00440000');
+});
+
+test('DEACTIVATE FILE case-1', () => {
+ const apdu = setup('sim', { cmd: 'deactivate-file' });
+ assert.strictEqual(apdu, 'A0040000');
+});
+
+test('ACTIVATE FILE by FID (P1=00 + Lc/FID)', () => {
+ const apdu = setup('usim', { cmd: 'activate-file', actTarget: 'fid', actFile: '6F3B' });
+ assert.strictEqual(apdu, '00440000026F3B');
+});
+
+test('ACTIVATE FILE by path from MF (P1=08)', () => {
+ const apdu = setup('usim', { cmd: 'activate-file', actTarget: 'mfpath', actFile: '7FFF6FC5' });
+ assert.strictEqual(apdu, '00440800047FFF6FC5');
+});
+
+test('DEACTIVATE FILE by path from current DF (P1=09, SIM CLA)', () => {
+ const apdu = setup('sim', { cmd: 'deactivate-file', actTarget: 'dfpath', actFile: '6F3B' });
+ assert.strictEqual(apdu, 'A0040900026F3B');
+});
+
+test('READ RECORD next mode forces P1=00', () => {
+ const apdu = setup('usim', { cmd: 'read-record', recMode: '02', le: '20' });
+ assert.strictEqual(apdu, '00B2000220');
+});
+
+test('SELECT by FID USIM: P2=0C, no Le', () => {
+ const apdu = setup('usim', { cmd: 'select', selMethod: 'fid', fid: '6FC5' });
+ assert.strictEqual(apdu, '00A4000C026FC5');
+});
+
+test('SELECT by path USIM: P2=04, Le=00', () => {
+ const apdu = setup('usim', { cmd: 'select', selMethod: 'path', path: '7FFF6FC5' });
+ assert.strictEqual(apdu, '00A40804047FFF6FC500');
+});
+
+test('SELECT by FID SIM (CLA A0): no Le anywhere', () => {
+ const apdu = setup('sim', { cmd: 'select', selMethod: 'fid', fid: '6FC5' });
+ assert.strictEqual(apdu, 'A0A40000026FC5');
+});
\ No newline at end of file
diff --git a/pyproject.toml b/pyproject.toml
index 62c8423..553e1b7 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "pysim-otaman-server"
-version = "1.9.1"
+version = "1.9.2"
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 194f863..9a3e24d 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.9.1'
+VERSION = '1.9.2'
# Static file serving (the PWA lives in /frontend, served by this server