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
+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()