diff --git a/docs/api.md b/docs/api.md index 6e6054a..c3cfdc7 100644 --- a/docs/api.md +++ b/docs/api.md @@ -297,12 +297,14 @@ the packet's, TS 102 225 §5.2 Table 3), which is why this probe exists. All fields except `preset_id` are optional: `kvn` picks a keyset (the first otherwise), `tar` defaults to the preset's ISD entry and `spi1` to that TAR's -MSL, `spi2` to `01`, `cntr` to the +MSL, `spi2` to the RAM listing transport `21`, `cntr` to the keyset's counter, `ceiling` to `FFFFFFFF` (must stay below `FFFFFFFFFF`) and -`max_attempts` to 40 (clamped to 1–64). The probe always sends the 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). +`max_attempts` to 40 (clamped to 1–64). The probe needs the PoR verdict, so +bit b1 of `spi2` is always set (a caller's `00` becomes `01`; the transport +bits are kept) - without a PoR the probe is blind. The probe always sends the +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). **Response:** `{"success": true, "accepted_cntr": "0001000000", "stored_cntr": "0001000001", "packets": 3, "stopped": "accepted", diff --git a/frontend/index.html b/frontend/index.html index c604979..d8a4ca0 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.0'; +const SIMPLE_VERSION = '3.14.1'; document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION; // ===== Tab switching ===== @@ -11323,7 +11323,12 @@ async function ramSyncCounter(presetIdx, view) { const tar = val('sp-tar').replace(/[^0-9a-fA-F]/g, '').toUpperCase(); if (tar) body.tar = tar; if (val('sp-spi1')) body.spi1 = val('sp-spi1'); - if (val('sp-spi2-hex')) body.spi2 = val('sp-spi2-hex'); + // The probe needs a PoR: use the transport the failed operation used - the + // RAM listings pin SPI2 0x21 (ramListingSpi2); a plain send whose SPI2 + // requested no PoR cannot raise the low-counter notice, so the form's + // value is already PoR-requesting here. + if (view === 'ram') body.spi2 = ramListingSpi2('40'); + else if (val('sp-spi2-hex')) body.spi2 = val('sp-spi2-hex'); const cntr = val('sp-cntr').replace(/[^0-9a-fA-F]/g, ''); if (cntr) body.cntr = cntr; if (box) box.innerHTML = '
' + diff --git a/frontend/sw.js b/frontend/sw.js index 8e4241c..b172f5a 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'simple-v375'; +const CACHE = 'simple-v376'; const URLS = [ 'index.html', 'help.html', diff --git a/frontend/tests/ram.test.js b/frontend/tests/ram.test.js index b4abe18..e98b10b 100644 --- a/frontend/tests/ram.test.js +++ b/frontend/tests/ram.test.js @@ -981,6 +981,8 @@ test('every SCP80/RAM flow checks the low counter and stops', () => { assert.ok(sync.includes('kvnOf(other)'), sync); assert.ok(sync.includes("getElementById('sp-cntr')"), sync); 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)'); }); test('the RAM form offers the command format choice', () => { diff --git a/pyproject.toml b/pyproject.toml index 6ba132d..366487c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pysim-simple-server" -version = "3.14.0" +version = "3.14.1" 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 076abc1..5e60122 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.0' +VERSION = '3.14.1' MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE @@ -1639,6 +1639,12 @@ COUNTER_PROBE_DEFAULT_APDU = '00A40000023F00' # SELECT MF, as TAR_PROBE_ # (a literal: that constant is defined later) COUNTER_PROBE_CEILING = 'FFFFFFFF' # 32-bit default COUNTER_PROBE_MAX_ATTEMPTS = 40 # doubling covers 40 bits +# The probe's SPI2 default: the RAM listing transport (PoR via SMS-SUBMIT). +# The probe cannot work without the PoR verdict - with b1 clear a card that +# honours the bit sends nothing at all (live 2026-10-02: the frontend posted +# the plain form's SPI2 00 and the probe stopped after one packet), and the +# live cards answer '01' with actual_response_sms_submit that never arrives. +COUNTER_PROBE_SPI2 = '21' def _counter_probe_candidates(start_hex, ceiling_hex, max_attempts): @@ -1716,13 +1722,19 @@ def _counter_probe_params(preset, body): if not _counter_tracked(spi1): raise ValueError('the probe needs a counter check - SPI1 %s has none ' '(b5b4 = 00)' % spi1) + spi2 = str(body.get('spi2') or COUNTER_PROBE_SPI2).strip().upper() + if not re.fullmatch(r'[0-9A-F]{2}', spi2): + raise ValueError('invalid SPI2') + # b1 (PoR required) is forced - the probe is blind without the verdict; + # the caller's transport bits are kept + spi2 = '%02X' % (int(spi2, 16) | 0x01) return { 'kvn': kvn, 'kic': kic, 'kid': kid, 'kic_key': kic_key, 'kid_key': kid_key, 'start': start, 'ceiling': ceiling, 'max_attempts': max_attempts, 'spi1': spi1, - 'spi2': str(body.get('spi2') or '01').strip().upper(), + 'spi2': spi2, 'tar': tar, # the probe command is fixed: read-only SELECT MF, which every applet # answers (an error SW is fine - the counter advances on the packet's @@ -1803,7 +1815,11 @@ def _counter_probe(server, scc, preset, body, send_fn=None): if pstatus == 'cntr_low': continue out['stopped'] = 'error' - out['error'] = send.get('error') or ('card verdict: %s' % (pstatus or 'no PoR')) + if pstatus: + out['error'] = send.get('error') or ('card verdict: %s' % pstatus) + else: + out['error'] = send.get('error') or ( + 'card verdict: no PoR (the card sent none for SPI2 %s)' % p['spi2']) return out if stop == 'ceiling': out['error'] = ('no counter up to the ceiling %s was accepted - the card\'s ' diff --git a/tests/test_ota_helpers.py b/tests/test_ota_helpers.py index ac75da5..4ff9cb1 100644 --- a/tests/test_ota_helpers.py +++ b/tests/test_ota_helpers.py @@ -2433,6 +2433,29 @@ class CounterProbeTests(unittest.TestCase): self.assertLessEqual(len(res['attempts']), 3) self.assertIn('raise the preset counter manually', res['error']) + def test_spi2_always_requests_the_por(self): + # the probe cannot work without the PoR verdict: a caller's value + # without b1 is corrected (live 2026-10-02: the frontend posted the + # plain form's 00 and the probe stopped after one packet), an invalid + # one is refused, and the default is the RAM transport + self.assertEqual(_counter_probe_params(self._preset(), {})['spi2'], '21') + self.assertEqual(_counter_probe_params(self._preset(), {'spi2': '00'})['spi2'], '01') + self.assertEqual(_counter_probe_params(self._preset(), {'spi2': '20'})['spi2'], '21') + self.assertEqual(_counter_probe_params(self._preset(), {'spi2': '21'})['spi2'], '21') + with self.assertRaises(ValueError): + _counter_probe_params(self._preset(), {'spi2': 'ZZ'}) + + def test_probe_names_the_spi2_when_no_por_arrives(self): + def send_fn(p, cntr): + return ({'success': True, 'sw': '9000'}, None) + + res = _counter_probe(None, None, self._preset(), {}, send_fn=send_fn) + self.assertFalse(res['success']) + self.assertEqual(res['stopped'], 'error') + self.assertEqual(res['packets'], 1) + self.assertIn('no PoR', res['error']) + self.assertIn('SPI2 21', res['error']) + def test_probe_params_validation(self): # a keyset number the preset does not define with self.assertRaises(ValueError) as cm: