v3.20.0 (review fixes): the PoR decode uses the effective counter; the PWA fallback matches the server's seed

Two boundary flaws found reviewing v3.20.0:

- /api/send-ota decoded the PoR with the request's `cntr`, not the counter
  the packet was actually built with.  With the new seeding an omitted
  counter is the normal case, so a warning ENVELOPE's verdict - notably the
  live card's cntr_low PoR - could fail its RC/CC check and be lost (the
  success and the failure path are both fixed).  Tests pin the decode counter
  on both paths; the failure path also echoes the effective `cntr`.
- the PWA's effective counter (preview / verify / the older-server fallback)
  fell back to the preset's first keyset and then 1, while the server's empty
  seed builds counter 0 (no preset / kvn 0 / an unknown keyset).  A manual
  form or a keyless packet then produced a spurious "Verify vs pySim"
  MISMATCH and a preview that differed from the delivered packet.
  spPresetCounterValue()/spCntrEffective() now mirror _preset_counter_seed()
  exactly: the packet's matched keyset, else 0.
- the `OTA SEND: counter not tracked` line reflects the effective counter (a
  seeded value is logged too); docs/api.md records that the seed uses the
  packet's keyset whatever SPI1.b5b4 says.

Tests: 823 frontend + 737 Python; sw cache simple-v394.
This commit is contained in:
2026-10-05 22:37:28 +03:00
parent 85cc4b9535
commit bf6730f028
6 changed files with 120 additions and 14 deletions
+78 -1
View File
@@ -598,7 +598,7 @@ class PresetStoreHttpTests(unittest.TestCase):
'success': True, 'sw': '9000', 'bytes': 34, 'segments': 1,
'response_data': '027100000e0ab0000000000000010000019000'}), \
mock.patch.object(self.srv, '_decode_por',
return_value={'response_status': 'por_ok'}):
return_value={'response_status': 'por_ok'}) as decode:
status, resp = self._post('/api/send-ota', {
'preset_id': pid, 'apdu': '80E2900000', 'tar': 'B00001',
'spi1': '16', 'spi2': '01', 'kic': '15', 'kid': '15',
@@ -606,10 +606,39 @@ class PresetStoreHttpTests(unittest.TestCase):
self.assertEqual(status, 200, resp)
self.assertTrue(resp.get('success'), resp)
self.assertEqual(build.call_args[0][5], '0000000001') # seeded
# the PoR decode must use the counter the packet was built with - a
# different one breaks the RC/CC check and loses the verdict
self.assertEqual(decode.call_args[0][4], '0000000001')
self.assertEqual(resp['cntr'], '0000000001')
self.assertEqual(resp['final_cntr'], '0000000002')
self.assertEqual(self.store.get(pid)['keysets'][0]['cntr'], '0000000002')
def test_send_ota_failure_reports_the_effective_counter(self):
"""A refused ENVELOPE still echoes the counter the packet carried and
decodes its PoR with it (v3.20.0)."""
status, resp = self._post('/api/presets', self._preset(iccid='8970119000004600098'))
self.assertEqual(status, 200, resp)
pid = resp['preset']['id']
self.server.app = object()
with mock.patch.object(self.srv, '_build_secured_packet',
return_value=('00' * 20, {})), \
mock.patch.object(self.srv, '_send_secured_packet',
return_value={'success': False, 'sw': '6A82',
'bytes': 34, 'segments': 1,
'error': 'card refused'}), \
mock.patch.object(self.srv, '_decode_por',
return_value={'response_status': 'cntr_low'}) as decode:
status, resp = self._post('/api/send-ota', {
'preset_id': pid, 'apdu': '80E2900000', 'tar': 'B00001',
'spi1': '16', 'spi2': '01', 'kic': '15', 'kid': '15',
'kicKey': 'AA', 'kidKey': 'BB'})
self.assertEqual(status, 200, resp)
self.assertFalse(resp['success'], resp)
self.assertEqual(resp['cntr'], '0000000001')
self.assertEqual(decode.call_args[0][4], '0000000001')
# the store must not move for a rejected packet
self.assertEqual(self.store.get(pid)['keysets'][0]['cntr'], '0000000001')
def test_send_ota_explicit_lower_counter_does_not_regress_the_store(self):
"""An explicit counter is used for the packet (a hand send), but the
store keeps its higher value (monotonic persistence, v3.20.0)."""
@@ -638,6 +667,54 @@ class PresetStoreHttpTests(unittest.TestCase):
# the lower value never regresses the store
self.assertEqual(self.store.get(pid)['keysets'][0]['cntr'], '0000000010')
def test_sp_verify_seeds_the_reference_counter(self):
"""v3.20.0: a verify without `cntr` builds the pySim reference from
the preset's keyset - the same value the JS packet was built with."""
status, resp = self._post('/api/presets', self._preset(iccid='8970119000004600098'))
self.assertEqual(status, 200, resp)
pid = resp['preset']['id']
key = '00112233445566778899AABBCCDDEEFF' # synthetic, tests only
body = {'preset_id': pid, 'apdu': '00A40000023F00', 'tar': 'B00000',
'spi1': '16', 'spi2': '01', 'kic': '15', 'kid': '15',
'kicKey': key, 'kidKey': key}
status, seeded = self._post('/api/sp-verify', body)
self.assertEqual(status, 200, seeded)
status, pinned = self._post('/api/sp-verify', dict(body, cntr='0000000001'))
self.assertEqual(status, 200, pinned)
self.assertEqual(seeded['py_sp'], pinned['py_sp'])
# the JS packet built with that counter matches the reference
status, match = self._post('/api/sp-verify', dict(body, sp=seeded['py_sp']))
self.assertEqual(status, 200, match)
self.assertTrue(match['match'], match)
# an explicit counter still wins over the store's
status, other = self._post('/api/sp-verify',
dict(body, sp=seeded['py_sp'], cntr='0000000002'))
self.assertFalse(other['match'])
def test_tar_probe_without_cntr_seeds_the_preset_counter(self):
"""v3.20.0: the probe's first packet carries the preset's counter."""
status, resp = self._post('/api/presets', self._preset(iccid='8970119000004600098'))
self.assertEqual(status, 200, resp)
pid = resp['preset']['id']
self.server.app = object() # the TAR probe needs an app + scc
with mock.patch.object(self.srv, '_build_secured_packet',
return_value=('00' * 20, {})) as build, \
mock.patch.object(self.srv, '_send_secured_packet', return_value={
'success': True, 'sw': '9000', 'bytes': 34, 'segments': 1,
'response_data': '027100000e0ab0000000000000010000019000'}), \
mock.patch.object(self.srv, '_decode_por', return_value={
'response_status': 'por_ok', 'tar': 'B00000', 'cntr': '0000000001',
'pcntr': 0, 'rpl': 11, 'rhl': 10, 'raw': ''}):
status, resp = self._post('/api/tar-probe', {
'preset_id': pid, 'kic': '15', 'kid': '15', 'spi1': '16',
'spi2': '01', 'kicKey': 'AA', 'kidKey': 'BB',
'tars': ['B00000']})
self.assertEqual(status, 200, resp)
self.assertTrue(resp.get('success'), resp)
self.assertEqual(build.call_args[0][5], '0000000001') # seeded
self.assertEqual(resp['final_cntr'], '0000000002')
self.assertEqual(self.store.get(pid)['keysets'][0]['cntr'], '0000000002')
def test_create_update_delete_round_trip(self):
status, resp = self._post('/api/presets', self._preset(iccid='8970119000004600098'))
self.assertEqual(status, 200, resp)