feat: low-counter guard for every SCP80/RAM operation (v3.6.58)
A cntr_low PoR verdict is not recoverable by retrying - every packet whose
counter is not above the card's is rejected again - so it now stops the
operation and is stated as plain data instead of a generic PoR error.
Server:
- _cntr_low_fields(status, cntr) -> {cntr_low, card_cntr, suggested_cntr}
(fixed-width wrap like the counter field); carried by /api/send-ota (plus
an 'OTA CNTR-LOW: card counter ...' log line) and by the RAM install /
install-app / cap-compat failure responses (from the failed step);
- _send_envelope's late-PoR poll now also runs after a warning ENVELOPE
answer (62xx/63xx - the live card answers 6200 for a low counter) and
_send_secured_packet passes the captured PoR on, so the verdict is named
instead of a bare 'ENVELOPE failed' (that is why it only surfaced on the
later SPI2=0x21 query in the live log).
PWA:
- spCntrLow(res) guards every flow; the Explore routes the whole run through
a checked sendOta(), so the paginate/expanded loops stop, no further
packet is sent and no counter is written back (delete and Send to Card
check it too);
- the result states the card's counter and offers 'Go to preset'
(ramGoToPreset: opens the failed operation's preset in the Cards tab for
editing and focuses the counter field) - nothing is written
automatically; the operation modal carries the same notice;
- help (EN/RU) documents the guard.
sw cache simple-v331; tests: 557 Python / 714 Node.
This commit is contained in:
@@ -279,7 +279,7 @@
|
||||
</section>
|
||||
<section class="mb-10">
|
||||
<h2 id="scp80" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">3. Вкладка SCP80</h2>
|
||||
<p class="mb-3">Верхнеуровневая вкладка <strong>SCP80</strong> объединяет разделы, связанные с SCP80. Переключение — двумя переключателями: <strong>Secured Packet</strong> и <strong>RAM</strong>. Собирает защищённые пакеты SCP80 по ETSI TS 102 225. При подключении карты сервер читает её <strong>EF.ICCID</strong> (2FE2); если в предустановке указан тот же номер (цифрами или сырым hex из EF), она автоматически выбирается в обоих представлениях.</p>
|
||||
<p class="mb-3">Верхнеуровневая вкладка <strong>SCP80</strong> объединяет разделы, связанные с SCP80. Переключение — двумя переключателями: <strong>Secured Packet</strong> и <strong>RAM</strong>. Собирает защищённые пакеты SCP80 по ETSI TS 102 225. При подключении карты сервер читает её <strong>EF.ICCID</strong> (2FE2); если в предустановке указан тот же номер (цифрами или сырым hex из EF), она автоматически выбирается в обоих представлениях. Вердикт <strong>низкого счётчика</strong> (<code class="font-mono text-sm">cntr_low</code> или предупреждающий ответ ENVELOPE, в котором всё ещё есть такой PoR) <em>не</em> исправляется повторной отправкой: любая операция SCP80/RAM немедленно останавливается, результат показывает счётчик карты и кнопку <strong>К пресету</strong>, открывающую соответствующий пресет на вкладке Cards, где счётчик нужно поднять выше — автоматически ничего не записывается.</p>
|
||||
|
||||
<h3 id="secured-packet" class="text-lg font-medium mb-2">3.1 Secured Packet</h3>
|
||||
<p class="mb-2">Собирает защищённые пакеты SCP80 по ETSI TS 102 225.</p>
|
||||
|
||||
+1
-1
@@ -310,7 +310,7 @@
|
||||
<li>AES requires a replay-protected counter: SPI1 bits b5 b4 must be <code class="font-mono text-sm">10</code> (counter higher) or <code class="font-mono text-sm">11</code> (counter +1) per TS 102 225 §5.1.2/§5.1.3.1</li>
|
||||
<li>Padding byte configurable (<code class="font-mono text-sm">00</code> default, or <code class="font-mono text-sm">FF</code>)</li>
|
||||
</ul>
|
||||
<p class="text-sm mb-3">A “Verify vs pySim” button cross-checks the assembled packet against pySim’s reference <code class="font-mono text-sm">OtaDialectSms.encode_cmd</code>. A “Send to Card” button delivers it via SMS-PP-DOWNLOAD ENVELOPE (when connected to the server). The 2-byte CPL prefix is transmitted whenever the packet is ciphered or carries RC/CC/DS (it is part of their input) and whenever the packet is concatenated; a single unprotected SMS keeps pySim’s CHL-first form (TS 31.115 Table 1 NOTE). Under the packet field the app reports its size and how many SMS it takes; a packet that does not fit one SMS is sent as a <strong>concatenated</strong> download (TS 31.115 §4.3) — the packet is split into SMS user-data parts (first SM 132 octets, following ones 134; the first one additionally carries the concatenation and CPI information elements) and the segments are sent in order. More than 5 segments is refused, that being the practical limit of the card’s concatenation buffer. The returned Proof of Receipt is decoded and shown as a PoR status line (status, TAR, counter, raw PoR); the last command’s status word and response data are filled into the <strong>R-APDU parser</strong> panel, and a successful PoR advances the replay counter and clears the packet.</p>
|
||||
<p class="text-sm mb-3">A “Verify vs pySim” button cross-checks the assembled packet against pySim’s reference <code class="font-mono text-sm">OtaDialectSms.encode_cmd</code>. A “Send to Card” button delivers it via SMS-PP-DOWNLOAD ENVELOPE (when connected to the server). The 2-byte CPL prefix is transmitted whenever the packet is ciphered or carries RC/CC/DS (it is part of their input) and whenever the packet is concatenated; a single unprotected SMS keeps pySim’s CHL-first form (TS 31.115 Table 1 NOTE). Under the packet field the app reports its size and how many SMS it takes; a packet that does not fit one SMS is sent as a <strong>concatenated</strong> download (TS 31.115 §4.3) — the packet is split into SMS user-data parts (first SM 132 octets, following ones 134; the first one additionally carries the concatenation and CPI information elements) and the segments are sent in order. More than 5 segments is refused, that being the practical limit of the card’s concatenation buffer. The returned Proof of Receipt is decoded and shown as a PoR status line (status, TAR, counter, raw PoR); the last command’s status word and response data are filled into the <strong>R-APDU parser</strong> panel, and a successful PoR advances the replay counter and clears the packet. A <strong>low-counter</strong> verdict (<code class="font-mono text-sm">cntr_low</code>, or a warning ENVELOPE answer that still carries such a PoR) is <em>not</em> recoverable by retrying: every SCP80/RAM operation stops immediately and the result states the card's counter with a <strong>Go to preset</strong> button that opens the matching preset in the Cards tab, where the counter must be raised above it — nothing is written automatically.</p>
|
||||
|
||||
<h3 id="ram" class="text-lg font-medium mb-2">3.2 RAM</h3>
|
||||
<p class="mb-2">Delivers Remote Application Management operations as SCP80 secured packets via SMS-PP-DOWNLOAD ENVELOPE. The card must support SCP03 (AES or 3DES). A saved card preset from the <strong>Cards</strong> tab provides the SPI, keys, TAR, and counter (RAM uses the preset's <strong>ISD TAR</strong>, <code class="font-mono text-sm">000000</code> by default).</p>
|
||||
|
||||
+147
-6
@@ -898,6 +898,7 @@
|
||||
</div>
|
||||
|
||||
<div id="ram-result" class="mt-2 text-base font-semibold font-mono hidden"></div>
|
||||
<div id="ram-cntr-low" class="mt-2 hidden"></div>
|
||||
<div id="ram-explorer" class="mt-2 text-xs font-mono hidden"></div>
|
||||
<div id="ram-steps" class="mt-2 text-xs font-mono whitespace-pre-wrap hidden"></div>
|
||||
</div>
|
||||
@@ -1749,7 +1750,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.57';
|
||||
const SIMPLE_VERSION = '3.6.58';
|
||||
document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION;
|
||||
|
||||
// ===== Tab switching =====
|
||||
@@ -8176,6 +8177,16 @@ async function pysimSendOta() {
|
||||
porStatusEl.classList.add('hidden');
|
||||
try {
|
||||
const data = await pysimFetch('/api/send-ota', { sp, ...spParams() });
|
||||
// low counter: not recoverable by retrying (v3.6.58)
|
||||
const low = spCntrLow(data);
|
||||
if (low) {
|
||||
sendResultEl.textContent = t('Low counter') + ' — ' + t('the card rejected the packet') +
|
||||
(low.card_cntr ? ' (' + t('card counter') + ' ' + low.card_cntr + ')' : '');
|
||||
sendResultEl.classList.remove('text-gray-500', 'text-green-600');
|
||||
sendResultEl.classList.add('text-red-600');
|
||||
ramShowCntrLow(low);
|
||||
return;
|
||||
}
|
||||
if (data.success) {
|
||||
if (data.por && data.por.decoded) {
|
||||
document.getElementById('resp-sw').value = data.por.decoded.last_status_word || '';
|
||||
@@ -8403,6 +8414,8 @@ function ramClearResults() {
|
||||
document.getElementById('ram-explorer').innerHTML = '';
|
||||
document.getElementById('ram-steps').classList.add('hidden');
|
||||
document.getElementById('ram-steps').textContent = '';
|
||||
const cntrLowEl = document.getElementById('ram-cntr-low');
|
||||
if (cntrLowEl) { cntrLowEl.innerHTML = ''; cntrLowEl.classList.add('hidden'); }
|
||||
}
|
||||
|
||||
// Read a File as a hex string via FileReader (client-side, no upload)
|
||||
@@ -9397,6 +9410,19 @@ async function ramExplore(sp) {
|
||||
// outcome report shown in the explorer.
|
||||
let expandedForm = null;
|
||||
const expandedReport = [];
|
||||
// Low counter: the card rejected a packet whose counter is not above its
|
||||
// own - a retry cannot help, so the run stops immediately and the user is
|
||||
// told to raise the preset counter (v3.6.58). Every send of the run goes
|
||||
// through sendOta() so the verdict is caught wherever it appears.
|
||||
let cntrLow = null;
|
||||
async function sendOta(apdu, params) {
|
||||
const res = await ramSendOta(apdu, params);
|
||||
if (!cntrLow) {
|
||||
const low = spCntrLow(res);
|
||||
if (low) cntrLow = low;
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
// One expanded query with the form ladder (see RAM_EXPANDED_FORMS): try
|
||||
// the remembered form first, then the others. The ISD gets a single
|
||||
@@ -9415,6 +9441,7 @@ async function ramExplore(sp) {
|
||||
let lastSw = '';
|
||||
let lastTransport = false;
|
||||
for (const form of forms) {
|
||||
if (cntrLow) break;
|
||||
let data = '';
|
||||
let sw = '';
|
||||
let accepted = true;
|
||||
@@ -9440,7 +9467,7 @@ async function ramExplore(sp) {
|
||||
const apdu = ramExpandedQueryApdu(p1, form, page > 1);
|
||||
const what = page > 1 ? 'P2=03 (' + form + ')' : 'P2=02 (' + form + ')';
|
||||
data += await sendOne(apdu, what);
|
||||
if (!accepted) break;
|
||||
if (cntrLow || !accepted) break;
|
||||
// '61xx': the card holds the response data - the GET RESPONSE
|
||||
// must ride in the same compact command string (a standalone
|
||||
// one answers 6700 on the live card); the expanded AA/22
|
||||
@@ -9448,7 +9475,7 @@ async function ramExplore(sp) {
|
||||
const pending = /^61[0-9A-F]{2}$/.test(sw);
|
||||
if (pending && sp.ram_format !== 'expanded') {
|
||||
data += await sendOne(ramChainGetResponse(apdu), what + ' + GET RESPONSE');
|
||||
if (!accepted) break;
|
||||
if (cntrLow || !accepted) break;
|
||||
}
|
||||
// the ISD is a single occurrence (GP 11.4.2.2); '6310' means
|
||||
// more occurrences, and a '61xx' page may mask that signal
|
||||
@@ -9475,6 +9502,7 @@ async function ramExplore(sp) {
|
||||
}
|
||||
|
||||
async function paginate(p1, collector, parser, label, quiet) {
|
||||
if (cntrLow) return; // the run was stopped by a low counter
|
||||
// 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
|
||||
// commands, so the PoR carries the listing; ramListingSpi2() picks the
|
||||
@@ -9487,7 +9515,8 @@ async function ramExplore(sp) {
|
||||
while (guard++ < 32) {
|
||||
const apdu = ramGetStatusApdu(p1, p2);
|
||||
ramShowProgress(label + ' P1=' + p1 + ' P2=' + p2 + '...');
|
||||
const res = await ramSendOta(apdu, Object.assign({}, sp, { cntr, spi2 }));
|
||||
const res = await sendOta(apdu, Object.assign({}, sp, { cntr, spi2 }));
|
||||
if (cntrLow) break;
|
||||
// Reuse the format detected for the first packet of this run
|
||||
if (res.ram_format) sp.ram_format = res.ram_format;
|
||||
// A packet the card accepted advances the counter even when the
|
||||
@@ -9561,7 +9590,8 @@ async function ramExplore(sp) {
|
||||
|
||||
ramShowProgress(t('Card resources (GET DATA FF21)') + '...');
|
||||
try {
|
||||
const memRes = await ramSendOta('80CAFF2100', Object.assign({}, sp, { spi2: '01' }));
|
||||
if (cntrLow) throw new Error(t('Low counter'));
|
||||
const memRes = await sendOta('80CAFF2100', Object.assign({}, sp, { spi2: '01' }));
|
||||
if (memRes.ram_format) sp.ram_format = memRes.ram_format;
|
||||
if (memRes.success && memRes.por && memRes.por.response_status === 'por_ok') {
|
||||
cntr = memRes.final_cntr || ramIncrementCntr(cntr);
|
||||
@@ -9606,7 +9636,12 @@ async function ramExplore(sp) {
|
||||
if (cntr !== sp.cntr) ramSaveCntr(cntr);
|
||||
ramHideProgress();
|
||||
|
||||
if (errors.length) {
|
||||
if (cntrLow) {
|
||||
// the run was stopped: state the verdict, do not retry
|
||||
resultEl.textContent = t('Low counter') + ' — ' + t('the operation was stopped');
|
||||
resultEl.classList.remove('hidden', 'text-green-600'); resultEl.classList.add('text-red-600');
|
||||
ramShowCntrLow(cntrLow);
|
||||
} else if (errors.length) {
|
||||
resultEl.textContent = t('Partial') + ' — ' + errors.join('; ');
|
||||
resultEl.classList.remove('hidden', 'text-green-600'); resultEl.classList.add('text-red-600');
|
||||
stepsEl.classList.remove('hidden');
|
||||
@@ -9707,6 +9742,15 @@ async function ramDeleteFromExplorer(aid, withCascade) {
|
||||
ramShowProgress(label + ' ' + aid + '...');
|
||||
const res = await ramSendOta(apdu, sp);
|
||||
ramHideProgress();
|
||||
// low counter: stop - retrying cannot help (v3.6.58)
|
||||
const low = spCntrLow(res);
|
||||
if (low) {
|
||||
const el = document.getElementById('ram-result');
|
||||
el.textContent = t('Low counter') + ' — ' + t('the operation was stopped');
|
||||
el.classList.remove('hidden', 'text-green-600'); el.classList.add('text-red-600');
|
||||
ramShowCntrLow(low);
|
||||
return;
|
||||
}
|
||||
// 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.
|
||||
@@ -10128,6 +10172,11 @@ async function ramCompatTest(sp) {
|
||||
let modal = esc(txt);
|
||||
if (probeNote) modal += '<div class="mt-2 text-gray-500 dark:text-slate-400">' + esc(probeNote) + '</div>';
|
||||
modal += ramCompatDetailHtml(data, mem);
|
||||
const low = spCntrLow(data);
|
||||
if (low) {
|
||||
modal += ramCntrLowHtml(low, ramCntrLowPresetIdx());
|
||||
ramShowCntrLow(low);
|
||||
}
|
||||
ramOpModalFinish(verdict, !!data.imports_ok, modal, true);
|
||||
}
|
||||
|
||||
@@ -10239,6 +10288,64 @@ function ramNvLine(data) {
|
||||
return label + ' ~' + Math.abs(used) + ' B (' + t('free') + ' ' + data.nv_before + ' \u2192 ' + data.nv_after + ')';
|
||||
}
|
||||
|
||||
// The preset the failed operation used (the RAM form's selection, else the
|
||||
// SCP80 form's) - the "Go to preset" target.
|
||||
function ramCntrLowPresetIdx() {
|
||||
const pick = id => {
|
||||
const el = document.getElementById(id);
|
||||
const i = el && el.value !== '' ? parseInt(el.value, 10) : NaN;
|
||||
return (!isNaN(i) && cards[i]) ? i : -1;
|
||||
};
|
||||
const ram = pick('ram-card-sel');
|
||||
return ram >= 0 ? ram : pick('sp-card-sel');
|
||||
}
|
||||
|
||||
// Low-counter notice (pure HTML): the facts plus a button that opens the
|
||||
// failed operation's preset in the Cards tab, where the user raises the
|
||||
// counter - nothing is written automatically.
|
||||
function ramCntrLowHtml(info, presetIdx) {
|
||||
if (!info) return '';
|
||||
const idx = Number.isInteger(presetIdx) && presetIdx >= 0 ? presetIdx : -1;
|
||||
let text = t('Low counter') + ' \u2014 ' + t('the card rejected the packet');
|
||||
if (info.card_cntr) text += ' (' + t('card counter') + ' ' + esc(info.card_cntr) + ')';
|
||||
if (info.suggested) text += ' \u2014 ' + t('the counter must be above it');
|
||||
return '<div class="flex items-center gap-3 text-sm text-red-600 dark:text-red-400">' +
|
||||
'<span>' + text + '</span>' +
|
||||
'<button type="button" onclick="ramGoToPreset(' + idx + ')" class="px-2 py-1 text-xs rounded border border-red-300 dark:border-red-700 hover:bg-red-100 dark:hover:bg-red-900/40 whitespace-nowrap">' +
|
||||
esc(t('Go to preset')) + '</button></div>';
|
||||
}
|
||||
|
||||
// Show the low-counter notice in the RAM result area (and, when the operation
|
||||
// modal is still open, in its detail block).
|
||||
function ramShowCntrLow(info, presetIdx) {
|
||||
if (!info) return;
|
||||
const idx = presetIdx === undefined ? ramCntrLowPresetIdx() : presetIdx;
|
||||
const html = ramCntrLowHtml(info, idx);
|
||||
const el = document.getElementById('ram-cntr-low');
|
||||
if (el && el.classList) {
|
||||
el.innerHTML = html;
|
||||
el.classList.remove('hidden');
|
||||
}
|
||||
if (typeof _ramOpActive !== 'undefined' && _ramOpActive) {
|
||||
const steps = document.getElementById('ram-op-steps');
|
||||
if (steps) steps.innerHTML += html;
|
||||
}
|
||||
}
|
||||
|
||||
// Open a preset in the Cards tab for editing (the user fixes the counter):
|
||||
// select it, scroll to the counter field and focus it.
|
||||
function ramGoToPreset(idx) {
|
||||
switchTab('cards');
|
||||
const i = Number(idx);
|
||||
if (Number.isInteger(i) && i >= 0 && cards[i]) cardsEdit(i);
|
||||
const el = document.getElementById('cards-cntr');
|
||||
if (el) {
|
||||
if (el.scrollIntoView) el.scrollIntoView({ block: 'center' });
|
||||
el.focus();
|
||||
if (el.select) el.select();
|
||||
}
|
||||
}
|
||||
|
||||
// Shared result rendering for /api/ram-install and /api/ram-install-app: the
|
||||
// per-step lines, the counter the card consumed, and the verdict. A failure
|
||||
// at a LOAD / INSTALL [for load] step also gets the same block analysis as
|
||||
@@ -10280,6 +10387,13 @@ function ramShowInstallResult(data, okText, capMem) {
|
||||
// successful run and for failures that are not LOAD/INSTALL [for load]).
|
||||
let modal = esc(txt);
|
||||
modal += ramCompatDetailHtml(data, capMem || null);
|
||||
// low counter: not recoverable by retrying - state it and point at the
|
||||
// preset, where the user raises the counter (v3.6.58)
|
||||
const low = spCntrLow(data);
|
||||
if (low) {
|
||||
modal += ramCntrLowHtml(low, ramCntrLowPresetIdx());
|
||||
ramShowCntrLow(low);
|
||||
}
|
||||
ramOpModalFinish(resultEl.textContent, !!data.success, modal, true);
|
||||
}
|
||||
|
||||
@@ -10989,6 +11103,27 @@ function spPorAccepted(por) {
|
||||
por.response_status === 'actual_response_sms_submit';
|
||||
}
|
||||
|
||||
// The card's low-counter verdict of a send response (v3.6.58). Every further
|
||||
// packet with a counter <= the card's is rejected again, so the operation must
|
||||
// stop and the preset counter must be raised - a retry cannot recover it.
|
||||
// Returns {card_cntr, suggested} or null.
|
||||
function spCntrLow(res) {
|
||||
if (!res) return null;
|
||||
const por = res.por || null;
|
||||
const status = (por && por.response_status) || '';
|
||||
if (!res.cntr_low && status !== 'cntr_low') return null;
|
||||
const card = String(res.card_cntr || (por && por.cntr) || '').toUpperCase();
|
||||
let suggested = String(res.suggested_cntr || '').toUpperCase();
|
||||
if (!suggested && /^[0-9A-F]+$/.test(card)) {
|
||||
try {
|
||||
// the SCP80 counter field is fixed-width: wrap like the server does
|
||||
const mod = 16n ** BigInt(card.length);
|
||||
suggested = ((BigInt('0x' + card) + 1n) % mod).toString(16).toUpperCase().padStart(card.length, '0');
|
||||
} catch (e) { suggested = ''; }
|
||||
}
|
||||
return { card_cntr: card, suggested: suggested };
|
||||
}
|
||||
|
||||
// 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).
|
||||
@@ -18779,6 +18914,12 @@ const LANG_RU = {
|
||||
'Package versions are only in the tag-list forms of the expanded registry query \u2014 this card accepted only the no-tag-list form.': 'Версии пакетов есть только в формах расширенного запроса со списком тегов \u2014 эта карта приняла только форму без списка тегов.',
|
||||
'from the app instance': 'от экземпляра апплета',
|
||||
'request error': 'ошибка запроса',
|
||||
'Low counter': 'Низкий счётчик',
|
||||
'the card rejected the packet': 'карта отклонила пакет',
|
||||
'card counter': 'счётчик карты',
|
||||
'the counter must be above it': 'счётчик должен быть больше',
|
||||
'Go to preset': 'К пресету',
|
||||
'the operation was stopped': 'операция остановлена',
|
||||
'STK menu entry:': 'Пункт меню STK:',
|
||||
'position': 'позиция',
|
||||
'Other registry data:': 'Прочие данные реестра:',
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
const CACHE = 'simple-v330';
|
||||
const CACHE = 'simple-v331';
|
||||
const URLS = [
|
||||
'index.html',
|
||||
'help.html',
|
||||
|
||||
@@ -26,6 +26,7 @@ const FNS = ['berLenStr', 'buildApdu', 'escHtml', 'esc', 'chainInit', 'chainRamB
|
||||
'stkParamsBuild', 'ramRemoteSwOk', 'spPorAccepted', 'ramIncrementCntr', 'ramDeleteFromExplorer', 'ramListingSpi2', 'ramRemoveFromExplorer', 'ramHasInstance', 'ramExpandedQueryApdu',
|
||||
'_parseRawElfEntry', '_parseRawAppEntry', 'ramParseElfStatus', 'ramParseAppStatus', 'ramParseModuleAids', 'parseTLV', '_parseE3Entry', '_parseMenuEntries',
|
||||
'ramExpandedReport', 'ramExpandedTags', 'ramExpandedGroups', 'ramExpandedElfForm', 'ramElfVersionHint', 'ramChainGetResponse', 'ramDeriveElfVersions',
|
||||
'spCntrLow', 'ramCntrLowHtml', 'ramCntrLowPresetIdx', 'ramShowCntrLow',
|
||||
'ramCardIdxAfterRemove', 'ramClearResults', 'ramHideProgress', 'ramOpChanged', 'ramRender', 'ramApplyCard', 'ramExecute', 'decodePrivileges', 'ramActionBtn', 'ramCapToolkitMode', 'ramOpProgressText',
|
||||
'jcAidNorm', 'jcAidName', 'jcAidSuffix', 'jcAidHtml'];
|
||||
let code = '';
|
||||
@@ -703,6 +704,52 @@ test('the explorer hints why the package versions are missing', () => {
|
||||
assert.ok(!withVer.includes('Package versions'), withVer);
|
||||
});
|
||||
|
||||
test('spCntrLow reads the low-counter verdict of a send response', () => {
|
||||
// the server's explicit fields
|
||||
const a = spCntrLow({ cntr_low: true, card_cntr: '00000000BB', suggested_cntr: '00000000BC' });
|
||||
assert.deepStrictEqual(a, { card_cntr: '00000000BB', suggested: '00000000BC' });
|
||||
// derived from the PoR alone (a 9000/91xx ENVELOPE can carry a cntr_low PoR)
|
||||
const b = spCntrLow({ por: { response_status: 'cntr_low', cntr: '00000000BB' } });
|
||||
assert.deepStrictEqual(b, { card_cntr: '00000000BB', suggested: '00000000BC' });
|
||||
// the counter is 40-bit: the wrap keeps the width
|
||||
const c = spCntrLow({ por: { response_status: 'cntr_low', cntr: 'FFFFFFFFFF' } });
|
||||
assert.strictEqual(c.suggested, '0000000000');
|
||||
// every other verdict (and a missing response) is not a low counter
|
||||
assert.strictEqual(spCntrLow({ por: { response_status: 'por_ok', cntr: '01' } }), null);
|
||||
assert.strictEqual(spCntrLow({ por: { response_status: 'cntr_high' } }), null);
|
||||
assert.strictEqual(spCntrLow({}), null);
|
||||
assert.strictEqual(spCntrLow(null), null);
|
||||
});
|
||||
|
||||
test('ramCntrLowHtml states the facts and offers the preset jump', () => {
|
||||
globalThis.t = s => s;
|
||||
const out = ramCntrLowHtml({ card_cntr: '00000000BB', suggested: '00000000BC' }, 2);
|
||||
const noPreset = ramCntrLowHtml({ card_cntr: '00000000BB' }, -1);
|
||||
delete globalThis.t;
|
||||
assert.ok(out.includes('Low counter \u2014 the card rejected the packet (card counter 00000000BB) \u2014 the counter must be above it'), out);
|
||||
assert.ok(out.includes('onclick="ramGoToPreset(2)"'), out);
|
||||
assert.ok(out.includes('Go to preset'), out);
|
||||
// without a known preset the button still opens the tab (-1)
|
||||
assert.ok(noPreset.includes('onclick="ramGoToPreset(-1)"'), noPreset);
|
||||
assert.strictEqual(ramCntrLowHtml(null, 0), '');
|
||||
});
|
||||
|
||||
test('every SCP80/RAM flow checks the low counter and stops', () => {
|
||||
// the Explore: the guarded sender, the loop guards and the final branch
|
||||
const explore = extractFunc(html, 'ramExplore');
|
||||
assert.ok(explore.includes('spCntrLow(res)'), 'the Explore must check every send');
|
||||
assert.ok(explore.includes('if (cntrLow) break;'), 'the loops must stop');
|
||||
assert.ok(explore.includes('ramShowCntrLow(cntrLow)'), 'the verdict must be shown');
|
||||
// the delete flow and the plain send check it too
|
||||
assert.ok(extractFunc(html, 'ramDeleteFromExplorer').includes('spCntrLow(res)'),
|
||||
'the delete flow must stop');
|
||||
// the preset jump opens the Cards tab, selects the preset and focuses the counter
|
||||
const go = extractFunc(html, 'ramGoToPreset');
|
||||
assert.ok(go.includes("switchTab('cards')"), go);
|
||||
assert.ok(go.includes('cardsEdit('), go);
|
||||
assert.ok(go.includes("getElementById('cards-cntr')"), go);
|
||||
});
|
||||
|
||||
test('the Explore detects the expanded query form and reports it', () => {
|
||||
const src = extractFunc(html, 'ramExplore');
|
||||
assert.ok(src.includes('RAM_EXPANDED_FORMS'), 'the form ladder must be used');
|
||||
|
||||
@@ -29,7 +29,9 @@ function extractFunc(src, name) {
|
||||
let code = '';
|
||||
for (const fn of ['berLenStr', 'ramDeleteApdu', 'ramIncrementCntr', 'ramSaveCntr',
|
||||
'getRamSpParams', 'spPorAccepted', 'ramRemoteSwOk', 'ramShowProgress',
|
||||
'ramHideProgress', 'ramDeleteFromExplorer']) {
|
||||
'ramHideProgress', 'ramDeleteFromExplorer',
|
||||
'spCntrLow', 'ramCntrLowHtml', 'ramCntrLowPresetIdx', 'ramShowCntrLow',
|
||||
'escHtml', 'esc']) {
|
||||
code += extractFunc(html, fn) + '\n';
|
||||
}
|
||||
eval(code);
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "pysim-simple-server"
|
||||
version = "3.6.57"
|
||||
version = "3.6.58"
|
||||
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.
|
||||
|
||||
@@ -31,7 +31,7 @@ from osmocom.tlv import BER_TLV_IE
|
||||
from osmocom.utils import rpad
|
||||
|
||||
|
||||
VERSION = '3.6.57'
|
||||
VERSION = '3.6.58'
|
||||
|
||||
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE
|
||||
|
||||
@@ -1136,8 +1136,12 @@ def _send_envelope(tpdu_hex, scc, sm_sc='12345678912', submit_handler=None,
|
||||
# Late-PoR fallback: only after the last segment of a packet (a
|
||||
# concatenated download cannot have produced its PoR before the card got
|
||||
# every part, and polling mid-packet made spurious STATUS exchanges).
|
||||
if (handle_proactive and poll_status and sw == '9000' and submit_handler
|
||||
and not submit_handler.submit_tpdu_hex and not data):
|
||||
if (handle_proactive and poll_status and submit_handler
|
||||
and not submit_handler.submit_tpdu_hex and not data
|
||||
and (sw == '9000' or sw[:2] in ('62', '63'))):
|
||||
# A warning answer (the live card answers 6200 to a low-counter
|
||||
# packet) can hold the PoR too: poll once so the card's verdict is
|
||||
# captured instead of a bare 'ENVELOPE failed' (v3.6.58).
|
||||
sys.stderr.write('STATUS poll (PoR not captured)\n')
|
||||
st_data, st_sw = _send_status(scc)
|
||||
sys.stderr.write('STATUS -> %s\n' % st_sw)
|
||||
@@ -1337,8 +1341,13 @@ def _send_secured_packet(scc, sp_hex, oa_number, sm_sc=None, include_cpi=True,
|
||||
handle_proactive=handle_proactive,
|
||||
poll_status=(i == total - 1))
|
||||
if sw != '9000' and not sw.startswith('91'):
|
||||
# A warning (62xx/63xx, e.g. the live card's 6200 for a low
|
||||
# counter) may still hold the PoR: pass it on so the caller can
|
||||
# name the card's verdict instead of a bare ENVELOPE failure.
|
||||
late_por = _sms_submit_por(submit_handler) if submit_handler else ''
|
||||
return {'success': False, 'sw': sw, 'bytes': len(pkt),
|
||||
'segments': total,
|
||||
'response_data': (data or late_por or None),
|
||||
'error': 'ENVELOPE failed at segment %d' % (i + 1)}
|
||||
return {'success': True, 'bytes': len(pkt), 'segments': total, 'sw': sw,
|
||||
'response_data': data if data else None}
|
||||
@@ -1370,6 +1379,27 @@ def _por_remote_sw(por):
|
||||
return ''
|
||||
|
||||
|
||||
def _cntr_low_fields(status, cntr_hex):
|
||||
"""The card's `cntr_low` verdict as plain data: the card's own counter and
|
||||
the next valid value (the packet counter must be above it). {} when the
|
||||
verdict is not a low counter.
|
||||
|
||||
A low counter is NOT recoverable by retrying: every further packet with a
|
||||
counter <= the card's is rejected again, so operations must stop and the
|
||||
user must raise the preset counter (v3.6.58)."""
|
||||
if str(status or '') != 'cntr_low':
|
||||
return {}
|
||||
card = str(cntr_hex or '').upper()
|
||||
suggested = None
|
||||
if card:
|
||||
try:
|
||||
width = 4 * len(card)
|
||||
suggested = '%0*X' % (len(card), (int(card, 16) + 1) % (1 << width))
|
||||
except ValueError:
|
||||
suggested = None
|
||||
return {'cntr_low': True, 'card_cntr': card, 'suggested_cntr': suggested}
|
||||
|
||||
|
||||
def _ram_next_cntr(cntr, advance):
|
||||
"""Advance the SCP80 counter by one only when the card accepted the
|
||||
packet (PoR ok / no PoR expected): a rejected packet (cntr_low,
|
||||
@@ -6698,6 +6728,25 @@ class PysimHandler(BaseHTTPRequestHandler):
|
||||
if ram_format is not None:
|
||||
resp['ram_format'] = ram_format
|
||||
resp['final_cntr'] = cntr
|
||||
# A warning ENVELOPE may still carry the PoR (the
|
||||
# card's counter verdict): decode it so the caller
|
||||
# sees 'cntr_low' instead of a bare failure.
|
||||
failed_por_hex = result.get('response_data') or ''
|
||||
if not failed_por_hex and submit_handler:
|
||||
failed_por_hex = _sms_submit_por(submit_handler)
|
||||
failed_por = _decode_por(body.get('spi1', ''), body.get('spi2', ''),
|
||||
body.get('kic', ''), body.get('kid', ''),
|
||||
body.get('cntr', ''), body.get('kicKey', ''),
|
||||
body.get('kidKey', ''), failed_por_hex)
|
||||
if failed_por:
|
||||
resp['por'] = failed_por
|
||||
low = _cntr_low_fields(failed_por.get('response_status'),
|
||||
failed_por.get('cntr'))
|
||||
if low:
|
||||
resp.update(low)
|
||||
sys.stderr.write('OTA CNTR-LOW: card counter %s '
|
||||
'(the packet counter must be above it)\n'
|
||||
% low['card_cntr'])
|
||||
sys.stderr.write('OTA SEND FAILED: %s\n' % result.get('error'))
|
||||
else:
|
||||
resp = {'success': True, 'sw': result['sw'],
|
||||
@@ -6728,6 +6777,14 @@ class PysimHandler(BaseHTTPRequestHandler):
|
||||
sys.stderr.write('RAM RESPONSE-PACKET: %s\n' % (por_hex if por_hex else 'empty'))
|
||||
if por:
|
||||
resp['por'] = por
|
||||
low = _cntr_low_fields(por.get('response_status'), por.get('cntr'))
|
||||
if low:
|
||||
# a 9000/91xx ENVELOPE can still carry a
|
||||
# cntr_low PoR (the live card does)
|
||||
resp.update(low)
|
||||
sys.stderr.write('OTA CNTR-LOW: card counter %s '
|
||||
'(the packet counter must be above it)\n'
|
||||
% low['card_cntr'])
|
||||
extra = ''
|
||||
if por.get('decoded'):
|
||||
extra = ' (compact: %s cmd, last SW %s)' % (por['decoded'].get('number_of_commands', '?'),
|
||||
@@ -6895,6 +6952,13 @@ class PysimHandler(BaseHTTPRequestHandler):
|
||||
'load_block_size_requested': block_size_req,
|
||||
'load_block_size_auto': not block_size_req}
|
||||
resp.update(_ram_nv_fields(nv_before, nv_after))
|
||||
failed_rec = steps[-1] if steps else {}
|
||||
resp.update(_cntr_low_fields(failed_rec.get('por_status'),
|
||||
failed_rec.get('por_cntr')))
|
||||
if resp.get('cntr_low'):
|
||||
sys.stderr.write('RAM-INSTALL: low counter - card counter %s, '
|
||||
'the preset counter must be above it\n'
|
||||
% resp.get('card_cntr'))
|
||||
self._send_json(resp)
|
||||
self._log_resp(resp)
|
||||
return
|
||||
@@ -6989,6 +7053,9 @@ class PysimHandler(BaseHTTPRequestHandler):
|
||||
resp['error'] = (state['encode_error'] or state['failure'].get('error')
|
||||
or ('%s failed' % step_name))
|
||||
resp['failed_step'] = len(state['steps'])
|
||||
failed_rec = state['steps'][-1] if state['steps'] else {}
|
||||
resp.update(_cntr_low_fields(failed_rec.get('por_status'),
|
||||
failed_rec.get('por_cntr')))
|
||||
self._send_json(resp)
|
||||
self._log_resp(resp)
|
||||
except Exception as e:
|
||||
@@ -7106,6 +7173,9 @@ class PysimHandler(BaseHTTPRequestHandler):
|
||||
resp['error'] = (state['encode_error'] or state['failure'].get('error')
|
||||
or 'the card rejected the import list')
|
||||
resp['failed_step'] = len(steps)
|
||||
failed_rec = steps[-1] if steps else {}
|
||||
resp.update(_cntr_low_fields(failed_rec.get('por_status'),
|
||||
failed_rec.get('por_cntr')))
|
||||
sys.stderr.write('CAP-COMPAT: %s — imports %s (LOADS to %d/%d)\n' % (
|
||||
loadfile_aid, 'OK' if ok else 'REJECTED', boundary, total_blocks))
|
||||
self._send_json(resp)
|
||||
|
||||
@@ -334,5 +334,98 @@ class RamInstallNvFootprintHttpTests(unittest.TestCase):
|
||||
self.assertNotIn(key, resp)
|
||||
|
||||
|
||||
class CntrLowGuardHttpTests(unittest.TestCase):
|
||||
"""The low-counter guard on the HTTP paths (v3.6.58): /api/send-ota
|
||||
reports the card's verdict as data, and a RAM chain stops at it."""
|
||||
|
||||
def setUp(self):
|
||||
from pysim_simple_server import server as srv
|
||||
self.srv = srv
|
||||
self.server = _build_http_server('127.0.0.1', 0, PysimHandler)
|
||||
self.server.log_requests = False
|
||||
self.server.app = None
|
||||
self.server.sl = None
|
||||
self.server.scc = object()
|
||||
self.server.sms_oa = '12345'
|
||||
self.server.sms_sc = '12345678912'
|
||||
threading.Thread(target=self.server.serve_forever, daemon=True).start()
|
||||
self.port = self.server.server_address[1]
|
||||
|
||||
def tearDown(self):
|
||||
self.server.shutdown()
|
||||
self.server.server_close()
|
||||
|
||||
def _post(self, path, body):
|
||||
req = urllib.request.Request(
|
||||
'http://127.0.0.1:%d%s' % (self.port, path),
|
||||
data=json.dumps(body).encode(),
|
||||
headers={'Content-Type': 'application/json'}, method='POST')
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=5) as res:
|
||||
return res.status, json.loads(res.read() or b'{}')
|
||||
except urllib.error.HTTPError as e:
|
||||
return e.code, json.loads(e.read() or b'{}')
|
||||
|
||||
def test_send_ota_reports_the_low_counter_verdict(self):
|
||||
patches = [
|
||||
mock.patch.object(self.srv, '_send_secured_packet', lambda *a, **k: {
|
||||
'success': True, 'sw': '9000', 'bytes': 34, 'segments': 1,
|
||||
'response_data': '027100000b0a00000000000000bb0002'}),
|
||||
mock.patch.object(self.srv, '_decode_por', lambda *a, **k: {
|
||||
'response_status': 'cntr_low', 'tar': '000000', 'cntr': '00000000BB',
|
||||
'pcntr': 0, 'rpl': 11, 'rhl': 10, 'raw': ''}),
|
||||
]
|
||||
self.server.app = object() # the send path only checks truthiness
|
||||
for p in patches:
|
||||
p.start()
|
||||
try:
|
||||
status, resp = self._post('/api/send-ota', {
|
||||
'sp': '00', 'spi1': '16', 'spi2': '01', 'kic': '15', 'kid': '15',
|
||||
'tar': '000000', 'cntr': '00000000BB'})
|
||||
finally:
|
||||
for p in reversed(patches):
|
||||
p.stop()
|
||||
self.assertEqual(status, 200, resp)
|
||||
self.assertTrue(resp['cntr_low'], resp)
|
||||
self.assertEqual(resp['card_cntr'], '00000000BB')
|
||||
self.assertEqual(resp['suggested_cntr'], '00000000BC')
|
||||
|
||||
def test_the_ram_chain_stops_at_a_low_counter(self):
|
||||
calls = []
|
||||
|
||||
def fake_send(server, scc, sp, state, name, apdu, silent=False):
|
||||
calls.append(name)
|
||||
if len(calls) == 2:
|
||||
# the card's verdict: stop - no further step may be sent
|
||||
state['steps'].append({'name': name, 'por_status': 'cntr_low',
|
||||
'por_cntr': '00000000BB'})
|
||||
return False
|
||||
state['steps'].append({'name': name, 'por_status': 'por_ok', 'por_sw': '9000'})
|
||||
return True
|
||||
|
||||
patches = [
|
||||
mock.patch.object(self.srv, '_cap_parse',
|
||||
lambda cap_hex: ('F0414C46416101', 'F0414C4641610101', b'\x01\x02')),
|
||||
mock.patch.object(self.srv, '_cap_apdu_sequence',
|
||||
lambda *a, **k: ['80E60200', '80E80000', '80E80000', '80E60000']),
|
||||
mock.patch.object(self.srv, '_ram_detect_format', lambda *a, **k: 'compact'),
|
||||
mock.patch.object(self.srv, '_ram_send_gp_apdu', fake_send),
|
||||
mock.patch.object(self.srv, '_ram_read_ff21', lambda *a, **k: None),
|
||||
]
|
||||
for p in patches:
|
||||
p.start()
|
||||
try:
|
||||
status, resp = self._post('/api/ram-install', {'cap_hex': 'AA'})
|
||||
finally:
|
||||
for p in reversed(patches):
|
||||
p.stop()
|
||||
self.assertEqual(status, 200, resp)
|
||||
self.assertFalse(resp['success'], resp)
|
||||
self.assertEqual(len(calls), 2, calls) # nothing after the verdict
|
||||
self.assertTrue(resp['cntr_low'], resp)
|
||||
self.assertEqual(resp['card_cntr'], '00000000BB')
|
||||
self.assertEqual(resp['suggested_cntr'], '00000000BC')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
||||
@@ -32,6 +32,7 @@ from pysim_simple_server.server import (
|
||||
_parse_setup_menu_items,
|
||||
_calc_ud_offset,
|
||||
_find_sms_tpdu,
|
||||
_cntr_low_fields,
|
||||
_parse_proactive_header,
|
||||
_parse_display_text,
|
||||
_tlv_map,
|
||||
@@ -1591,6 +1592,61 @@ class RamSendGpApduLoggingTest(unittest.TestCase):
|
||||
self.assertIn('RAM SECURED-PACKET (LOAD (1/2)): AABB', out)
|
||||
|
||||
|
||||
class CntrLowGuardTests(unittest.TestCase):
|
||||
"""The low-counter guard (v3.6.58): the card's verdict is plain data and a
|
||||
warning ENVELOPE answer still gets its PoR polled."""
|
||||
|
||||
def test_cntr_low_fields(self):
|
||||
self.assertEqual(_cntr_low_fields('cntr_low', '00000000BB'),
|
||||
{'cntr_low': True, 'card_cntr': '00000000BB',
|
||||
'suggested_cntr': '00000000BC'})
|
||||
# the counter field is fixed-width: the wrap keeps it
|
||||
self.assertEqual(_cntr_low_fields('cntr_low', 'FFFFFFFFFF'),
|
||||
{'cntr_low': True, 'card_cntr': 'FFFFFFFFFF',
|
||||
'suggested_cntr': '0000000000'})
|
||||
# only the low-counter verdict counts
|
||||
self.assertEqual(_cntr_low_fields('por_ok', '01'), {})
|
||||
self.assertEqual(_cntr_low_fields('cntr_high', '01'), {})
|
||||
self.assertEqual(_cntr_low_fields(None, None), {})
|
||||
|
||||
def test_a_warning_envelope_answer_polls_for_the_por(self):
|
||||
from pysim_simple_server import server as srv
|
||||
# the live PoR TPDU (response status 02 = cntr_low, card counter BB)
|
||||
tpdu = '410005812143f500f610027100000b0a00000000000000bb0002'
|
||||
fetch = 'd02e8103011300820281838607919733824009f08b1a' + tpdu
|
||||
|
||||
class FakeTp:
|
||||
def __init__(self):
|
||||
self.sent = []
|
||||
self.proactive_handler = None
|
||||
self.status_calls = 0
|
||||
|
||||
def send_apdu(self, apdu):
|
||||
self.sent.append(apdu)
|
||||
if apdu.startswith('80c20000'):
|
||||
return ('', '6200') # the live warning answer
|
||||
if apdu.startswith('80f2000c'):
|
||||
self.status_calls += 1
|
||||
return ('', '9130' if self.status_calls == 1 else '9000')
|
||||
if apdu.startswith('80120000'):
|
||||
return (fetch, '9000')
|
||||
return ('', '9000')
|
||||
|
||||
class FakeScc:
|
||||
cat_cla = '80'
|
||||
|
||||
def __init__(self):
|
||||
self._tp = FakeTp()
|
||||
|
||||
scc = FakeScc()
|
||||
handler = srv.PoRSubmitHandler()
|
||||
_data, sw = srv._send_envelope(tpdu, scc, submit_handler=handler)
|
||||
self.assertEqual(sw, '6200') # the ENVELOPE's own answer
|
||||
self.assertIn('80f2000c00', scc._tp.sent) # the late-PoR poll ran
|
||||
self.assertTrue(handler.submit_tpdu_hex, scc._tp.sent)
|
||||
self.assertIn('bb0002', handler.submit_tpdu_hex.lower())
|
||||
|
||||
|
||||
class ProactiveLongTlvTests(unittest.TestCase):
|
||||
"""BER long-form lengths in proactive command TLVs (v3.6.57).
|
||||
|
||||
|
||||
Reference in New Issue
Block a user