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