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:
@@ -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