From 7ece8a531c52334e28311e8ceb1bf7923b23ce20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D1=82=D0=BE=D0=BD=20=D0=A2=D1=80=D0=BE=D1=88?= =?UTF-8?q?=D0=B8=D0=BD?= Date: Mon, 24 Aug 2026 21:10:37 +0300 Subject: [PATCH] Response parser: add TS 51.011 SIM status word families; v1.9.6 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OTA inner-APDU SWs from SIM-domain applications surfaced as 'Unknown status word' (e.g. 9404 on a failed SELECT inside an authenticated B00000 packet). SW_MAP.generic gains the complete TS 51.011 §9.4 families, so they decode in every resp-cmd context: - 9200/9240 memory management (retry / memory problem) - 9400/9402/9404/9408 referencing management (no EF selected, out of range, file ID/pattern not found, file inconsistent) - 9802/9804/9808/9810/9840/9850 security management (CHV/access- condition/invalidation contradictions and blocked states; 9850 also added to generic alongside its gp/uicc copy) - 9EXX/9FXX data-download error length / response length wildcards Exact keys safely coexist with the 92XX proactive wildcard (exact match wins). TS 102 221 sweep found no further stragglers: 63C0-C9 PIN retry counters are covered by the existing 63CX wildcard. Secured packet page: enforce fresh packet per send - any change to APDU/TAR/CNTR/keys/padding/SPI now clears the secured packet textarea via spInvalidate(), so Send cannot reuse a stale packet ('No secured packet to send' guards the path) - on por_ok the CNTR field auto-increments (10-digit hex normalized, wraps modulo 2^40) and the textarea clears; security-error verdicts leave both untouched for retry after fixing the cause SIM/USIM SELECT: P1/P2 per spec + live RFM idioms - USIM FID selects requested no response data (P2=0C) - that coding is reserved for the select-MF-by-empty-data special case; ordinary FID selects now request the FCP template (P2=04) with Le='00' per TS 102 221 Table 11.2 / pySim sel_ctrl convention; preset tables fixed in both genSimUsim and updateP1P2Display - path method gains base selector: from MF (P1=08) / from current DF (P1=09) - reproduces the dominant live RFM idiom 09/0C - 'silent' checkbox on fid/path/chain emits P2=0C without Le for hops where the FCI is not needed - chain syntax gains GET RESPONSE hops: 'C0' emits CLA C0 00 00 00 and 'C0:NN' sets explicit Le, enabling classic SELECT -> 9FXX -> GET RESPONSE pairs in a single secured packet so the PoR carries the actual FCI/response bytes instead of a bare length SW - SW dictionary: 9FXX reworded to point at GET RESPONSE - placeholders show the new chain syntax - findings & backport decisions written to ~/WSL/RFM_notes.md response_map.test.js: +8 assertions across the new families. sim.test.js: USIM FID expectation updated; silent/base/chain cases. apdu_parse.test.js: live capture lines 1-3 as regression fixtures. Version 1.9.5 -> 1.9.6 everywhere; SW cache otaman-v16 -> otaman-v17 --- docs/api.md | 2 +- frontend/index.html | 127 ++++++++++++++++++++-------- frontend/sw.js | 2 +- frontend/tests/apdu_parse.test.js | 22 +++++ frontend/tests/response_map.test.js | 11 +++ frontend/tests/sim.test.js | 52 +++++++++++- pysim_otaman_server/server.py | 2 +- 7 files changed, 177 insertions(+), 41 deletions(-) diff --git a/docs/api.md b/docs/api.md index 190d4af..e0f16b4 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.5"} +{"version": "1.9.6"} ``` ### `GET /api/status` diff --git a/frontend/index.html b/frontend/index.html index 89ef629..f174b12 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -18,7 +18,7 @@
-

OTAMan SIM OTA with a Human Face v1.9.5

+

OTAMan SIM OTA with a Human Face v1.9.6

