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:
+91
-27
@@ -582,21 +582,6 @@ class CntrLowGuardHttpTests(unittest.TestCase):
|
||||
self.assertNotIn('suggested_cntr', resp)
|
||||
|
||||
|
||||
class TarProbeListTests(unittest.TestCase):
|
||||
"""The PWA's TAR checklist must match the server's TAR_PROBE_TARS - the
|
||||
panel is pre-populated client-side, the server's list is the API fallback,
|
||||
and both must describe the same allocations (TS 101 220 Annex D)."""
|
||||
|
||||
def test_the_pwa_and_the_server_share_the_standard_tar_list(self):
|
||||
from pysim_simple_server import server as srv
|
||||
html = (ROOT / 'frontend' / 'index.html').read_text(encoding='utf-8')
|
||||
block = html[html.index('const TAR_PROBE_TARS = ['):]
|
||||
block = block[:block.index('\n];')]
|
||||
pwa = re.findall(r"tar:\s*'([0-9A-F]{6})',\s*label:\s*'([^']*)'", block)
|
||||
self.assertEqual(pwa, list(srv.TAR_PROBE_TARS))
|
||||
self.assertTrue(pwa, 'the PWA list must not be empty')
|
||||
|
||||
|
||||
class PresetStoreHttpTests(unittest.TestCase):
|
||||
"""The card preset endpoints (v3.8.0): the PWA's Cards tab talks to the
|
||||
server store instead of localStorage, and the counter an operation
|
||||
@@ -655,7 +640,18 @@ class PresetStoreHttpTests(unittest.TestCase):
|
||||
|
||||
def _preset(self, **kw):
|
||||
p = {'name': 'C', 'keysets': [self._keyset()]}
|
||||
if 'tars' not in kw:
|
||||
# v3.26.0: the TARs name their default keyset
|
||||
p['tars'] = [{'role': r, 'tar': t, 'msl': '16'}
|
||||
for r, t in (('isd', '000000'), ('uiccRfm', 'B00000'),
|
||||
('usimRfm', 'B00001'))]
|
||||
p.update(kw)
|
||||
if 'tars' not in kw and len(p.get('keysets') or []) > 1:
|
||||
# a multi-keyset fixture must name its TAR keysets (a single-keyset
|
||||
# one is auto-filled by the store): point them at the first keyset
|
||||
kvn = int(str((p['keysets'][0] or {}).get('kic') or '10'), 16) >> 4
|
||||
for t in p['tars']:
|
||||
t['keyset'] = kvn
|
||||
return p
|
||||
|
||||
def test_the_get_route_is_card_free_and_reports_the_store(self):
|
||||
@@ -1136,6 +1132,55 @@ class PresetStoreHttpTests(unittest.TestCase):
|
||||
self.assertEqual(self.store.find_keyset(p['id'], 2)['cntr'], '0000000006')
|
||||
self.assertEqual(self.store.find_keyset(p['id'], 1)['cntr'], '0000000001')
|
||||
|
||||
def test_an_operation_uses_the_tar_default_keyset(self):
|
||||
# v3.26.0: without explicit KIc/KID the RAM-style APDU send resolves
|
||||
# the keyset from the preset's TAR table (the ISD role entry)
|
||||
p = self.store.add(self._preset(iccid='8970119000004600098', keysets=[
|
||||
self._keyset(kic='25', kid='25', kicKey='CC' * 16, kidKey='DD' * 16,
|
||||
cntr='0000000005')]))
|
||||
built = []
|
||||
patches = [
|
||||
mock.patch.object(self.srv, '_build_secured_packet',
|
||||
side_effect=lambda *a, **k: (built.append(a) or ('00' * 30, {}))),
|
||||
mock.patch.object(self.srv, '_send_secured_packet', lambda *a, **k: {
|
||||
'success': True, 'sw': '9000', 'bytes': 34, 'segments': 1,
|
||||
'response_data': '027100000000'}),
|
||||
mock.patch.object(self.srv, '_decode_por', lambda *a, **k: {
|
||||
'response_status': 'por_ok', 'tar': '000000', 'cntr': '0000000005',
|
||||
'pcntr': 0, 'rpl': 11, 'rhl': 10, 'raw': '',
|
||||
'decoded': {'number_of_commands': 1, 'last_status_word': '9000',
|
||||
'last_response_data': ''}}),
|
||||
]
|
||||
self.server.app = object()
|
||||
for patch in patches:
|
||||
patch.start()
|
||||
try:
|
||||
status, resp = self._post('/api/send-ota', {
|
||||
'apdu': '00A40000023F00', 'spi2': '01', 'preset_id': p['id']})
|
||||
finally:
|
||||
for patch in reversed(patches):
|
||||
patch.stop()
|
||||
self.assertEqual(status, 200, resp)
|
||||
args = built[0]
|
||||
self.assertEqual((args[2], args[3]), ('25', '25')) # the TAR keyset's KIc/KID
|
||||
self.assertEqual(args[5], '0000000005') # ... and its counter
|
||||
self.assertEqual(resp['final_cntr'], '0000000006')
|
||||
self.assertEqual(self.store.find_keyset(p['id'], 2)['cntr'], '0000000006')
|
||||
|
||||
def test_a_security_tar_without_a_keyset_refuses_the_operation(self):
|
||||
# a legacy (v5) preset loads leniently; an operation addressed to a
|
||||
# security TAR without a default keyset refuses instead of sending a
|
||||
# keyless packet the card would reject (v3.26.0)
|
||||
p = self.store.add(self._preset(keysets=[self._keyset(),
|
||||
self._keyset(kic='29', kid='29')]))
|
||||
# simulate the legacy file: the ISD TAR carries no default keyset
|
||||
self.store._presets[0]['tars'][0]['keyset'] = None
|
||||
self.server.app = object()
|
||||
status, resp = self._post('/api/send-ota', {
|
||||
'apdu': '00A40000023F00', 'spi2': '01', 'preset_id': p['id']})
|
||||
self.assertEqual(status, 400, resp)
|
||||
self.assertIn('needs a default keyset', resp['error'])
|
||||
|
||||
def test_a_ram_install_refuses_an_undefined_key_version(self):
|
||||
p = self.store.add(self._preset(iccid='8970119000004600098'))
|
||||
status, resp = self._post('/api/ram-install', {
|
||||
@@ -1200,8 +1245,15 @@ class PresetStoreHttpTests(unittest.TestCase):
|
||||
self.assertEqual(sent[0].lower(), '0d00010000b0000000000000010000a40000023f00')
|
||||
|
||||
def test_the_tar_probe_classifies_each_tar(self):
|
||||
p = self.store.add(self._preset(iccid='8970119000004600098', keysets=[
|
||||
self._keyset(kic='25', kid='25', kicKey='AA', kidKey='BB', cntr='0000000005')]))
|
||||
# v3.26.0: the probe checks the preset's TAR list only and each row
|
||||
# uses its TAR's default keyset (a single-keyset preset auto-fills it)
|
||||
p = self.store.add(self._preset(iccid='8970119000004600098',
|
||||
tars=[{'role': 'isd', 'tar': '000000', 'msl': '16'},
|
||||
{'role': 'uiccRfm', 'tar': 'B00000', 'msl': '16'},
|
||||
{'role': 'usimRfm', 'tar': 'B00001', 'msl': '16'},
|
||||
{'tar': 'B00200', 'msl': '16', 'desc': 'Vendor'}],
|
||||
keysets=[self._keyset(kic='25', kid='25', kicKey='AA', kidKey='BB',
|
||||
cntr='0000000005')]))
|
||||
seen = []
|
||||
|
||||
def fake_step(server, scc, sp, state, name, apdu, silent=False):
|
||||
@@ -1222,8 +1274,7 @@ class PresetStoreHttpTests(unittest.TestCase):
|
||||
self.server.app = object() # the probe only checks truthiness
|
||||
with mock.patch.object(self.srv, '_ram_send_gp_apdu', fake_step):
|
||||
status, resp = self._post('/api/tar-probe', {
|
||||
'preset_id': p['id'], 'spi1': '16', 'kic': '25', 'kid': '25',
|
||||
'kicKey': 'AA', 'kidKey': 'BB', 'cntr': '0000000005',
|
||||
'preset_id': p['id'], 'spi1': '16',
|
||||
'tars': ['000000', 'B00000', 'B00001', 'B00200']})
|
||||
self.assertEqual(status, 200, resp)
|
||||
self.assertEqual(seen, ['000000', 'B00000', 'B00001', 'B00200'])
|
||||
@@ -1233,19 +1284,32 @@ class PresetStoreHttpTests(unittest.TestCase):
|
||||
self.assertEqual(resp['registered'], 2)
|
||||
self.assertEqual(resp['total'], 4)
|
||||
self.assertEqual(resp['kvn'], 2)
|
||||
self.assertEqual(resp['results'][0]['label'], 'Issuer Security Domain (compact)')
|
||||
self.assertEqual([r['kvn'] for r in resp['results']], [2, 2, 2, 2])
|
||||
self.assertEqual(resp['results'][0]['label'], 'ISD')
|
||||
self.assertEqual(resp['results'][3]['label'], 'Vendor')
|
||||
self.assertEqual(resp['results'][0]['por_sw'], '6D00')
|
||||
|
||||
def test_the_tar_probe_needs_a_counter_check_and_a_defined_keyset(self):
|
||||
def test_the_tar_probe_refuses_what_it_cannot_probe(self):
|
||||
self.server.app = object()
|
||||
status, resp = self._post('/api/tar-probe', {'spi1': '00', 'kic': '15', 'kid': '15'})
|
||||
# a preset is required - the probe checks its TAR list
|
||||
status, resp = self._post('/api/tar-probe', {'spi1': '16'})
|
||||
self.assertEqual(status, 400, resp)
|
||||
self.assertIn('preset_id', resp['error'])
|
||||
p = self.store.add(self._preset(iccid='8970119000004600098'))
|
||||
# a TAR outside the preset is refused (no arbitrary TARs)
|
||||
status, resp = self._post('/api/tar-probe',
|
||||
{'preset_id': p['id'], 'spi1': '16',
|
||||
'tars': ['B0FFFF']})
|
||||
self.assertEqual(status, 400, resp)
|
||||
self.assertIn('not in the preset', resp['error'])
|
||||
# an MSL 00 TAR has no counter check: the probe refuses it
|
||||
p00 = self.store.add(self._preset(iccid='8970119000004600010',
|
||||
tars=[{'role': 'isd', 'tar': '000000', 'msl': '00'},
|
||||
{'role': 'uiccRfm', 'tar': 'B00000', 'msl': '16'},
|
||||
{'role': 'usimRfm', 'tar': 'B00001', 'msl': '16'}]))
|
||||
status, resp = self._post('/api/tar-probe', {'preset_id': p00['id']})
|
||||
self.assertEqual(status, 400, resp)
|
||||
self.assertIn('counter check', resp['error'])
|
||||
p = self.store.add(self._preset(iccid='8970119000004600098'))
|
||||
status, resp = self._post('/api/tar-probe',
|
||||
{'preset_id': p['id'], 'kic': '35', 'kid': '35'})
|
||||
self.assertEqual(status, 400, resp)
|
||||
self.assertIn('not defined', resp['error'])
|
||||
|
||||
def test_a_store_write_failure_answers_a_json_500(self):
|
||||
with mock.patch.object(self.store, 'add', side_effect=OSError('read-only file system')):
|
||||
|
||||
+70
-2
@@ -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):
|
||||
|
||||
@@ -25,11 +25,12 @@ def _resolver(name):
|
||||
return {n.upper(): c for c, n in S.PROACTIVE_TYPE_NAMES.items()}.get(name.upper())
|
||||
|
||||
|
||||
def preset_tars(isd='000000', msl='16'):
|
||||
"""The three mandatory role entries of a card preset's TAR table."""
|
||||
return [{'role': 'isd', 'tar': isd, 'msl': msl, 'desc': ''},
|
||||
{'role': 'uiccRfm', 'tar': 'B00000', 'msl': '16', 'desc': ''},
|
||||
{'role': 'usimRfm', 'tar': 'B00001', 'msl': '16', 'desc': ''}]
|
||||
def preset_tars(isd='000000', msl='16', keyset=1):
|
||||
"""The three mandatory role entries of a card preset's TAR table (v3.26.0:
|
||||
each carries its default keyset - the fixtures name keyset 1)."""
|
||||
return [{'role': 'isd', 'tar': isd, 'msl': msl, 'desc': '', 'keyset': keyset},
|
||||
{'role': 'uiccRfm', 'tar': 'B00000', 'msl': '16', 'desc': '', 'keyset': keyset},
|
||||
{'role': 'usimRfm', 'tar': 'B00001', 'msl': '16', 'desc': '', 'keyset': keyset}]
|
||||
|
||||
|
||||
class TestValidation(unittest.TestCase):
|
||||
@@ -1974,7 +1975,8 @@ class TestSuiteRunner(SuiteRunnerTestCase):
|
||||
preset = {'id': 'p1',
|
||||
'keysets': [{'kic': '15', 'kid': '15', 'kicKey': '00' * 16,
|
||||
'kidKey': '00' * 16, 'cntr': '0000000010'}],
|
||||
'tars': [{'role': 'isd', 'tar': 'B00000', 'msl': '16'}]}
|
||||
'tars': [{'role': 'isd', 'tar': 'B00000', 'msl': '16',
|
||||
'keyset': 1}]}
|
||||
members = [({'script_id': 'a' * 32, 'role': 'member', 'on_fail': 'stop'}, script),
|
||||
({'script_id': 'b' * 32, 'role': 'member', 'on_fail': 'stop'}, script)]
|
||||
with mock.patch.object(S, '_build_secured_packet',
|
||||
|
||||
Reference in New Issue
Block a user