From 3c5882d16d5024012de3797dcc823c7a1a27a862 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D1=82=D0=BE=D0=BD=20=D0=A2=D1=80=D0=BE=D1=88?= =?UTF-8?q?=D0=B8=D0=BD?= Date: Fri, 2 Oct 2026 14:58:18 +0300 Subject: [PATCH] v3.14.2: the counter probe's verdict rule The ladder only advances on cntr_low; any other verdict stops the search. A stop on a non-por_ok verdict after at least one cntr_low keeps the value anyway - the earlier rejection proves it is above the card's counter - but the response reports success: false with counter_saved, the verdict and the saved counter ("something is likely wrong with the security settings"); the PWA shows it amber. Without a prior cntr_low nothing is saved, and a missing PoR stays an error. actual_response_sms_submit/ussd count as accepted. Docs: docs/api.md, repo AGENTS. sw cache simple-v377. Tests: 672 Python + 798 frontend green. --- docs/api.md | 10 ++++++ frontend/index.html | 14 ++++++-- frontend/sw.js | 2 +- frontend/tests/ram.test.js | 2 ++ pyproject.toml | 2 +- pysim_simple_server/server.py | 44 +++++++++++++++++++----- tests/test_ota_helpers.py | 63 ++++++++++++++++++++++++++++++----- 7 files changed, 117 insertions(+), 20 deletions(-) diff --git a/docs/api.md b/docs/api.md index c3cfdc7..a444ec4 100644 --- a/docs/api.md +++ b/docs/api.md @@ -306,6 +306,16 @@ read-only `SELECT MF` - the counter advances on the packet's security acceptance whatever the applet answers (`6A86`/`6D00` are normal there). A SPI1 without a counter check is refused (400). +The ladder only advances on a `cntr_low` verdict (the card's "your counter is +not above mine"); **any other verdict stops the search**. A stop on a +non-`por_ok` verdict (`rc_cc_ds_failed`, `tar_unknown`, …) *after* at least one +`cntr_low` keeps the counter value anyway - the earlier rejection proves the +value is above the card's counter - but the response reports `success: false`, +`counter_saved: true`, the `verdict` and the saved counter, stating that +something is likely wrong with the security settings. Without a prior +`cntr_low` nothing is saved (the value is not established), and a missing PoR +is an error too. + **Response:** `{"success": true, "accepted_cntr": "0001000000", "stored_cntr": "0001000001", "packets": 3, "stopped": "accepted", "attempts": [{"cntr": "…", "por_status": "cntr_low"}, …], diff --git a/frontend/index.html b/frontend/index.html index d8a4ca0..37d8c3f 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1877,7 +1877,7 @@ // ===== Version ===== // Single source of truth for the PWA version: shown in the header and used // by the server version check in pysimConnect(). -const SIMPLE_VERSION = '3.14.1'; +const SIMPLE_VERSION = '3.14.2'; document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION; // ===== Tab switching ===== @@ -11335,15 +11335,24 @@ async function ramSyncCounter(presetIdx, view) { esc(t('Syncing counter...')) + ''; try { const data = await pysimFetch('/api/counter-probe', body); - if (data.success) { + if (data.success || data.counter_saved) { await cardsFetch(); // the keyset selects' labels carry the counter - refresh them too spKeysetSync(); const cntrEl = document.getElementById('sp-cntr'); if (cntrEl && data.stored_cntr) cntrEl.value = data.stored_cntr; + } + if (data.success) { const msg = t('Counter synced') + ': ' + esc(data.accepted_cntr) + ' \u2192 ' + esc(data.stored_cntr) + '. ' + t('Retry the operation.'); if (box) box.innerHTML = '
' + msg + '
'; + } else if (data.counter_saved) { + // the search stopped on a security verdict, but the value was + // saved (a cntr_low rejection preceded it, so it is above the + // card's counter) - amber: the security settings need a look + const msg = t('Counter sync error') + ': ' + esc(data.error || '') + ' ' + + t('Retry the operation.'); + if (box) box.innerHTML = '
' + msg + '
'; } else { const msg = t('Counter sync failed') + ': ' + esc(data.error || ''); if (box) box.innerHTML = '
' + msg + '
'; @@ -21133,6 +21142,7 @@ const LANG_RU = { 'Syncing counter...': 'Синхронизация счётчика...', 'Counter synced': 'Счётчик синхронизирован', 'Counter sync failed': 'Не удалось синхронизировать счётчик', + 'Counter sync error': 'Ошибка синхронизации счётчика', 'Retry the operation.': 'Повторите операцию.', 'No card preset selected': 'Пресет карты не выбран', 'Format': 'Формат', diff --git a/frontend/sw.js b/frontend/sw.js index b172f5a..3071e92 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'simple-v376'; +const CACHE = 'simple-v377'; const URLS = [ 'index.html', 'help.html', diff --git a/frontend/tests/ram.test.js b/frontend/tests/ram.test.js index e98b10b..3c712a0 100644 --- a/frontend/tests/ram.test.js +++ b/frontend/tests/ram.test.js @@ -983,6 +983,8 @@ test('every SCP80/RAM flow checks the low counter and stops', () => { assert.ok(sync.includes('spKeysetSync()'), 'the keyset labels must refresh'); assert.ok(sync.includes("ramListingSpi2('40')"), 'the RAM view must probe with the RAM listing transport (a PoR is required)'); + assert.ok(sync.includes('data.counter_saved'), + 'the saved-on-error case must be reported in amber'); }); test('the RAM form offers the command format choice', () => { diff --git a/pyproject.toml b/pyproject.toml index 366487c..54153cc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pysim-simple-server" -version = "3.14.1" +version = "3.14.2" description = "HTTP REST server wrapping pysim for the SIMple PWA" requires-python = ">=3.8" # pysim is a git-only dependency installed explicitly by setup.bat/setup.sh. diff --git a/pysim_simple_server/server.py b/pysim_simple_server/server.py index 5e60122..7d12a26 100644 --- a/pysim_simple_server/server.py +++ b/pysim_simple_server/server.py @@ -32,7 +32,7 @@ from osmocom.tlv import BER_TLV_IE from osmocom.utils import rpad -VERSION = '3.14.1' +VERSION = '3.14.2' MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE @@ -1765,6 +1765,7 @@ def _counter_probe(server, scc, preset, body, send_fn=None): if not candidates: out['error'] = 'the counter is already at the ceiling %s' % p['ceiling'] return out + saw_low = False for cntr in candidates: if send_fn is not None: send, por = send_fn(p, cntr) @@ -1803,20 +1804,47 @@ def _counter_probe(server, scc, preset, body, send_fn=None): out['attempts'].append(attempt) sys.stderr.write('COUNTER-PROBE: %s -> %s\n' % (cntr, pstatus or ('ENVELOPE %s' % (send.get('sw') or '?')))) - if pstatus == 'por_ok': + if pstatus == 'cntr_low': + # the only verdict that keeps the ladder going: the card says the + # attempted counter is not above its own + saw_low = True + continue + if pstatus in ('por_ok', 'actual_response_sms_submit', 'actual_response_ussd'): + # accepted (an actual-response status means the response travels + # via SMS/USSD - the packet itself was accepted) stored = _ram_next_cntr(cntr, True) _preset_counter_persist(server, preset.get('id'), stored, 'counter-probe', p['kvn']) - out.update({'success': True, 'stopped': 'accepted', + out.update({'success': True, 'stopped': 'accepted', 'verdict': pstatus, + 'counter_saved': True, 'accepted_cntr': cntr, 'stored_cntr': stored}) - sys.stderr.write('COUNTER-PROBE: accepted %s, preset stores %s\n' - % (cntr, stored)) + sys.stderr.write('COUNTER-PROBE: accepted %s (%s), preset stores %s\n' + % (cntr, pstatus, stored)) return out - if pstatus == 'cntr_low': - continue out['stopped'] = 'error' if pstatus: - out['error'] = send.get('error') or ('card verdict: %s' % pstatus) + if saw_low: + # A cntr_low rejection earlier proved the card's counter is + # below this value, so the packet got past the counter check + # and failed a later security/permission one (wrong keys, + # TAR, level). Keep the value - it is the sync point the + # probe was looking for - but report the error so the + # security settings get looked at. + stored = _ram_next_cntr(cntr, True) + _preset_counter_persist(server, preset.get('id'), stored, + 'counter-probe', p['kvn']) + out.update({'counter_saved': True, 'verdict': pstatus, + 'accepted_cntr': cntr, 'stored_cntr': stored, + 'error': 'card verdict: %s - the counter value was still ' + 'saved (accepted %s, stored %s); something is ' + 'likely wrong with the security settings' + % (pstatus, cntr, stored)}) + sys.stderr.write('COUNTER-PROBE: %s -> %s, value saved (preset stores %s)\n' + % (cntr, pstatus, stored)) + else: + out['error'] = send.get('error') or ( + 'card verdict: %s - the counter was not synced ' + '(the search never saw a cntr_low rejection)' % pstatus) else: out['error'] = send.get('error') or ( 'card verdict: no PoR (the card sent none for SPI2 %s)' % p['spi2']) diff --git a/tests/test_ota_helpers.py b/tests/test_ota_helpers.py index 4ff9cb1..002305a 100644 --- a/tests/test_ota_helpers.py +++ b/tests/test_ota_helpers.py @@ -2410,17 +2410,64 @@ class CounterProbeTests(unittest.TestCase): 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'}) + def test_probe_saves_a_post_cntr_low_verdict_but_reports_it(self): + # a cntr_low -> other-error transition proves the value is above the + # card's counter (the packet got past the counter check): keep it, but + # report the security error so the settings get looked at + persisted = [] - res = _counter_probe(None, None, self._preset(), {'max_attempts': 4}, - send_fn=send_fn) + 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', 'rc_cc_ds_failed'] + + def send_fn(p, cntr): + status = verdicts.pop(0) + return ({'success': False, 'sw': '9000', 'bytes': 42, 'segments': 1}, + {'response_status': status, 'decoded': {'last_status_word': '9000'}}) + + res = _counter_probe(server, None, self._preset(), {}, send_fn=send_fn) self.assertFalse(res['success']) - self.assertEqual(res['packets'], 1) self.assertEqual(res['stopped'], 'error') - self.assertIn('6200', res['error']) + self.assertTrue(res['counter_saved']) + self.assertEqual(res['verdict'], 'rc_cc_ds_failed') + self.assertEqual(res['accepted_cntr'], '0000000C30') + self.assertEqual(res['stored_cntr'], '0000000C31') + self.assertEqual(persisted, [('p1', '0000000C31', 'counter-probe', 2)]) + self.assertIn('security settings', res['error']) + + def test_probe_does_not_save_a_verdict_without_a_cntr_low(self): + # no transition: the value is not established, nothing may be stored + def send_fn(p, cntr): + return ({'success': False, 'sw': '9000'}, + {'response_status': 'rc_cc_ds_failed', 'decoded': {}}) + + res = _counter_probe(None, None, self._preset(), {}, send_fn=send_fn) + self.assertFalse(res['success']) + self.assertFalse(res.get('counter_saved')) + self.assertNotIn('stored_cntr', res) + self.assertIn('not synced', res['error']) + + def test_probe_treats_an_actual_response_as_accepted(self): + # 0x0B: the packet was accepted, the response travels via SMS-SUBMIT + persisted = [] + + class Store: + def set_counter(self, pid, cntr, source, kvn=None): + persisted.append((pid, cntr, source, kvn)) + + server = types.SimpleNamespace(card_presets=Store()) + + def send_fn(p, cntr): + return ({'success': True, 'sw': '9000'}, + {'response_status': 'actual_response_sms_submit', 'decoded': {}}) + + res = _counter_probe(server, None, self._preset(), {}, send_fn=send_fn) + self.assertTrue(res['success']) + self.assertEqual(res['verdict'], 'actual_response_sms_submit') + self.assertEqual(persisted, [('p1', '0000000C2F', 'counter-probe', 2)]) def test_probe_reports_a_ceiling_overrun(self): def send_fn(p, cntr):