v3.26.0: per-TAR default keysets (the preset/SCP80/RAM rework)

Every preset TAR entry carries `keyset` (int 1-15) - the keyset every
operation addressed to that TAR uses: mandatory for any non-zero MSL
(the counters are keyset-linked), optional for MSL 00 (allowed: some
cards reject the no-security 00/00 KIc/KID form).  The store fills it
automatically when the preset has a single keyset, the validation
refuses an entry naming an undefined keyset, and the schema is 6 (a v5
file loads leniently; editing it forces the fix).

The keyset selectors are gone: the SCP80 TAR field became a select of
the preset's entries (applying the TAR's keyset to the form and seeding
the SPI1 from its MSL); the RAM operations use the preset's ISD role
entry (the PWA sends only the preset id, the ISD TAR, SPI and the
counter override - the server resolves the keyset and the keys:
presets.tar_keyset, _preset_tar_keyset, _resolve_operation_keyset,
_op_keyset, used by send-ota's APDU path, ram-install, ram-install-app
and cap-compat); the counter probe and the test runner's scp80 steps
default to their TAR's keyset (an explicit kvn stays an override).  A
security TAR without a keyset refuses the operation ("set it in the
Cards tab") - never a silent keyless send - and the start buttons wait
for a usable operation-TAR keyset.

The TAR probe checks the preset's TAR list only (the standard
TAR_PROBE_TARS list and the custom-TAR localStorage are gone; a `tars`
list selects a subset, a TAR outside the preset is refused), each row
using its own MSL and default keyset.  The Cards editor's TAR table
gained a Keyset column (the only keyset preselected, an amber marker
when a security MSL lacks one).

