Compare commits

..

3 Commits

Author SHA1 Message Date
catarrh e607637782 Code review fixes: CB label, trailing newlines
- PARSE_INS CB: rename 'RETRIEVE DATA (GET DATA)' -> 'RETRIEVE DATA'
  (CA = GET DATA, CB = RETRIEVE DATA are separate commands per TS 102 221)
- Add trailing newlines to sim.test.js + response_map.test.js
2026-08-23 10:07:56 +03:00
catarrh 072e8fe08b C-APDU encoder features + GET STATUS E3 coverage; v1.9.2
SIM/USIM encoder (registry §2.3):
- New PIN commands: DISABLE '26' / ENABLE '28' (single FF-padded PIN,
  Lc=08) and UNBLOCK '2C' (unblock+new, Lc=10); dynamic field labels
- ACTIVATE/DEACTIVATE FILE target selection: Current EF (case 1),
  by FID (P1=00), path from MF (P1=08), path from current DF (P1=09)
  with Lc+FID/path data form
- LANG_RU entries for new labels

Tests:
- sim.test.js: new stub-DOM genSimUsim harness; exact-hex for all PIN
  ops, ACTIVATE/DEACTIVATE forms, SELECT Le rules, RECORD P1=00 rule
- response_map.test.js: lookupSw wildcards (91XX / 63CX), LIFECYCLE_MAP
  per GPC v2.3, PRIVILEGE_NAMES vs Tables 11-7/11-8/11-9

GET STATUS E3 template decoding verified already present
(TLV_TAG_NAMES + decodeTlvValue cover 4F/9F70/C5/CF/C4/CC/CE/84).

Version 1.9.1 -> 1.9.2, SW cache otaman-v12 -> otaman-v13
2026-08-22 23:54:49 +03:00
catarrh feeefa3018 C-APDU parser + Response parser alignment with updated spec registry
PARSE_INS: +24 command labels per TS 102 221 table 10.5 / TS 102 222 /
GPC (TERMINAL PROFILE, ENVELOPE, FETCH, TERMINAL RESPONSE, SEARCH RECORD,
INCREASE, RETRIEVE DATA, PUT/SET DATA, GET CHALLENGE, AUTHENTICATE odd
INS, SUSPEND UICC, TERMINAL CAPABILITY, MANAGE SECURE CHANNEL, TRANSACT
DATA, GET IDENTITY, EXCHANGE CAPABILITIES, MANAGE LSI, CREATE FILE,
RESIZE FILE 'D4' (registry fixes 'D8' typo), INITIALIZE UPDATE, PUT KEY,
MANAGE CHANNEL; DISABLE/ENABLE/UNBLOCK PIN). INS '88' relabelled
AUTHENTICATE per TS 102 221 §11.1.16.

describeP1P2: SELECT P2 response field per ETSI b5-b3 ('001' FCP /
'011' none); READ/UPDATE BINARY offset / SFI-mode descriptors.

Response parser maps:
- LIFECYCLE_MAP: SD PERSONALIZED is '1F'; '0F' = SECURED (card);
  '01' dual-context OP_READY (card) / LOADED (ELF)
