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
+56
View File
@@ -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).