v3.20.0: the server-owned SCP80 counter (an untouched field = the preset's next value)
The counter an operation consumed was re-entered by the PWA on every send (the form field was filled from the preset and posted back), so a stale tab could send a value the card rejects (cntr_low), and typing in the field silently wrote the preset. Now the store is the single source of truth and the form field is a per-send override (Option B). - _preset_counter_seed: an explicit `cntr` in the request wins (a hand send, the counter probe or a pre-built packet may deliberately pick one); otherwise the preset store's value for the packet's keyset. Wired into /api/send-ota (the APDU path; the pre-built `sp` path keeps the caller's bookkeeping value - the packet carries its own counter), /api/ram-install, /api/ram-install-app, /api/cap-compat, /api/tar-probe and /api/sp-verify (the reference must use the counter the JS packet was built with). - the send-ota response echoes the effective `cntr` (a caller that omitted one learns what was used) and the OTA SEND log line prints it. - persistence is unchanged and monotonic (presets.set_counter refuses backwards writes), pinned by tests: an explicit lower counter is used for the packet but never lowers the store, a higher accepted one is saved. - PWA: the Secured packet counter field starts empty with the preset's next value as its placeholder (auto: <cntr>); typing affects that send only and is never written to the preset (spCntrSyncPreset is gone), spParams / getRamSpParams omit an empty counter, the RAM/send flows sync only the local preset copy (forward-only) instead of filling the field, the +/- buttons start from the effective value, and the preview/verify packet is built from the effective counter (genSp). Placeholder translated EN/RU. - the runner and /api/counter-probe keep their existing seeding (the probe already starts at the failed value and walks above it). - docs: api.md's optional-`cntr` contract + seeding notes on every endpoint, help EN/RU 3.1. Tests: 822 frontend + 734 Python; sw cache simple-v393.
This commit is contained in:
@@ -580,10 +580,64 @@ class PresetStoreHttpTests(unittest.TestCase):
|
||||
status, resp = self._post('/api/counter-probe', {'preset_id': pid})
|
||||
self.assertEqual(status, 200, resp)
|
||||
self.assertTrue(resp['success'], resp)
|
||||
self.assertEqual(resp['start'], '0000000001') # seeded from the keyset
|
||||
self.assertEqual(resp['accepted_cntr'], '0000000002')
|
||||
self.assertEqual(resp['stored_cntr'], '0000000003')
|
||||
self.assertEqual(self.store.get(pid)['keysets'][0]['cntr'], '0000000003')
|
||||
|
||||
def test_send_ota_without_cntr_seeds_the_preset_counter(self):
|
||||
"""v3.20.0: the PWA's send buttons omit an untouched counter; the
|
||||
server then uses the preset store's value for the packet's keyset."""
|
||||
status, resp = self._post('/api/presets', self._preset(iccid='8970119000004600098'))
|
||||
self.assertEqual(status, 200, resp)
|
||||
pid = resp['preset']['id']
|
||||
self.server.app = object() # /api/send-ota refuses an uninitialized app
|
||||
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'}):
|
||||
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.assertTrue(resp.get('success'), resp)
|
||||
self.assertEqual(build.call_args[0][5], '0000000001') # seeded
|
||||
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_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)."""
|
||||
status, resp = self._post('/api/presets', self._preset(iccid='8970119000004600098'))
|
||||
self.assertEqual(status, 200, resp)
|
||||
pid = resp['preset']['id']
|
||||
keysets = [dict(ks, cntr='0000000010') for ks in resp['preset']['keysets']]
|
||||
status, resp = self._post('/api/presets/update',
|
||||
{'id': pid, 'fields': {'keysets': keysets}})
|
||||
self.assertEqual(status, 200, resp)
|
||||
self.server.app = object() # /api/send-ota refuses an uninitialized app
|
||||
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'}):
|
||||
status, resp = self._post('/api/send-ota', {
|
||||
'preset_id': pid, 'apdu': '80E2900000', 'tar': 'B00001',
|
||||
'spi1': '16', 'spi2': '01', 'kic': '15', 'kid': '15',
|
||||
'cntr': '0000000005', 'kicKey': 'AA', 'kidKey': 'BB'})
|
||||
self.assertEqual(status, 200, resp)
|
||||
self.assertEqual(build.call_args[0][5], '0000000005') # explicit wins
|
||||
self.assertEqual(resp['final_cntr'], '0000000006')
|
||||
# the lower value never regresses the store
|
||||
self.assertEqual(self.store.get(pid)['keysets'][0]['cntr'], '0000000010')
|
||||
|
||||
def test_create_update_delete_round_trip(self):
|
||||
status, resp = self._post('/api/presets', self._preset(iccid='8970119000004600098'))
|
||||
self.assertEqual(status, 200, resp)
|
||||
|
||||
Reference in New Issue
Block a user