From 8dfbb02c931a74bd813141f5719a066e2f38d80c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D1=82=D0=BE=D0=BD=20=D0=A2=D1=80=D0=BE=D1=88?= =?UTF-8?q?=D0=B8=D0=BD?= Date: Wed, 14 Oct 2026 12:32:04 +0300 Subject: [PATCH] v3.26.0 review: the keyless-TAR path An MSL 00 TAR without a default keyset is a legitimate keyless entry, but _resolve_operation_keyset left the request's KIc/KID empty and the RAM / APDU builds failed with "invalid literal for int() with base 16: ''". The resolution now fills the spec's no-security 00/00 KIc/KID form for a preset entry without a keyset, and refuses a TAR outside the preset with a hint (the old silent '25' default was arbitrary). The PWA's TAR change resets the form the same way - a stale previous TAR's KIc/KID would otherwise travel explicitly and build a non-keyless packet. Also: the dead _preset_tar_keyset helper removed; tests for both keyless paths. 892 frontend + 886 Python; sw cache simple-v435. --- frontend/index.html | 4 +++ frontend/sw.js | 2 +- frontend/tests/keysets.test.js | 40 +++++++++++++++++++++++++++++- pysim_simple_server/server.py | 21 +++++++++------- tests/test_fetch_routes.py | 45 ++++++++++++++++++++++++++++++++++ 5 files changed, 101 insertions(+), 11 deletions(-) diff --git a/frontend/index.html b/frontend/index.html index 637e49c..ddc212f 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -5075,6 +5075,10 @@ function spTarChanged() { if (entry) { const ks = entry.keyset ? spKeysetFor(preset, entry.keyset) : null; if (ks) spKeysetApply(ks); + // A TAR without a default keyset (MSL 00) gets the spec's no-security + // values - a stale previous TAR's KIc/KID would otherwise travel + // explicitly and build a non-keyless packet (v3.26.0 review). + else spKeysetApply({ kic: '00', kid: '00', kicKey: '', kidKey: '' }); if (entry.msl) spSpi1Seed(entry.msl); } spInvalidate(); diff --git a/frontend/sw.js b/frontend/sw.js index d6f48c1..8bae2f6 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'simple-v434'; +const CACHE = 'simple-v435'; const URLS = [ 'index.html', 'help.html', diff --git a/frontend/tests/keysets.test.js b/frontend/tests/keysets.test.js index c8ff51d..2c94a14 100644 --- a/frontend/tests/keysets.test.js +++ b/frontend/tests/keysets.test.js @@ -25,7 +25,8 @@ let code = ''; for (const fn of ['spKeysetKvnOf', 'spKeysetList', 'spKeysetFor', 'spKeysetCheck', 'spTarOptionsHtml', 'spTarRender', 'spSpi1Seed', 'spTarKeysetGuard', 'cardsTarKeysetCell', 'cardsTarKeysetFromForm', 'cardsTarKeysetRefresh', 'cardsTarRowsRender', 'cardsTarRowHtml', 'cardsTarsFromTable', 'cardsFreeTarsFromForm', 'spCounterTracked', 'spPresetIdx', 'ramPresetIdx', 'tarPresetIdx', 'spKeysetSync', 'ramRender', 'esc', 'escHtml', - 'cardsTarList', 'cardsTarEntry', 'cardsRoleTar']) { + 'cardsTarList', 'cardsTarEntry', 'cardsRoleTar', + 'spTarChanged', 'spKeysetApply', 'updateSpKic', 'updateSpKid']) { code += extractFunc(html, fn) + '\n'; } code += 'function t(s){return s;}\n'; @@ -180,6 +181,43 @@ test('the RAM and TAR views rebuild their keyset lists when their preset is know assert.ok(html.includes("if (name === 'tar') { tarRender(); tarProbeRender(); }")); }); +test('spTarChanged applies the TAR keyset and clears it for a keyless TAR', () => { + // v3.26.0 review: a TAR without a default keyset (MSL 00) must reset the + // form to the no-security '00/00' form - a stale previous TAR's KIc/KID + // would otherwise travel explicitly and build a non-keyless packet + globalThis.updateKeyPlaceholder = () => {}; + globalThis.spInvalidate = () => {}; + globalThis.updateSp = () => {}; + globalThis.spCntrPlaceholder = () => {}; + const els = { + 'sp-tar': { value: '', disabled: false, innerHTML: '', options: [] }, + 'sp-card-sel': { value: '0' }, + 'sp-kic-idx': { value: '' }, 'sp-kic-alg': { value: '' }, 'sp-kic-hex': { value: '' }, + 'sp-kid-idx': { value: '' }, 'sp-kid-alg': { value: '' }, 'sp-kid-hex': { value: '' }, + 'sp-kic-key': { value: '' }, 'sp-kid-key': { value: '' }, 'sp-cntr': { value: 'X' }, + }; + globalThis.document = { getElementById: id => els[id] || null }; + globalThis.cards = [{ name: 'EP', keysets: [ + { kic: '15', kid: '15', kicKey: 'AA', kidKey: 'BB', cntr: '0000000001' }], + tars: [ + { role: 'isd', tar: '000000', msl: '16', keyset: 1 }, + { tar: 'B00011', msl: '00' }] }]; + // a keyset TAR applies its KIc/KID and keys (the counter override clears) + els['sp-tar'].value = '000000'; + spTarChanged(); + assert.strictEqual(els['sp-kic-hex'].value, '15'); + assert.strictEqual(els['sp-kid-hex'].value, '15'); + assert.strictEqual(els['sp-kic-key'].value, 'AA'); + assert.strictEqual(els['sp-cntr'].value, ''); + // a keyless TAR (MSL 00, no default keyset) resets to the no-security form + els['sp-kic-key'].value = 'AA'; + els['sp-tar'].value = 'B00011'; + spTarChanged(); + assert.strictEqual(els['sp-kic-hex'].value, '00'); + assert.strictEqual(els['sp-kid-hex'].value, '00'); + assert.strictEqual(els['sp-kic-key'].value, ''); +}); + test('the TAR selector waits for a preset and lists its entries', () => { const els = { 'sp-tar': { value: '', disabled: false, innerHTML: '', options: [] }, diff --git a/pysim_simple_server/server.py b/pysim_simple_server/server.py index 63c3020..f5a3bdf 100644 --- a/pysim_simple_server/server.py +++ b/pysim_simple_server/server.py @@ -1700,15 +1700,6 @@ def _preset_keyset_check(server, preset_id, kic, kid): return None -def _preset_tar_keyset(server, preset_id, tar): - """The default keyset number of a TAR in the named preset (v3.26.0), or - None (no preset, no such TAR entry, or an MSL 00 entry without one).""" - preset = _preset_by_id(server, preset_id) - if preset is None: - return None - return presets.tar_keyset(preset, tar) - - def _preset_keyset_by_kvn(preset, kvn): """The preset's keyset dict with number `kvn`, or None.""" return next((ks for ks in _preset_keysets(preset) @@ -1737,6 +1728,18 @@ def _resolve_operation_keyset(server, body, tar): if msl and msl != '00': return None, ('TAR %s needs a default keyset (its MSL %s requires ' 'security) - set it in the Cards tab' % (tar, msl)) + if presets.tar_entry(preset, tar) is None: + # A TAR outside the preset: the caller must name its keyset - the + # old silent '25' default was arbitrary (v3.26.0 review). + return None, ('TAR %s is not in the preset - add it in the Cards ' + 'tab or pass the keyset (KIc/KID) explicitly' % tar) + # An MSL 00 entry without a default keyset: the spec's no-security + # values (A.2) - the packet is built keyless. A keyset set on an + # MSL 00 entry was handled above (it supplies its KIc/KID). + body['kic'] = '00' + body['kid'] = '00' + body['kicKey'] = '' + body['kidKey'] = '' return None, None keyset = _preset_keyset_by_kvn(preset, kvn) if keyset is None: diff --git a/tests/test_fetch_routes.py b/tests/test_fetch_routes.py index 8a0f2fe..63a2e0e 100644 --- a/tests/test_fetch_routes.py +++ b/tests/test_fetch_routes.py @@ -1181,6 +1181,51 @@ class PresetStoreHttpTests(unittest.TestCase): self.assertEqual(status, 400, resp) self.assertIn('needs a default keyset', resp['error']) + def test_a_keyless_tar_builds_the_no_security_form(self): + # v3.26.0 review: an MSL 00 entry without a default keyset is a + # legitimate keyless TAR - the operation sends the spec's '00/00' + # KIc/KID form instead of failing on an empty one, and a TAR outside + # the preset is refused with a hint + p = self.store.add(self._preset(iccid='8970119000004600098', + tars=[{'role': 'isd', 'tar': '000000', 'msl': '00'}, + {'role': 'uiccRfm', 'tar': 'B00000', 'msl': '16', 'keyset': 1}, + {'role': 'usimRfm', 'tar': 'B00001', 'msl': '16', 'keyset': 1}], + keysets=[self._keyset(), self._keyset(kic='29', kid='29')])) + built = [] + patches = [ + mock.patch.object(self.srv, '_build_secured_packet', + side_effect=lambda *a, **k: (built.append(a) or ('00' * 21, {}))), + mock.patch.object(self.srv, '_send_secured_packet', lambda *a, **k: { + 'success': True, 'sw': '9000', 'bytes': 21, 'segments': 1, + 'response_data': ''}), + mock.patch.object(self.srv, '_decode_por', lambda *a, **k: None), + ] + 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[0], args[2], args[3]), ('00', '00', '00')) + # a TAR outside the preset without explicit KIc/KID is refused: the + # SPI1 resolution names it first, and with an explicit SPI1 the + # keyset hint follows + status, resp = self._post('/api/send-ota', { + 'apdu': '00A40000023F00', 'spi2': '01', 'preset_id': p['id'], + 'tar': 'B00099'}) + self.assertEqual(status, 400, resp) + self.assertIn('B00099', resp['error']) + status, resp = self._post('/api/send-ota', { + 'apdu': '00A40000023F00', 'spi2': '01', 'preset_id': p['id'], + 'tar': 'B00099', 'spi1': '16'}) + self.assertEqual(status, 400, resp) + self.assertIn('is not in the preset', 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', {