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