Code-review fixes: bad-format visibility, view-aware sync, counter hygiene

- The RAM step line (and the plain send's PoR line) now name a bad-format
  failure ("bad format 02 (wrong length)") instead of reading "PoR ok": the
  step record carries por_bad_format_name and ramStepLine renders it.
- ramSyncCounter follows the view the notice came from ('ram'/'sp' - preset
  and keyset selectors alike) and refreshes the keyset labels after a sync.
- The ETSI-release line no longer hides behind the memory block (it needs
  only the ELF list).
- /api/send-ota reports final_cntr only for a packet the card accepted: the
  failed-envelope path used to persist the packet's own counter on a
  rejection (the audit trail's small bump on cntr_low).
- _ram_detect_format's docstring corrected (both expanded codings execute on
  the live ISD; the earlier bad format was a length-mismatched template).
- Tests: ramStepLine bad format, the view-aware notice, the accepted/rejected
  final_cntr cases, and an end-to-end /api/counter-probe endpoint test
  (400s, no write on a rejected walk, accepted+1 stored).
- sw cache simple-v356.
This commit is contained in:
2026-10-02 07:20:36 +03:00
parent 0f69c6d588
commit e147ab90b4
6 changed files with 181 additions and 43 deletions
+64 -25
View File
@@ -8335,6 +8335,8 @@ async function pysimSendOta() {
if (por && por.response_status) { if (por && por.response_status) {
msg += ' | PoR status: ' + por.response_status + ' (TAR ' + por.tar + ')'; msg += ' | PoR status: ' + por.response_status + ' (TAR ' + por.tar + ')';
if (por.cntr) msg += ' | PoR CNTR: ' + por.cntr; if (por.cntr) msg += ' | PoR CNTR: ' + por.cntr;
if (por.bad_format) msg += ' | ' + t('bad format') + ' ' + por.bad_format +
(por.bad_format_name ? ' (' + por.bad_format_name + ')' : '');
if (por.decoded && por.decoded.last_status_word) msg += ' | last SW ' + por.decoded.last_status_word + ' ' + (por.decoded.last_response_data || ''); if (por.decoded && por.decoded.last_status_word) msg += ' | last SW ' + por.decoded.last_status_word + ' ' + (por.decoded.last_response_data || '');
const okPor = por.response_status === 'por_ok'; const okPor = por.response_status === 'por_ok';
porStatusEl.textContent = 'PoR: ' + por.response_status; porStatusEl.textContent = 'PoR: ' + por.response_status;
@@ -9503,26 +9505,33 @@ function ramDeriveElfVersions(elfs, apps) {
// Render the full explorer result as structured HTML // Render the full explorer result as structured HTML
function ramRenderExploreHtml(mem, isd, apps, elfs, fmt, expanded, elfForm) { function ramRenderExploreHtml(mem, isd, apps, elfs, fmt, expanded, elfForm) {
let html = ''; let html = '';
// The card's ETSI release needs only the ELF list: it shows inside the
// resources box when that exists, and on its own line when the memory
// query failed
const etsi = cardEtsiRelease(elfs);
let etsiHtml = '';
if (etsi) {
let val;
if (etsi.release) val = etsi.release + (etsi.basis ? ' (' + etsi.basis + ')' : '');
else if (etsi.basis) val = t('unknown') + ' (' + etsi.basis + ')';
else val = t('unknown') + ' \u2014 ' + ramElfVersionHint(elfForm);
etsiHtml = '<div title="' + esc(t('Inferred from the card\'s installed uicc.toolkit package version (the same table as the CAP analysis); the expanded registry query provides the versions.')) + '">' +
esc(t('ETSI release:')) + ' ' + esc(val) + '</div>';
}
if (mem && (mem.appCount != null || mem.freeNV != null || mem.freeV != null)) { if (mem && (mem.appCount != null || mem.freeNV != null || mem.freeV != null)) {
html += '<div class="mb-4 p-3 bg-gray-50 dark:bg-slate-800 rounded">'; html += '<div class="mb-4 p-3 bg-gray-50 dark:bg-slate-800 rounded">';
html += '<div class="font-semibold text-sm mb-1" title="' + esc(t('Extended Card Resources (GP tag FF21, ETSI TS 102 226 8.2.1.7.2): installed applications, free non-volatile and free volatile memory')) + '">' + esc(t('Card resources (GET DATA FF21)')) + '</div>'; html += '<div class="font-semibold text-sm mb-1" title="' + esc(t('Extended Card Resources (GP tag FF21, ETSI TS 102 226 8.2.1.7.2): installed applications, free non-volatile and free volatile memory')) + '">' + esc(t('Card resources (GET DATA FF21)')) + '</div>';
html += '<div>' + esc(t('Applications:')) + ' ' + (mem.appCount != null ? mem.appCount : '?') + '</div>'; html += '<div>' + esc(t('Applications:')) + ' ' + (mem.appCount != null ? mem.appCount : '?') + '</div>';
html += '<div>' + esc(t('Free NV:')) + ' ' + (mem.freeNV != null ? mem.freeNV + ' B' : '?') + '</div>'; html += '<div>' + esc(t('Free NV:')) + ' ' + (mem.freeNV != null ? mem.freeNV + ' B' : '?') + '</div>';
html += '<div>' + esc(t('Free Volatile:')) + ' ' + (mem.freeV != null ? mem.freeV + ' B' : '?') + '</div>'; html += '<div>' + esc(t('Free Volatile:')) + ' ' + (mem.freeV != null ? mem.freeV + ' B' : '?') + '</div>';
const etsi = cardEtsiRelease(elfs); html += etsiHtml;
if (etsi) {
let val;
if (etsi.release) val = etsi.release + (etsi.basis ? ' (' + etsi.basis + ')' : '');
else if (etsi.basis) val = t('unknown') + ' (' + etsi.basis + ')';
else val = t('unknown') + ' \u2014 ' + ramElfVersionHint(elfForm);
html += '<div title="' + esc(t('Inferred from the card\'s installed uicc.toolkit package version (the same table as the CAP analysis); the expanded registry query provides the versions.')) + '">' +
esc(t('ETSI release:')) + ' ' + esc(val) + '</div>';
}
if (fmt) { if (fmt) {
html += '<div title="' + esc(t('compact = the TS 102 226 5.2.1 command string (one C-APDU per secured packet, e.g. GET STATUS + GET RESPONSE); expanded = the AA/22 Command Scripting template (5.2.1.0). Detected per operation with a read-only GET STATUS probe; a card that rejects the deprecated compact P2.b2=0 form answers the expanded one.')) + '">' + html += '<div title="' + esc(t('compact = the TS 102 226 5.2.1 command string (one C-APDU per secured packet, e.g. GET STATUS + GET RESPONSE); expanded = the AA/22 Command Scripting template (5.2.1.0). Detected per operation with a read-only GET STATUS probe; a card that rejects the deprecated compact P2.b2=0 form answers the expanded one.')) + '">' +
esc(t('RAM command format:')) + ' ' + esc(fmt) + '</div>'; esc(t('RAM command format:')) + ' ' + esc(fmt) + '</div>';
} }
html += '</div>'; html += '</div>';
} else if (etsiHtml) {
html += '<div class="mb-4 text-xs text-gray-600 dark:text-slate-400">' + etsiHtml + '</div>';
} }
if (expanded) { if (expanded) {
html += '<div class="mb-4 text-xs text-gray-600 dark:text-slate-400">' + esc(expanded) + '</div>'; html += '<div class="mb-4 text-xs text-gray-600 dark:text-slate-400">' + esc(expanded) + '</div>';
@@ -10173,7 +10182,13 @@ function ramStepLine(s, idx, comps, blockSize) {
if (s.bytes) line += ' \u00b7 ' + s.bytes + ' B'; if (s.bytes) line += ' \u00b7 ' + s.bytes + ' B';
if (s.segments > 1) line += ' / ' + s.segments + ' SMS'; if (s.segments > 1) line += ' / ' + s.segments + ' SMS';
} else { } else {
if (s.por_status === 'por_ok') line += t('PoR ok'); if (s.por_bad_format) {
// the card parsed the scripting template but aborted on a
// malformed command (TS 102 226 5.2.2 Bad format TLV): there is no
// remote status word to show
line += t('bad format') + ' ' + s.por_bad_format +
(s.por_bad_format_name ? ' (' + s.por_bad_format_name + ')' : '');
} else if (s.por_status === 'por_ok') line += t('PoR ok');
else if (s.por_status === 'no_por') line += t('no PoR'); else if (s.por_status === 'no_por') line += t('no PoR');
else if (s.por_status && s.por_status.indexOf('por_error') === 0) { else if (s.por_status && s.por_status.indexOf('por_error') === 0) {
const legacy = s.por_status.replace(/^por_error_?/, ''); const legacy = s.por_status.replace(/^por_error_?/, '');
@@ -10519,8 +10534,9 @@ async function ramCompatTest(sp) {
if (probeNote) modal += '<div class="mt-2 text-gray-500 dark:text-slate-400">' + esc(probeNote) + '</div>'; if (probeNote) modal += '<div class="mt-2 text-gray-500 dark:text-slate-400">' + esc(probeNote) + '</div>';
modal += ramCompatDetailHtml(data, mem); modal += ramCompatDetailHtml(data, mem);
if (spCntrLow(data)) { if (spCntrLow(data)) {
modal += ramCntrLowHtml(ramCntrLowPresetIdx()); const v = ramCntrLowView();
ramShowCntrLow(); modal += ramCntrLowHtml(ramCntrLowPresetIdx(v), v);
ramShowCntrLow(undefined, v);
} }
ramOpModalFinish(verdict, !!data.imports_ok, modal, true); ramOpModalFinish(verdict, !!data.imports_ok, modal, true);
} }
@@ -10880,27 +10896,42 @@ function ramNvLine(data) {
// The preset the failed operation used (the RAM form's selection, else the // The preset the failed operation used (the RAM form's selection, else the
// SCP80 form's) - the "Go to preset" target. // SCP80 form's) - the "Go to preset" target.
function ramCntrLowPresetIdx() { // The view a low-counter notice came from ('ram' | 'sp'): the notice's
// actions (sync probe, preset jump) must follow the failed operation's view -
// the two views can sit on different presets.
function ramCntrLowView() {
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;
};
return pick('ram-card-sel') >= 0 ? 'ram' : 'sp';
}
function ramCntrLowPresetIdx(view) {
const pick = id => { const pick = id => {
const el = document.getElementById(id); const el = document.getElementById(id);
const i = el && el.value !== '' ? parseInt(el.value, 10) : NaN; const i = el && el.value !== '' ? parseInt(el.value, 10) : NaN;
return (!isNaN(i) && cards[i]) ? i : -1; return (!isNaN(i) && cards[i]) ? i : -1;
}; };
const ram = pick('ram-card-sel'); const ram = pick('ram-card-sel');
return ram >= 0 ? ram : pick('sp-card-sel'); const sp = pick('sp-card-sel');
if (view === 'sp') return sp >= 0 ? sp : ram;
return ram >= 0 ? ram : sp;
} }
// Low-counter notice (pure HTML): the facts plus two actions - a bounded // Low-counter notice (pure HTML): the facts plus two actions - a bounded
// counter sync probe and a button that opens the failed operation's preset in // counter sync probe and a button that opens the failed operation's preset in
// the Cards tab, where the counter can be raised by hand. // the Cards tab, where the counter can be raised by hand.
function ramCntrLowHtml(presetIdx) { function ramCntrLowHtml(presetIdx, view) {
const idx = Number.isInteger(presetIdx) && presetIdx >= 0 ? presetIdx : -1; const idx = Number.isInteger(presetIdx) && presetIdx >= 0 ? presetIdx : -1;
const v = view === 'sp' ? 'sp' : 'ram';
const text = t('Low counter') + ' \u2014 ' + t('the card rejected the packet'); const text = t('Low counter') + ' \u2014 ' + t('the card rejected the packet');
const btnCls = 'px-2 py-1 text-xs rounded border border-red-300 dark:border-red-700 ' + const btnCls = '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'; 'hover:bg-red-100 dark:hover:bg-red-900/40 whitespace-nowrap';
return '<div class="flex items-center gap-3 text-sm text-red-600 dark:text-red-400">' + return '<div class="flex items-center gap-3 text-sm text-red-600 dark:text-red-400">' +
'<span>' + text + '</span>' + '<span>' + text + '</span>' +
'<button type="button" onclick="ramSyncCounter(' + idx + ')" class="' + btnCls + '">' + '<button type="button" onclick="ramSyncCounter(' + idx + ', \'' + v + '\')" class="' + btnCls + '">' +
esc(t('Sync counter')) + '</button>' + esc(t('Sync counter')) + '</button>' +
'<button type="button" onclick="ramGoToPreset(' + idx + ')" class="' + btnCls + '">' + '<button type="button" onclick="ramGoToPreset(' + idx + ')" class="' + btnCls + '">' +
esc(t('Go to preset')) + '</button></div>'; esc(t('Go to preset')) + '</button></div>';
@@ -10908,9 +10939,10 @@ function ramCntrLowHtml(presetIdx) {
// Show the low-counter notice in the RAM result area (and, when the operation // Show the low-counter notice in the RAM result area (and, when the operation
// modal is still open, in its detail block). // modal is still open, in its detail block).
function ramShowCntrLow(presetIdx) { function ramShowCntrLow(presetIdx, view) {
const idx = presetIdx === undefined ? ramCntrLowPresetIdx() : presetIdx; const v = view === undefined ? ramCntrLowView() : view;
const html = ramCntrLowHtml(idx); const idx = presetIdx === undefined ? ramCntrLowPresetIdx(v) : presetIdx;
const html = ramCntrLowHtml(idx, v);
const el = document.getElementById('ram-cntr-low'); const el = document.getElementById('ram-cntr-low');
if (el && el.classList) { if (el && el.classList) {
el.innerHTML = html; el.innerHTML = html;
@@ -10927,21 +10959,24 @@ function ramShowCntrLow(presetIdx) {
// +4, ...) until the card accepts one, stops at a 32-bit ceiling and persists // +4, ...) until the card accepts one, stops at a 32-bit ceiling and persists
// accepted+1. A rejected attempt changes nothing on the card, so only the // accepted+1. A rejected attempt changes nothing on the card, so only the
// accepted packet moves its counter (the card never reports its value). // accepted packet moves its counter (the card never reports its value).
async function ramSyncCounter(presetIdx) { async function ramSyncCounter(presetIdx, view) {
const i = Number(presetIdx); const i = Number(presetIdx);
const preset = (Number.isInteger(i) && i >= 0 && cards[i]) ? cards[i] : null; const preset = (Number.isInteger(i) && i >= 0 && cards[i]) ? cards[i] : null;
const box = document.getElementById('ram-cntr-low'); const box = document.getElementById('ram-cntr-low');
if (!preset) { alert(t('No card preset selected')); return; } if (!preset) { alert(t('No card preset selected')); return; }
// The keyset the failed operation used: the RAM form's selector, else the // The keyset the failed operation used: the originating view's selector
// SCP80 form's (both hold the keyset number as their option value). // first, the other view's as the fallback (both hold the keyset number as
// their option value).
const kvnOf = id => { const kvnOf = id => {
const sel = document.getElementById(id); const sel = document.getElementById(id);
const v = sel && sel.value !== '' ? parseInt(sel.value, 10) : NaN; const v = sel && sel.value !== '' ? parseInt(sel.value, 10) : NaN;
return Number.isInteger(v) && v > 0 ? v : null; return Number.isInteger(v) && v > 0 ? v : null;
}; };
const own = view === 'sp' ? 'sp-keyset-sel' : 'ram-keyset-sel';
const other = own === 'ram-keyset-sel' ? 'sp-keyset-sel' : 'ram-keyset-sel';
const val = id => (document.getElementById(id) || {}).value || ''; const val = id => (document.getElementById(id) || {}).value || '';
const body = { preset_id: preset.id }; const body = { preset_id: preset.id };
const kvn = kvnOf('ram-keyset-sel') || kvnOf('sp-keyset-sel'); const kvn = kvnOf(own) || kvnOf(other);
if (kvn) body.kvn = kvn; if (kvn) body.kvn = kvn;
const tar = val('sp-tar').replace(/[^0-9a-fA-F]/g, '').toUpperCase(); const tar = val('sp-tar').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
if (tar) body.tar = tar; if (tar) body.tar = tar;
@@ -10955,6 +10990,8 @@ async function ramSyncCounter(presetIdx) {
const data = await pysimFetch('/api/counter-probe', body); const data = await pysimFetch('/api/counter-probe', body);
if (data.success) { if (data.success) {
await cardsFetch(); await cardsFetch();
// the keyset selects' labels carry the counter - refresh them too
spKeysetSync();
const cntrEl = document.getElementById('sp-cntr'); const cntrEl = document.getElementById('sp-cntr');
if (cntrEl && data.stored_cntr) cntrEl.value = data.stored_cntr; if (cntrEl && data.stored_cntr) cntrEl.value = data.stored_cntr;
const msg = t('Counter synced') + ': ' + esc(data.accepted_cntr) + ' \u2192 ' + const msg = t('Counter synced') + ': ' + esc(data.accepted_cntr) + ' \u2192 ' +
@@ -11037,8 +11074,9 @@ function ramShowInstallResult(data, okText, capMem) {
// low counter: not recoverable by retrying - state it and offer the // low counter: not recoverable by retrying - state it and offer the
// bounded sync probe or the preset jump (v3.6.58) // bounded sync probe or the preset jump (v3.6.58)
if (spCntrLow(data)) { if (spCntrLow(data)) {
modal += ramCntrLowHtml(ramCntrLowPresetIdx()); const v = ramCntrLowView();
ramShowCntrLow(); modal += ramCntrLowHtml(ramCntrLowPresetIdx(v), v);
ramShowCntrLow(undefined, v);
} }
ramOpModalFinish(resultEl.textContent, !!data.success, modal, true); ramOpModalFinish(resultEl.textContent, !!data.success, modal, true);
} }
@@ -19644,6 +19682,7 @@ const LANG_RU = {
'Free NV:': 'Свободно NV:', 'Free NV:': 'Свободно NV:',
'ETSI release:': 'Релиз ETSI:', 'ETSI release:': 'Релиз ETSI:',
'unknown': 'неизвестно', 'unknown': 'неизвестно',
'bad format': 'неверный формат',
'Inferred from the card\'s installed uicc.toolkit package version (the same table as the CAP analysis); the expanded registry query provides the versions.': 'Определяется по версии установленного на карте пакета uicc.toolkit (та же таблица, что и в анализе CAP); версии даёт расширенный запрос реестра.', 'Inferred from the card\'s installed uicc.toolkit package version (the same table as the CAP analysis); the expanded registry query provides the versions.': 'Определяется по версии установленного на карте пакета uicc.toolkit (та же таблица, что и в анализе CAP); версии даёт расширенный запрос реестра.',
'Free Volatile:': 'Свободно volatile:', 'Free Volatile:': 'Свободно volatile:',
'AID:': 'AID:', 'AID:': 'AID:',
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'simple-v355'; const CACHE = 'simple-v356';
const URLS = [ const URLS = [
'index.html', 'index.html',
'help.html', 'help.html',
+23 -5
View File
@@ -27,7 +27,7 @@ const FNS = ['berLenStr', 'buildApdu', 'escHtml', 'esc', 'chainInit', 'chainRamB
'stkParamsBuild', 'ramRemoteSwOk', 'spPorAccepted', 'ramIncrementCntr', 'ramDeleteFromExplorer', 'ramListingSpi2', 'ramRemoveFromExplorer', 'ramHasInstance', 'ramExpandedQueryApdu', 'stkParamsBuild', 'ramRemoteSwOk', 'spPorAccepted', 'ramIncrementCntr', 'ramDeleteFromExplorer', 'ramListingSpi2', 'ramRemoveFromExplorer', 'ramHasInstance', 'ramExpandedQueryApdu',
'_parseRawElfEntry', '_parseRawAppEntry', 'ramParseElfStatus', 'ramParseAppStatus', 'ramParseModuleAids', 'parseTLV', '_parseE3Entry', '_parseMenuEntries', '_parseRawElfEntry', '_parseRawAppEntry', 'ramParseElfStatus', 'ramParseAppStatus', 'ramParseModuleAids', 'parseTLV', '_parseE3Entry', '_parseMenuEntries',
'ramExpandedReport', 'ramExpandedMore', 'ramExpandedTags', 'ramExpandedGroups', 'ramExpandedElfForm', 'ramElfVersionHint', 'cardEtsiRelease', 'ramChainGetResponse', 'ramDeriveElfVersions', 'ramElfAppletCandidate', 'ramExpandedReport', 'ramExpandedMore', 'ramExpandedTags', 'ramExpandedGroups', 'ramExpandedElfForm', 'ramElfVersionHint', 'cardEtsiRelease', 'ramChainGetResponse', 'ramDeriveElfVersions', 'ramElfAppletCandidate',
'spCntrLow', 'ramCntrLowHtml', 'ramCntrLowPresetIdx', 'ramShowCntrLow', 'spCntrLow', 'ramCntrLowHtml', 'ramCntrLowPresetIdx', 'ramCntrLowView', 'ramShowCntrLow',
'spKeysetKvnOf', 'spKeysetList', 'spKeysetFor', 'spKeysetCheck', 'spKeysetKvnOf', 'spKeysetList', 'spKeysetFor', 'spKeysetCheck',
'spKeysetOptionsHtml', 'spPresetIdx', 'tarPresetIdx', 'spKeysetOptionsHtml', 'spPresetIdx', 'tarPresetIdx',
'cardsKeysetsFromForm', 'spKeysetSync', 'spKeysetApply', 'spKeysetChanged', 'cardsKeysetsFromForm', 'spKeysetSync', 'spKeysetApply', 'spKeysetChanged',
@@ -852,18 +852,21 @@ test('ramCntrLowHtml states the facts without a fabricated counter', () => {
globalThis.t = s => s; globalThis.t = s => s;
const out = ramCntrLowHtml(2); const out = ramCntrLowHtml(2);
const noPreset = ramCntrLowHtml(-1); const noPreset = ramCntrLowHtml(-1);
// the notice follows the failed operation's view ('sp' -> its own keyset)
const spView = ramCntrLowHtml(1, 'sp');
delete globalThis.t; delete globalThis.t;
assert.ok(out.includes('Low counter \u2014 the card rejected the packet'), out); assert.ok(out.includes('Low counter \u2014 the card rejected the packet'), out);
// the PoR's CNTR is a copy of the command's counter, never the card's: no // the PoR's CNTR is a copy of the command's counter, never the card's: no
// value may be shown (v3.9.x), only the verdict and the two actions // value may be shown (v3.9.x), only the verdict and the two actions
assert.ok(!out.includes('card counter'), out); assert.ok(!out.includes('card counter'), out);
assert.ok(out.includes('onclick="ramSyncCounter(2)"'), out); assert.ok(out.includes('onclick="ramSyncCounter(2, \'ram\')"'), out);
assert.ok(out.includes('Sync counter'), out); assert.ok(out.includes('Sync counter'), out);
assert.ok(out.includes('onclick="ramGoToPreset(2)"'), out); assert.ok(out.includes('onclick="ramGoToPreset(2)"'), out);
assert.ok(out.includes('Go to preset'), out); assert.ok(out.includes('Go to preset'), out);
// without a known preset the buttons still work (-1) // without a known preset the buttons still work (-1)
assert.ok(noPreset.includes('onclick="ramGoToPreset(-1)"'), noPreset); assert.ok(noPreset.includes('onclick="ramGoToPreset(-1)"'), noPreset);
assert.ok(noPreset.includes('onclick="ramSyncCounter(-1)"'), noPreset); assert.ok(noPreset.includes('onclick="ramSyncCounter(-1, \'ram\')"'), noPreset);
assert.ok(spView.includes('onclick="ramSyncCounter(1, \'sp\')"'), spView);
}); });
test('the explorer shows versions on app and ISD rows too', () => { test('the explorer shows versions on app and ISD rows too', () => {
@@ -877,6 +880,19 @@ test('the explorer shows versions on app and ISD rows too', () => {
assert.ok(out.includes('Version: 9.3'), out); assert.ok(out.includes('Version: 9.3'), out);
}); });
test('ramStepLine names a bad-format failure instead of "PoR ok"', () => {
globalThis.t = s => s;
const line = ramStepLine({ name: 'P2=02 (lean)', por_status: 'por_ok',
por_error: 'bad format 02 (wrong length)', por_bad_format: '02',
por_bad_format_name: 'wrong length' }, 0);
const plain = ramStepLine({ name: 'LOAD', por_status: 'por_ok', por_sw: '9000' }, 1);
delete globalThis.t;
assert.ok(line.includes('bad format 02 (wrong length)'), line);
assert.ok(!line.includes('PoR ok'), line);
// a healthy step is unchanged
assert.ok(plain.includes('9000') || plain.includes('PoR ok'), plain);
});
test('the Explore retries the ELF tag lists with P1=20 and per applet AID', () => { test('the Explore retries the ELF tag lists with P1=20 and per applet AID', () => {
const src = extractFunc(html, 'ramExplore'); const src = extractFunc(html, 'ramExplore');
assert.ok(src.includes('elfTagListRetry'), 'the P1=20 tag-list retry must exist'); assert.ok(src.includes('elfTagListRetry'), 'the P1=20 tag-list retry must exist');
@@ -908,9 +924,11 @@ test('every SCP80/RAM flow checks the low counter and stops', () => {
// preset, the keyset the failed operation used and its SPI/TAR // preset, the keyset the failed operation used and its SPI/TAR
const sync = extractFunc(html, 'ramSyncCounter'); const sync = extractFunc(html, 'ramSyncCounter');
assert.ok(sync.includes("'/api/counter-probe'"), sync); assert.ok(sync.includes("'/api/counter-probe'"), sync);
assert.ok(sync.includes("kvnOf('ram-keyset-sel')"), sync); assert.ok(sync.includes("own = view === 'sp'"), sync);
assert.ok(sync.includes("kvnOf('sp-keyset-sel')"), sync); assert.ok(sync.includes('kvnOf(own)'), sync);
assert.ok(sync.includes('kvnOf(other)'), sync);
assert.ok(sync.includes("getElementById('sp-cntr')"), sync); assert.ok(sync.includes("getElementById('sp-cntr')"), sync);
assert.ok(sync.includes('spKeysetSync()'), 'the keyset labels must refresh');
}); });
test('the RAM form offers the command format choice', () => { test('the RAM form offers the command format choice', () => {
+1 -1
View File
@@ -30,7 +30,7 @@ let code = '';
for (const fn of ['berLenStr', 'ramDeleteApdu', 'ramIncrementCntr', 'ramSaveCntr', for (const fn of ['berLenStr', 'ramDeleteApdu', 'ramIncrementCntr', 'ramSaveCntr',
'getRamSpParams', 'spPorAccepted', 'ramRemoteSwOk', 'ramShowProgress', 'getRamSpParams', 'spPorAccepted', 'ramRemoteSwOk', 'ramShowProgress',
'ramHideProgress', 'ramDeleteFromExplorer', 'ramHideProgress', 'ramDeleteFromExplorer',
'spCntrLow', 'ramCntrLowHtml', 'ramCntrLowPresetIdx', 'ramShowCntrLow', 'spCntrLow', 'ramCntrLowHtml', 'ramCntrLowPresetIdx', 'ramCntrLowView', 'ramShowCntrLow',
'escHtml', 'esc', 'escHtml', 'esc',
'spKeysetKvnOf', 'spKeysetList', 'spKeysetFor', 'spKeysetCheck', 'spKeysetKvnOf', 'spKeysetList', 'spKeysetFor', 'spKeysetCheck',
'spKeysetOptionsHtml', 'cardsKeysetRowHtml', 'cardsKeysetRowsRender', 'spKeysetOptionsHtml', 'cardsKeysetRowHtml', 'cardsKeysetRowsRender',
+25 -11
View File
@@ -1553,10 +1553,11 @@ def _ram_detect_format(server, scc, sp, state):
"""Detect the card's RAM command format for one operation: send the """Detect the card's RAM command format for one operation: send the
read-only GET STATUS [ISD] probe compact, then in the two expanded read-only GET STATUS [ISD] probe compact, then in the two expanded
codings; the first format whose remote SW succeeds wins (compact codings; the first format whose remote SW succeeds wins (compact
preferred). Cards implement different subsets - the live ISD answers the preferred). Cards implement different subsets: the reference TCA loader
definite form with a Bad format TLV (wrong length) and executes the sends the listing queries wrapped and unchained, while the live card needs
indefinite one, while an RFM applet took the definite form. Probe the compact chain (a length-mismatched template draws a Bad format TLV,
packets are recorded as steps and consume counters only when accepted; which the step result names). Probe packets are recorded as steps and
consume counters only when accepted;
the result is used for this operation only - the next operation re-checks the result is used for this operation only - the next operation re-checks
(v3.6.24).""" (v3.6.24)."""
for fmt, apdu in (('compact', _RAM_PROBE_APDU), for fmt, apdu in (('compact', _RAM_PROBE_APDU),
@@ -1579,7 +1580,8 @@ def _ram_detect_format(server, scc, sp, state):
# accepted, so rejected attempts change nothing; the search walks a doubling # accepted, so rejected attempts change nothing; the search walks a doubling
# ladder (start+1, +2, +4, ...) and stops at a ceiling far below the 40-bit # ladder (start+1, +2, +4, ...) and stops at a ceiling far below the 40-bit
# maximum, where the counter would get blocked. # maximum, where the counter would get blocked.
COUNTER_PROBE_DEFAULT_APDU = '00A40000023F00' # SELECT MF: read-only, applet-visible COUNTER_PROBE_DEFAULT_APDU = '00A40000023F00' # SELECT MF, as TAR_PROBE_DEFAULT_APDU
# (a literal: that constant is defined later)
COUNTER_PROBE_CEILING = 'FFFFFFFF' # 32-bit default COUNTER_PROBE_CEILING = 'FFFFFFFF' # 32-bit default
COUNTER_PROBE_MAX_ATTEMPTS = 40 # doubling covers 40 bits COUNTER_PROBE_MAX_ATTEMPTS = 40 # doubling covers 40 bits
@@ -1810,6 +1812,7 @@ def _ram_step_result(step_name, last_sw, por, por_hex, bytes_, segments):
# the expanded script was rejected as malformed before executing: # the expanded script was rejected as malformed before executing:
# there is no remote status word # there is no remote status word
step['por_bad_format'] = por['bad_format'] step['por_bad_format'] = por['bad_format']
step['por_bad_format_name'] = por.get('bad_format_name')
error = 'bad format %s (%s)' % (por['bad_format'], error = 'bad format %s (%s)' % (por['bad_format'],
por.get('bad_format_name') or '') por.get('bad_format_name') or '')
elif pstatus != 'por_ok': elif pstatus != 'por_ok':
@@ -3793,7 +3796,8 @@ def _build_tr(scc, cmd_num, cmd_type, dev_src, dev_dst, cmd_qual, dt=None):
The object order follows TS 102 223 6.8.0: command details, device The object order follows TS 102 223 6.8.0: command details, device
identities, Result, then the command-specific data objects (the echoed identities, Result, then the command-specific data objects (the echoed
Duration for POLL INTERVAL, the PLI data for PROVIDE LOCAL INFORMATION).""" Duration for POLL INTERVAL, the PLI data for PROVIDE LOCAL INFORMATION).
`dt` is a test seam for the PLI date/time default (see _pli_data_hex)."""
base = bytes([0x81, 0x03, cmd_num, cmd_type, 0x00, base = bytes([0x81, 0x03, cmd_num, cmd_type, 0x00,
0x82, 0x02, dev_dst, dev_src, 0x82, 0x02, dev_dst, dev_src,
0x03, 0x01, 0x00]) 0x03, 0x01, 0x00])
@@ -7275,8 +7279,6 @@ class PysimHandler(BaseHTTPRequestHandler):
resp = result resp = result
if ram_format is not None: if ram_format is not None:
resp['ram_format'] = ram_format resp['ram_format'] = ram_format
if _counter_valid(cntr) and counter_tracked:
resp['final_cntr'] = cntr
# A warning ENVELOPE may still carry the PoR (the # A warning ENVELOPE may still carry the PoR (the
# card's counter verdict): decode it so the caller # card's counter verdict): decode it so the caller
# sees 'cntr_low' instead of a bare failure. # sees 'cntr_low' instead of a bare failure.
@@ -7294,6 +7296,15 @@ class PysimHandler(BaseHTTPRequestHandler):
resp.update(low) resp.update(low)
sys.stderr.write('OTA CNTR-LOW: the packet counter is not ' sys.stderr.write('OTA CNTR-LOW: the packet counter is not '
'above the card\'s\n') 'above the card\'s\n')
# only a packet the card accepted consumes the
# counter (a warning ENVELOPE can still carry a
# por_ok PoR); a rejected one must leave the store
# untouched (v3.9.x - it used to persist the
# packet's own counter here)
if (_counter_valid(cntr) and counter_tracked and
failed_por.get('response_status') in
('por_ok', 'actual_response_sms_submit')):
resp['final_cntr'] = _ram_next_cntr(cntr, True)
sys.stderr.write('OTA SEND FAILED: %s\n' % result.get('error')) sys.stderr.write('OTA SEND FAILED: %s\n' % result.get('error'))
else: else:
resp = {'success': True, 'sw': result['sw'], resp = {'success': True, 'sw': result['sw'],
@@ -7358,9 +7369,12 @@ class PysimHandler(BaseHTTPRequestHandler):
# (v3.8.0). A pre-built packet may come without a # (v3.8.0). A pre-built packet may come without a
# counter, and a packet whose SPI1 does not ask the card # counter, and a packet whose SPI1 does not ask the card
# to check it has none to track - nothing is reported # to check it has none to track - nothing is reported
# then (and int('') never happens). # then (and int('') never happens). A rejected verdict
if _counter_valid(cntr) and counter_tracked: # (cntr_low, PoR error) reports nothing either: the
resp['final_cntr'] = _ram_next_cntr(cntr, accepted) # store must not move on a packet the card did not
# consume.
if _counter_valid(cntr) and counter_tracked and accepted:
resp['final_cntr'] = _ram_next_cntr(cntr, True)
finally: finally:
if submit_handler and hasattr(scc, '_tp'): if submit_handler and hasattr(scc, '_tp'):
scc._tp.proactive_handler = old_proactive scc._tp.proactive_handler = old_proactive
+67
View File
@@ -393,6 +393,31 @@ class CntrLowGuardHttpTests(unittest.TestCase):
# the verdict is reported without a value (v3.9.x) # the verdict is reported without a value (v3.9.x)
self.assertNotIn('card_cntr', resp) self.assertNotIn('card_cntr', resp)
self.assertNotIn('suggested_cntr', resp) self.assertNotIn('suggested_cntr', resp)
# a rejected packet reports no counter to use next either (v3.9.x):
# the store must not move on a packet the card did not consume
self.assertNotIn('final_cntr', resp)
def test_send_ota_reports_the_next_counter_only_when_accepted(self):
patches = [
mock.patch.object(self.srv, '_send_secured_packet', lambda *a, **k: {
'success': True, 'sw': '9000', 'bytes': 34, 'segments': 1,
'response_data': '027100000b0a00000000000000bb0000'}),
mock.patch.object(self.srv, '_decode_por', lambda *a, **k: {
'response_status': 'por_ok', '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.assertEqual(resp['final_cntr'], '00000000BC')
def test_the_ram_chain_stops_at_a_low_counter(self): def test_the_ram_chain_stops_at_a_low_counter(self):
calls = [] calls = []
@@ -517,6 +542,48 @@ class PresetStoreHttpTests(unittest.TestCase):
self.assertEqual(resp['presets'], []) self.assertEqual(resp['presets'], [])
self.assertEqual(resp['path'], str(self.store.path)) self.assertEqual(resp['path'], str(self.store.path))
def test_counter_probe_endpoint(self):
status, resp = self._post('/api/presets', self._preset(iccid='8970119000004600098'))
self.assertEqual(status, 200, resp)
pid = resp['preset']['id']
status, resp = self._post('/api/counter-probe', {})
self.assertEqual(status, 400, resp)
self.assertIn('preset_id', resp['error'])
status, resp = self._post('/api/counter-probe', {'preset_id': 'nope'})
self.assertEqual(status, 400, resp)
self.assertIn('unknown preset', resp['error'])
# a walk that never gets accepted leaves the store untouched
with mock.patch.object(self.srv, '_build_secured_packet',
lambda *a, **k: ('00' * 20, {})), \
mock.patch.object(self.srv, '_send_secured_packet', lambda *a, **k: {
'success': True, 'sw': '9000', 'bytes': 34, 'segments': 1,
'response_data': '027100000b0a00000000000000000000'}), \
mock.patch.object(self.srv, '_decode_por', lambda *a, **k: {
'response_status': 'cntr_low', 'tar': '000000', 'cntr': '0000000000',
'pcntr': 0, 'rpl': 11, 'rhl': 10, 'raw': ''}):
status, resp = self._post('/api/counter-probe',
{'preset_id': pid, 'max_attempts': 2})
self.assertEqual(status, 200, resp)
self.assertFalse(resp['success'], resp)
self.assertEqual(resp['packets'], 2)
self.assertEqual(resp['stopped'], 'attempts')
self.assertEqual(self.store.get(pid)['keysets'][0]['cntr'], '0000000001')
# an accepted walk stores accepted+1 (the first ladder value)
with mock.patch.object(self.srv, '_build_secured_packet',
lambda *a, **k: ('00' * 20, {})), \
mock.patch.object(self.srv, '_send_secured_packet', lambda *a, **k: {
'success': True, 'sw': '9000', 'bytes': 34, 'segments': 1,
'response_data': '027100000b0a00000000000000000000'}), \
mock.patch.object(self.srv, '_decode_por', lambda *a, **k: {
'response_status': 'por_ok', 'tar': '000000', 'cntr': '0000000002',
'pcntr': 0, 'rpl': 11, 'rhl': 10, 'raw': ''}):
status, resp = self._post('/api/counter-probe', {'preset_id': pid})
self.assertEqual(status, 200, resp)
self.assertTrue(resp['success'], resp)
self.assertEqual(resp['accepted_cntr'], '0000000002')
self.assertEqual(resp['stored_cntr'], '0000000003')
self.assertEqual(self.store.get(pid)['keysets'][0]['cntr'], '0000000003')
def test_create_update_delete_round_trip(self): def test_create_update_delete_round_trip(self):
status, resp = self._post('/api/presets', self._preset(iccid='8970119000004600098')) status, resp = self._post('/api/presets', self._preset(iccid='8970119000004600098'))
self.assertEqual(status, 200, resp) self.assertEqual(status, 200, resp)