Low-counter reporting, counter sync probe, Bad-format decode, expanded forms

Live findings that drove this: the "card counter 0000000000" the low-counter
notice showed is not the card's counter at all (the PoR's CNTR is a copy of
the command's counter, TS 102 225 5.2 table 3, and a rejection leaves it
zeroed); and an earlier "no card accepts the expanded format" observation was
a hand-made template whose declared length did not match its body - the card
validates the lengths and answers with a Bad format TLV.

- _cntr_low_fields(status) reports the verdict as a flag; card_cntr/
  suggested_cntr and the UI's "(card counter ...)" text are gone - the value
  is not observable.  The notice offers a bounded Sync counter probe instead.
- POST /api/counter-probe: a doubling counter ladder (start+1, +2, +4, ...)
  with a read-only command until the card accepts one, then accepted+1 is
  persisted (monotonic, source counter-probe).  Rejected attempts change
  nothing on the card; the search stops at a ceiling (default FFFFFFFF, far
  below the 40-bit maximum where the counter blocks) and after max_attempts
  (default 40, clamped 1-64); a non-cntr_low failure stops it at once.
- _parse_response_scripting decodes the Bad format TLV (90, TS 101 220 table
  7.20; error types per TS 102 226 table 5.12) and returns a dict; the PoR
  carries bad_format/bad_format_name and _ram_step_result fails the step with
  "bad format 02 (wrong length)" instead of falling through to the compact
  decoder (which read the AB tag as 171 commands).
- _wrap_expanded_apdu(apdu, form) emits the definite (AA) or the indefinite
  (AE 80 ... 00 00) scripting template; the RAM command formats are compact/
  expanded/expanded-ae and _ram_detect_format probes compact, then both
  expanded codings (the expanded probes use Le='00' and no chained GET
  RESPONSE, TS 102 226 5.2.1.1).  The RAM form gains a Format selector.
- Live-verified: the verdict carries no value; the probe walked
  0x1000001/0x1000003 (low) then 0x1000007 (accepted, stored 0x1000008); the
  ISD executes both expanded codings (R-APDU 08A0000000030000000F80, SW 9000)
  and a length-mismatched template answers bad_format 02 - decoded now.
- Tests: the ladder, the probe walk + persistence, params validation, both
  template forms, the Bad-format TLV (definite and plain tag styles), the
  live ISD response through _decode_por, the step-result failure, and the PWA
  guards; help EN/RU, docs/api.md, the repo AGENTS.md; sw cache simple-v352.
