fix: keep the preset counter intact through the Explore delete flow (v3.6.7)
Delete All worked but reset the preset counter: the handler never re-read the preset (unlike ramExecute), saved N+1, then re-ran the Explore with the OLD sp (counter N). Every page answered cntr_low, nothing was accepted, and ramExplore's final ramSaveCntr wrote N back over the preset. - ramDeleteFromExplorer re-reads the preset first (spRefreshFromPreset 'ram-card-sel'), advances and saves the counter only for a packet the card accepted (spPorAccepted) - a rejected packet leaves it untouched - and continues the follow-up Explore from the consumed counter instead of replaying it. - the delete result also checks the remote command's SW via a new `ramRemoteSwOk` (9000 / 61xx / 62xx / 63xx / CAFE, mirroring the server's `_ram_remote_sw_ok`): a por_ok packet whose DELETE was refused (e.g. 6A88) no longer shows "OK" (the counter still advances, the card consumed it). - ramExplore only saves the counter when it advanced, so a stale caller can never lower a preset counter again. - tests: flow tests for accepted / rejected / refused-remote-SW deletes (refresh order, saved counter, the counter passed to the re-explore) and the ramRemoteSwOk set. 615 frontend / 495 Python green; version 3.6.7; sw simple-v280.
This commit is contained in:
+33
-6
@@ -1665,7 +1665,7 @@
|
|||||||
// ===== Version =====
|
// ===== Version =====
|
||||||
// Single source of truth for the PWA version: shown in the header and used
|
// Single source of truth for the PWA version: shown in the header and used
|
||||||
// by the server version check in pysimConnect().
|
// 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;
|
document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION;
|
||||||
|
|
||||||
// ===== Tab switching =====
|
// ===== Tab switching =====
|
||||||
@@ -8817,7 +8817,9 @@ async function ramExplore(sp) {
|
|||||||
await paginate('10', modules, (hex) => ramParseElfStatus(hex, true), t('ELF Modules'));
|
await paginate('10', modules, (hex) => ramParseElfStatus(hex, true), t('ELF Modules'));
|
||||||
|
|
||||||
ramMergeElfData(elfs, 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();
|
ramHideProgress();
|
||||||
|
|
||||||
if (errors.length) {
|
if (errors.length) {
|
||||||
@@ -8848,6 +8850,10 @@ function ramDeleteApdu(aid, withCascade) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function ramDeleteFromExplorer(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();
|
const sp = getRamSpParams();
|
||||||
if (!sp.kicKey || !sp.kidKey) {
|
if (!sp.kicKey || !sp.kidKey) {
|
||||||
alert(t('Select a card preset with keys first (RAM subtab → Card preset)'));
|
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 + '...');
|
ramShowProgress(label + ' ' + aid + '...');
|
||||||
const res = await ramSendOta(apdu, sp);
|
const res = await ramSendOta(apdu, sp);
|
||||||
ramHideProgress();
|
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 resultEl = document.getElementById('ram-result');
|
||||||
const stepsEl = document.getElementById('ram-steps');
|
const stepsEl = document.getElementById('ram-steps');
|
||||||
if (!res.success || !res.por || res.por.response_status !== 'por_ok') {
|
const sw = res.por && res.por.decoded ? res.por.decoded.last_status_word : '';
|
||||||
resultEl.textContent = t('Failed') + ': ' + (res.por ? res.por.response_status : res.error || res.sw);
|
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');
|
resultEl.classList.remove('hidden', 'text-green-600'); resultEl.classList.add('text-red-600');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ramSaveCntr(ramIncrementCntr(sp.cntr));
|
|
||||||
const sw = res.por.decoded ? res.por.decoded.last_status_word : '';
|
|
||||||
stepsEl.classList.remove('hidden');
|
stepsEl.classList.remove('hidden');
|
||||||
stepsEl.textContent = label + ' ' + aid + ' -> ' + sw;
|
stepsEl.textContent = label + ' ' + aid + ' -> ' + sw;
|
||||||
resultEl.textContent = t('OK');
|
resultEl.textContent = t('OK');
|
||||||
resultEl.classList.remove('hidden', 'text-red-600'); resultEl.classList.add('text-green-600');
|
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);
|
await ramExplore(sp);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -9475,6 +9493,15 @@ function spPorAccepted(por) {
|
|||||||
por.response_status === 'actual_response_sms_submit';
|
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
|
// 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
|
// 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
|
// occurrences) and a chained GET RESPONSE (00C0000000) in the same secured
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
const CACHE = 'simple-v279';
|
const CACHE = 'simple-v280';
|
||||||
const URLS = [
|
const URLS = [
|
||||||
'index.html',
|
'index.html',
|
||||||
'help.html',
|
'help.html',
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ function extractFunc(src, name) {
|
|||||||
|
|
||||||
// Extract chain builder functions and dependencies
|
// Extract chain builder functions and dependencies
|
||||||
const FNS = ['berLenStr', 'buildApdu', 'escHtml', 'esc', 'chainInit', 'chainRamBuildRowHex', 'ramFmtLifecycle', 'ramFmtPrivileges', 'ramRenderExploreHtml', 'ramStepLine', 'ramGetStatusApdu', 'ramDeleteApdu',
|
const FNS = ['berLenStr', 'buildApdu', 'escHtml', 'esc', 'chainInit', 'chainRamBuildRowHex', 'ramFmtLifecycle', 'ramFmtPrivileges', 'ramRenderExploreHtml', 'ramStepLine', 'ramGetStatusApdu', 'ramDeleteApdu',
|
||||||
|
'ramRemoteSwOk', 'spPorAccepted', 'ramIncrementCntr', 'ramDeleteFromExplorer',
|
||||||
'_parseRawElfEntry', '_parseRawAppEntry', 'ramParseElfStatus', 'ramParseAppStatus', 'parseTLV', '_parseE3Entry',
|
'_parseRawElfEntry', '_parseRawAppEntry', 'ramParseElfStatus', 'ramParseAppStatus', 'parseTLV', '_parseE3Entry',
|
||||||
'ramCardIdxAfterRemove', 'ramClearResults', 'ramHideProgress', 'ramOpChanged', 'ramRender', 'ramApplyCard', 'ramExecute',
|
'ramCardIdxAfterRemove', 'ramClearResults', 'ramHideProgress', 'ramOpChanged', 'ramRender', 'ramApplyCard', 'ramExecute',
|
||||||
'jcAidNorm', 'jcAidName', 'jcAidSuffix', 'jcAidHtml'];
|
'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
|
// the dead helper reference must not come back
|
||||||
assert.ok(!html.includes('_ber_len('), 'undefined _ber_len() call is 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();
|
||||||
|
});
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "pysim-simple-server"
|
name = "pysim-simple-server"
|
||||||
version = "3.6.6"
|
version = "3.6.7"
|
||||||
description = "HTTP REST server wrapping pysim for the SIMple PWA"
|
description = "HTTP REST server wrapping pysim for the SIMple PWA"
|
||||||
requires-python = ">=3.8"
|
requires-python = ">=3.8"
|
||||||
# pysim is a git-only dependency installed explicitly by setup.bat/setup.sh.
|
# pysim is a git-only dependency installed explicitly by setup.bat/setup.sh.
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ from osmocom.tlv import BER_TLV_IE
|
|||||||
from osmocom.utils import rpad
|
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
|
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user