- SW_MAP.uicc: add '91XX' wildcard (proactive lengths vary)
- SW_MAP.generic: add 6A85 / 6A89 / 6A8A; 63CX notes blocked/no-counter
- SW_MAP.gp: reword stale 6310 (P2=42 option was removed as RFU)
2026-08-22 23:39:50 +03:00
8 changed files with 367 additions and 23 deletions
+1 -1
View File
@@ -54,7 +54,7 @@ Returns server version for compatibility checking.
**Example response:**
```json
{"version": "1.9.1"}
{"version": "1.9.2"}
```
### `GET /api/status`
+116 -17
View File
@@ -18,7 +18,7 @@
<div class="max-w-7xl mx-auto px-6 py-2">
<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.9.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.2</span></h1>
<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>
<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>
@@ -62,6 +62,9 @@
<option value="deactivate-file">DEACTIVATE FILE</option>
<option value="verify">VERIFY PIN</option>
<option value="change">CHANGE PIN</option>
<option value="disable">DISABLE PIN</option>
<option value="enable">ENABLE PIN</option>
<option value="unblock">UNBLOCK PIN</option>
</select>
</div>
@@ -121,6 +124,17 @@
<input id="sim-offset" onchange="updateP1P2Display('sim')" class="font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800" placeholder="0000" maxlength="4" value="0000">
</div>
<div id="sim-act-row" class="mb-3">
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300" data-l10n="Target">Target</label>
<select id="sim-act-target" onchange="updateActFileInput('sim')" class="w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800">
<option value="current" data-l10n="Current EF (case 1)">Current EF (case 1)</option>
<option value="fid" data-l10n="By FID">By FID</option>
<option value="mfpath" data-l10n="Path from MF">Path from MF</option>
<option value="dfpath" data-l10n="Path from current DF">Path from current DF</option>
</select>
<input id="sim-act-file" class="mt-1.5 font-mono w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800" placeholder="6FC5 / 7FFF6FC5" style="display:none">
</div>
<div id="sim-le-row" class="mb-3">
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300">Le (hex)</label>
<input id="sim-le" class="font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800" placeholder="00" maxlength="2" value="00">
@@ -198,6 +212,9 @@
<option value="deactivate-file">DEACTIVATE FILE</option>
<option value="verify">VERIFY PIN</option>
<option value="change">CHANGE PIN</option>
<option value="disable">DISABLE PIN</option>
<option value="enable">ENABLE PIN</option>
<option value="unblock">UNBLOCK PIN</option>
</select>
</div>
@@ -257,6 +274,17 @@
<input id="usim-offset" onchange="updateP1P2Display('usim')" class="font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800" placeholder="0000" maxlength="4" value="0000">
</div>
<div id="usim-act-row" class="mb-3">
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300" data-l10n="Target">Target</label>
<select id="usim-act-target" onchange="updateActFileInput('usim')" class="w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800">
<option value="current" data-l10n="Current EF (case 1)">Current EF (case 1)</option>
<option value="fid" data-l10n="By FID">By FID</option>
<option value="mfpath" data-l10n="Path from MF">Path from MF</option>
<option value="dfpath" data-l10n="Path from current DF">Path from current DF</option>
</select>
<input id="usim-act-file" class="mt-1.5 font-mono w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800" placeholder="6FC5 / 7FFF6FC5" style="display:none">
</div>
<div id="usim-le-row" class="mb-3">
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300">Le (hex)</label>
<input id="usim-le" class="font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800" placeholder="00" maxlength="2" value="00">
@@ -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; }
@@ -2544,16 +2602,42 @@ const PARSE_INS = {
'04': {name:'DEACTIVATE FILE', data:'none'},
'20': {name:'VERIFY', data:'lc'},
'24': {name:'CHANGE', data:'lc'},
'26': {name:'DISABLE PIN', data:'lc'},
'28': {name:'ENABLE PIN', data:'lc'},
'2C': {name:'UNBLOCK PIN', data:'lc'},
'E6': {name:'INSTALL', data:'lc'},
'E8': {name:'LOAD', data:'lc'},
'D4': {name:'RESIZE FILE', data:'lc'},
'E0': {name:'CREATE FILE', data:'lc'},
'E4': {name:'DELETE', data:'lc'},
'F2': {name:'GET STATUS', data:'lc'},
'CA': {name:'GET DATA', data:'none'},
'DA': {name:'PUT DATA', data:'lc'},
'CB': {name:'RETRIEVE DATA', data:'le'},
'DB': {name:'SET DATA', data:'lc'},
'A2': {name:'SEARCH RECORD', data:'lc'},
'32': {name:'INCREASE', data:'lc'},
'84': {name:'GET CHALLENGE', data:'le'},
'88': {name:'AUTHENTICATE', data:'lc'},
'89': {name:'AUTHENTICATE (odd INS)', data:'lc'},
'E2': {name:'STORE DATA', data:'lc'},
'F0': {name:'SET STATUS', data:'lc'},
'82': {name:'EXTERNAL AUTHENTICATE', data:'lc'},
'88': {name:'INTERNAL AUTHENTICATE', data:'lc'},
'C0': {name:'GET RESPONSE', data:'le'},
'10': {name:'TERMINAL PROFILE', data:'lc'},
'C2': {name:'ENVELOPE', data:'lc'},
'12': {name:'FETCH', data:'le'},
'14': {name:'TERMINAL RESPONSE', data:'lc'},
'76': {name:'SUSPEND UICC', data:'lc'},
'AA': {name:'TERMINAL CAPABILITY', data:'lc'},
'73': {name:'MANAGE SECURE CHANNEL', data:'lc'},
'75': {name:'TRANSACT DATA', data:'lc'},
'78': {name:'GET IDENTITY', data:'le'},
'7A': {name:'EXCHANGE CAPABILITIES', data:'lc'},
'7C': {name:'MANAGE LSI', data:'lc'},
'50': {name:'INITIALIZE UPDATE', data:'lc'},
'D8': {name:'PUT KEY', data:'lc'},
'70': {name:'MANAGE CHANNEL', data:'none'},
};
function parseBerLen(hex, i) {
@@ -2905,13 +2989,17 @@ function describeP1P2(ins, p1, p2) {
let d1 = '', d2 = '';
if (ins === 'A4') {
d1 = {'00':'by FID','01':'child DF','03':'parent DF','04':'by DF name (AID)','08':'path from MF','09':'path from current DF'}[p1] || '';
const resp = (v2 >> 2) & 0x03;
const resp = (v2 >> 2) & 0x07;
if (resp === 0x01) d2 = 'return FCP template';
else if (resp === 0x03) d2 = 'no response data';
if (v1 === 0x04) {
const occName = {'00':'first or only occurrence','01':'last occurrence','10':'next occurrence','11':'previous occurrence'}[(v2 & 0x03).toString(2).padStart(2,'0')];
if (occName) d2 += (d2 ? ', ' : '') + occName;
}
} else if (ins === 'B0' || ins === 'D6') {
if (v1 & 0x80) d1 = 'SFI mode, SFI ' + (v1 & 0x1F);
else d1 = 'offset high';
d2 = 'offset low';
} else if (ins === 'B2' || ins === 'DC') {
const modeKey = (v2 & 0x07).toString(16).padStart(2, '0');
d2 = {'02':'next record','03':'previous record','04':'absolute/current'}[modeKey] || '';
@@ -3262,7 +3350,7 @@ const SW_MAP = {
'62F3': 'Warning: Response data available',
'63F1': 'Warning: More data expected',
'63F2': 'Warning: More data expected and proactive command pending',
'63CX': 'Verification failed, X retries remaining (SW2 & 0x0F = tries)',
'63CX': 'Verification failed, X retries remaining (SW2 & 0x0F = tries; X=0: no counter provided / blocked for VERIFY)',
'6400': 'No information given, state of NV memory unchanged',
'6500': 'No information given, state of NV memory changed',
'6581': 'Memory problem',
@@ -3285,7 +3373,10 @@ const SW_MAP = {
'6A84': 'Not enough memory space',
'6A86': 'Incorrect parameters P1-P2',
'6A87': 'Lc inconsistent with P1-P2',
'6A85': 'Nc inconsistent with TLV data object',
'6A88': 'Referenced data not found',
'6A89': 'File already exists',
'6A8A': 'DF name already exists',
'6B00': 'Wrong parameter(s) P1-P2',
'6D00': 'Instruction code not supported or invalid',
'6E00': 'Class not supported',
@@ -3296,7 +3387,7 @@ const SW_MAP = {
'9864': 'Minimum UICC suspension time is too long',
},
gp: {
'6310': 'More data available — send GET STATUS [next] (P2=42)',
'6310': 'More data available — issue GET STATUS [get next occurrence]',
'6581': 'Memory failure',
'6A80': 'Incorrect values in command data',
'6A81': 'Function not supported (card Life Cycle State is CARD_LOCKED)',
@@ -3313,7 +3404,8 @@ const SW_MAP = {
'6283': 'Warning: Card Life Cycle State is CARD_LOCKED',
},
uicc: {
'9100': 'Normal ending with proactive UICC command pending (SW2 = length of response data)',
'91XX': 'Normal ending with proactive UICC command pending (SW2 = length of response data)',
'9100': 'Normal ending with proactive UICC command pending (no response data)',
'92XX': 'Normal ending with ongoing data transfer session info',
'9300': 'SIM Application Toolkit is busy',
'62F1': 'More data available (ENVELOPE, RETRIEVE DATA, SET DATA)',
@@ -3381,10 +3473,11 @@ const SW_CMD_NAMES = {
};
const LIFECYCLE_MAP = {
'01': 'LOADED (ELF)',
'01': 'OP_READY (card) / LOADED (ELF)',
'03': 'INSTALLED',
'07': 'SELECTABLE',
'0F': 'PERSONALIZED (SD) / SECURED (card)',
'0F': 'SECURED (card)',
'1F': 'PERSONALIZED (SD)',
'7F': 'CARD_LOCKED',
'FF': 'TERMINATED',
'83': 'LOCKED (SD)',
@@ -5681,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',
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'otaman-v12';
const CACHE = 'otaman-v13';
const URLS = [
'index.html',
'help.html',
+34 -2
View File
@@ -140,7 +140,7 @@ test('INTERNAL AUTHENTICATE', () => {
assert.strictEqual(tree.children.length, 1);
const apdu = tree.children[0];
assert.strictEqual(apdu.label, 'APDU');
assert.ok(findNode(apdu, 'INS').desc.includes('INTERNAL AUTHENTICATE'));
assert.ok(findNode(apdu, 'INS').desc.includes('AUTHENTICATE'));
assert.ok(findNode(apdu, 'Data'));
});
@@ -327,4 +327,36 @@ test('CLA 84-87 labeled GlobalPlatform secure messaging', () => {
const tree = parseHexTree('8482030010' + '00112233445566778899AABBCCDDEEFF');
const apdu = tree.children[0];
assert.strictEqual(findNode(apdu, 'CLA').desc, 'GlobalPlatform (secure messaging)');
});
});
test('New PARSE_INS labels decode (FETCH le, RESIZE D4, RETRIEVE CB, AUTH 89)', () => {
const fetchTree = parseHexTree('8012000020');
assert.ok(findNode(fetchTree.children[0], 'INS').desc.includes('FETCH'));
assert.ok(findNode(fetchTree.children[0], 'Le'));
const resizeTree = parseHexTree('80D4000004' + '00000FA0');
assert.ok(findNode(resizeTree.children[0], 'INS').desc.includes('RESIZE FILE'));
assert.strictEqual(findNode(resizeTree.children[0], 'Data').desc, '00000FA0');
const retrTree = parseHexTree('80CB000000');
assert.ok(findNode(retrTree.children[0], 'INS').desc.includes('RETRIEVE DATA'));
const authTree = parseHexTree('0089000008' + '0011223344556677');
assert.ok(findNode(authTree.children[0], 'INS').desc.includes('odd INS'));
});
test('READ BINARY SFI mode and offset descriptors', () => {
const sfi = parseHexTree('00B0830602');
const p1sfi = findNode(sfi.children[0], 'P1').desc;
assert.ok(p1sfi.includes('SFI') && p1sfi.includes('3'), p1sfi);
assert.strictEqual(findNode(sfi.children[0], 'P2').desc, 'offset low');
const off = parseHexTree('00B0010202');
assert.strictEqual(findNode(off.children[0], 'P1').desc, 'offset high');
});
test('SELECT P2 response field per ETSI b5-b3', () => {
const fcp = parseHexTree('00A40804047FFF6FC500');
assert.ok(findNode(fcp.children[0], 'P2').desc.includes('FCP'));
const none = parseHexTree('00A4000C026F3B');
assert.ok(findNode(none.children[0], 'P2').desc.includes('no response data'));
});
+57
View File
@@ -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');
});
+156
View File
@@ -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');
});
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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 <repo>/frontend, served by this server