This commit is contained in:
2026-10-02 00:15:40 +03:00
parent ad2a253f2a
commit 391d4a322d
10 changed files with 683 additions and 160 deletions
+169 -14
View File
@@ -33,6 +33,10 @@ from pysim_simple_server.server import (
_calc_ud_offset,
_find_sms_tpdu,
_cntr_low_fields,
_counter_probe,
_counter_probe_candidates,
_counter_probe_params,
_ram_normalize_format,
_parse_proactive_header,
_parse_display_text,
_tlv_map,
@@ -1653,20 +1657,32 @@ class ResponseScriptingTest(unittest.TestCase):
data = bytes([0xAB, len(body)]) + body
scripted = _parse_response_scripting(data)
self.assertIsNotNone(scripted)
count, sw, listing = scripted
self.assertEqual((count, sw), (1, '9000'))
self.assertEqual((scripted['count'], scripted['sw']), (1, '9000'))
listing = scripted['data']
self.assertTrue(listing.startswith('10A1130001'), listing[:20])
self.assertIn('F0414C46416101', listing)
def test_indefinite_template_and_plain_data(self):
body = bytes([0x80, 0x01, 0x02, 0x23, 0x04, 0xAA, 0xBB, 0x90, 0x00])
data = bytes([0xAF, 0x80]) + body + b'\x00\x00'
count, sw, listing = _parse_response_scripting(data)
self.assertEqual((count, sw, listing), (2, '9000', 'AABB'))
r = _parse_response_scripting(data)
self.assertEqual((r['count'], r['sw'], r['data']), (2, '9000', 'AABB'))
# a compact response is not a scripting template
self.assertIsNone(_parse_response_scripting(bytes.fromhex('027100000263100BD2')))
self.assertIsNone(_parse_response_scripting(b''))
def test_bad_format_tlv(self):
# the live ISD's answer to a definite-length scripting template: zero
# executed commands plus the Bad format TLV, error type 02 = wrong
# length (TS 102 226 5.2.2, table 5.12)
r = _parse_response_scripting(bytes.fromhex('ab06800100900102'))
self.assertEqual(r, {'count': 0, 'sw': None, 'data': '', 'bad_format': '02'})
# the plain (CR-flag cleared) tag style is accepted like everywhere else
self.assertEqual(_parse_response_scripting(bytes.fromhex('ab06800100100102'))['bad_format'],
'02')
# a plain R-APDU template carries no bad format
self.assertIsNone(_parse_response_scripting(bytes.fromhex('ab0780010123029000'))['bad_format'])
class RamSendGpApduLoggingTest(unittest.TestCase):
@@ -1709,17 +1725,13 @@ class CntrLowGuardTests(unittest.TestCase):
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'})
# the verdict is a flag: the card's counter is not derivable from the
# PoR, whose CNTR is a copy of the command's counter (TS 102 225 5.2)
self.assertEqual(_cntr_low_fields('cntr_low'), {'cntr_low': True})
# 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), {})
self.assertEqual(_cntr_low_fields('por_ok'), {})
self.assertEqual(_cntr_low_fields('cntr_high'), {})
self.assertEqual(_cntr_low_fields(None), {})
def test_a_warning_envelope_answer_polls_for_the_por(self):
from pysim_simple_server import server as srv
@@ -2233,5 +2245,148 @@ class NoSecurityAndCounterTrackingTests(unittest.TestCase):
store.set_counter.assert_called_once_with('pid', '0000000002', 'send-ota', 2)
class ExpandedRemoteTests(unittest.TestCase):
"""Expanded remote-management format (TS 102 226 5.2.1/5.2.2): the two
scripting templates, the Bad format TLV and the format helper."""
def test_wrap_expanded_apdu_forms(self):
# SELECT MF (the TAR probe's command) in both codings; the indefinite
# form is the one the spec recommends for RAM/RFM over HTTPS
self.assertEqual(_wrap_expanded_apdu('00A40000023F00'),
'AA09220700A40000023F00')
self.assertEqual(_wrap_expanded_apdu('00A40000023F00', 'indefinite'),
'AE80220700A40000023F000000')
self.assertEqual(_ram_format_apdu('00A40000023F00', 'compact'),
'00A40000023F00')
def test_format_apdu_uses_the_le_form_for_expanded_probes(self):
# the expanded format does not use GET RESPONSE (5.2.1.1): the probe
# drops the chained GET RESPONSE of the compact form and sets Le='00'
self.assertEqual(_ram_format_apdu('80F28000024F0000', 'expanded'),
'AA0A220880F28000024F0000')
self.assertEqual(_ram_format_apdu('80F28000024F0000', 'expanded-ae'),
'AE80220880F28000024F00000000')
def test_ram_normalize_format(self):
for v in ('auto', 'compact', 'expanded', 'expanded-ae', 'AUTO', ' expanded-ae '):
self.assertEqual(_ram_normalize_format(v), v.strip().lower())
for v in ('', None, 'bogus', 'AA'):
self.assertEqual(_ram_normalize_format(v), 'auto')
def test_live_bad_format_response_is_decoded_as_scripting(self):
# the live ISD's answer to a definite-length scripting template: zero
# executed commands plus the Bad format TLV (error type 02 = wrong
# length); it must not fall through to the compact decoder
r = _decode_por('16', '21', '25', '25', '0001000002', K, K,
'02710000130a00000000010000020000ab06800100900102')
self.assertEqual(r['response_status'], 'por_ok')
self.assertEqual(r['response_type'], 'scripting')
self.assertEqual(r['bad_format'], '02')
self.assertEqual(r['bad_format_name'], 'wrong length')
self.assertEqual(r['decoded']['number_of_commands'], 0)
def test_ram_step_result_treats_a_bad_format_as_failure(self):
por = {'response_status': 'por_ok', 'response_type': 'scripting',
'bad_format': '02', 'bad_format_name': 'wrong length',
'decoded': {'number_of_commands': 0, 'last_status_word': None,
'last_response_data': ''}}
step, error = _ram_step_result('FORMAT CHECK (expanded)', '9000', por, 'aabb', 42, 1)
self.assertEqual(step['por_bad_format'], '02')
self.assertIn('bad format 02', error)
self.assertIn('wrong length', error)
class CounterProbeTests(unittest.TestCase):
"""The bounded counter synchronisation probe (v3.9.x)."""
@staticmethod
def _preset(cntr='0000000C2D'):
return {'id': 'p1', 'name': 'test card', 'spi1': '16', 'spi2': '21',
'tar': '000000',
'keysets': [{'kic': '25', 'kid': '25', 'kicKey': '00' * 16,
'kidKey': '11' * 16, 'cntr': cntr}]}
def test_candidates_double_and_stop_at_the_ceiling(self):
cands, reason = _counter_probe_candidates('0000000C2D', '0000FFFF', 40)
self.assertEqual(cands[:4], ['0000000C2E', '0000000C30', '0000000C34', '0000000C3C'])
self.assertTrue(all(int(c, 16) <= 0xFFFF for c in cands), cands)
self.assertEqual(reason, 'ceiling')
# the attempt budget caps a far-away ceiling
cands, reason = _counter_probe_candidates('0000000000', 'FFFFFFFFFF', 5)
self.assertEqual(len(cands), 5)
self.assertEqual(reason, 'attempts')
# nothing to try when the start is already at the ceiling
self.assertEqual(_counter_probe_candidates('0000FFFF', '0000FFFF', 40), ([], 'ceiling'))
def test_probe_walks_up_and_persists_accepted_plus_one(self):
persisted = []
class Store:
def set_counter(self, pid, cntr, source, kvn=None):
persisted.append((pid, cntr, source, kvn))
server = types.SimpleNamespace(card_presets=Store())
verdicts = ['cntr_low', 'cntr_low', 'por_ok']
def send_fn(p, cntr):
status = verdicts.pop(0)
return ({'success': status == 'por_ok', 'sw': '9000', 'bytes': 42, 'segments': 1},
{'response_status': status,
'decoded': {'last_status_word': '9000', 'last_response_data': ''}})
res = _counter_probe(server, None, self._preset(), {}, send_fn=send_fn)
self.assertTrue(res['success'])
self.assertEqual([a['cntr'] for a in res['attempts']],
['0000000C2E', '0000000C30', '0000000C34'])
self.assertEqual(res['accepted_cntr'], '0000000C34')
self.assertEqual(res['stored_cntr'], '0000000C35')
self.assertEqual(res['packets'], 3)
self.assertEqual(persisted, [('p1', '0000000C35', 'counter-probe', 2)])
def test_probe_stops_on_a_non_counter_error(self):
def send_fn(p, cntr):
return ({'success': False, 'sw': '6200', 'error': 'ENVELOPE failed (6200)'},
{'response_status': 'refused'})
res = _counter_probe(None, None, self._preset(), {'max_attempts': 4},
send_fn=send_fn)
self.assertFalse(res['success'])
self.assertEqual(res['packets'], 1)
self.assertEqual(res['stopped'], 'error')
self.assertIn('6200', res['error'])
def test_probe_reports_a_ceiling_overrun(self):
def send_fn(p, cntr):
return ({'success': False, 'sw': '9000'}, {'response_status': 'cntr_low'})
res = _counter_probe(None, None, self._preset('0000FFFC'),
{'ceiling': '0000FFFF', 'max_attempts': 40}, send_fn=send_fn)
self.assertFalse(res['success'])
self.assertEqual(res['stopped'], 'ceiling')
self.assertLessEqual(len(res['attempts']), 3)
self.assertIn('raise the preset counter manually', res['error'])
def test_probe_params_validation(self):
# a keyset number the preset does not define
with self.assertRaises(ValueError) as cm:
_counter_probe_params(self._preset(), {'kvn': 5})
self.assertIn('not defined', str(cm.exception))
# SPI1 without a counter check cannot probe
no_check = self._preset()
no_check['spi1'] = '06' # ciphering+CC but b5b4 = 00: no counter field
with self.assertRaises(ValueError):
_counter_probe_params(no_check, {})
# the ceiling must stay below the 40-bit maximum
with self.assertRaises(ValueError):
_counter_probe_params(self._preset(), {'ceiling': 'FFFFFFFFFF'})
def test_cntr_low_fields_is_a_flag_only(self):
# the card's counter is not derivable from the PoR (TS 102 225 5.2):
# the verdict is stated without a value
self.assertEqual(_cntr_low_fields('cntr_low'), {'cntr_low': True})
self.assertEqual(_cntr_low_fields('por_ok'), {})
self.assertEqual(_cntr_low_fields(None), {})
if __name__ == '__main__':
unittest.main()