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:
2026-09-30 11:10:54 +03:00
parent 81a7aace15
commit 162930eb96
10 changed files with 423 additions and 14 deletions
+47
View File
@@ -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');