diff --git a/docs/api.md b/docs/api.md index 7744247..876fa29 100644 --- a/docs/api.md +++ b/docs/api.md @@ -514,7 +514,10 @@ the counter that was sent, advanced by one when the card accepted the packet. - with `apdu` (the server builds the packet) a missing `cntr` is seeded from the named preset's keyset (the one the packet's KIc/KID resolve to); the effective value is echoed back as `cntr`, so a caller that omits it learns - what was used; + what was used. The seed uses the packet's keyset whatever `SPI1.b5b4` + says — with no counter check the CNTR field is spec-ignored, nothing is + advanced or persisted, and the `OTA SEND: counter not tracked` line names + it; - an explicit `cntr` always wins — the packet uses it verbatim (a hand send, the counter probe or a pre-built `sp` may deliberately pick one); - the store only moves **forward**: an explicit value (or its next counter) diff --git a/frontend/index.html b/frontend/index.html index fcb0337..468437e 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -8583,10 +8583,12 @@ function spPresetCounterValue() { if (idx < 0) return ''; const kvn = spKeysetKvnOf((document.getElementById('sp-kic-hex') || {}).value) || spKeysetKvnOf((document.getElementById('sp-kid-hex') || {}).value) || 0; - // the same fallback cardsApplyFields uses: the matching keyset, else the - // preset's first one (an undefined duplicate would otherwise leave the - // placeholder empty) - const ks = (kvn && spKeysetFor(cards[idx], kvn)) || spKeysetList(cards[idx])[0] || null; + // Only a keyset the packet's KIc/KID actually resolve - exactly the value + // the server's _preset_counter_seed() uses (the body carries the same two + // bytes). No preset / kvn 0 / an unknown keyset = no seed: the packet + // then carries counter 0, and neither the preview nor the pySim reference + // may show a value the send would not use. + const ks = kvn ? spKeysetFor(cards[idx], kvn) : null; return (ks && ks.cntr) || ''; } @@ -8598,11 +8600,12 @@ function spCntrPlaceholder() { } // The counter the packet is built with (preview / verify): the typed -// override, else the preset's next value, else the historic 1 default. +// override, else the preset's next value, else 0 - the same value the +// server's empty seed puts into the packet (v3.20.0). function spCntrEffective() { const el = document.getElementById('sp-cntr'); const typed = ((el && el.value) || '').replace(/[^0-9a-fA-F]/g, ''); - const v = typed || spPresetCounterValue() || '1'; + const v = typed || spPresetCounterValue() || '0'; return v.toUpperCase().padStart(10, '0').slice(0, 10); } diff --git a/frontend/sw.js b/frontend/sw.js index abc4cac..e129576 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'simple-v393'; +const CACHE = 'simple-v394'; const URLS = [ 'index.html', 'help.html', diff --git a/frontend/tests/cards_counter.test.js b/frontend/tests/cards_counter.test.js index 6d75040..ec9d7d1 100644 --- a/frontend/tests/cards_counter.test.js +++ b/frontend/tests/cards_counter.test.js @@ -64,6 +64,10 @@ function fakeEnv(selValue, selId, presetCntr) { 'sp-kid-idx', 'sp-kid-alg', 'sp-tar', 'sp-cntr', 'sp-kic-key', 'sp-kid-key']) { els[id] = { value: '' }; } + // the combined key bytes the server reads (updateSpKic maintains them in + // the real form): the fake preset's keyset resolves to kvn 1 + els['sp-kic-hex'] = { value: '15' }; + els['sp-kid-hex'] = { value: '15' }; els[selId || 'sp-card-sel'] = { value: selValue }; globalThis.document = { getElementById: id => els[id] || null }; globalThis.cards = [{ name: 'C', cntr: presetCntr, @@ -143,6 +147,22 @@ test('the counter +/- buttons start from the preset value and create an override assert.strictEqual(env.els['sp-cntr'].value, '0000000020'); }); +test('spCntrEffective mirrors the server seed: matched keyset, else 0', () => { + const env = fakeEnv('0', 'sp-card-sel', '0000000020'); + assert.strictEqual(spCntrEffective(), '0000000020'); + env.els['sp-cntr'].value = '0000000042'; // a typed override wins + assert.strictEqual(spCntrEffective(), '0000000042'); + env.els['sp-cntr'].value = ''; + env.els['sp-kic-hex'].value = '00'; // keyless: no keyset + env.els['sp-kid-hex'].value = '00'; + assert.strictEqual(spCntrEffective(), '0000000000', + 'the server seeds nothing for kvn 0 - the packet carries 0'); + env.els['sp-kic-hex'].value = '15'; + env.els['sp-kid-hex'].value = '15'; + globalThis.cards = []; // no preset: no seed + assert.strictEqual(spCntrEffective(), '0000000000'); +}); + test('spPorAccepted treats a missing PoR and por_ok as accepted', () => { assert.strictEqual(spPorAccepted(undefined), true); assert.strictEqual(spPorAccepted({ response_status: 'por_ok' }), true); diff --git a/pysim_simple_server/server.py b/pysim_simple_server/server.py index 28f3f01..52e3e13 100644 --- a/pysim_simple_server/server.py +++ b/pysim_simple_server/server.py @@ -7800,9 +7800,6 @@ class PysimHandler(BaseHTTPRequestHandler): # keyless SPI1 00 case) the field is ignored and never updated, so # no counter is advanced or persisted. counter_tracked = _counter_tracked(spi1) - if body.get('cntr') and not counter_tracked: - sys.stderr.write('OTA SEND: counter not tracked (SPI1 %s: ' - 'no counter check)\n' % spi1) include_cpi = body.get('includeCpi', True) ram_format = None try: @@ -7845,6 +7842,12 @@ class PysimHandler(BaseHTTPRequestHandler): # preset seed - the packet may hold anything). sp_hex = sp cntr = str(body.get('cntr') or '').strip().upper() + if cntr and not counter_tracked: + # the packet carries a counter (explicit or seeded) but + # the card will not track it: SPI1.b5b4 = 00 means + # "present, ignored, never updated" (TS 102 225 5.1.1) + sys.stderr.write('OTA SEND: counter not tracked (SPI1 %s: ' + 'no counter check)\n' % spi1) spi2_val = int(spi2, 16) # Capture the PoR from either transport: inline in the # ENVELOPE response or as a proactive SEND SHORT MESSAGE (some @@ -7879,7 +7882,7 @@ class PysimHandler(BaseHTTPRequestHandler): failed_por_hex = _sms_submit_por(submit_handler) failed_por = _decode_por(spi1, spi2, body.get('kic', ''), body.get('kid', ''), - body.get('cntr', ''), body.get('kicKey', ''), + cntr, body.get('kicKey', ''), body.get('kidKey', ''), failed_por_hex, cmd_len=len(body.get('apdu') or '') // 2 or None) if failed_por: @@ -7917,7 +7920,7 @@ class PysimHandler(BaseHTTPRequestHandler): por_hex = tpdu_b[idx:].hex() por_src = 'sms-submit' por = _decode_por(spi1, spi2, body.get('kic', ''), - body.get('kid', ''), body.get('cntr', ''), body.get('kicKey', ''), + body.get('kid', ''), cntr, body.get('kicKey', ''), body.get('kidKey', ''), por_hex, cmd_len=len(body.get('apdu') or '') // 2 or None) # Check for SPI2=0x21 (PoR required) but got 9000 with no PoR → card refuses PoR diff --git a/tests/test_fetch_routes.py b/tests/test_fetch_routes.py index 02fc4ea..5a0b814 100644 --- a/tests/test_fetch_routes.py +++ b/tests/test_fetch_routes.py @@ -598,7 +598,7 @@ class PresetStoreHttpTests(unittest.TestCase): 'success': True, 'sw': '9000', 'bytes': 34, 'segments': 1, 'response_data': '027100000e0ab0000000000000010000019000'}), \ mock.patch.object(self.srv, '_decode_por', - return_value={'response_status': 'por_ok'}): + return_value={'response_status': 'por_ok'}) as decode: status, resp = self._post('/api/send-ota', { 'preset_id': pid, 'apdu': '80E2900000', 'tar': 'B00001', 'spi1': '16', 'spi2': '01', 'kic': '15', 'kid': '15', @@ -606,10 +606,39 @@ class PresetStoreHttpTests(unittest.TestCase): self.assertEqual(status, 200, resp) self.assertTrue(resp.get('success'), resp) self.assertEqual(build.call_args[0][5], '0000000001') # seeded + # the PoR decode must use the counter the packet was built with - a + # different one breaks the RC/CC check and loses the verdict + self.assertEqual(decode.call_args[0][4], '0000000001') self.assertEqual(resp['cntr'], '0000000001') self.assertEqual(resp['final_cntr'], '0000000002') self.assertEqual(self.store.get(pid)['keysets'][0]['cntr'], '0000000002') + def test_send_ota_failure_reports_the_effective_counter(self): + """A refused ENVELOPE still echoes the counter the packet carried and + decodes its PoR with it (v3.20.0).""" + status, resp = self._post('/api/presets', self._preset(iccid='8970119000004600098')) + self.assertEqual(status, 200, resp) + pid = resp['preset']['id'] + self.server.app = object() + with mock.patch.object(self.srv, '_build_secured_packet', + return_value=('00' * 20, {})), \ + mock.patch.object(self.srv, '_send_secured_packet', + return_value={'success': False, 'sw': '6A82', + 'bytes': 34, 'segments': 1, + 'error': 'card refused'}), \ + mock.patch.object(self.srv, '_decode_por', + return_value={'response_status': 'cntr_low'}) as decode: + status, resp = self._post('/api/send-ota', { + 'preset_id': pid, 'apdu': '80E2900000', 'tar': 'B00001', + 'spi1': '16', 'spi2': '01', 'kic': '15', 'kid': '15', + 'kicKey': 'AA', 'kidKey': 'BB'}) + self.assertEqual(status, 200, resp) + self.assertFalse(resp['success'], resp) + self.assertEqual(resp['cntr'], '0000000001') + self.assertEqual(decode.call_args[0][4], '0000000001') + # the store must not move for a rejected packet + self.assertEqual(self.store.get(pid)['keysets'][0]['cntr'], '0000000001') + def test_send_ota_explicit_lower_counter_does_not_regress_the_store(self): """An explicit counter is used for the packet (a hand send), but the store keeps its higher value (monotonic persistence, v3.20.0).""" @@ -638,6 +667,54 @@ class PresetStoreHttpTests(unittest.TestCase): # the lower value never regresses the store self.assertEqual(self.store.get(pid)['keysets'][0]['cntr'], '0000000010') + def test_sp_verify_seeds_the_reference_counter(self): + """v3.20.0: a verify without `cntr` builds the pySim reference from + the preset's keyset - the same value the JS packet was built with.""" + status, resp = self._post('/api/presets', self._preset(iccid='8970119000004600098')) + self.assertEqual(status, 200, resp) + pid = resp['preset']['id'] + key = '00112233445566778899AABBCCDDEEFF' # synthetic, tests only + body = {'preset_id': pid, 'apdu': '00A40000023F00', 'tar': 'B00000', + 'spi1': '16', 'spi2': '01', 'kic': '15', 'kid': '15', + 'kicKey': key, 'kidKey': key} + status, seeded = self._post('/api/sp-verify', body) + self.assertEqual(status, 200, seeded) + status, pinned = self._post('/api/sp-verify', dict(body, cntr='0000000001')) + self.assertEqual(status, 200, pinned) + self.assertEqual(seeded['py_sp'], pinned['py_sp']) + # the JS packet built with that counter matches the reference + status, match = self._post('/api/sp-verify', dict(body, sp=seeded['py_sp'])) + self.assertEqual(status, 200, match) + self.assertTrue(match['match'], match) + # an explicit counter still wins over the store's + status, other = self._post('/api/sp-verify', + dict(body, sp=seeded['py_sp'], cntr='0000000002')) + self.assertFalse(other['match']) + + def test_tar_probe_without_cntr_seeds_the_preset_counter(self): + """v3.20.0: the probe's first packet carries the preset's counter.""" + status, resp = self._post('/api/presets', self._preset(iccid='8970119000004600098')) + self.assertEqual(status, 200, resp) + pid = resp['preset']['id'] + self.server.app = object() # the TAR probe needs an app + scc + with mock.patch.object(self.srv, '_build_secured_packet', + return_value=('00' * 20, {})) as build, \ + mock.patch.object(self.srv, '_send_secured_packet', return_value={ + 'success': True, 'sw': '9000', 'bytes': 34, 'segments': 1, + 'response_data': '027100000e0ab0000000000000010000019000'}), \ + mock.patch.object(self.srv, '_decode_por', return_value={ + 'response_status': 'por_ok', 'tar': 'B00000', 'cntr': '0000000001', + 'pcntr': 0, 'rpl': 11, 'rhl': 10, 'raw': ''}): + status, resp = self._post('/api/tar-probe', { + 'preset_id': pid, 'kic': '15', 'kid': '15', 'spi1': '16', + 'spi2': '01', 'kicKey': 'AA', 'kidKey': 'BB', + 'tars': ['B00000']}) + self.assertEqual(status, 200, resp) + self.assertTrue(resp.get('success'), resp) + self.assertEqual(build.call_args[0][5], '0000000001') # seeded + self.assertEqual(resp['final_cntr'], '0000000002') + self.assertEqual(self.store.get(pid)['keysets'][0]['cntr'], '0000000002') + def test_create_update_delete_round_trip(self): status, resp = self._post('/api/presets', self._preset(iccid='8970119000004600098')) self.assertEqual(status, 200, resp)