diff --git a/frontend/index.html b/frontend/index.html index 893bb75..1dfda98 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1665,7 +1665,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.6'; +const SIMPLE_VERSION = '3.6.7'; document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION; // ===== Tab switching ===== @@ -8817,7 +8817,9 @@ async function ramExplore(sp) { await paginate('10', modules, (hex) => ramParseElfStatus(hex, true), t('ELF Modules')); ramMergeElfData(elfs, modules); - ramSaveCntr(cntr); + // Only write the counter when a packet was actually accepted: a run whose + // start value was stale must never lower the preset counter. + if (cntr !== sp.cntr) ramSaveCntr(cntr); ramHideProgress(); if (errors.length) { @@ -8848,6 +8850,10 @@ function ramDeleteApdu(aid, withCascade) { } 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 + // counter. + spRefreshFromPreset('ram-card-sel'); const sp = getRamSpParams(); if (!sp.kicKey || !sp.kidKey) { alert(t('Select a card preset with keys first (RAM subtab → Card preset)')); @@ -8860,19 +8866,31 @@ async function ramDeleteFromExplorer(aid, withCascade) { ramShowProgress(label + ' ' + aid + '...'); const res = await ramSendOta(apdu, sp); ramHideProgress(); + // The card consumes the counter as soon as it accepts the packet, even + // when the remote command fails; a rejected packet (cntr_low, PoR error) + // leaves it untouched. + if (res.success && spPorAccepted(res.por)) { + sp.cntr = ramIncrementCntr(sp.cntr); + ramSaveCntr(sp.cntr); + } const resultEl = document.getElementById('ram-result'); const stepsEl = document.getElementById('ram-steps'); - if (!res.success || !res.por || res.por.response_status !== 'por_ok') { - resultEl.textContent = t('Failed') + ': ' + (res.por ? res.por.response_status : res.error || res.sw); + const sw = res.por && res.por.decoded ? res.por.decoded.last_status_word : ''; + if (!res.success || !res.por || res.por.response_status !== 'por_ok' || + (sw && !ramRemoteSwOk(sw))) { + resultEl.textContent = t('Failed') + ': ' + + (res.por ? res.por.response_status : (res.error || res.sw)) + + (sw ? ' · ' + t('remote SW') + ' ' + sw : ''); resultEl.classList.remove('hidden', 'text-green-600'); resultEl.classList.add('text-red-600'); return; } - ramSaveCntr(ramIncrementCntr(sp.cntr)); - const sw = res.por.decoded ? res.por.decoded.last_status_word : ''; stepsEl.classList.remove('hidden'); stepsEl.textContent = label + ' ' + aid + ' -> ' + sw; resultEl.textContent = t('OK'); 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); } @@ -9475,6 +9493,15 @@ function spPorAccepted(por) { por.response_status === 'actual_response_sms_submit'; } +// Remote-command status words that count as success (mirrors the server's +// `_ram_remote_sw_ok`): 9000 normal; 61xx more data available; 62xx/63xx +// warnings; CAFE GlobalPlatform "more data available" (GET STATUS pages). +function ramRemoteSwOk(sw) { + const s = (sw || '').toUpperCase(); + return !!s && (s === '9000' || s === 'CAFE' || + ['61', '62', '63'].indexOf(s.slice(0, 2)) >= 0); +} + // GET STATUS APDU for the compact listing format: P2.b2=0 (GP Card Spec // v2.3.1 11.4.2.2) with the mandatory '4F00' search criterion (all // occurrences) and a chained GET RESPONSE (00C0000000) in the same secured diff --git a/frontend/sw.js b/frontend/sw.js index e4d2c5d..6d08cd9 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'simple-v279'; +const CACHE = 'simple-v280'; const URLS = [ 'index.html', 'help.html', diff --git a/frontend/tests/ram.test.js b/frontend/tests/ram.test.js index 6feac18..a2da085 100644 --- a/frontend/tests/ram.test.js +++ b/frontend/tests/ram.test.js @@ -23,6 +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', + 'ramRemoteSwOk', 'spPorAccepted', 'ramIncrementCntr', 'ramDeleteFromExplorer', '_parseRawElfEntry', '_parseRawAppEntry', 'ramParseElfStatus', 'ramParseAppStatus', 'parseTLV', '_parseE3Entry', 'ramCardIdxAfterRemove', 'ramClearResults', 'ramHideProgress', 'ramOpChanged', 'ramRender', 'ramApplyCard', 'ramExecute', 'jcAidNorm', 'jcAidName', 'jcAidSuffix', 'jcAidHtml']; @@ -429,3 +430,74 @@ test('ramDeleteApdu builds the GP DELETE with the 4F AID TLV and Le (F0414C46416 // the dead helper reference must not come back assert.ok(!html.includes('_ber_len('), 'undefined _ber_len() call is back'); }); + +test('ramRemoteSwOk mirrors the server success set', () => { + for (const sw of ['9000', '6113', '62F1', '6310', 'CAFE']) { + assert.ok(ramRemoteSwOk(sw), sw); + } + for (const sw of ['6700', '6F00', '6A88', '', null]) { + assert.ok(!ramRemoteSwOk(sw), String(sw)); + } +}); + +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 }; + globalThis.t = s => s; + globalThis.spRefreshFromPreset = sel => { calls.refresh.push(sel); }; + globalThis.getRamSpParams = () => ({ cntr: '0000000005', kicKey: 'AA', kidKey: 'BB' }); + globalThis.confirm = () => true; + globalThis.alert = () => {}; + globalThis.ramShowProgress = () => {}; // not part of the extracted FNS + globalThis.ramSendOta = async (apdu, sp) => { + calls.sent.push({ apdu: apdu, cntr: sp.cntr }); + return sendResult; + }; + globalThis.ramSaveCntr = c => { calls.saved.push(c); }; + globalThis.ramExplore = async sp => { calls.explored = sp.cntr; }; + return { els, calls }; +} + +function unstubDeleteEnv() { + for (const k of ['getRamSpParams', 'confirm', 'alert', 'ramShowProgress', + 'ramSendOta', 'ramSaveCntr', 'ramExplore']) { + delete globalThis[k]; + } + globalThis.spRefreshFromPreset = () => ''; // the top-level stub +} + +test('ramDeleteFromExplorer refreshes the preset and continues from the consumed counter', async () => { + const { els, calls } = stubDeleteEnv({ success: true, + por: { response_status: 'por_ok', decoded: { last_status_word: '9000' } } }); + await ramDeleteFromExplorer('F0414C46416101', true); + assert.deepStrictEqual(calls.refresh, ['ram-card-sel'], + 'the preset must be re-read before the operation'); + assert.strictEqual(calls.sent.length, 1); + assert.ok(calls.sent[0].apdu.startsWith('80E40080'), calls.sent[0].apdu); + 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.strictEqual(els['ram-result'].textContent, 'OK'); + unstubDeleteEnv(); +}); + +test('ramDeleteFromExplorer leaves the counter untouched on a rejected packet', async () => { + const { calls } = stubDeleteEnv({ success: true, por: { response_status: 'cntr_low' } }); + await ramDeleteFromExplorer('F0414C46416101', false); + assert.deepStrictEqual(calls.saved, [], 'a rejected packet must not advance the counter'); + assert.strictEqual(calls.explored, null, 'no re-explore after a rejected delete'); + unstubDeleteEnv(); +}); + +test('ramDeleteFromExplorer advances but does not re-explore on a failed remote command', async () => { + const { calls } = stubDeleteEnv({ success: true, + por: { response_status: 'por_ok', decoded: { last_status_word: '6A88' } } }); + await ramDeleteFromExplorer('F0414C46416101', false); + assert.deepStrictEqual(calls.saved, ['0000000006'], + 'the card consumed the packet, so the counter still advances'); + assert.strictEqual(calls.explored, null, 'a failed DELETE must not re-explore'); + unstubDeleteEnv(); +}); diff --git a/pyproject.toml b/pyproject.toml index 952489e..b2dd372 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pysim-simple-server" -version = "3.6.6" +version = "3.6.7" 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 d20f051..102777e 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.6' +VERSION = '3.6.7' MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE