From 138760f75c2d16cd652b26eec615104343531c69 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, 28 Sep 2026 03:03:53 +0300 Subject: [PATCH] fix: Explore delete - real SPI2, no-PoR verdict, local removal (v3.6.13) "Failed: 9000" although the delete executed: the RAM helpers sent `sp-spi2` - the base SPI2 select, whose default is "00 - No PoR" - so the card answered the envelope 9000 with no PoR and the verdict treated the missing PoR as a failure. `cardsApplyFields` also set `sp-spi2-hex` from the preset and then `updateSp()` recomputed it from the selects (which the preset never updated), clobbering the preset byte for anything reading it. - `getRamSpParams()` reads the computed `sp-spi2-hex`; `cardsApplyFields` syncs the SPI2 selects from the preset byte (base = low 5 bits, bit 0x20 = PoR via SMS-SUBMIT, dynamic option for unlisted bases) and the base select gains the missing cipher + RC/CC/DS combinations (15/19/1D). - a missing PoR is no longer a failure (the SPI may request none, and the card sometimes refuses one): `OK` with the SW, or `OK (no PoR)`. - a successful delete drops the object from the Explore result locally (no re-explore): an applet row goes away; a cascade package delete also drops the applets whose AID starts with the package AID. The consumed counter is still saved. - tests: ramRemoveFromExplorer (app/cascade/prefix), the no-PoR flow, the SPI2 source guard, and the updated delete-flow stubs. 641 frontend / 496 Python green; version 3.6.13; sw simple-v286. --- frontend/index.html | 65 +++++++++++++++++++++---- frontend/sw.js | 2 +- frontend/tests/ram.test.js | 43 ++++++++++++++-- frontend/tests/ram_counter_flow.test.js | 53 ++++++++++++-------- pyproject.toml | 2 +- pysim_simple_server/server.py | 2 +- 6 files changed, 129 insertions(+), 38 deletions(-) diff --git a/frontend/index.html b/frontend/index.html index 6bf8591..56a57cb 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -555,6 +555,9 @@ + + + @@ -1679,7 +1682,7 @@ // ===== Version ===== // Single source of truth for the PWA version: shown in the header and used // by the server version check in pysimConnect(). -const SIMPLE_VERSION = '3.6.12'; +const SIMPLE_VERSION = '3.6.13'; document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION; // ===== Tab switching ===== @@ -8331,7 +8334,7 @@ function scriptsCapFileChanged() { function getRamSpParams() { return { spi1: document.getElementById('sp-spi1').value, - spi2: document.getElementById('sp-spi2').value, + spi2: document.getElementById('sp-spi2-hex').value, kic: document.getElementById('sp-kic-hex').value, kid: document.getElementById('sp-kid-hex').value, tar: (document.getElementById('sp-tar').value || '000000').replace(/[^0-9a-fA-F]/g, ''), @@ -8903,6 +8906,34 @@ function ramDeleteApdu(aid, withCascade) { return '80E400' + (withCascade ? '80' : '00') + berLenStr(data.length / 2) + data + '00'; } +// A successful delete drops the object from the Explore result locally (the +// re-explore is not re-run). The registry has no package-to-applet link, so +// a cascade package delete also drops the applets whose AID starts with the +// package AID. +function ramRemoveFromExplorer(aid, cascade) { + const d = _ramExplorerData; + if (!d) return false; + const a = (aid || '').toUpperCase(); + const elfs = d.elfs || []; + const apps = d.apps || []; + const isElf = elfs.some(e => (e.aid || '').toUpperCase() === a); + const isApp = apps.some(x => (x.aid || '').toUpperCase() === a); + if (!isElf && !isApp) return false; + if (isElf) { + d.elfs = elfs.filter(e => (e.aid || '').toUpperCase() !== a); + if (cascade) { + d.apps = apps.filter(x => { + const xa = (x.aid || '').toUpperCase(); + return xa !== a && !xa.startsWith(a); + }); + } + } else { + d.apps = apps.filter(x => (x.aid || '').toUpperCase() !== a); + } + ramRenderExplorer(); + return true; +} + async function ramDeleteFromExplorer(aid, withCascade) { // The preset is the source of truth (see ramExecute): re-read it before // the operation so a stale form copy cannot send an already-consumed @@ -8930,7 +8961,7 @@ async function ramDeleteFromExplorer(aid, withCascade) { const resultEl = document.getElementById('ram-result'); const stepsEl = document.getElementById('ram-steps'); const sw = res.por && res.por.decoded ? res.por.decoded.last_status_word : ''; - if (!res.success || !res.por || !spPorAccepted(res.por) || + if (!res.success || !spPorAccepted(res.por) || (sw && !ramRemoteSwOk(sw))) { resultEl.textContent = t('Failed') + ': ' + (res.por ? res.por.response_status : (res.error || res.sw)) + @@ -8938,14 +8969,13 @@ async function ramDeleteFromExplorer(aid, withCascade) { resultEl.classList.remove('hidden', 'text-green-600'); resultEl.classList.add('text-red-600'); return; } + const removed = ramRemoveFromExplorer(aid, withCascade); stepsEl.classList.remove('hidden'); - stepsEl.textContent = label + ' ' + aid + (sw ? ' -> ' + sw : ''); - resultEl.textContent = t('OK'); + stepsEl.textContent = label + ' ' + aid + ' -> ' + (sw || t('no PoR')); + resultEl.textContent = t('OK') + (removed ? '' : ' (' + t('not in the list') + ')'); resultEl.classList.remove('hidden', 'text-red-600'); resultEl.classList.add('text-green-600'); - // Continue from the counter the delete consumed: replaying the old one - // gets cntr_low on every page and the failed run writes it back over the - // preset (the counter reset reported after Delete All). - await ramExplore(sp); + // The record is dropped locally (no re-explore); the counter the card + // consumed was saved above. } // One RAM install step line: the PoR verdict plus the remote command's @@ -9512,6 +9542,21 @@ function cardsApplyFields(idx) { const c = cards[parseInt(idx)]; if (!c) return false; document.getElementById('sp-spi1').value = c.spi1; + // Sync the SPI2 selects with the preset byte (low 5 bits = PoR/security, + // bit 0x20 = PoR via SMS-SUBMIT): updateSp() recomputes sp-spi2-hex from + // them, so without this the preset value was clobbered on every refresh. + const spi2Val = parseInt(c.spi2, 16) || 0; + const baseHex = (spi2Val & 0x1F).toString(16).padStart(2, '0').toUpperCase(); + const baseSel = document.getElementById('sp-spi2'); + if (baseSel) { + if (baseSel.options && typeof Option === 'function' && + !Array.prototype.some.call(baseSel.options, o => o.value === baseHex)) { + baseSel.appendChild(new Option(baseHex + ' — preset', baseHex)); + } + baseSel.value = baseHex; + } + const smSel = document.getElementById('sp-spi2-sm'); + if (smSel) smSel.value = String((spi2Val >> 5) & 1); document.getElementById('sp-spi2-hex').value = c.spi2; const kicByte = parseInt(c.kic, 16); document.getElementById('sp-kic-idx').value = ((kicByte >> 4) & 0x0F).toString(16).toUpperCase(); @@ -16537,6 +16582,8 @@ const LANG_RU = { 'no data': 'нет данных', '(no data)': '(нет данных)', 'response via SMS not captured': 'ответ по SMS не перехвачен', + 'no PoR': 'нет PoR', + 'not in the list': 'нет в списке', 'free NV': 'свободно NV', 'Failed': 'Ошибка', 'cascade': 'каскадно', diff --git a/frontend/sw.js b/frontend/sw.js index 2f6e73f..d5395b8 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'simple-v285'; +const CACHE = 'simple-v286'; const URLS = [ 'index.html', 'help.html', diff --git a/frontend/tests/ram.test.js b/frontend/tests/ram.test.js index 820fe13..2f8d055 100644 --- a/frontend/tests/ram.test.js +++ b/frontend/tests/ram.test.js @@ -23,7 +23,7 @@ function extractFunc(src, name) { // Extract chain builder functions and dependencies const FNS = ['berLenStr', 'buildApdu', 'escHtml', 'esc', 'chainInit', 'chainRamBuildRowHex', 'ramFmtLifecycle', 'ramFmtPrivileges', 'ramRenderExploreHtml', 'ramStepLine', 'ramGetStatusApdu', 'ramDeleteApdu', - 'stkParamsBuild', 'ramRemoteSwOk', 'spPorAccepted', 'ramIncrementCntr', 'ramDeleteFromExplorer', 'ramListingSpi2', + 'stkParamsBuild', 'ramRemoteSwOk', 'spPorAccepted', 'ramIncrementCntr', 'ramDeleteFromExplorer', 'ramListingSpi2', 'ramRemoveFromExplorer', '_parseRawElfEntry', '_parseRawAppEntry', 'ramParseElfStatus', 'ramParseAppStatus', 'parseTLV', '_parseE3Entry', 'ramCardIdxAfterRemove', 'ramClearResults', 'ramHideProgress', 'ramOpChanged', 'ramRender', 'ramApplyCard', 'ramExecute', 'jcAidNorm', 'jcAidName', 'jcAidSuffix', 'jcAidHtml']; @@ -440,6 +440,33 @@ test('ramRemoteSwOk mirrors the server success set', () => { } }); +test('ramRemoveFromExplorer drops the object locally (cascade drops its applets)', () => { + let renders = 0; + globalThis.ramRenderExplorer = () => { renders++; }; + _ramExplorerData = { + apps: [{ aid: 'F0414C4641610101' }, { aid: 'F0414C4641610199' }, + { aid: 'A1130001180001FFFFFFFF89A1003908' }], + elfs: [{ aid: 'F0414C46416101' }, { aid: 'A1130001180001FFFFFFFF89A1003900' }], + }; + assert.strictEqual(ramRemoveFromExplorer('f0414c4641610101', false), true); + assert.deepStrictEqual(_ramExplorerData.apps.map(a => a.aid), + ['F0414C4641610199', 'A1130001180001FFFFFFFF89A1003908']); + assert.strictEqual(renders, 1); + assert.strictEqual(ramRemoveFromExplorer('F0414C46416101', true), true); + assert.deepStrictEqual(_ramExplorerData.elfs.map(e => e.aid), + ['A1130001180001FFFFFFFF89A1003900']); + assert.deepStrictEqual(_ramExplorerData.apps.map(a => a.aid), + ['A1130001180001FFFFFFFF89A1003908'], 'an unrelated applet stays'); + assert.strictEqual(renders, 2); + assert.strictEqual(ramRemoveFromExplorer('DEADBEEF', false), false); + delete globalThis.ramRenderExplorer; +}); + +test('getRamSpParams reads the computed SPI2 byte, not the base select', () => { + const fn = extractFunc(html, 'getRamSpParams'); + assert.ok(/spi2: document\.getElementById\('sp-spi2-hex'\)/.test(fn), fn); +}); + test('ramListingSpi2 requests the SMS-submit PoR for the listing queries', () => { // Apps (40) and ELF (20/10) listings can exceed the ENVELOPE response; // with SPI2=0x01 the card answers actual_response_sms_submit and the data @@ -451,7 +478,7 @@ test('ramListingSpi2 requests the SMS-submit PoR for the listing queries', () => function stubDeleteEnv(sendResult) { // ramShowProgress/ramHideProgress are the real extracted helpers const els = fakeRamDocument(['ram-result', 'ram-steps', 'ram-progress', 'ram-progress-text']); - const calls = { refresh: [], saved: [], sent: [], explored: null }; + const calls = { refresh: [], saved: [], sent: [], explored: null, removed: null }; globalThis.t = s => s; globalThis.spRefreshFromPreset = sel => { calls.refresh.push(sel); }; globalThis.getRamSpParams = () => ({ cntr: '0000000005', kicKey: 'AA', kidKey: 'BB' }); @@ -475,10 +502,13 @@ function unstubDeleteEnv() { globalThis.spRefreshFromPreset = () => ''; // the top-level stub } -test('ramDeleteFromExplorer refreshes the preset and continues from the consumed counter', async () => { +test('ramDeleteFromExplorer refreshes the preset and drops the record locally', async () => { const { els, calls } = stubDeleteEnv({ success: true, por: { response_status: 'por_ok', decoded: { last_status_word: '9000' } } }); + _ramExplorerData = { apps: [{ aid: 'F0414C4641610101' }], elfs: [{ aid: 'F0414C46416101' }] }; + globalThis.ramRenderExplorer = () => {}; await ramDeleteFromExplorer('F0414C46416101', true); + delete globalThis.ramRenderExplorer; assert.deepStrictEqual(calls.refresh, ['ram-card-sel'], 'the preset must be re-read before the operation'); assert.strictEqual(calls.sent.length, 1); @@ -486,8 +516,11 @@ test('ramDeleteFromExplorer refreshes the preset and continues from the consumed assert.strictEqual(calls.sent[0].cntr, '0000000005'); assert.deepStrictEqual(calls.saved, ['0000000006'], 'the accepted packet advances the saved counter'); - assert.strictEqual(calls.explored, '0000000006', - 'the re-explore must start from the consumed counter, never replay it'); + assert.deepStrictEqual(_ramExplorerData.elfs, [], + 'a successful cascade delete drops the package from the Explore result'); + assert.deepStrictEqual(_ramExplorerData.apps, [], + 'and the package applets (AID prefix) with it'); + assert.strictEqual(calls.explored, null, 'no re-explore is run'); assert.strictEqual(els['ram-result'].textContent, 'OK'); unstubDeleteEnv(); }); diff --git a/frontend/tests/ram_counter_flow.test.js b/frontend/tests/ram_counter_flow.test.js index 2153b47..2852017 100644 --- a/frontend/tests/ram_counter_flow.test.js +++ b/frontend/tests/ram_counter_flow.test.js @@ -22,8 +22,10 @@ function extractFunc(src, name) { } // The real functions behind the Explore Delete flow: the counter the card -// consumed must be persisted into the preset, or the next operation starts -// one behind and is rejected with cntr_low. +// consumed must be persisted into the preset, a successful delete drops the +// record locally (no re-explore), and a delete with no PoR (the SPI may +// request none) is not a failure - live 2026-09-28 showed "Failed: 9000" +// although the delete executed. let code = ''; for (const fn of ['berLenStr', 'ramDeleteApdu', 'ramIncrementCntr', 'ramSaveCntr', 'getRamSpParams', 'spPorAccepted', 'ramRemoteSwOk', 'ramShowProgress', @@ -36,7 +38,7 @@ function fakeEnv(por, success) { const els = {}; const mk = () => ({ value: '', textContent: '', classList: { add() {}, remove() {}, toggle() {} } }); - for (const id of ['sp-spi1', 'sp-spi2', 'sp-kic-hex', 'sp-kid-hex', 'sp-tar', + for (const id of ['sp-spi1', 'sp-spi2-hex', 'sp-kic-hex', 'sp-kid-hex', 'sp-tar', 'sp-cntr', 'sp-kic-key', 'sp-kid-key', 'ram-result', 'ram-steps', 'ram-progress', 'ram-progress-text']) els[id] = mk(); els['ram-card-sel'] = { value: '0' }; @@ -44,74 +46,83 @@ function fakeEnv(por, success) { // the form (the delete flow aborts without them) els['sp-kic-key'].value = 'AA'; els['sp-kid-key'].value = 'BB'; + els['sp-spi2-hex'].value = '01'; globalThis.document = { getElementById: id => els[id] || null }; globalThis.cards = [{ name: 'C', cntr: '0000000005', kicKey: 'AA', kidKey: 'BB' }]; - const calls = { saved: 0, explored: null, sent: null }; + const calls = { saved: 0, explored: null, removed: null, sent: null }; globalThis.cardsSave = () => { calls.saved++; }; globalThis.cardsRender = () => {}; globalThis.ramRender = () => {}; globalThis.t = s => s; globalThis.alert = () => {}; globalThis.confirm = () => true; - // the preset re-read (cardsApply -> the sp-* form fields); the real - // spRefreshFromPreset is covered by cards_counter.test.js globalThis.spRefreshFromPreset = () => { els['sp-cntr'].value = cards[0].cntr; return cards[0].cntr; }; globalThis.ramSendOta = async (apdu, sp) => { - calls.sent = { apdu: apdu, cntr: sp.cntr }; + calls.sent = { apdu: apdu, cntr: sp.cntr, spi2: sp.spi2 }; return { success: success !== false, por: por }; }; globalThis.ramExplore = async sp => { calls.explored = sp.cntr; }; + globalThis.ramRemoveFromExplorer = (aid, cascade) => { + calls.removed = { aid: aid, cascade: cascade }; + return true; + }; return { els, calls }; } -test('accepted delete persists the consumed counter and re-explores from it', async () => { +test('accepted delete persists the consumed counter and drops the record', async () => { const { els, calls } = fakeEnv({ response_status: 'por_ok', decoded: { last_status_word: '9000' } }); await ramDeleteFromExplorer('F0414C46416101', false); assert.strictEqual(calls.sent.cntr, '0000000005'); assert.strictEqual(calls.sent.apdu, '80E40000094F07F0414C4641610100'); + assert.strictEqual(calls.sent.spi2, '01', 'the computed SPI2 byte must be used'); assert.strictEqual(cards[0].cntr, '0000000006', 'the preset must carry the counter the card consumed'); assert.strictEqual(els['sp-cntr'].value, '0000000006'); assert.ok(calls.saved > 0, 'cardsSave() must persist it'); - assert.strictEqual(calls.explored, '0000000006', - 'the re-explore must start at N+1, never replay N'); + assert.deepStrictEqual(calls.removed, { aid: 'F0414C46416101', cascade: false }); + assert.strictEqual(calls.explored, null, 'no re-explore: the record is dropped locally'); assert.strictEqual(els['ram-result'].textContent, 'OK'); }); +test('a delete with no PoR is accepted (the envelope 9000 is the result)', async () => { + const { els, calls } = fakeEnv(undefined); + await ramDeleteFromExplorer('F0414C46416101', false); + assert.strictEqual(cards[0].cntr, '0000000006'); + assert.deepStrictEqual(calls.removed, { aid: 'F0414C46416101', cascade: false }); + assert.strictEqual(els['ram-result'].textContent, 'OK'); + assert.ok(els['ram-steps'].textContent.includes('no PoR'), els['ram-steps'].textContent); +}); + test('cntr_low leaves the preset untouched (the card did not consume the packet)', async () => { const { calls } = fakeEnv({ response_status: 'cntr_low' }); await ramDeleteFromExplorer('F0414C46416101', false); assert.strictEqual(cards[0].cntr, '0000000005'); assert.strictEqual(calls.saved, 0); - assert.strictEqual(calls.explored, null); + assert.strictEqual(calls.removed, null); }); -test('a refused DELETE still advances the counter but does not re-explore', async () => { +test('a refused DELETE still advances the counter but drops nothing', async () => { const { calls } = fakeEnv({ response_status: 'por_ok', decoded: { last_status_word: '6A88' } }); await ramDeleteFromExplorer('F0414C46416101', true); assert.strictEqual(cards[0].cntr, '0000000006'); - assert.strictEqual(calls.explored, null); + assert.strictEqual(calls.removed, null); }); -test('a delete accepted via actual_response_sms_submit advances and re-explores', async () => { - // The card consumed the packet and will deliver the remote result as an - // SMS-SUBMIT; the counter must advance and the re-explore must run (the - // remote SW is unknown, so no SW is shown). - const { els, calls } = fakeEnv({ response_status: 'actual_response_sms_submit' }); +test('a delete accepted via actual_response_sms_submit advances and drops the record', async () => { + const { calls } = fakeEnv({ response_status: 'actual_response_sms_submit' }); await ramDeleteFromExplorer('F0414C46416101', false); assert.strictEqual(cards[0].cntr, '0000000006'); - assert.strictEqual(calls.explored, '0000000006'); - assert.strictEqual(els['ram-result'].textContent, 'OK'); + assert.deepStrictEqual(calls.removed, { aid: 'F0414C46416101', cascade: false }); }); test('a send failure leaves the preset untouched', async () => { const { calls } = fakeEnv({ response_status: 'por_ok' }, false); await ramDeleteFromExplorer('F0414C46416101', false); assert.strictEqual(cards[0].cntr, '0000000005'); - assert.strictEqual(calls.explored, null); + assert.strictEqual(calls.removed, null); }); diff --git a/pyproject.toml b/pyproject.toml index 817c7a2..8de9e97 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pysim-simple-server" -version = "3.6.12" +version = "3.6.13" description = "HTTP REST server wrapping pysim for the SIMple PWA" requires-python = ">=3.8" # pysim is a git-only dependency installed explicitly by setup.bat/setup.sh. diff --git a/pysim_simple_server/server.py b/pysim_simple_server/server.py index 356f66e..f3baa22 100644 --- a/pysim_simple_server/server.py +++ b/pysim_simple_server/server.py @@ -31,7 +31,7 @@ from osmocom.tlv import BER_TLV_IE from osmocom.utils import rpad -VERSION = '3.6.12' +VERSION = '3.6.13' MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE