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
+85 -5
View File
@@ -91,15 +91,80 @@ class StoreCrudTests(unittest.TestCase):
def test_add_applies_the_form_defaults_and_assigns_an_id(self):
p = self.store.add(preset(iccid=DIGITS))
self.assertTrue(p['id'])
self.assertEqual(p['spi1'], '16')
self.assertEqual(p['spi2'], '01')
self.assertEqual(p['tar'], '000000')
self.assertEqual(p['uiccTar'], 'B00000')
self.assertEqual(p['usimTar'], 'B00001')
self.assertEqual([t['role'] for t in p['tars']],
['isd', 'uiccRfm', 'usimRfm'])
self.assertEqual([t['tar'] for t in p['tars']],
['000000', 'B00000', 'B00001'])
self.assertEqual([t['msl'] for t in p['tars']], ['16', '16', '16'])
self.assertNotIn('spi1', p) # the card-wide SPI1 is gone: per-TAR MSL
self.assertNotIn('spi2', p)
self.assertEqual(p['keysets'][0]['cntr'], '0000000001')
self.assertEqual(presets.keyset_kvn(p['keysets'][0]), 1)
self.assertEqual([x['id'] for x in self.store.list()], [p['id']])
def test_a_preset_carries_extra_tars_with_optional_descriptions(self):
p = self.store.add(preset(tars=[
{'role': 'isd', 'tar': 'AF4D01', 'msl': '1A'},
{'role': 'uiccRfm', 'tar': 'B00000', 'msl': '16'},
{'role': 'usimRfm', 'tar': 'B00001', 'msl': '16'},
{'tar': 'AF4D02', 'msl': '0A', 'desc': 'My applet'},
{'tar': '', 'msl': '', 'desc': ''}, # a blank editor row
]))
self.assertEqual([(t['role'], t['tar'], t['msl']) for t in p['tars']],
[('isd', 'AF4D01', '1A'), ('uiccRfm', 'B00000', '16'),
('usimRfm', 'B00001', '16'), ('', 'AF4D02', '0A')])
self.assertEqual(p['tars'][3]['desc'], 'My applet')
self.assertEqual(len(p['tars']), 4) # the blank row was dropped
def test_every_tar_needs_its_msl_and_a_unique_tar(self):
with self.assertRaises(presets.PresetError) as ctx:
self.store.add(preset(tars=[
{'role': 'isd', 'tar': '000000', 'msl': '16'},
{'role': 'uiccRfm', 'tar': 'B00000', 'msl': '16'},
{'role': 'usimRfm', 'tar': 'B00001', 'msl': '16'},
{'tar': 'AF4D02', 'msl': ''},
]))
self.assertIn('MSL', str(ctx.exception))
with self.assertRaises(presets.PresetError) as ctx:
self.store.add(preset(tars=[
{'role': 'isd', 'tar': '000000', 'msl': '16'},
{'role': 'uiccRfm', 'tar': 'B00000', 'msl': '16'},
{'role': 'usimRfm', 'tar': '000000', 'msl': '16'},
]))
self.assertIn('duplicate TAR', str(ctx.exception))
with self.assertRaises(presets.PresetError) as ctx:
self.store.add(preset(tars=[
{'role': 'isd', 'tar': '0000', 'msl': '16'}]))
self.assertIn('three hex bytes', str(ctx.exception))
def test_tar_lookup_helpers(self):
p = self.store.add(preset(tars=[
{'role': 'isd', 'tar': 'AF4D01', 'msl': '1A'},
{'role': 'uiccRfm', 'tar': 'B00000', 'msl': '16'},
{'role': 'usimRfm', 'tar': 'B00001', 'msl': '16'},
{'tar': 'AF4D02', 'msl': '0A', 'desc': 'My applet'},
]))
self.assertEqual(presets.role_tar(p, 'isd'), 'AF4D01')
self.assertEqual(presets.role_tar(p, 'usimRfm'), 'B00001')
self.assertEqual(presets.role_tar(p, 'nope'), '')
self.assertEqual(presets.tar_msl(p, 'af4d01'), '1A')
self.assertEqual(presets.tar_msl(p, 'AF4D02'), '0A')
self.assertEqual(presets.tar_msl(p, '123456'), '')
self.assertIsNone(presets.tar_entry(p, ''))
self.assertEqual(presets.tar_entry(p, 'AF4D02')['desc'], 'My applet')
def test_update_replaces_the_tar_table(self):
p = self.store.add(preset())
upd = self.store.update(p['id'], {'tars': [
{'role': 'isd', 'tar': 'AF4D01', 'msl': '1A'},
{'role': 'uiccRfm', 'tar': 'B00000', 'msl': '16'},
{'role': 'usimRfm', 'tar': 'B00001', 'msl': '16'}]})
self.assertEqual(presets.role_tar(upd, 'isd'), 'AF4D01')
self.assertEqual(presets.tar_msl(upd, 'AF4D01'), '1A')
# a malformed tars value must not silently reset the table
again = self.store.update(p['id'], {'tars': 'nonsense'})
self.assertEqual(presets.role_tar(again, 'isd'), 'AF4D01')
def test_a_preset_carries_several_keysets(self):
p = self.store.add(preset(keysets=[keyset(kic='15', kid='15'),
keyset(kic='29', kid='29', cntr='0000000005')]))
@@ -317,6 +382,21 @@ class StorePersistenceTests(unittest.TestCase):
self.assertEqual(p['keysets'][0]['cntr'], '0000000007')
self.assertEqual(p['keysets'][0]['kicKey'], '11')
self.assertNotIn('cntr', p)
# the card-wide spi1 becomes the MSL of all three role entries
self.assertEqual([t['msl'] for t in p['tars']], ['16', '16', '16'])
def test_a_v391_flat_tars_preset_converts_to_the_tar_table(self):
path = pathlib.Path(self.tmp.name) / 'card_presets.json'
path.write_text(json.dumps({'version': 2, 'presets': [
{'name': 'Old', 'tar': 'AF4D01', 'uiccTar': 'B00000',
'usimTar': 'B00001', 'spi1': '1A', 'spi2': '21',
'keysets': [keyset()]}]}), encoding='utf-8')
store = make_store(self.tmp.name)
p = store.list()[0]
self.assertEqual([t['tar'] for t in p['tars']],
['AF4D01', 'B00000', 'B00001'])
self.assertEqual([t['msl'] for t in p['tars']], ['1A', '1A', '1A'])
self.assertNotIn('spi2', p)
def test_no_temporary_files_are_left_behind(self):
store = make_store(self.tmp.name)