v3.14.1: the counter probe demands its PoR

_counter_probe_params validates SPI2, forces b1 (PoR required; a caller's
00 becomes 01) and defaults to the RAM listing transport 21; the no-PoR
error names the effective SPI2, and ramSyncCounter posts
ramListingSpi2('40') for the RAM view (the failed operation's transport).

Live 2026-10-02 (NP BAP): the probe had sent the plain SCP80 form's 00
(no PoR) - the card answered the ENVELOPE 9000 and sent nothing, so the
probe stopped after one packet with "card verdict: no PoR".

Docs: docs/api.md, repo AGENTS.  sw cache simple-v376.
Tests: 670 Python + 798 frontend green.
This commit is contained in:
2026-10-02 14:40:07 +03:00
parent c18b6cd0d4
commit 475c13aeb8
7 changed files with 60 additions and 12 deletions
+7 -5
View File
@@ -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 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 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 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 `max_attempts` to 40 (clamped to 1–64). The probe needs the PoR verdict, so
`SELECT MF` - the counter advances on the packet's security acceptance whatever bit b1 of `spi2` is always set (a caller's `00` becomes `01`; the transport
the applet answers (`6A86`/`6D00` are normal there). A SPI1 without a counter bits are kept) - without a PoR the probe is blind. The probe always sends the
check is refused (400). 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", **Response:** `{"success": true, "accepted_cntr": "0001000000",
"stored_cntr": "0001000001", "packets": 3, "stopped": "accepted", "stored_cntr": "0001000001", "packets": 3, "stopped": "accepted",
+7 -2
View File
@@ -1877,7 +1877,7 @@
// ===== Version ===== // ===== Version =====
// Single source of truth for the PWA version: shown in the header and used // Single source of truth for the PWA version: shown in the header and used
// by the server version check in pysimConnect(). // 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; document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION;
// ===== Tab switching ===== // ===== Tab switching =====
@@ -11323,7 +11323,12 @@ async function ramSyncCounter(presetIdx, view) {
const tar = val('sp-tar').replace(/[^0-9a-fA-F]/g, '').toUpperCase(); const tar = val('sp-tar').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
if (tar) body.tar = tar; if (tar) body.tar = tar;
if (val('sp-spi1')) body.spi1 = val('sp-spi1'); 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, ''); const cntr = val('sp-cntr').replace(/[^0-9a-fA-F]/g, '');
if (cntr) body.cntr = cntr; if (cntr) body.cntr = cntr;
if (box) box.innerHTML = '<div class="text-sm text-gray-500 dark:text-slate-400">' + if (box) box.innerHTML = '<div class="text-sm text-gray-500 dark:text-slate-400">' +
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'simple-v375'; const CACHE = 'simple-v376';
const URLS = [ const URLS = [
'index.html', 'index.html',
'help.html', 'help.html',
+2
View File
@@ -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('kvnOf(other)'), sync);
assert.ok(sync.includes("getElementById('sp-cntr')"), sync); assert.ok(sync.includes("getElementById('sp-cntr')"), sync);
assert.ok(sync.includes('spKeysetSync()'), 'the keyset labels must refresh'); 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', () => { test('the RAM form offers the command format choice', () => {
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "pysim-simple-server" name = "pysim-simple-server"
version = "3.14.0" version = "3.14.1"
description = "HTTP REST server wrapping pysim for the SIMple PWA" description = "HTTP REST server wrapping pysim for the SIMple PWA"
requires-python = ">=3.8" requires-python = ">=3.8"
# pysim is a git-only dependency installed explicitly by setup.bat/setup.sh. # pysim is a git-only dependency installed explicitly by setup.bat/setup.sh.
+19 -3
View File
@@ -32,7 +32,7 @@ from osmocom.tlv import BER_TLV_IE
from osmocom.utils import rpad 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 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) # (a literal: that constant is defined later)
COUNTER_PROBE_CEILING = 'FFFFFFFF' # 32-bit default COUNTER_PROBE_CEILING = 'FFFFFFFF' # 32-bit default
COUNTER_PROBE_MAX_ATTEMPTS = 40 # doubling covers 40 bits 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): 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): if not _counter_tracked(spi1):
raise ValueError('the probe needs a counter check - SPI1 %s has none ' raise ValueError('the probe needs a counter check - SPI1 %s has none '
'(b5b4 = 00)' % spi1) '(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 { return {
'kvn': kvn, 'kic': kic, 'kid': kid, 'kvn': kvn, 'kic': kic, 'kid': kid,
'kic_key': kic_key, 'kid_key': kid_key, 'kic_key': kic_key, 'kid_key': kid_key,
'start': start, 'ceiling': ceiling, 'start': start, 'ceiling': ceiling,
'max_attempts': max_attempts, 'max_attempts': max_attempts,
'spi1': spi1, 'spi1': spi1,
'spi2': str(body.get('spi2') or '01').strip().upper(), 'spi2': spi2,
'tar': tar, 'tar': tar,
# the probe command is fixed: read-only SELECT MF, which every applet # 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 # 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': if pstatus == 'cntr_low':
continue continue
out['stopped'] = 'error' 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 return out
if stop == 'ceiling': if stop == 'ceiling':
out['error'] = ('no counter up to the ceiling %s was accepted - the card\'s ' out['error'] = ('no counter up to the ceiling %s was accepted - the card\'s '
+23
View File
@@ -2433,6 +2433,29 @@ class CounterProbeTests(unittest.TestCase):
self.assertLessEqual(len(res['attempts']), 3) self.assertLessEqual(len(res['attempts']), 3)
self.assertIn('raise the preset counter manually', res['error']) 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): def test_probe_params_validation(self):
# a keyset number the preset does not define # a keyset number the preset does not define
with self.assertRaises(ValueError) as cm: with self.assertRaises(ValueError) as cm: