fix: Explore Apps listing uses the SMS-submit PoR transport (v3.6.10)
The Apps query (P1=40) was sent with SPI2=0x01 while only the ELF queries
(P1=20/10) used SPI2=0x21. The Apps listing can exceed the ENVELOPE
response, and the card then answers the envelope PoR with
`actual_response_sms_submit` - the actual response would follow as an
SMS-SUBMIT, which is never sent unless the PoR is requested in submit mode.
The paginate only accepted `por_ok`, so the Explore reported the raw status
as an error ("Partial - Apps: actual_response_sms_submit") and the Apps list
never rendered (live 2026-09-28).
- `ramListingSpi2(p1)`: 40/20/10 -> '21', else '01'; the paginate uses it.
- the paginate accepts `actual_response_sms_submit` via `spPorAccepted` and,
when no data arrived, notes "response via SMS not captured" instead of
reporting the card status as a failure.
- the Explore delete verdict follows the same acceptance rule (an accepted
SMS-submit delete no longer shows "Failed" while its counter advanced) and
no longer prints a dangling " -> " without a SW.
- tests: ramListingSpi2 + the delete-accepted-via-sms-submit flow.
633 frontend / 496 Python green; version 3.6.10; sw simple-v283.
This commit is contained in:
+24
-9
@@ -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.9';
|
const SIMPLE_VERSION = '3.6.10';
|
||||||
document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION;
|
document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION;
|
||||||
|
|
||||||
// ===== Tab switching =====
|
// ===== Tab switching =====
|
||||||
@@ -8721,11 +8721,9 @@ async function ramExplore(sp) {
|
|||||||
async function paginate(p1, collector, parser, label) {
|
async function paginate(p1, collector, parser, label) {
|
||||||
// Compact listing format (GP 11.4.2.2: P2.b2=0) with the chained GET
|
// Compact listing format (GP 11.4.2.2: P2.b2=0) with the chained GET
|
||||||
// RESPONSE - see ramGetStatusApdu(). The card's SCP80 layer runs both
|
// RESPONSE - see ramGetStatusApdu(). The card's SCP80 layer runs both
|
||||||
// commands, so the PoR carries the listing. ELF queries (P1=20/10) use
|
// commands, so the PoR carries the listing; ramListingSpi2() picks the
|
||||||
// SPI2=0x21 (PoR via SMS-SUBMIT) because the listing won't fit in the
|
// PoR transport per query.
|
||||||
// ENVELOPE response.
|
const spi2 = ramListingSpi2(p1);
|
||||||
const isElf = (p1 === '20' || p1 === '10');
|
|
||||||
const spi2 = isElf ? '21' : '01';
|
|
||||||
let p2 = '00';
|
let p2 = '00';
|
||||||
let guard = 0;
|
let guard = 0;
|
||||||
while (guard++ < 32) {
|
while (guard++ < 32) {
|
||||||
@@ -8736,13 +8734,19 @@ async function ramExplore(sp) {
|
|||||||
// page itself is incomplete: the card consumed it, and a retry with
|
// page itself is incomplete: the card consumed it, and a retry with
|
||||||
// the same counter is rejected (cntr_low).
|
// the same counter is rejected (cntr_low).
|
||||||
if (res.success && spPorAccepted(res.por)) cntr = ramIncrementCntr(cntr);
|
if (res.success && spPorAccepted(res.por)) cntr = ramIncrementCntr(cntr);
|
||||||
if (!res.success || !res.por || res.por.response_status !== 'por_ok') {
|
if (!res.success || !res.por || !spPorAccepted(res.por)) {
|
||||||
const errorMsg = res.por ? res.por.response_status : (res.error || t('no data'));
|
const errorMsg = res.por ? res.por.response_status : (res.error || t('no data'));
|
||||||
errors.push(label + ': ' + errorMsg);
|
errors.push(label + ': ' + errorMsg);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
const data = res.por.decoded ? res.por.decoded.last_response_data : '';
|
const data = res.por.decoded ? res.por.decoded.last_response_data : '';
|
||||||
const sw = (res.por.decoded ? res.por.decoded.last_status_word : '').toUpperCase();
|
const sw = (res.por.decoded ? res.por.decoded.last_status_word : '').toUpperCase();
|
||||||
|
if (!data && res.por.response_status === 'actual_response_sms_submit') {
|
||||||
|
// Accepted, but the actual-response SMS was not captured: there
|
||||||
|
// is nothing to parse for this page (not a card error).
|
||||||
|
errors.push(label + ': ' + t('response via SMS not captured'));
|
||||||
|
break;
|
||||||
|
}
|
||||||
// Defensive: a remote 61XX means the data is still pending (the
|
// Defensive: a remote 61XX means the data is still pending (the
|
||||||
// chained GET RESPONSE normally prevents this).
|
// chained GET RESPONSE normally prevents this).
|
||||||
if (sw && sw.startsWith('61')) {
|
if (sw && sw.startsWith('61')) {
|
||||||
@@ -8858,7 +8862,7 @@ async function ramDeleteFromExplorer(aid, withCascade) {
|
|||||||
const resultEl = document.getElementById('ram-result');
|
const resultEl = document.getElementById('ram-result');
|
||||||
const stepsEl = document.getElementById('ram-steps');
|
const stepsEl = document.getElementById('ram-steps');
|
||||||
const sw = res.por && res.por.decoded ? res.por.decoded.last_status_word : '';
|
const sw = res.por && res.por.decoded ? res.por.decoded.last_status_word : '';
|
||||||
if (!res.success || !res.por || res.por.response_status !== 'por_ok' ||
|
if (!res.success || !res.por || !spPorAccepted(res.por) ||
|
||||||
(sw && !ramRemoteSwOk(sw))) {
|
(sw && !ramRemoteSwOk(sw))) {
|
||||||
resultEl.textContent = t('Failed') + ': ' +
|
resultEl.textContent = t('Failed') + ': ' +
|
||||||
(res.por ? res.por.response_status : (res.error || res.sw)) +
|
(res.por ? res.por.response_status : (res.error || res.sw)) +
|
||||||
@@ -8867,7 +8871,7 @@ async function ramDeleteFromExplorer(aid, withCascade) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
stepsEl.classList.remove('hidden');
|
stepsEl.classList.remove('hidden');
|
||||||
stepsEl.textContent = label + ' ' + aid + ' -> ' + sw;
|
stepsEl.textContent = label + ' ' + aid + (sw ? ' -> ' + 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
|
// Continue from the counter the delete consumed: replaying the old one
|
||||||
@@ -9502,6 +9506,16 @@ function ramGetStatusApdu(p1, p2) {
|
|||||||
return '80F2' + p1 + p2 + '024F0000' + 'C0000000';
|
return '80F2' + p1 + p2 + '024F0000' + 'C0000000';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The ISD (80) and memory responses fit the ENVELOPE; the Apps (40) and ELF
|
||||||
|
// (20/10) listings can exceed it, and the card then delivers the actual
|
||||||
|
// response as an SMS-SUBMIT - those queries must request the PoR in submit
|
||||||
|
// mode (SPI2=0x21). With SPI2=0x01 the card answers the envelope PoR with
|
||||||
|
// actual_response_sms_submit and the data never arrives (live 2026-09-28:
|
||||||
|
// "Partial - Apps: actual_response_sms_submit").
|
||||||
|
function ramListingSpi2(p1) {
|
||||||
|
return (p1 === '40' || p1 === '20' || p1 === '10') ? '21' : '01';
|
||||||
|
}
|
||||||
|
|
||||||
function downloadJson(name, obj) {
|
function downloadJson(name, obj) {
|
||||||
const blob = new Blob([JSON.stringify(obj, null, 2)], {type: 'application/json'});
|
const blob = new Blob([JSON.stringify(obj, null, 2)], {type: 'application/json'});
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
@@ -16447,6 +16461,7 @@ const LANG_RU = {
|
|||||||
'Memory': 'Память',
|
'Memory': 'Память',
|
||||||
'no data': 'нет данных',
|
'no data': 'нет данных',
|
||||||
'(no data)': '(нет данных)',
|
'(no data)': '(нет данных)',
|
||||||
|
'response via SMS not captured': 'ответ по SMS не перехвачен',
|
||||||
'free NV': 'свободно NV',
|
'free NV': 'свободно NV',
|
||||||
'Failed': 'Ошибка',
|
'Failed': 'Ошибка',
|
||||||
'cascade': 'каскадно',
|
'cascade': 'каскадно',
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
const CACHE = 'simple-v282';
|
const CACHE = 'simple-v283';
|
||||||
const URLS = [
|
const URLS = [
|
||||||
'index.html',
|
'index.html',
|
||||||
'help.html',
|
'help.html',
|
||||||
|
|||||||
@@ -23,7 +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',
|
||||||
'stkParamsBuild', 'ramRemoteSwOk', 'spPorAccepted', 'ramIncrementCntr', 'ramDeleteFromExplorer',
|
'stkParamsBuild', 'ramRemoteSwOk', 'spPorAccepted', 'ramIncrementCntr', 'ramDeleteFromExplorer', 'ramListingSpi2',
|
||||||
'_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'];
|
||||||
@@ -440,6 +440,14 @@ test('ramRemoteSwOk mirrors the server success set', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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
|
||||||
|
// never arrives (the live "Partial - Apps" bug).
|
||||||
|
for (const p1 of ['40', '20', '10']) assert.strictEqual(ramListingSpi2(p1), '21', p1);
|
||||||
|
for (const p1 of ['80', '00', 'FF']) assert.strictEqual(ramListingSpi2(p1), '01', p1);
|
||||||
|
});
|
||||||
|
|
||||||
function stubDeleteEnv(sendResult) {
|
function stubDeleteEnv(sendResult) {
|
||||||
// ramShowProgress/ramHideProgress are the real extracted helpers
|
// ramShowProgress/ramHideProgress are the real extracted helpers
|
||||||
const els = fakeRamDocument(['ram-result', 'ram-steps', 'ram-progress', 'ram-progress-text']);
|
const els = fakeRamDocument(['ram-result', 'ram-steps', 'ram-progress', 'ram-progress-text']);
|
||||||
|
|||||||
@@ -98,6 +98,17 @@ test('a refused DELETE still advances the counter but does not re-explore', asyn
|
|||||||
assert.strictEqual(calls.explored, null);
|
assert.strictEqual(calls.explored, 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' });
|
||||||
|
await ramDeleteFromExplorer('F0414C46416101', false);
|
||||||
|
assert.strictEqual(cards[0].cntr, '0000000006');
|
||||||
|
assert.strictEqual(calls.explored, '0000000006');
|
||||||
|
assert.strictEqual(els['ram-result'].textContent, 'OK');
|
||||||
|
});
|
||||||
|
|
||||||
test('a send failure leaves the preset untouched', async () => {
|
test('a send failure leaves the preset untouched', async () => {
|
||||||
const { calls } = fakeEnv({ response_status: 'por_ok' }, false);
|
const { calls } = fakeEnv({ response_status: 'por_ok' }, false);
|
||||||
await ramDeleteFromExplorer('F0414C46416101', false);
|
await ramDeleteFromExplorer('F0414C46416101', false);
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "pysim-simple-server"
|
name = "pysim-simple-server"
|
||||||
version = "3.6.9"
|
version = "3.6.10"
|
||||||
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.9'
|
VERSION = '3.6.10'
|
||||||
|
|
||||||
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