v3.10.0: preset TAR table with per-TAR MSL; derive the SPI1 from the TAR

Presets drop the card-wide SPI1/SPI2 and the fixed tar/uiccTar/usimTar
fields: a preset now carries `tars` - the three mandatory role entries
(isd/uiccRfm/usimRfm, spec defaults pre-settled) plus free TARs with an
optional description, each with its MSL (Minimum SPI1, TS 102 226
8.2.1.3.2.4).  v3.9.1 presets convert on load/import (the spi1 seeds all
three MSLs); duplicate TARs and empty TAR/MSL values are refused; the
store schema version is 3.

Operations derive a packet's SPI1 from its TAR's MSL (_spi1_for_tar): the
RAM ops, the test runner (per-step overrides still win), the counter probe
and each TAR-probe row; the plain send keeps an explicit SPI1 with a
`warning` when it is below the MSL, and a TAR outside the preset without an
explicit SPI1 is refused with a hint.  The TAR probe resolves per checked
TAR before sending and reports the effective spi1 per row.  The secured
packet form's SPI2 default is 00 (the RAM/probe operations pin their own).

PWA: the Cards form edits the TAR table (role rows with MSL inputs plus
Add/Remove free rows with a description), the list shows a TARs (MSL)
column, the SCP80 TAR field suggests the preset's entries (datalist) and a
preset apply seeds the SPI1 from the operation TAR's MSL with an amber
warning below it; the preset-wide SPI1/SPI2 fields and the header marker's
SPI requirement are gone (completeness = a keyset + the three role entries
with their MSLs).  Help EN/RU, docs/api.md and AGENTS updated; sw cache
simple-v358.

Live: TAR probe 000000 at its MSL 16 (registered, por_ok, counter
advanced); a send without spi1 derived 16 (por_ok, counter advanced); a
send with spi1 0A returned the warning and the card refused it (6200, no
counter consumed); an unknown TAR without spi1 was refused with the hint.
This commit is contained in:
2026-10-02 08:28:46 +03:00
parent f059d05040
commit 6360dc5bae
19 changed files with 1187 additions and 269 deletions
+52 -3
View File
@@ -54,6 +54,7 @@ from pysim_simple_server.server import (
_ram_step_result,
_record_tr,
_send_secured_packet,
_spi1_for_tar,
_spi_from_bytes,
_split_secured_packet,
_tr_data_only,
@@ -2296,13 +2297,61 @@ class ExpandedRemoteTests(unittest.TestCase):
self.assertIn('wrong length', error)
class Spi1ForTarTests(unittest.TestCase):
"""The SPI1 of a packet comes from the MSL (Minimum SPI1) of its TAR
(TS 102 226 8.2.1.3.2.4) - the preset's TAR table is the source."""
PRESET = {'tars': [
{'role': 'isd', 'tar': '000000', 'msl': '16', 'desc': ''},
{'role': 'uiccRfm', 'tar': 'B00000', 'msl': '10', 'desc': ''},
{'role': 'usimRfm', 'tar': 'B00001', 'msl': '16', 'desc': ''},
{'tar': 'AF4D01', 'msl': '0A', 'desc': 'applet'},
]}
def test_a_hand_send_keeps_its_explicit_spi1(self):
self.assertEqual(_spi1_for_tar(self.PRESET, '000000', '21'), ('21', ''))
self.assertEqual(_spi1_for_tar(self.PRESET, '000000', '1A'), ('1A', ''))
def test_an_explicit_spi1_below_the_msl_warns(self):
spi1, warning = _spi1_for_tar(self.PRESET, 'B00000', '0A')
self.assertEqual(spi1, '0A')
self.assertIn('MSL 10', warning)
self.assertIn('B00000', warning)
def test_the_comparison_is_numeric(self):
# '9' < '10' numerically - a lexicographic compare would miss it
_, warning = _spi1_for_tar(self.PRESET, 'B00000', '9')
self.assertIn('MSL 10', warning)
def test_without_an_explicit_value_the_msl_is_used(self):
self.assertEqual(_spi1_for_tar(self.PRESET, 'B00001'), ('16', ''))
self.assertEqual(_spi1_for_tar(self.PRESET, 'af4d01'), ('0A', ''))
def test_operations_prefer_the_msl_over_a_stale_explicit_value(self):
self.assertEqual(_spi1_for_tar(self.PRESET, 'B00001', '21',
prefer_msl=True), ('16', ''))
# ... but the explicit value covers a TAR the preset does not carry
self.assertEqual(_spi1_for_tar(self.PRESET, 'B00200', '21',
prefer_msl=True), ('21', ''))
def test_an_unknown_tar_without_an_explicit_value_is_refused(self):
with self.assertRaises(ValueError) as cm:
_spi1_for_tar(self.PRESET, 'B00200')
self.assertIn('B00200', str(cm.exception))
self.assertIn('MSL', str(cm.exception))
with self.assertRaises(ValueError):
_spi1_for_tar({}, '000000')
class CounterProbeTests(unittest.TestCase):
"""The bounded counter synchronisation probe (v3.9.x)."""
@staticmethod
def _preset(cntr='0000000C2D'):
return {'id': 'p1', 'name': 'test card', 'spi1': '16', 'spi2': '21',
'tar': '000000',
return {'id': 'p1', 'name': 'test card',
'tars': [{'role': 'isd', 'tar': '000000', 'msl': '16', 'desc': ''},
{'role': 'uiccRfm', 'tar': 'B00000', 'msl': '16', 'desc': ''},
{'role': 'usimRfm', 'tar': 'B00001', 'msl': '16', 'desc': ''}],
'keysets': [{'kic': '25', 'kid': '25', 'kicKey': '00' * 16,
'kidKey': '11' * 16, 'cntr': cntr}]}
@@ -2373,7 +2422,7 @@ class CounterProbeTests(unittest.TestCase):
self.assertIn('not defined', str(cm.exception))
# SPI1 without a counter check cannot probe
no_check = self._preset()
no_check['spi1'] = '06' # ciphering+CC but b5b4 = 00: no counter field
no_check['tars'][0]['msl'] = '06' # ciphering+CC but b5b4 = 00: no counter field
with self.assertRaises(ValueError):
_counter_probe_params(no_check, {})
# the ceiling must stay below the 40-bit maximum