github @@ -99,7 +99,7 @@
- +
@@ -235,13 +235,23 @@ +
+ +
- +
- + +
+ + +
@@ -249,7 +259,7 @@
- +
@@ -767,7 +777,7 @@
- +
@@ -779,7 +789,7 @@
- +
@@ -788,17 +798,17 @@
- +
- +
- @@ -806,7 +816,7 @@
- +
@@ -1198,19 +1208,22 @@ function updateSimSelectSection(mode) { } function updateP1P2Display(mode) { - const preset = mode === 'sim' ? {selP1: 0x00, selP2: 0x00} : {selP1: 0x00, selP2: 0x0C}; + const preset = mode === 'sim' ? {selP1: 0x00, selP2: 0x00} : {selP1: 0x00, selP2: 0x04}; const cmd = document.getElementById(mode + '-cmd').value; if (cmd === 'select') { const method = document.getElementById(mode + '-sel-method').value; + const silent = mode === 'usim' && !!document.getElementById('usim-sel-silent').checked; + const p2Base = silent ? 0x0C : preset.selP2; let p1, p2; if (method === 'fid' || method === 'chain') { - p1 = preset.selP1; p2 = preset.selP2; + p1 = preset.selP1; p2 = p2Base; } else if (method === 'path') { - p1 = 0x08; p2 = mode === 'sim' ? 0x00 : 0x04; + p1 = (mode === 'usim' && document.querySelector('input[name="usim-sel-base"]:checked')?.value === 'df') ? 0x09 : 0x08; + p2 = mode === 'sim' ? 0x00 : p2Base; } else if (method === 'dfname') { - p1 = 0x04; p2 = mode === 'sim' ? 0x00 : 0x04; + p1 = 0x04; p2 = mode === 'sim' ? 0x00 : p2Base; } else { - p1 = preset.selP1; p2 = preset.selP2; + p1 = preset.selP1; p2 = p2Base; } document.getElementById(mode + '-p1').value = p1.toString(16).padStart(2, '0').toUpperCase(); document.getElementById(mode + '-p2').value = p2.toString(16).padStart(2, '0').toUpperCase(); @@ -1452,14 +1465,6 @@ function buildSelect(cla, p1, p2, fid) { return buildApdu(cla, 0xA4, p1, p2, 0x02, data); } -function buildOpNoCla(ins, p1, p2, p3, data) { - let s = ins.toString(16).padStart(2, '0').toUpperCase() + - p1.toString(16).padStart(2, '0').toUpperCase() + - p2.toString(16).padStart(2, '0').toUpperCase(); - if (p3 !== null && p3 !== undefined) s += p3.toString(16).padStart(2, '0').toUpperCase(); - if (data) s += data.toUpperCase(); - return s; -} const OPS = { 'select': {ins: 0xA4}, @@ -1494,33 +1499,52 @@ function getSelValue(mode) { function buildSelectApdu(mode, preset, selInfo) { const isSim = preset.cla === 0xA0; if (!selInfo || !selInfo.value) return ''; + // USIM response control: FCP request (P2=04 + Le) vs silent hop (P2=0C, no Le) + const silent = !isSim && !!document.getElementById('usim-sel-silent').checked; + const p2 = silent ? 0x0C : preset.selP2; + const le = (!isSim && !silent) ? '00' : null; if (selInfo.type === 'fid') { if (selInfo.value.length !== 4) return ''; - return buildSelect(preset.cla, preset.selP1, preset.selP2, selInfo.value); + let apdu = buildSelect(preset.cla, preset.selP1, p2, selInfo.value); + if (le) apdu += le; + return apdu; } else if (selInfo.type === 'path') { if (selInfo.value.length < 2 || selInfo.value.length % 2 !== 0) return ''; const p3 = selInfo.value.length / 2; - const p2 = isSim ? 0x00 : 0x04; - let apdu = buildApdu(preset.cla, 0xA4, 0x08, p2, p3, selInfo.value); - if (!isSim) apdu += '00'; + let p1 = isSim ? 0x08 : (document.querySelector('input[name="usim-sel-base"]:checked')?.value === 'df' ? 0x09 : 0x08); + let apdu = buildApdu(preset.cla, 0xA4, p1, p2, p3, selInfo.value); + if (le) apdu += le; return apdu; } else if (selInfo.type === 'dfname') { if (selInfo.value.length < 2) return ''; const p3 = selInfo.value.length / 2; - const p2 = isSim ? 0x00 : 0x04; let apdu = buildApdu(preset.cla, 0xA4, 0x04, p2, p3, selInfo.value); - if (!isSim) apdu += '00'; + if (le) apdu += le; return apdu; } else if (selInfo.type === 'chain') { - const fids = selInfo.value.split(',').map(f => f.trim().replace(/[^0-9a-fA-F]/g, '')).filter(f => f.length === 4); - if (fids.length === 0) return ''; - return fids.map(fid => buildSelect(preset.cla, preset.selP1, preset.selP2, fid)).join(''); + const tokens = selInfo.value.split(',').map(t => t.trim()).filter(t => t.length > 0); + let out = ''; + for (const tok of tokens) { + // 'C0' or 'C0:NN' - GET RESPONSE hop (Le defaults to '00') + const gr = /^c0(?::([0-9a-fA-F]{1,2}))?$/i.exec(tok); + if (gr) { + const leByte = parseInt(gr[1] || '00', 16); + out += buildApdu(preset.cla, 0xC0, 0x00, 0x00, leByte, ''); + continue; + } + const fid = tok.replace(/[^0-9a-fA-F]/g, ''); + if (fid.length !== 4) continue; + let apdu = buildSelect(preset.cla, preset.selP1, p2, fid); + if (le) apdu += le; + out += apdu; + } + return out; } return ''; } function genSimUsim(mode) { - const preset = mode === 'sim' ? {cla: 0xA0, selP1: 0x00, selP2: 0x00} : {cla: 0x00, selP1: 0x00, selP2: 0x0C}; + const preset = mode === 'sim' ? {cla: 0xA0, selP1: 0x00, selP2: 0x00} : {cla: 0x00, selP1: 0x00, selP2: 0x04}; const cmd = document.getElementById(mode + '-cmd').value; const doSelect = document.getElementById(mode + '-select').checked; const selInfo = getSelValue(mode); @@ -1605,7 +1629,9 @@ function genSimUsim(mode) { if (doSelect) { const selectApdu = buildSelectApdu(mode, preset, selInfo); if (selectApdu) { - apdu = selectApdu + buildOpNoCla(op.ins, p1, p2, p3, data); + // full CLA on every command - scripted secured-packet streams must be + // self-delimiting; implied-CLA fragments desync the card's parser + apdu = selectApdu + buildApdu(preset.cla, op.ins, p1, p2, p3, data); } else { apdu = buildApdu(preset.cla, op.ins, p1, p2, p3, data); } @@ -1867,12 +1893,17 @@ function spCntrAdjust(delta) { val = Math.max(0, val + delta); el.value = val.toString(16).padStart(10, '0').toUpperCase(); } +function spInvalidate() { + document.getElementById('sp-result').value = ''; +} + function updateSp() { document.getElementById('sp-spi1-hex').value = document.getElementById('sp-spi1').value; const spi2Base = parseInt(document.getElementById('sp-spi2').value, 16); const sm = parseInt(document.getElementById('sp-spi2-sm').value, 16); const spi2Byte = spi2Base | (sm << 5); document.getElementById('sp-spi2-hex').value = spi2Byte.toString(16).padStart(2, '0').toUpperCase(); + spInvalidate(); } function updateSpKic() { const idx = parseInt(document.getElementById('sp-kic-idx').value, 16); @@ -1880,6 +1911,7 @@ function updateSpKic() { const byte = (idx << 4) | alg; document.getElementById('sp-kic-hex').value = byte.toString(16).padStart(2, '0').toUpperCase(); updateKeyPlaceholder('kic', alg & 0x0F); + spInvalidate(); } function updateSpKid() { const idx = parseInt(document.getElementById('sp-kid-idx').value, 16); @@ -1887,6 +1919,7 @@ function updateSpKid() { const byte = (idx << 4) | alg; document.getElementById('sp-kid-hex').value = byte.toString(16).padStart(2, '0').toUpperCase(); updateKeyPlaceholder('kid', alg & 0x0F); + spInvalidate(); } function updateKeyPlaceholder(prefix, algNib) { const keyLen = {1: 8, 2: 16, 5: 16, 9: 24, 0xD: 8}[algNib] || 8; @@ -3352,6 +3385,21 @@ const SW_MAP = { '9000': 'Normal ending of command', '61XX': 'Send GET RESPONSE (Le = SW2)', '6CXX': 'Repeat command with Le = SW2', + // TS 51.011 §9.4 SIM-specific families + '9200': 'Command successful after internal update retry routine', + '9240': 'Memory problem', + '9400': 'No EF selected', + '9402': 'Out of range (invalid address)', + '9404': 'File ID not found / pattern not found', + '9408': 'File inconsistent with the command', + '9802': 'No CHV initialized', + '9804': 'Access condition not fulfilled / CHV verification failed, attempts left / authentication failed', + '9808': 'In contradiction with CHV status', + '9810': 'In contradiction with invalidation status', + '9840': 'CHV verification failed - no attempt left; CHV blocked; UNBLOCK blocked', + '9850': 'Increase cannot be performed, max value reached', + '9EXX': 'SIM data download error (SW2 = length)', + '9FXX': 'Response data available (SW2 = N bytes) - send GET RESPONSE', '6200': 'Warning: No information given, NV memory unchanged', '6281': 'Warning: Part of returned data may be corrupted', '6282': 'Warning: End of file/record reached before reading Le bytes', @@ -4061,6 +4109,14 @@ async function pysimSendOta() { porStatusEl.textContent = 'PoR: ' + por.response_status; porStatusEl.classList.remove('hidden', okPor ? 'text-red-600' : 'text-green-600'); porStatusEl.classList.add(okPor ? 'text-green-600' : 'text-red-600'); + if (okPor) { + // packet was accepted and executed - advance the counter so the + // same secured packet can never be sent twice (v1.9.6) + const cntrEl = document.getElementById('sp-cntr'); + cntrEl.value = ((parseInt(cntrEl.value, 16) || 0) + 1) + .toString(16).toUpperCase().padStart(10, '0').slice(-10); + document.getElementById('sp-result').value = ''; + } } sendResultEl.textContent = msg; if (por && por.raw) { @@ -4319,6 +4375,7 @@ function cardsApply(idx) { document.getElementById('sp-cntr').value = c.cntr; document.getElementById('sp-kic-key').value = c.kicKey; document.getElementById('sp-kid-key').value = c.kidKey; + spInvalidate(); updateSp(); genSp(); } diff --git a/frontend/sw.js b/frontend/sw.js index bea221c..caf0d21 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'otaman-v16'; +const CACHE = 'otaman-v17'; const URLS = [ 'index.html', 'help.html', diff --git a/frontend/tests/apdu_parse.test.js b/frontend/tests/apdu_parse.test.js index 9e11846..adcb44b 100644 --- a/frontend/tests/apdu_parse.test.js +++ b/frontend/tests/apdu_parse.test.js @@ -360,3 +360,25 @@ test('SELECT P2 response field per ETSI b5-b3', () => { const none = parseHexTree('00A4000C026F3B'); assert.ok(findNode(none.children[0], 'P2').desc.includes('no response data')); }); + +test('live RFM captures decode (silent hop / implied CLA / SIM chain)', () => { + // capture line 1 head: silent relative hop 09/0C + const t1 = parseHexTree('00A4090C026F46'); + const a1 = t1.children[0]; + assert.ok(findNode(a1, 'P1').desc.includes('path from current DF')); + assert.ok(findNode(a1, 'P2').desc.includes('no response data')); + assert.strictEqual(findNode(a1, 'Data').desc, '6F46'); + + // capture line 2: silent hop + READ RECORD as implied-CLA continuation + const t2 = parseHexTree('00A4090C026FC5B2010414'); + assert.strictEqual(t2.children.length, 2); + assert.strictEqual(t2.children[1].label, 'APDU (implied CLA)'); + assert.ok(findNode(t2.children[1], 'INS').desc.includes('READ RECORD')); + + // capture line 3: SIM chained FID selects + UPDATE RECORD (34 x FF) + const t3 = parseHexTree('A0A40000023F00A0A40000027F20A0A40000026FC5A0DC010422' + 'FF'.repeat(34)); + assert.strictEqual(t3.children.length, 4); + assert.strictEqual(t3.children[3].label, 'APDU'); + assert.ok(findNode(t3.children[3], 'INS').desc.includes('UPDATE RECORD')); + assert.ok(findNode(t3.children[3], 'Lc').desc.includes('34')); +}); diff --git a/frontend/tests/response_map.test.js b/frontend/tests/response_map.test.js index cc6f4f5..fbef7b5 100644 --- a/frontend/tests/response_map.test.js +++ b/frontend/tests/response_map.test.js @@ -55,3 +55,14 @@ test('PRIVILEGE_NAMES matches GPC v2.3 Tables 11-7/11-8/11-9', () => { assert.strictEqual(PRIVILEGE_NAMES[2][0], 'Receipt Generation'); assert.strictEqual(PRIVILEGE_NAMES[2][3], 'Contactless Self-Activation'); }); + +test('TS 51.011 SIM families resolve (94xx/98xx/92xx/9Exx/9Fxx)', () => { + assert.ok(lookupSw('94', '04', 'uicc').includes('not found')); + assert.ok(lookupSw('94', '00', 'uicc').includes('No EF selected')); + assert.ok(lookupSw('94', '08', 'uicc').includes('inconsistent with the command')); + assert.ok(lookupSw('98', '40', 'uicc').includes('blocked')); + assert.ok(lookupSw('92', '40', 'uicc').includes('Memory problem')); + assert.ok(lookupSw('98', '50', 'gp').includes('max value')); + assert.ok(lookupSw('9E', '15', 'uicc').includes('download')); + assert.ok(lookupSw('9F', '20', 'uicc').includes('GET RESPONSE')); +}); diff --git a/frontend/tests/sim.test.js b/frontend/tests/sim.test.js index d1e5963..772893e 100644 --- a/frontend/tests/sim.test.js +++ b/frontend/tests/sim.test.js @@ -31,16 +31,17 @@ class StubEl { } const els = {}; +let usimBase = 'mf'; const doc = { getElementById: (id) => { if (!els[id]) els[id] = new StubEl(); return els[id]; }, - querySelector: () => new StubEl(), + querySelector: (sel) => (sel.includes('usim-sel-base') ? { value: usimBase } : null), }; global.document = doc; -const FNS = ['buildApdu', 'buildOpNoCla', 'buildSelect', 'getSelValue', 'buildSelectApdu', +const FNS = ['buildApdu', 'buildSelect', 'getSelValue', 'buildSelectApdu', 'updateSimUsimFields', 'updateP1P2Display', 'genSimUsim', 'OPS']; let code = ''; for (const f of FNS) { @@ -54,11 +55,13 @@ for (const f of FNS) { 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]; + usimBase = opts.base || 'mf'; set(mode + '-cmd', opts.cmd); setChecked(mode + '-select', !!opts.doSelect); set(mode + '-sel-method', opts.selMethod || 'fid'); @@ -78,6 +81,8 @@ function setup(mode, opts) { set(mode + '-pin-new', opts.pinNew || ''); set(mode + '-act-target', opts.actTarget || 'current'); set(mode + '-act-file', opts.actFile || ''); + els['usim-sel-silent'] = Object.assign(new StubEl(), { checked: !!opts.silent }); + els[mode + '-select'] = Object.assign(new StubEl(), { checked: !!opts.doSelect }); set(mode + '-override', ''); els[mode + '-result'] = new StubEl(); els[mode + '-pack-btn'] = new StubEl(); @@ -140,8 +145,49 @@ test('READ RECORD next mode forces P1=00', () => { assert.strictEqual(apdu, '00B2000220'); }); -test('SELECT by FID USIM: P2=0C, no Le', () => { +test('SELECT by FID USIM: P2=04 requests FCP, Le=00', () => { const apdu = setup('usim', { cmd: 'select', selMethod: 'fid', fid: '6FC5' }); + assert.strictEqual(apdu, '00A40004026FC500'); +}); + +test('USIM chain: every hop requests FCP (04 + Le)', () => { + const apdu = setup('usim', { cmd: 'select', selMethod: 'chain', chain: '3F00,2FE2' }); + assert.strictEqual(apdu, '00A40004023F000000A40004022FE200'); +}); + +test('SIM chain with GET RESPONSE hop', () => { + const apdu = setup('sim', { cmd: 'select', selMethod: 'chain', chain: '3F00,2FE2,C0' }); + assert.strictEqual(apdu, 'A0A40000023F00A0A40000022FE2A0C0000000'); +}); + +test('GET RESPONSE hop with explicit Le (C0:NN)', () => { + const apdu = setup('usim', { cmd: 'select', selMethod: 'chain', chain: 'C0:0F' }); + assert.strictEqual(apdu, '00C000000F'); +}); + +test('silent path select + READ RECORD keeps full CLA (live PNN read)', () => { + const apdu = setup('usim', { + cmd: 'read-record', selMethod: 'path', base: 'df', silent: true, doSelect: true, + path: '6FC5', record: '1', recMode: '04', le: '14', + }); + assert.strictEqual(apdu, '00A4090C026FC500B2010414'); +}); + +test('select + READ BINARY emits explicit CLA on second command', () => { + const apdu = setup('usim', { + cmd: 'read-binary', selMethod: 'path', base: 'df', silent: true, doSelect: true, + path: '6FC5', offset: '0000', le: '0A', + }); + assert.strictEqual(apdu, '00A4090C026FC500B000000A'); +}); + +test('USIM silent path-from-current-DF hop (live RFM idiom 09/0C)', () => { + const apdu = setup('usim', { cmd: 'select', selMethod: 'path', path: '6F46', base: 'df', silent: true }); + assert.strictEqual(apdu, '00A4090C026F46'); +}); + +test('USIM silent FID select (P2=0C, no Le)', () => { + const apdu = setup('usim', { cmd: 'select', selMethod: 'fid', fid: '6FC5', silent: true }); assert.strictEqual(apdu, '00A4000C026FC5'); }); diff --git a/pysim_otaman_server/server.py b/pysim_otaman_server/server.py index 58143a0..104bc85 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.5' +VERSION = '1.9.6' # Static file serving (the PWA lives in /frontend, served by this server