Docs: help EN/RU, api.md's tar-probe contract, both AGENTS.  Tests: the
store (the field, the auto-fill, the schema, the string forms), the
server resolution (send-ota + the refusal, the probe's per-row keysets
and refusals), the PWA (the TAR select, the RAM ISD resolution, the
keyset-free requests) - several fixtures updated for the new schema.
891 frontend + 885 Python; sw cache simple-v434.
This commit is contained in:
2026-10-14 12:21:16 +03:00
parent 6e4494195c
commit cb40662e4e
18 changed files with 955 additions and 611 deletions
+70 -2
View File
@@ -28,8 +28,20 @@ def keyset(**kw):
return ks
# The three role entries with their default keyset (v3.26.0) - the base for
# the multi-keyset fixtures, where no auto-fill happens.
ROLE_TARS = [{'role': role, 'tar': tar, 'msl': '16', 'keyset': 1}
for role, tar in (('isd', '000000'), ('uiccRfm', 'B00000'),
('usimRfm', 'B00001'))]
def preset(**kw):
p = {'name': 'C', 'keysets': [keyset()]}
if 'tars' not in kw:
# v3.26.0: the TARs carry their default keyset - the fixtures name
# keyset 1, so a multi-keyset preset still validates (a single-keyset
# one would be auto-filled anyway).
p['tars'] = [dict(t) for t in ROLE_TARS]
p.update(kw)
return p
@@ -174,6 +186,54 @@ class StoreCrudTests(unittest.TestCase):
self.assertEqual(self.store.find_keyset(p['id'], 2)['cntr'], '0000000005')
self.assertIsNone(self.store.find_keyset(p['id'], 3))
def test_a_tar_keyset_is_mandatory_for_a_nonzero_msl(self):
# v3.26.0: the counters are keyset-linked, so any non-zero MSL needs a
# default keyset; MSL 00 may omit it (the packet is built keyless) and
# may still carry one (some cards reject the '00/00' KIc/KID form)
two = [keyset(), keyset(kic='29', kid='29')]
with self.assertRaises(presets.PresetError) as ctx:
self.store.add(preset(keysets=two, tars=ROLE_TARS + [
{'tar': 'B00011', 'msl': '16', 'desc': 'push'}]))
self.assertIn('needs a default keyset', str(ctx.exception))
p = self.store.add(preset(keysets=two, tars=ROLE_TARS + [
{'tar': 'B00011', 'msl': '00', 'desc': 'push'}]))
self.assertIsNone(p['tars'][3]['keyset'])
p = self.store.add(preset(keysets=two, tars=ROLE_TARS + [
{'tar': 'B00011', 'msl': '00', 'keyset': 1}]))
self.assertEqual(p['tars'][3]['keyset'], 1)
def test_a_tar_keyset_must_exist_in_the_preset(self):
with self.assertRaises(presets.PresetError) as ctx:
self.store.add(preset(keysets=[keyset(), keyset(kic='29', kid='29')],
tars=ROLE_TARS + [
{'tar': 'B00011', 'msl': '16', 'keyset': 3}]))
self.assertIn('does not define', str(ctx.exception))
def test_a_single_keyset_fills_the_tar_defaults(self):
# v3.26.0: one keyset in the preset sets every TAR's default keyset
p = self.store.add(preset(tars=[{'tar': 'B00011', 'msl': '16'},
{'tar': 'B00012', 'msl': '00'}]))
self.assertTrue(all(t['keyset'] == 1 for t in p['tars']))
# several keysets leave an MSL 00 entry alone...
p = self.store.add(preset(keysets=[keyset(), keyset(kic='29', kid='29')],
tars=ROLE_TARS + [
{'tar': 'B00011', 'msl': '00'}]))
self.assertIsNone(p['tars'][3]['keyset'])
# ...and a non-zero MSL must name one explicitly
p = self.store.add(preset(keysets=[keyset(), keyset(kic='29', kid='29')],
tars=ROLE_TARS + [
{'tar': 'B00011', 'msl': '16', 'keyset': 2}]))
self.assertEqual(p['tars'][3]['keyset'], 2)
def test_tar_keyset_string_forms_and_the_helper(self):
p = self.store.add(preset(keysets=[keyset(), keyset(kic='29', kid='29')],
tars=ROLE_TARS + [
{'tar': 'B00011', 'msl': '16', 'keyset': '02'},
{'tar': 'B00012', 'msl': '16', 'keyset': '2'}]))
self.assertEqual([t['keyset'] for t in p['tars']], [1, 1, 1, 2, 2])
self.assertEqual(presets.tar_keyset(p, 'B00011'), 2)
self.assertIsNone(presets.tar_keyset(p, 'B00099'))
def test_validation_requires_at_least_one_keyset(self):
with self.assertRaises(presets.PresetError) as ctx:
self.store.add(preset(keysets=[]))
@@ -271,11 +331,19 @@ class StoreCrudTests(unittest.TestCase):
self.assertEqual(up['id'], p['id'])
self.assertEqual(up['name'], 'Renamed')
self.assertEqual(up['keysets'][0]['kic'], '15')
# the keysets are replaceable as a whole (the Cards tab edit path)
up = self.store.update(p['id'], {'keysets': [keyset(kic='29', kid='29',
# replacing the keysets with another number must re-point the TARs'
# default keysets in the same update (the Cards-form edit path)
with self.assertRaises(presets.PresetError) as ctx:
self.store.update(p['id'], {'keysets': [keyset(kic='29', kid='29',
cntr='0000000009')]})
self.assertIn('does not define', str(ctx.exception))
tars = [dict(t, keyset=2) for t in up['tars']]
up = self.store.update(p['id'], {'keysets': [keyset(kic='29', kid='29',
cntr='0000000009')],
'tars': tars})
self.assertEqual(presets.keyset_kvn(up['keysets'][0]), 2)
self.assertEqual(up['keysets'][0]['cntr'], '0000000009')
self.assertTrue(all(t['keyset'] == 2 for t in up['tars']))
self.assertIsNone(self.store.update('nope', {'name': 'x'}))
def test_the_counter_must_be_hex_and_at_most_ten_digits(self):