feat: TAR probe + the SPI1 counter-tracking rule; keyless packets build again (v3.8.0)

Three things learned from probing a live card, plus the capability they enabled:

1. Counter tracking follows SPI1.b5b4 (TS 102 225 5.1.1): with 00 the counter
   is "present, ignored, never updated", so a packet without a counter check
   must leave every counter untouched.  _counter_tracked() now guards
   /api/send-ota (no final_cntr, no persistence, a log line), the RAM step
   sender and the test runner; _preset_counter_persist() requires a non-zero
   key version (the "single keyset" fallback could advance a keyset spuriously
   for a keyless send).  The PWA advances the form counter only when the server
   reports final_cntr and says "counter not tracked" otherwise.

2. The spec's no-security form builds again: _ota_keyset() refused the valid
   KIc/KID '00' (TS 102 225 A.2: '00' is legal with SPI1.b3=0 for KIc and
   SPI1.b2b1=00 for KID) because pySim wants an algorithm.  An unknown nibble
   is now refused only when the algorithm is actually used (ciphering / a
   ciphered PoR for KIc, RC/CC/DS / a MAC'd PoR for KID) and 'implicit' codes
   the nibble as 0 otherwise - POST /api/send-ota {apdu, spi1:"00", kic:"00",
   kid:"00"} now builds 0D00010000B0000000000000010000A40000, the exact packet
   the card accepted (a fake 15/15 was refused with 6200).  A no-security PoR
   is decodable again too, and a requested-but-missing PoR sets por_missing
   on the plain path as well.

3. TAR probe (POST /api/tar-probe, RAM op "Probe TARs"): one secured packet per
   TAR - the standard allocations of TS 101 220 V18.3.0 Annex D, Table D.1
   (TAR_PROBE_TARS), or a custom list - carrying a harmless C-APDU (SELECT MF)
   with the preset's keyset and SPI 16/01.  Each TAR is classified
   (registered / no_answer / no_por / refused) from the ENVELOPE verdict and
   the application's PoR, counters advance and persist normally, and the run is
   refused when SPI1 has no counter check.  Live on a card: 000000, B00000 and
   B00001 registered (compact-format applications, answering 6D00 / 6B00),
   the expanded-format TARs, USAT, multiplexing and CASD refused with 6200 -
   with the keyless form the same card accepted every TAR and answered no PoR.

Docs (api.md, help EN/RU, AGENTS), sw cache simple-v337.  Tests: 618 Python
(no-security build, the algorithm-only-when-used rule, the tracking rule, the
persist guard, the probe classification) and 736 Node (the probe renderer and
wiring, spCounterTracked).
This commit is contained in:
2026-09-30 21:17:59 +03:00
parent 386958b261
commit 6eeb8dacb3
10 changed files with 615 additions and 31 deletions
+104
View File
@@ -646,6 +646,110 @@ class PresetStoreHttpTests(unittest.TestCase):
self.assertEqual(status, 400, resp)
self.assertIn('key version 3 is not defined', resp['error'])
def test_a_keyless_send_leaves_every_counter_untouched(self):
# SPI1 00: the counter is "present, ignored, never updated"
# (TS 102 225 5.1.1 b5b4=00) - the server must not report, advance or
# persist any counter for such a packet
p = self.store.add(self._preset(keysets=[
self._keyset(kic='15', kid='15', cntr='0000000005'),
self._keyset(kic='29', kid='29', kicKey='CC', kidKey='DD', cntr='0000000007')]))
before = [ks['cntr'] for ks in self.store.get(p['id'])['keysets']]
patches = [
mock.patch.object(self.srv, '_send_secured_packet', lambda *a, **k: {
'success': True, 'sw': '9000', 'bytes': 18, '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', {
'sp': '00', 'spi1': '00', 'spi2': '01', 'kic': '00', 'kid': '00',
'tar': 'B00000', 'cntr': '00000000AA', 'preset_id': p['id']})
finally:
for patch in reversed(patches):
patch.stop()
self.assertEqual(status, 200, resp)
self.assertNotIn('final_cntr', resp)
self.assertEqual([ks['cntr'] for ks in self.store.get(p['id'])['keysets']], before)
self.assertFalse(self.store.audit_path.exists(),
'a keyless send must not write a counter at all')
def test_a_no_security_packet_is_built_with_zero_kic_kid(self):
# TS 102 225 A.2: KIc/KID '00' are valid with SPI1 00 - the server
# builds the packet with no keys at all (the card accepted this exact
# form; a fake KIc/KID 15/15 is refused with 6200)
sent = []
patches = [
mock.patch.object(self.srv, '_send_secured_packet',
lambda scc, sp_hex, **kw: (sent.append(sp_hex), {
'success': True, 'sw': '9000', 'bytes': 18,
'segments': 1, 'response_data': ''})[1]),
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', 'spi1': '00', 'spi2': '01',
'kic': '00', 'kid': '00', 'tar': 'B00000', 'cntr': '0000000001',
'kicKey': '', 'kidKey': ''})
finally:
for patch in reversed(patches):
patch.stop()
self.assertEqual(status, 200, resp)
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')]))
seen = []
def fake_step(server, scc, sp, state, name, apdu, silent=False):
tar = sp['tar']
seen.append(tar)
if tar == '000000':
state['steps'].append({'name': name, 'sw': '9000', 'por_status': 'por_ok',
'por_sw': '6D00', 'por_data': ''})
elif tar == 'B00000':
state['steps'].append({'name': name, 'sw': '9000', 'por_status': 'por_ok',
'por_sw': '6B00', 'por_data': '3F00'})
elif tar == 'B00001':
state['steps'].append({'name': name, 'sw': '9000', 'por_status': 'no_por'})
else:
state['steps'].append({'name': name, 'sw': '6200', 'por_status': 'envelope_error'})
return True
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'], 'kic': '25', 'kid': '25',
'kicKey': 'AA', 'kidKey': 'BB', 'cntr': '0000000005',
'tars': ['000000', 'B00000', 'B00001', 'B00200']})
self.assertEqual(status, 200, resp)
self.assertEqual(seen, ['000000', 'B00000', 'B00001', 'B00200'])
verdicts = {r['tar']: r['verdict'] for r in resp['results']}
self.assertEqual(verdicts, {'000000': 'registered', 'B00000': 'registered',
'B00001': 'no_por', 'B00200': 'refused'})
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(resp['results'][0]['por_sw'], '6D00')
def test_the_tar_probe_needs_a_counter_check_and_a_defined_keyset(self):
self.server.app = object()
status, resp = self._post('/api/tar-probe', {'spi1': '00', 'kic': '15', 'kid': '15'})
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')):
status, resp = self._post('/api/presets', self._preset())
+52
View File
@@ -2071,3 +2071,55 @@ class RamProgressTests(unittest.TestCase):
self.assertGreaterEqual(p['elapsed'], 0)
srv._ram_progress_end()
self.assertFalse(srv._ram_progress_payload()['active'])
class NoSecurityAndCounterTrackingTests(unittest.TestCase):
"""The keyless packet form and the counter-tracking rule (v3.8.0).
TS 102 225 A.2: KIc '00' is valid when no ciphering is applied (SPI1.b3=0)
and KID '00' when no RC/CC/DS is applied (SPI1.b2b1=00) - a no-security
packet carries no algorithm and no keys. TS 102 225 5.1.1 b5b4: with '00'
the counter is "present, ignored, never updated", so no counter may be
advanced or persisted for such a packet."""
def test_zero_kic_kid_build_the_keyless_packet(self):
from pysim_simple_server import server as S
sp, spi = S._build_secured_packet('00', '01', '00', '00', 'B00000',
'0000000001', '00A40000', '', '')
self.assertEqual(sp.lower(), '0d00010000b0000000000000010000a40000')
self.assertFalse(spi['ciphering'])
self.assertEqual(spi['rc_cc_ds'], 'no_rc_cc_ds')
def test_the_algorithm_is_required_only_when_it_is_used(self):
from pysim_simple_server import server as S
# ciphering on (SPI1.b3) with an unknown KIc nibble: refused
with self.assertRaises(ValueError):
S._ota_keyset('04', '01', '00', '15', '0000000001', '', '')
# CC on (SPI1.b2b1) with an unknown KID nibble: refused
with self.assertRaises(ValueError):
S._ota_keyset('16', '01', '15', '00', '0000000001', '', '')
# a ciphered PoR needs the KIc key too
with self.assertRaises(ValueError):
S._ota_keyset('00', '11', '00', '15', '0000000001', '', '')
# ... the same nibbles are fine when unused
S._ota_keyset('00', '01', '00', '00', '0000000001', '', '')
def test_counter_tracking_follows_the_spi(self):
from pysim_simple_server import server as S
for spi1, tracked in (('00', False), ('06', False), ('01', False),
('08', True), ('10', True), ('16', True), ('18', True),
('', False), ('zz', False)):
self.assertEqual(S._counter_tracked(spi1), tracked, spi1)
def test_persist_needs_a_key_version(self):
from pysim_simple_server import server as S
store = mock.Mock()
server = types.SimpleNamespace(card_presets=store)
S._preset_counter_persist(server, 'pid', '0000000002', 'send-ota', None)
S._preset_counter_persist(server, 'pid', '0000000002', 'send-ota', 0)
self.assertFalse(store.set_counter.called, 'no key version -> no write')
S._preset_counter_persist(server, 'pid', '0000000002', 'send-ota', 2)
store.set_counter.assert_called_once_with('pid', '0000000002', 'send-ota', 2)
if __name__ == '__main__':
unittest.main()