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:
2026-10-05 22:22:50 +03:00
parent 0e06b918a7
commit 85cc4b9535
14 changed files with 331 additions and 101 deletions
+38
View File
@@ -36,6 +36,7 @@ from pysim_simple_server.server import (
_counter_probe,
_counter_probe_candidates,
_counter_probe_params,
_preset_counter_seed,
_ram_normalize_format,
_parse_proactive_header,
_parse_display_text,
@@ -535,6 +536,43 @@ class TestDecodePor(unittest.TestCase):
self.assertEqual(r['secured_data'], '016A86')
class TestPresetCounterSeed(unittest.TestCase):
"""v3.20.0: the effective counter of an operation - explicit `cntr` wins,
otherwise the preset store's value for the packet's keyset."""
PRESET = {'keysets': [
{'kic': '15', 'kid': '15', 'kicKey': 'AA', 'kidKey': 'BB',
'cntr': '0000000001'},
{'kic': '25', 'kid': '25', 'kicKey': 'CC', 'kidKey': 'DD',
'cntr': '0000000020'}]}
def _server(self):
preset = self.PRESET
class Store:
def get(self, pid):
return preset if pid == 'p1' else None
return types.SimpleNamespace(card_presets=Store())
def test_explicit_counter_wins(self):
self.assertEqual(_preset_counter_seed(self._server(),
{'cntr': ' 0000000009 '}, 1),
'0000000009')
# an explicit counter is used even without a preset
self.assertEqual(_preset_counter_seed(self._server(), {'cntr': '0000000009'}, 0),
'0000000009')
def test_missing_counter_seeds_from_the_keyset(self):
self.assertEqual(_preset_counter_seed(self._server(), {'preset_id': 'p1'}, 2),
'0000000020')
# no/unknown preset, unknown keyset or no kvn: nothing to seed
self.assertEqual(_preset_counter_seed(self._server(), {}, 1), '')
self.assertEqual(_preset_counter_seed(self._server(), {'preset_id': 'nope'}, 1), '')
self.assertEqual(_preset_counter_seed(self._server(), {'preset_id': 'p1'}, 3), '')
self.assertEqual(_preset_counter_seed(self._server(), {'preset_id': 'p1'}, 0), '')
class TestProactiveDecode(unittest.TestCase):
"""Server-side proactive command/TR decode helpers (v1.8.0 log feature)."""